├── .editorconfig ├── .github └── workflows │ ├── pylint.yml │ └── pypi.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── amiyabot ├── __init__.py ├── _assets │ ├── font │ │ ├── HarmonyOS_Sans_SC.ttf │ │ └── font.css │ └── markdown │ │ ├── js │ │ ├── highlight.min.js │ │ ├── marked.min.js │ │ └── vue.min.js │ │ ├── style │ │ ├── github-markdown-dark.css │ │ ├── github-markdown.css │ │ └── highlight │ │ │ ├── vs.min.css │ │ │ └── vs2015.min.css │ │ └── template.html ├── adapters │ ├── __init__.py │ ├── apiProtocol.py │ ├── comwechat │ │ ├── __init__.py │ │ ├── builder.py │ │ └── package.py │ ├── cqhttp │ │ ├── __init__.py │ │ ├── api.py │ │ └── forwardMessage.py │ ├── kook │ │ ├── __init__.py │ │ ├── api.py │ │ ├── builder.py │ │ └── package.py │ ├── mirai │ │ ├── __init__.py │ │ ├── api.py │ │ ├── builder.py │ │ ├── forwardMessage.py │ │ ├── package.py │ │ └── payload.py │ ├── onebot │ │ ├── __init__.py │ │ ├── v11 │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── builder.py │ │ │ └── package.py │ │ └── v12 │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── builder.py │ │ │ └── package.py │ ├── tencent │ │ ├── __init__.py │ │ ├── intents.py │ │ ├── qqGlobal │ │ │ ├── __init__.py │ │ │ └── package.py │ │ ├── qqGroup │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── builder.py │ │ │ └── package.py │ │ └── qqGuild │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── builder.py │ │ │ ├── model.py │ │ │ └── package.py │ └── test │ │ ├── __init__.py │ │ ├── builder.py │ │ └── server.py ├── builtin │ ├── __init__.py │ ├── lib │ │ ├── __init__.py │ │ ├── browserService │ │ │ ├── __init__.py │ │ │ ├── launchConfig.py │ │ │ ├── pageContext.py │ │ │ └── pagePool.py │ │ ├── eventBus.py │ │ ├── imageCreator.py │ │ └── timedTask │ │ │ ├── __init__.py │ │ │ └── scheduler.py │ ├── message │ │ ├── __init__.py │ │ ├── structure.py │ │ └── waitEvent.py │ └── messageChain │ │ ├── __init__.py │ │ ├── element.py │ │ └── keyboard.py ├── database │ └── __init__.py ├── factory │ ├── __init__.py │ ├── factoryCore.py │ ├── factoryTyping.py │ └── implemented.py ├── handler │ ├── __init__.py │ └── messageHandler.py ├── network │ ├── __init__.py │ ├── download.py │ └── httpRequests.py ├── signalHandler.py └── typeIndexes.py ├── pylint.conf ├── requirements.txt ├── scripts ├── black.sh ├── publish.sh └── pylint.sh └── setup.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: Pylint 2 | 3 | on: [ pull_request ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [ '3.8' ] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install -r requirements.txt 21 | pip install pylint 22 | - name: Analysing the code with pylint 23 | run: | 24 | pylint amiyabot --rcfile=pylint.conf 25 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Pypi 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | pypi-publish: 10 | name: upload release to PyPI 11 | runs-on: ubuntu-latest 12 | environment: release 13 | permissions: 14 | id-token: write 15 | steps: 16 | - name: Check out the repository 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: '3.8' 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install setuptools wheel twine 28 | 29 | - name: Build package 30 | run: | 31 | python setup.py bdist_wheel --auto-increment-version 32 | 33 | - name: Publish package distributions to PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /__pycache__/ 3 | /resource/ 4 | /plugins/ 5 | /build/ 6 | /dist/ 7 | /venv/ 8 | /logs/ 9 | /testTemp/ 10 | /*.egg-info/ 11 | 12 | .DS_Store 13 | 14 | *.pyc 15 | *.spec 16 | *.zip 17 | 18 | main*.py 19 | test*.json 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 AmiyaBot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include amiyabot/assets *.ttf 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amiya-Bot 2 | 3 | ![PyPI](https://img.shields.io/pypi/v/amiyabot) 4 | 5 | 简洁高效的 Python 异步渐进式 QQ 机器人框架! 6 | 7 | 现已支持: 8 | 9 | - [QQ频道机器人](https://www.amiyabot.com/develop/adapters/qqChannel) 10 | - [QQ群机器人](https://www.amiyabot.com/develop/adapters/qqGroup) 11 | - [QQ全域机器人](https://www.amiyabot.com/develop/adapters/qqGlobal) 12 | - [KOOK机器人](https://www.amiyabot.com/develop/adapters/kook) 13 | - [Mirai-Api-Http](https://www.amiyabot.com/develop/adapters/mah) 14 | - [Go-CQHttp](https://www.amiyabot.com/develop/adapters/gocq) 15 | - [ComWeChatBot Client](https://www.amiyabot.com/develop/adapters/comwechat) 16 | - [OneBot 11](https://www.amiyabot.com/develop/adapters/onebot11) 17 | - [OneBot 12](https://www.amiyabot.com/develop/adapters/onebot12) 18 | 19 | 官方文档:[www.amiyabot.com](https://www.amiyabot.com/) 20 | 21 | ## Install 22 | 23 | pip install amiyabot 24 | 25 | ## Get started 26 | 27 | ```python 28 | import asyncio 29 | 30 | from amiyabot import AmiyaBot, Message, Chain 31 | 32 | bot = AmiyaBot(appid='******', token='******') 33 | 34 | 35 | @bot.on_message(keywords='hello') 36 | async def _(data: Message): 37 | return Chain(data).text(f'hello, {data.nickname}') 38 | 39 | 40 | asyncio.run(bot.start()) 41 | ``` 42 | 43 | ### 多账户 44 | 45 | ```python 46 | import asyncio 47 | 48 | from amiyabot import MultipleAccounts, AmiyaBot, Message, Chain 49 | 50 | bots = MultipleAccounts( 51 | AmiyaBot(appid='******', token='******'), 52 | AmiyaBot(appid='******', token='******'), 53 | ... 54 | ) 55 | 56 | 57 | @bots.on_message(keywords='hello') 58 | async def _(data: Message): 59 | return Chain(data).text(f'hello, {data.nickname}') 60 | 61 | 62 | asyncio.run(bots.start()) 63 | ``` 64 | 65 | ### 使用适配器 66 | 67 | ```python 68 | import asyncio 69 | 70 | from amiyabot import AmiyaBot, Message, Chain 71 | from amiyabot.adapters.onebot.v11 import onebot11 72 | 73 | bot = AmiyaBot( 74 | appid='******', 75 | token='******', 76 | adapter=onebot11(host='127.0.0.1', http_port=8080, ws_port=8060) 77 | ) 78 | 79 | 80 | @bot.on_message(keywords='hello') 81 | async def _(data: Message): 82 | return Chain(data).text(f'hello, {data.nickname}') 83 | 84 | 85 | asyncio.run(bot.start()) 86 | ``` 87 | 88 | -------------------------------------------------------------------------------- /amiyabot/__init__.py: -------------------------------------------------------------------------------- 1 | import jieba 2 | import typing 3 | import asyncio 4 | 5 | from typing import Optional, Union 6 | 7 | from amiyalog import logger as log 8 | from amiyautils import random_code 9 | 10 | # adapters 11 | from amiyabot.adapters import BotAdapterProtocol 12 | from amiyabot.adapters.kook import KOOKBotInstance 13 | from amiyabot.adapters.mirai import MiraiBotInstance 14 | from amiyabot.adapters.cqhttp import CQHttpBotInstance 15 | from amiyabot.adapters.onebot.v11 import OneBot11Instance 16 | from amiyabot.adapters.onebot.v12 import OneBot12Instance 17 | from amiyabot.adapters.tencent.qqGuild import QQGuildBotInstance, QQGuildSandboxBotInstance 18 | from amiyabot.adapters.comwechat import ComWeChatBotInstance 19 | 20 | # factory 21 | from amiyabot.factory import BotInstance, PluginInstance, GroupConfig 22 | 23 | # handler 24 | from amiyabot.handler.messageHandler import message_handler 25 | from amiyabot.signalHandler import SignalHandler 26 | 27 | # lib 28 | from amiyabot.builtin.lib.eventBus import event_bus 29 | from amiyabot.builtin.lib.timedTask import TasksControl 30 | from amiyabot.builtin.lib.browserService import BrowserLaunchConfig, basic_browser_service 31 | 32 | # message 33 | from amiyabot.builtin.messageChain import Chain, ChainBuilder, InlineKeyboard, CQCode 34 | from amiyabot.builtin.message import ( 35 | Event, 36 | EventList, 37 | Message, 38 | Waiter, 39 | WaitEventCancel, 40 | WaitEventOutOfFocus, 41 | Equal, 42 | ) 43 | 44 | jieba.setLogLevel(jieba.logging.INFO) 45 | 46 | 47 | class AmiyaBot(BotInstance): 48 | def __init__( 49 | self, 50 | appid: Optional[str] = None, 51 | token: Optional[str] = None, 52 | private: bool = False, 53 | adapter: typing.Type[BotAdapterProtocol] = QQGuildBotInstance, 54 | ): 55 | if not appid: 56 | appid = random_code(10) 57 | 58 | super().__init__(appid, token, adapter, private) 59 | 60 | self.send_message = self.instance.send_message 61 | 62 | self.__closed = False 63 | 64 | SignalHandler.on_shutdown.append(self.close) 65 | 66 | async def start(self, launch_browser: typing.Union[bool, BrowserLaunchConfig] = False): 67 | TasksControl.start() 68 | 69 | if launch_browser: 70 | await basic_browser_service.launch(BrowserLaunchConfig() if launch_browser is True else launch_browser) 71 | 72 | self.run_timed_tasks() 73 | await self.instance.start(self.__message_handler) 74 | 75 | async def close(self): 76 | if not self.__closed: 77 | self.__closed = True 78 | await self.instance.close() 79 | 80 | async def __message_handler(self, data: Optional[Union[Message, Event, EventList]]): 81 | if not data: 82 | return False 83 | 84 | async with log.catch( 85 | desc='handler error:', 86 | ignore=[asyncio.TimeoutError, WaitEventCancel, WaitEventOutOfFocus], 87 | handler=self.__exception_handler(data), 88 | ): 89 | await message_handler(self, data) 90 | 91 | def __exception_handler(self, data: Union[Message, Event, EventList]): 92 | async def handler(err: Exception): 93 | if self.exception_handlers: 94 | subclass = type(err) 95 | if subclass not in self.exception_handlers: 96 | subclass = Exception 97 | 98 | for func in self.exception_handlers[subclass]: 99 | async with log.catch('exception handler error:'): 100 | await func(err, self.instance, data) 101 | 102 | return handler 103 | 104 | 105 | class MultipleAccounts(BotInstance): 106 | def __init__(self, *bots: AmiyaBot): 107 | super().__init__() 108 | 109 | self.__ready = False 110 | self.__instances: typing.Dict[str, AmiyaBot] = {str(item.appid): item for item in bots} 111 | self.__keep_alive = True 112 | 113 | SignalHandler.on_shutdown.append(self.close) 114 | 115 | def __iter__(self): 116 | return iter(self.__instances.values()) 117 | 118 | def __contains__(self, appid: typing.Union[str, int]): 119 | return str(appid) in self.__instances 120 | 121 | def __getitem__(self, appid: typing.Union[str, int]): 122 | return self.__instances.get(str(appid), None) 123 | 124 | def __delitem__(self, appid: typing.Union[str, int]): 125 | asyncio.create_task(self.__instances[str(appid)].close()) 126 | del self.__instances[str(appid)] 127 | 128 | async def start(self, launch_browser: typing.Union[bool, BrowserLaunchConfig] = False): 129 | assert not self.__ready, 'MultipleAccounts already started' 130 | 131 | self.__ready = True 132 | 133 | if self.__instances: 134 | await asyncio.wait( 135 | [self.append(item, start_up=False).start(launch_browser) for _, item in self.__instances.items()] 136 | ) 137 | 138 | while self.__keep_alive: 139 | await asyncio.sleep(1) 140 | 141 | def append( 142 | self, 143 | item: AmiyaBot, 144 | launch_browser: typing.Union[bool, BrowserLaunchConfig] = False, 145 | start_up: bool = True, 146 | ): 147 | assert self.__ready, 'MultipleAccounts not started' 148 | 149 | item.combine_factory(self) 150 | 151 | appid = str(item.appid) 152 | 153 | if appid not in self.__instances: 154 | self.__instances[appid] = item 155 | if start_up: 156 | asyncio.create_task(item.start(launch_browser)) 157 | 158 | return item 159 | 160 | async def close(self): 161 | for _, item in self.__instances.items(): 162 | await item.close() 163 | 164 | self.__keep_alive = False 165 | -------------------------------------------------------------------------------- /amiyabot/_assets/font/HarmonyOS_Sans_SC.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmiyaBot/Amiya-Bot-core/5bd2377dd78247500726d117918d074cf6de4bf1/amiyabot/_assets/font/HarmonyOS_Sans_SC.ttf -------------------------------------------------------------------------------- /amiyabot/_assets/font/font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Harmony'; 3 | src: url('./HarmonyOS_Sans_SC.ttf'); 4 | font-weight: normal; 5 | font-style: normal; 6 | } 7 | 8 | * { 9 | font-family: 'Harmony', serif !important 10 | } 11 | -------------------------------------------------------------------------------- /amiyabot/_assets/markdown/style/highlight/vs.min.css: -------------------------------------------------------------------------------- 1 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#fff;color:#000}.hljs-comment,.hljs-quote,.hljs-variable{color:green}.hljs-built_in,.hljs-keyword,.hljs-name,.hljs-selector-tag,.hljs-tag{color:#00f}.hljs-addition,.hljs-attribute,.hljs-literal,.hljs-section,.hljs-string,.hljs-template-tag,.hljs-template-variable,.hljs-title,.hljs-type{color:#a31515}.hljs-deletion,.hljs-meta,.hljs-selector-attr,.hljs-selector-pseudo{color:#2b91af}.hljs-doctag{color:grey}.hljs-attr{color:red}.hljs-bullet,.hljs-link,.hljs-symbol{color:#00b0e8}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700} -------------------------------------------------------------------------------- /amiyabot/_assets/markdown/style/highlight/vs2015.min.css: -------------------------------------------------------------------------------- 1 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#1e1e1e;color:#dcdcdc}.hljs-keyword,.hljs-literal,.hljs-name,.hljs-symbol{color:#569cd6}.hljs-link{color:#569cd6;text-decoration:underline}.hljs-built_in,.hljs-type{color:#4ec9b0}.hljs-class,.hljs-number{color:#b8d7a3}.hljs-meta .hljs-string,.hljs-string{color:#d69d85}.hljs-regexp,.hljs-template-tag{color:#9a5334}.hljs-formula,.hljs-function,.hljs-params,.hljs-subst,.hljs-title{color:#dcdcdc}.hljs-comment,.hljs-quote{color:#57a64a;font-style:italic}.hljs-doctag{color:#608b4e}.hljs-meta,.hljs-meta .hljs-keyword,.hljs-tag{color:#9b9b9b}.hljs-template-variable,.hljs-variable{color:#bd63c5}.hljs-attr,.hljs-attribute{color:#9cdcfe}.hljs-section{color:gold}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-bullet,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-selector-pseudo,.hljs-selector-tag{color:#d7ba7d}.hljs-addition{background-color:#144212;display:inline-block;width:100%}.hljs-deletion{background-color:#600;display:inline-block;width:100%} -------------------------------------------------------------------------------- /amiyabot/_assets/markdown/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | template 10 | 28 | 29 | 30 |
31 |
32 |
33 | 34 | 35 | 36 | 37 | 94 | 95 | -------------------------------------------------------------------------------- /amiyabot/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import socket 3 | import asyncio 4 | import websockets 5 | import contextlib 6 | 7 | from typing import Any, List, Union, Callable, Coroutine, Optional 8 | from websockets.legacy.client import WebSocketClientProtocol 9 | from amiyabot.typeIndexes import T_BotHandlerFactory 10 | from amiyabot.builtin.message import Event, EventList, Message, MessageCallback 11 | from amiyabot.builtin.messageChain import Chain 12 | from amiyalog import LoggerManager 13 | 14 | from .apiProtocol import BotInstanceAPIProtocol 15 | 16 | HANDLER_TYPE = Callable[[Optional[Union[Message, Event, EventList]]], Coroutine[Any, Any, None]] 17 | 18 | 19 | class BotAdapterProtocol: 20 | def __init__(self, appid: str, token: str, private: bool = False): 21 | self.appid = appid 22 | self.token = token 23 | self.alive = False 24 | self.keep_run = True 25 | 26 | self.private = private 27 | 28 | # 适配器实例连接信息 29 | self.host: Optional[str] = None 30 | self.ws_port: Optional[int] = None 31 | self.http_port: Optional[int] = None 32 | self.session: Optional[str] = None 33 | self.headers: Optional[dict] = None 34 | 35 | self.bot_name = '' 36 | 37 | self.log = LoggerManager(self.__str__()) 38 | self.bot: Optional[T_BotHandlerFactory] = None 39 | 40 | def __str__(self): 41 | return 'Adapter' 42 | 43 | def set_alive(self, status: bool): 44 | self.alive = status 45 | 46 | async def send_message( 47 | self, 48 | chain: Chain, 49 | user_id: str = '', 50 | channel_id: str = '', 51 | direct_src_guild_id: str = '', 52 | ): 53 | chain = await self.build_active_message_chain(chain, user_id, channel_id, direct_src_guild_id) 54 | 55 | async with self.bot.processing_context(chain): 56 | callback = await self.send_chain_message(chain, is_sync=True) 57 | 58 | return callback 59 | 60 | @contextlib.asynccontextmanager 61 | async def get_websocket_connection(self, mark: str, url: str, headers: Optional[dict] = None): 62 | async with WebSocketConnect(self, mark, url, headers) as ws: 63 | try: 64 | yield ws 65 | except WebSocketConnect.ignore_errors: 66 | pass 67 | 68 | @property 69 | def api(self): 70 | return BotInstanceAPIProtocol() 71 | 72 | @abc.abstractmethod 73 | async def close(self): 74 | """ 75 | 关闭此实例 76 | """ 77 | raise NotImplementedError 78 | 79 | @abc.abstractmethod 80 | async def start(self, handler: HANDLER_TYPE): 81 | """ 82 | 启动实例,执行 handler 方法处理消息 83 | 84 | :param handler: 消息处理方法 85 | """ 86 | raise NotImplementedError 87 | 88 | @abc.abstractmethod 89 | async def send_chain_message(self, chain: Chain, is_sync: bool = False) -> List[MessageCallback]: 90 | """ 91 | 使用 Chain 对象发送消息 92 | 93 | :param chain: Chain 对象 94 | :param is_sync: 是否同步发送消息 95 | :return: 如果是同步发送,则返回 MessageCallback 列表 96 | """ 97 | raise NotImplementedError 98 | 99 | @abc.abstractmethod 100 | async def build_active_message_chain( 101 | self, chain: Chain, user_id: str, channel_id: str, direct_src_guild_id: str 102 | ) -> Chain: 103 | """ 104 | 构建主动消息的 Chain 对象 105 | 106 | :param chain: 消息 Chain 对象 107 | :param user_id: 用户 ID 108 | :param channel_id: 子频道 ID 109 | :param direct_src_guild_id: 来源的频道 ID(私信时需要) 110 | :return: Chain 对象 111 | """ 112 | raise NotImplementedError 113 | 114 | @abc.abstractmethod 115 | async def recall_message(self, message_id: Union[str, int], data: Optional[Message] = None): 116 | """ 117 | 撤回消息 118 | 119 | :param message_id: 消息 ID 120 | :param data: Message 对象,可以是自定义的,仅需赋值属性 is_direct、user_id、guild_id 以及 channel_id 121 | """ 122 | raise NotImplementedError 123 | 124 | 125 | class ManualCloseException(Exception): 126 | def __str__(self): 127 | return 'ManualCloseException' 128 | 129 | 130 | class WebSocketConnect: 131 | ignore_errors = ( 132 | socket.gaierror, 133 | asyncio.CancelledError, 134 | asyncio.exceptions.TimeoutError, 135 | websockets.ConnectionClosedError, 136 | websockets.ConnectionClosedOK, 137 | websockets.InvalidStatusCode, 138 | ManualCloseException, 139 | ) 140 | 141 | def __init__(self, instance: BotAdapterProtocol, mark: str, url: str, headers: Optional[dict] = None): 142 | self.mark = mark 143 | self.url = url 144 | self.log = instance.log 145 | self.instance = instance 146 | self.headers = headers or {} 147 | 148 | self.connection: Optional[WebSocketClientProtocol] = None 149 | 150 | async def __aenter__(self) -> Optional[WebSocketClientProtocol]: 151 | self.log.info(f'connecting {self.mark}...') 152 | 153 | try: 154 | self.connection = await websockets.connect(self.url, extra_headers=self.headers) 155 | self.instance.set_alive(True) 156 | except self.ignore_errors as e: 157 | self.log.error(f'websocket connection({self.mark}) error: {repr(e)}') 158 | except ConnectionRefusedError: 159 | self.log.error(f'cannot connect to server.') 160 | 161 | return self.connection 162 | 163 | async def __aexit__(self, *args, **kwargs): 164 | if self.connection: 165 | await self.connection.close() 166 | 167 | if self.instance.alive: 168 | self.instance.set_alive(False) 169 | self.log.info(f'websocket connection({self.mark}) closed.') 170 | -------------------------------------------------------------------------------- /amiyabot/adapters/apiProtocol.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from typing import Optional, Union 4 | 5 | 6 | class BotInstanceAPIProtocol: 7 | @abc.abstractmethod 8 | async def get(self, url: str, params: Optional[dict] = None, *args, **kwargs): 9 | raise NotImplementedError 10 | 11 | @abc.abstractmethod 12 | async def post(self, url: str, data: Optional[Union[dict, list]] = None, *args, **kwargs): 13 | raise NotImplementedError 14 | 15 | @abc.abstractmethod 16 | async def request(self, url: str, method: str, *args, **kwargs): 17 | raise NotImplementedError 18 | 19 | async def get_user_avatar(self, *args, **kwargs): 20 | return '' 21 | 22 | 23 | class UnsupportedMethod(Exception): 24 | def __init__(self, text: str): 25 | self.text = text 26 | 27 | def __str__(self): 28 | return self.text 29 | -------------------------------------------------------------------------------- /amiyabot/adapters/comwechat/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from amiyabot.adapters import HANDLER_TYPE 4 | from amiyabot.adapters.onebot.v12 import OneBot12Instance 5 | from amiyabot.builtin.messageChain import Chain 6 | 7 | from .package import package_com_wechat_message 8 | from .builder import build_message_send, ComWeChatMessageCallback 9 | 10 | 11 | def com_wechat(host: str, ws_port: int, http_port: int): 12 | def adapter(appid: str, token: str): 13 | return ComWeChatBotInstance(appid, token, host, ws_port, http_port) 14 | 15 | return adapter 16 | 17 | 18 | class ComWeChatBotInstance(OneBot12Instance): 19 | def __str__(self): 20 | return 'ComWeChat' 21 | 22 | async def start(self, handler: HANDLER_TYPE): 23 | while self.keep_run: 24 | await self.keep_connect(handler, package_method=package_com_wechat_message) 25 | await asyncio.sleep(10) 26 | 27 | async def send_chain_message(self, chain: Chain, is_sync: bool = False): 28 | reply = await build_message_send(self.api, chain) 29 | 30 | res = [] 31 | request = await self.api.post('/', self.api.ob12_action('send_message', reply)) 32 | if request: 33 | res.append(request) 34 | 35 | return [ComWeChatMessageCallback(chain.data, self, item) for item in res] 36 | -------------------------------------------------------------------------------- /amiyabot/adapters/comwechat/builder.py: -------------------------------------------------------------------------------- 1 | from amiyabot.adapters import MessageCallback 2 | from amiyabot.adapters.apiProtocol import BotInstanceAPIProtocol 3 | from amiyabot.adapters.onebot.v12 import build_message_send as build_ob12 4 | from amiyabot.builtin.message import Message 5 | from amiyabot.builtin.messageChain import Chain 6 | from amiyabot.builtin.messageChain.element import * 7 | 8 | 9 | class ComWeChatMessageCallback(MessageCallback): 10 | async def recall(self): 11 | return False 12 | 13 | async def get_message(self) -> Optional[Message]: 14 | return None 15 | 16 | 17 | async def build_message_send(api: BotInstanceAPIProtocol, chain: Chain): 18 | async def handle_item(item: CHAIN_ITEM): 19 | # Face 20 | if isinstance(item, Face): 21 | return {'type': 'wx.emoji', 'data': {'file_id': item.face_id}} 22 | 23 | return await build_ob12(api, chain, handle_item) 24 | -------------------------------------------------------------------------------- /amiyabot/adapters/comwechat/package.py: -------------------------------------------------------------------------------- 1 | from amiyabot.adapters import BotAdapterProtocol 2 | from amiyabot.adapters.onebot.v12 import package_onebot12_message 3 | 4 | 5 | async def package_com_wechat_message(instance: BotAdapterProtocol, data: dict): 6 | msg = await package_onebot12_message(instance, data) 7 | 8 | if msg: 9 | if data['type'] == 'message': 10 | message_chain = data['message'] 11 | 12 | if message_chain: 13 | for chain in message_chain: 14 | chain_data = chain['data'] 15 | 16 | if chain['type'] == 'wx.emoji': 17 | msg.face.append(chain_data['file_id']) 18 | 19 | else: 20 | if data['detail_type']: 21 | if data['sub_type']: 22 | msg.append(instance, '{type}.{detail_type}.{sub_type}'.format(**data), data) 23 | 24 | return msg 25 | -------------------------------------------------------------------------------- /amiyabot/adapters/cqhttp/__init__.py: -------------------------------------------------------------------------------- 1 | from amiyabot.adapters.onebot.v11 import OneBot11Instance 2 | 3 | from .api import CQHttpAPI 4 | from .forwardMessage import CQHTTPForwardMessage 5 | 6 | 7 | def cq_http(host: str, ws_port: int, http_port: int): 8 | def adapter(appid: str, token: str): 9 | return CQHttpBotInstance(appid, token, host, ws_port, http_port) 10 | 11 | return adapter 12 | 13 | 14 | class CQHttpBotInstance(OneBot11Instance): 15 | def __str__(self): 16 | return 'CQHttp' 17 | 18 | @property 19 | def api(self): 20 | return CQHttpAPI(self.host, self.http_port, self.token) 21 | -------------------------------------------------------------------------------- /amiyabot/adapters/cqhttp/api.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from amiyabot.adapters.onebot.v11.api import OneBot11API 4 | from amiyabot.adapters.mirai.api import MiraiAPI 5 | 6 | 7 | class CQHttpAPI(OneBot11API): 8 | get_user_avatar = MiraiAPI.get_user_avatar 9 | 10 | async def send_cq_code(self, user_id: str, group_id: str = '', code: str = ''): 11 | await self.post( 12 | '/send_msg', 13 | { 14 | 'message_type': 'group' if group_id else 'private', 15 | 'user_id': user_id, 16 | 'group_id': group_id, 17 | 'message': code, 18 | }, 19 | ) 20 | 21 | async def send_group_forward_msg(self, group_id: str, forward_node: list): 22 | return await self.post('/send_group_forward_msg', {'group_id': group_id, 'messages': forward_node}) 23 | 24 | async def send_group_notice(self, group_id: str, content: str, image: Optional[str] = None): 25 | data = {'group_id': group_id, 'content': content} 26 | if image: 27 | data['image'] = image 28 | 29 | return await self.post('/set_group_notice', data) 30 | 31 | async def send_nudge(self, user_id: str, group_id: str): 32 | await self.send_cq_code(user_id, group_id, f'[CQ:poke,qq={user_id}]') 33 | -------------------------------------------------------------------------------- /amiyabot/adapters/cqhttp/forwardMessage.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from typing import Union, Optional 4 | from amiyabot.builtin.message import Message 5 | from amiyabot.builtin.messageChain import Chain 6 | from amiyabot.adapters.onebot.v11.builder import build_message_send, OneBot11MessageCallback 7 | 8 | from .api import CQHttpAPI 9 | 10 | 11 | class CQHTTPForwardMessage: 12 | def __init__(self, data: Message): 13 | self.data = data 14 | self.api: CQHttpAPI = data.instance.api 15 | self.node = [] 16 | 17 | async def add_message( 18 | self, chain: Union[Chain, list], user_id: Optional[int] = None, nickname: Optional[str] = None 19 | ): 20 | node = { 21 | 'type': 'node', 22 | 'data': { 23 | 'uin': user_id, 24 | 'name': nickname, 25 | 'content': chain if isinstance(chain, list) else [], 26 | }, 27 | } 28 | 29 | if isinstance(chain, Chain): 30 | if not chain.data: 31 | source = Message(self.data.instance) 32 | source.user_id = user_id 33 | source.nickname = nickname 34 | source.message_type = 'group' 35 | 36 | chain.data = source 37 | 38 | chain_data, voice_list, cq_codes = await build_message_send(chain, chain_only=True) 39 | 40 | node['data']['content'] = chain_data 41 | 42 | self.node.append(copy.deepcopy(node)) 43 | 44 | for _ in voice_list: 45 | node['data']['content'] = [{'type': 'text', 'data': {'text': '[语音]'}}] 46 | self.node.append(copy.deepcopy(node)) 47 | 48 | for item in cq_codes: 49 | node['data']['content'] = [{'type': 'text', 'data': {'text': item['message']}}] 50 | self.node.append(copy.deepcopy(node)) 51 | else: 52 | self.node.append(node) 53 | 54 | async def add_message_by_id(self, message_id: int): 55 | self.node.append({'type': 'node', 'data': {'id': message_id}}) 56 | 57 | async def send(self): 58 | chain = Chain() 59 | chain.raw_chain = self.node 60 | 61 | async with self.data.bot.processing_context(chain, self.data.factory_name): 62 | callback = OneBot11MessageCallback( 63 | self.data, 64 | self.data.instance, 65 | await self.api.send_group_forward_msg(self.data.channel_id, self.node), 66 | ) 67 | 68 | return callback 69 | -------------------------------------------------------------------------------- /amiyabot/adapters/kook/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | import json 3 | import asyncio 4 | import dataclasses 5 | 6 | from typing import Optional, Union 7 | from dataclasses import dataclass 8 | from amiyabot.builtin.message import Message 9 | from amiyabot.builtin.messageChain import Chain 10 | from amiyabot.adapters import BotAdapterProtocol, ManualCloseException, HANDLER_TYPE 11 | 12 | from .package import package_kook_message, RolePermissionCache 13 | from .builder import build_message_send, KOOKMessageCallback 14 | from .api import KOOKAPI, log 15 | 16 | 17 | class KOOKBotInstance(BotAdapterProtocol): 18 | def __init__(self, appid: str, token: str): 19 | super().__init__(appid, token) 20 | 21 | self.ws_url = '' 22 | self.connection = None 23 | 24 | self.pong = 0 25 | self.last_sn = 0 26 | 27 | self.bot_name = appid 28 | 29 | def __str__(self): 30 | return 'KOOK' 31 | 32 | @property 33 | def api(self): 34 | return KOOKAPI(self.token) 35 | 36 | @property 37 | def __still_alive(self): 38 | return self.keep_run and self.connection 39 | 40 | async def start(self, handler: HANDLER_TYPE): 41 | me_req = await self.api.get_me() 42 | if me_req: 43 | self.appid = me_req.json['data']['id'] 44 | self.bot_name = me_req.json['data']['username'] 45 | 46 | while self.keep_run: 47 | await self.__connect(handler) 48 | await asyncio.sleep(10) 49 | 50 | async def __connect(self, handler: HANDLER_TYPE): 51 | try: 52 | if not self.ws_url: 53 | log.info(f'requesting appid {self.appid} gateway') 54 | 55 | resp = await self.api.get('/gateway/index', params={'compress': 0}) 56 | if not resp: 57 | raise ManualCloseException 58 | 59 | self.ws_url = resp.json['data']['url'] 60 | 61 | async with self.get_websocket_connection(self.appid, self.ws_url) as websocket: 62 | if websocket: 63 | self.connection = websocket 64 | 65 | while self.__still_alive: 66 | await asyncio.sleep(0) 67 | 68 | recv = await websocket.recv() 69 | payload = WSPayload(**json.loads(recv)) 70 | 71 | if payload.sn is not None: 72 | self.last_sn = payload.sn 73 | 74 | if payload.s == 0: 75 | asyncio.create_task( 76 | handler( 77 | await self.package_message(payload.d), 78 | ), 79 | ) 80 | 81 | if payload.s == 1: 82 | if payload.d['code'] != 0: 83 | self.ws_url = '' 84 | self.last_sn = 0 85 | raise ManualCloseException 86 | 87 | log.info(f'connected({self.appid}): {self.bot_name}') 88 | 89 | if self.last_sn: 90 | log.info(f'resuming({self.appid})...') 91 | await self.connection.send(WSPayload(4, sn=self.last_sn).to_json()) 92 | 93 | self.session = payload.d['session_id'] 94 | asyncio.create_task(self.heartbeat_interval()) 95 | 96 | if payload.s == 3: 97 | self.pong = 1 98 | 99 | if payload.s == 5: 100 | self.ws_url = '' 101 | self.last_sn = 0 102 | await self.close_connection() 103 | 104 | if payload.s == 6: 105 | log.info(f'resume({self.appid}) done.') 106 | 107 | if payload.sn: 108 | self.last_sn = payload.sn 109 | 110 | finally: 111 | await self.close_connection() 112 | 113 | async def heartbeat_interval(self): 114 | sec = 0 115 | while self.__still_alive: 116 | await asyncio.sleep(1) 117 | sec += 1 118 | if sec >= 30: 119 | sec = 0 120 | await self.connection.send(WSPayload(2, sn=self.last_sn).to_json()) 121 | 122 | asyncio.create_task(self.wait_heartbeat()) 123 | 124 | async def wait_heartbeat(self): 125 | sec = 0 126 | while self.pong == 0 and self.__still_alive: 127 | await asyncio.sleep(1) 128 | sec += 1 129 | if sec >= 30: 130 | await self.close_connection() 131 | self.pong = 0 132 | 133 | async def close_connection(self): 134 | if self.connection: 135 | await self.connection.close() 136 | self.connection = None 137 | 138 | async def record_role_list(self, guild_id: str): 139 | if guild_id in RolePermissionCache.cache_create_time: 140 | if time.time() - RolePermissionCache.cache_create_time[guild_id] < 5: 141 | return 142 | 143 | res = await self.api.get('/guild-role/list', params={'guild_id': guild_id}, ignore_error=True) 144 | if res and res.json['code'] == 0: 145 | roles = {} 146 | for item in res.json['data']['items']: 147 | roles[item['role_id']] = item['permissions'] 148 | 149 | RolePermissionCache.guild_role[guild_id] = roles 150 | 151 | elif guild_id in RolePermissionCache.guild_role: 152 | del RolePermissionCache.guild_role[guild_id] 153 | 154 | RolePermissionCache.cache_create_time[guild_id] = time.time() 155 | 156 | async def close(self): 157 | log.info(f'closing {self}(appid {self.appid})...') 158 | self.keep_run = False 159 | await self.close_connection() 160 | 161 | async def package_message(self, message: dict): 162 | if message['type'] != 255: 163 | guild_id = message['extra'].get('guild_id', '') 164 | if guild_id: 165 | await self.record_role_list(guild_id) 166 | 167 | return await package_kook_message(self, message) 168 | 169 | async def send_chain_message(self, chain: Chain, is_sync: bool = False): 170 | message = await build_message_send(self.api, chain) 171 | callback = [] 172 | 173 | url = '/direct-message/create' if chain.data.is_direct else '/message/create' 174 | 175 | for item in [message]: 176 | payload = { 177 | 'target_id': chain.data.user_id if chain.data.is_direct else chain.data.channel_id, 178 | **item, 179 | } 180 | if chain.reference: 181 | payload['quote'] = chain.data.message_id 182 | 183 | res = await self.api.post(url, payload) 184 | if res: 185 | callback.append(KOOKMessageCallback(chain.data, self, res.json)) 186 | 187 | return callback 188 | 189 | async def build_active_message_chain(self, chain: Chain, user_id: str, channel_id: str, direct_src_guild_id: str): 190 | data = Message(self) 191 | 192 | data.user_id = user_id 193 | data.channel_id = channel_id 194 | 195 | if not channel_id and not user_id: 196 | raise TypeError('send_message() missing argument: "channel_id" or "user_id"') 197 | 198 | if not channel_id and user_id: 199 | data.is_direct = True 200 | 201 | message = Chain(data) 202 | message.chain = chain.chain 203 | message.builder = chain.builder 204 | 205 | return message 206 | 207 | async def recall_message(self, message_id: Union[str, int], data: Optional[Message] = None): 208 | await self.api.post('/message/delete', {'msg_id': message_id}) 209 | 210 | 211 | @dataclass 212 | class WSPayload: 213 | s: int 214 | d: Optional[dict] = None 215 | sn: Optional[int] = None 216 | extra: Optional[dict] = None 217 | 218 | def to_json(self): 219 | return json.dumps(dataclasses.asdict(self), ensure_ascii=False) 220 | -------------------------------------------------------------------------------- /amiyabot/adapters/kook/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from typing import Optional, Union 4 | from amiyabot.adapters.apiProtocol import BotInstanceAPIProtocol 5 | from amiyabot.network.httpRequests import http_requests 6 | from amiyalog import LoggerManager 7 | 8 | log = LoggerManager('KOOK') 9 | 10 | 11 | class KOOKAPI(BotInstanceAPIProtocol): 12 | def __init__(self, token): 13 | self.host = 'https://www.kookapp.cn/api/v3' 14 | self.token = token 15 | 16 | @property 17 | def headers(self): 18 | return {'Authorization': f'Bot {self.token}'} 19 | 20 | async def get(self, url: str, params: Optional[dict] = None, *args, **kwargs): 21 | return await http_requests.get( 22 | self.host + url, 23 | params, 24 | headers=self.headers, 25 | **kwargs, 26 | ) 27 | 28 | async def post(self, url: str, data: Optional[dict] = None, *args, **kwargs): 29 | return await http_requests.post( 30 | self.host + url, 31 | data, 32 | headers=self.headers, 33 | **kwargs, 34 | ) 35 | 36 | async def request(self, url: str, method: str, *args, **kwargs): 37 | return await http_requests.request( 38 | self.host + url, 39 | method, 40 | headers=self.headers, 41 | **kwargs, 42 | ) 43 | 44 | async def get_me(self): 45 | return await self.get('/user/me') 46 | 47 | async def get_message(self, message_id: str): 48 | return await self.get('/message/view', params={'msg_id': message_id}) 49 | 50 | async def get_user_info(self, user_id: str, group_id: Optional[str] = None): 51 | params = {'user_id': user_id} 52 | if group_id: 53 | params['guild_id'] = group_id 54 | 55 | return await self.get('/user/view', params=params) 56 | 57 | async def get_user_avatar(self, user_id: str, group_id: Optional[str] = None, *args, **kwargs) -> Optional[str]: 58 | res = await self.get_user_info(user_id, group_id) 59 | if res: 60 | data = json.loads(res) 61 | return data['data']['avatar'] 62 | 63 | async def create_asset(self, file: Union[str, bytes]): 64 | return await http_requests.post_form( 65 | self.host + '/asset/create', 66 | {'file': file}, 67 | headers=self.headers, 68 | ) 69 | -------------------------------------------------------------------------------- /amiyabot/adapters/kook/builder.py: -------------------------------------------------------------------------------- 1 | from amiyabot.adapters import MessageCallback 2 | from amiyabot.builtin.message import Message 3 | from amiyabot.builtin.messageChain import Chain 4 | from amiyabot.builtin.messageChain.element import * 5 | 6 | from .api import KOOKAPI, log 7 | 8 | 9 | class KOOKMessageCallback(MessageCallback): 10 | async def recall(self): 11 | if not self.response: 12 | log.warning('can not recall message because the response is None.') 13 | return False 14 | 15 | await self.instance.recall_message(self.response.json['data']['msg_id']) 16 | 17 | async def get_message(self): 18 | if not self.response: 19 | return None 20 | 21 | api: KOOKAPI = self.instance.api 22 | 23 | message = await api.get_message(self.response.json['data']['msg_id']) 24 | 25 | if message.json['code'] != 0: 26 | return None 27 | 28 | message_data = message.json['data'] 29 | user = message_data['author'] 30 | 31 | data = Message(self.instance, message_data) 32 | 33 | data.message_id = message_data['id'] 34 | data.message_type = 'GROUP' 35 | 36 | data.is_at = bool(message_data['mention']) 37 | data.at_target = message_data['mention'] 38 | 39 | data.user_id = user['id'] 40 | data.guild_id = message_data.get('guild_id', '') 41 | data.channel_id = message_data['channel_id'] 42 | data.nickname = user['nickname'] or user['username'] 43 | data.avatar = user['vip_avatar'] or user['avatar'] 44 | 45 | data.text = message_data['content'] 46 | 47 | return data 48 | 49 | 50 | async def build_message_send(api: KOOKAPI, chain: Chain, custom_chain: Optional[CHAIN_LIST] = None): 51 | chain_list = custom_chain or chain.chain 52 | 53 | message = { 54 | 'type': 9, 55 | 'content': '', 56 | } 57 | card_message = { 58 | 'type': 'card', 59 | 'theme': 'none', 60 | 'size': 'lg', 61 | 'modules': [], 62 | } 63 | 64 | use_card = len(chain_list) > 1 and len([n for n in chain_list if type(n) in [Image, Html, Extend]]) 65 | 66 | async def make_text_message(data): 67 | if use_card: 68 | if card_message['modules'] and card_message['modules'][-1]['type'] == 'section': 69 | card_message['modules'][-1]['text']['content'] += data 70 | return 71 | 72 | card_message['modules'].append( 73 | { 74 | 'type': 'section', 75 | 'text': {'type': 'kmarkdown', 'content': data}, 76 | } 77 | ) 78 | return 79 | 80 | message['content'] += data 81 | 82 | async def make_image_message(data): 83 | res = await api.create_asset(data) 84 | if res: 85 | async with log.catch('make image error'): 86 | url = res.json['data']['url'] 87 | 88 | if use_card: 89 | card_message['modules'].append( 90 | { 91 | 'type': 'container', 92 | 'elements': [{'type': 'image', 'src': url}], 93 | } 94 | ) 95 | else: 96 | message['type'] = 2 97 | message['content'] = url 98 | 99 | for item in chain_list: 100 | # At 101 | if isinstance(item, At): 102 | await make_text_message(f'(met){item.target}(met)') 103 | 104 | # AtAll 105 | if isinstance(item, AtAll): 106 | await make_text_message('(met)all(met)') 107 | 108 | # Face 109 | if isinstance(item, Face): 110 | await make_text_message(f'(emj){item.face_id}(emj)[{item.face_id}]') 111 | 112 | # Text 113 | if isinstance(item, Text): 114 | await make_text_message(item.content) 115 | 116 | # Image 117 | if isinstance(item, Image): 118 | await make_image_message(await item.get()) 119 | 120 | # Html 121 | if isinstance(item, Html): 122 | result = await item.create_html_image() 123 | if result: 124 | await make_image_message(result) 125 | 126 | # Extend 127 | if isinstance(item, Extend): 128 | if use_card: 129 | card_message['modules'].append(item.get()) 130 | else: 131 | message = item.get() 132 | 133 | return {'type': 10, 'content': json.dumps([card_message])} if use_card else message 134 | -------------------------------------------------------------------------------- /amiyabot/adapters/kook/package.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from typing import List, Dict 4 | from amiyabot.builtin.message import Event, Message, File 5 | from amiyabot.adapters import BotAdapterProtocol 6 | 7 | 8 | class RolePermissionCache: 9 | guild_role: Dict[str, Dict[str, int]] = {} 10 | cache_create_time: Dict[str, float] = {} 11 | 12 | 13 | async def package_kook_message(instance: BotAdapterProtocol, message: dict): 14 | if message['type'] == 255: 15 | return Event(instance, message['extra']['type'], message) 16 | 17 | extra: dict = message['extra'] 18 | user: dict = extra['author'] 19 | 20 | if user['bot']: 21 | return None 22 | 23 | t: int = extra['type'] 24 | 25 | data = Message(instance, message) 26 | 27 | data.message_id = message['msg_id'] 28 | data.message_type = message['channel_type'] 29 | 30 | data.is_at = instance.appid in extra['mention'] 31 | data.is_at_all = bool(extra.get('mention_all') or extra.get('mention_here')) 32 | data.is_direct = message['channel_type'] == 'PERSON' 33 | 34 | data.user_id = user['id'] 35 | data.guild_id = extra.get('guild_id', '') 36 | data.channel_id = message['target_id'] 37 | data.nickname = user['nickname'] or user['username'] 38 | data.avatar = user['vip_avatar'] or user['avatar'] 39 | 40 | if data.guild_id in RolePermissionCache.guild_role: 41 | for item in user['roles']: 42 | if item not in RolePermissionCache.guild_role[data.guild_id]: 43 | continue 44 | 45 | permission = RolePermissionCache.guild_role[data.guild_id][item] 46 | if permission & (1 << 0) == (1 << 0) or permission & (1 << 1) == (1 << 1): 47 | data.is_admin = True 48 | 49 | for item in extra.get('emoji', []): 50 | data.face.append(list(item.keys())[0]) 51 | 52 | for user_id in extra['mention']: 53 | data.at_target.append(user_id) 54 | 55 | text = '' 56 | 57 | if t == 2: 58 | data.image.append(message['content']) 59 | 60 | if t == 3: 61 | data.video = message['content'] 62 | 63 | if t == 9: 64 | text = extra['kmarkdown']['raw_content'] 65 | 66 | if t == 10: 67 | card: List[dict] = json.loads(message['content']) 68 | for item in card: 69 | modules: List[dict] = item.get('modules', []) 70 | 71 | for module in modules: 72 | if module['type'] == 'file' and module['canDownload']: 73 | data.files.append(File(module['src'], module['title'])) 74 | 75 | if extra.get('quote'): 76 | if extra['quote']['type'] == 2: 77 | data.image.append(extra['quote']['content']) 78 | 79 | data.set_text(text) 80 | return data 81 | -------------------------------------------------------------------------------- /amiyabot/adapters/mirai/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import asyncio 3 | import websockets 4 | 5 | from typing import Optional 6 | from amiyabot.adapters import BotAdapterProtocol, HANDLER_TYPE 7 | from amiyabot.builtin.message import Message 8 | from amiyabot.builtin.messageChain import Chain 9 | from amiyalog import LoggerManager 10 | 11 | from .forwardMessage import MiraiForwardMessage 12 | from .package import package_mirai_message 13 | from .builder import build_message_send, MiraiMessageCallback 14 | from .api import MiraiAPI 15 | 16 | log = LoggerManager('Mirai') 17 | 18 | 19 | def mirai_api_http(host: str, ws_port: int, http_port: int): 20 | def adapter(appid: str, token: str): 21 | return MiraiBotInstance(appid, token, host, ws_port, http_port) 22 | 23 | return adapter 24 | 25 | 26 | class MiraiBotInstance(BotAdapterProtocol): 27 | def __init__(self, appid: str, token: str, host: str, ws_port: int, http_port: int): 28 | super().__init__(appid, token) 29 | 30 | self.url = f'ws://{host}:{ws_port}/all?verifyKey={token}&&qq={appid}' 31 | 32 | self.connection: Optional[websockets.WebSocketClientProtocol] = None 33 | 34 | self.host = host 35 | self.ws_port = ws_port 36 | self.http_port = http_port 37 | 38 | self.session = None 39 | 40 | def __str__(self): 41 | return 'Mirai' 42 | 43 | @property 44 | def api(self): 45 | return MiraiAPI(self.host, self.http_port, self.session) 46 | 47 | async def close(self): 48 | log.info(f'closing {self}(appid {self.appid})...') 49 | self.keep_run = False 50 | 51 | if self.connection: 52 | await self.connection.close() 53 | 54 | async def start(self, handler: HANDLER_TYPE): 55 | while self.keep_run: 56 | await self.keep_connect(handler) 57 | await asyncio.sleep(10) 58 | 59 | async def keep_connect(self, handler: HANDLER_TYPE): 60 | mark = f'websocket({self.appid})' 61 | 62 | async with self.get_websocket_connection(mark, self.url) as websocket: 63 | if websocket: 64 | log.info(f'{mark} connect successful. waiting handshake...') 65 | self.connection = websocket 66 | 67 | while self.keep_run: 68 | message = await websocket.recv() 69 | 70 | if message == b'': 71 | await websocket.close() 72 | log.warning(f'{mark} mirai-api-http close the connection.') 73 | return None 74 | 75 | await self.handle_message(str(message), handler) 76 | 77 | await websocket.close() 78 | 79 | async def handle_message(self, message: str, handler: HANDLER_TYPE): 80 | async with log.catch(ignore=[json.JSONDecodeError]): 81 | data = json.loads(message) 82 | data = data['data'] 83 | 84 | if 'session' in data: 85 | self.session = data['session'] 86 | log.info(f'websocket({self.appid}) handshake successful. session: ' + self.session) 87 | return None 88 | 89 | asyncio.create_task( 90 | handler( 91 | package_mirai_message(self, self.appid, data), 92 | ), 93 | ) 94 | 95 | async def send_chain_message(self, chain: Chain, is_sync: bool = False): 96 | reply, voice_list = await build_message_send(self.api, chain, use_http=is_sync) 97 | 98 | res = [] 99 | 100 | for reply_list in [[reply], voice_list]: 101 | for item in reply_list: 102 | if is_sync: 103 | request = await self.api.post('/' + item[0], item[1]) 104 | res.append(request) 105 | else: 106 | await self.connection.send(item[1]) 107 | 108 | return [MiraiMessageCallback(chain.data, self, item) for item in res] 109 | 110 | async def build_active_message_chain(self, chain: Chain, user_id: str, channel_id: str, direct_src_guild_id: str): 111 | data = Message(self) 112 | 113 | data.user_id = user_id 114 | data.channel_id = channel_id 115 | data.message_type = 'group' 116 | 117 | if not channel_id and not user_id: 118 | raise TypeError('send_message() missing argument: "channel_id" or "user_id"') 119 | 120 | if not channel_id and user_id: 121 | data.message_type = 'friend' 122 | data.is_direct = True 123 | 124 | message = Chain(data) 125 | message.chain = chain.chain 126 | message.builder = chain.builder 127 | 128 | return message 129 | 130 | async def recall_message(self, message_id: str, data: Optional[Message] = None): 131 | await self.api.post( 132 | '/recall', 133 | { 134 | 'messageId': message_id, 135 | 'target': data.channel_id or data.user_id, 136 | }, 137 | ) 138 | -------------------------------------------------------------------------------- /amiyabot/adapters/mirai/api.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import hashlib 4 | 5 | from typing import Optional, Union 6 | from amiyabot.adapters.apiProtocol import BotInstanceAPIProtocol 7 | from amiyabot.network.download import download_async 8 | from amiyabot.network.httpRequests import http_requests 9 | 10 | from .payload import HttpAdapter 11 | 12 | 13 | class MiraiAPI(BotInstanceAPIProtocol): 14 | def __init__(self, host: str, port: int, session: str): 15 | self.session = session 16 | self.host = f'http://{host}:{port}' 17 | 18 | async def get(self, url: str, params: Optional[dict] = None, *args, **kwargs): 19 | return await http_requests.get( 20 | self.host + url, 21 | params, 22 | **kwargs, 23 | ) 24 | 25 | async def post(self, url: str, data: Optional[dict] = None, *args, **kwargs): 26 | return await http_requests.post( 27 | self.host + url, 28 | { 29 | 'sessionKey': self.session, 30 | **data, 31 | }, 32 | **kwargs, 33 | ) 34 | 35 | async def request(self, url: str, method: str, *args, **kwargs): 36 | return await http_requests.request( 37 | self.host + url, 38 | method, 39 | **kwargs, 40 | ) 41 | 42 | async def get_user_avatar(self, user_id: str, *args, **kwargs) -> Optional[str]: 43 | url = f'https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640' 44 | data = await download_async(url) 45 | if data and hashlib.md5(data).hexdigest() == 'acef72340ac0e914090bd35799f5594e': 46 | url = f'https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=100' 47 | 48 | return url 49 | 50 | async def upload(self, interface: str, field_type: str, file: bytes, msg_type: str): 51 | res = await http_requests.post_upload( 52 | self.host + interface, 53 | file, 54 | file_field=field_type, 55 | payload={'sessionKey': self.session, 'type': msg_type}, 56 | ) 57 | if res: 58 | return json.loads(res) 59 | 60 | async def upload_image(self, file: bytes, msg_type: str): 61 | res = await self.upload('/uploadImage', 'img', file, msg_type) 62 | if res and 'imageId' in res: 63 | return res['imageId'] 64 | 65 | async def upload_voice(self, file: bytes, msg_type: str): 66 | res = await self.upload('/uploadVoice', 'voice', file, msg_type) 67 | if res and 'voiceId' in res: 68 | return res['voiceId'] 69 | 70 | async def send_group_message(self, group_id: str, chain_list: list): 71 | return await self.post(*HttpAdapter.group_message(self.session, group_id, chain_list)) 72 | 73 | async def send_group_notice( 74 | self, 75 | group_id: str, 76 | content: str, 77 | send_to_new_member: Optional[bool] = None, 78 | pinned: Optional[bool] = None, 79 | show_edit_card: Optional[bool] = None, 80 | show_pop_up: Optional[bool] = None, 81 | require_confirm: Optional[bool] = None, 82 | image: Optional[Union[str, bytes]] = None, 83 | ) -> Optional[bool]: 84 | """发布群公告 85 | 86 | Args: 87 | group_id (str): 群号 88 | content (str): 公告内容 89 | 90 | 可选 - 91 | send_to_new_member (bool): 是否发送给新成员 92 | pinned (bool): 是否置顶 93 | show_edit_card (bool): 是否显示修改群名片引导 94 | show_pop_up (bool): 是否弹窗提示 95 | require_confirm (bool): 是否需要确认 96 | image (Union[str, bytes]): 图片链接或图片文件 97 | 98 | Returns: 99 | bool: 是否成功 100 | """ 101 | data = {'target': group_id, 'content': content} 102 | if image is not None: 103 | if isinstance(image, str): 104 | regex = re.compile( 105 | r'^(?:http|ftp)s?://' # http:// or https:// 106 | r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... 107 | r'localhost|' # localhost... 108 | r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip 109 | r'(?::\d+)?' # optional port 110 | r'(?:/?|[/?]\S+)$', 111 | re.IGNORECASE, 112 | ) 113 | if re.match(regex, image): 114 | data['imageUrl'] = image 115 | else: 116 | data['imagePath'] = image 117 | elif isinstance(image, bytes): 118 | data['imageBase64'] = image 119 | if send_to_new_member is not None: 120 | data['sendToNewMember'] = send_to_new_member 121 | if pinned is not None: 122 | data['pinned'] = pinned 123 | if show_edit_card is not None: 124 | data['showEditCard'] = show_edit_card 125 | if show_pop_up is not None: 126 | data['showPopup'] = show_pop_up 127 | if require_confirm is not None: 128 | data['requireConfirmation'] = require_confirm 129 | 130 | return await self.post('/anno/publish', data) 131 | 132 | async def send_nudge(self, user_id: str, group_id: str): 133 | await self.post(*HttpAdapter.nudge(self.session, user_id, group_id)) 134 | -------------------------------------------------------------------------------- /amiyabot/adapters/mirai/builder.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from typing import Type 4 | from graiax import silkcoder 5 | from amiyabot.adapters import MessageCallback 6 | from amiyabot.builtin.messageChain import Chain 7 | from amiyabot.builtin.messageChain.element import * 8 | from amiyautils import is_valid_url 9 | 10 | from .payload import WebsocketAdapter, HttpAdapter, MiraiPostPayload 11 | from .api import MiraiAPI 12 | 13 | 14 | class MiraiMessageCallback(MessageCallback): 15 | async def recall(self): 16 | if not self.response: 17 | log.warning('can not recall message because the response is None.') 18 | return False 19 | 20 | await self.instance.recall_message(self.response.json['messageId'], self.data) 21 | 22 | async def get_message(self): 23 | return None 24 | 25 | 26 | async def build_message_send( 27 | api: MiraiAPI, 28 | chain: Chain, 29 | custom_chain: Optional[CHAIN_LIST] = None, 30 | chain_only: bool = False, 31 | use_http: bool = False, 32 | ): 33 | chain_list = custom_chain or chain.chain 34 | chain_data = [] 35 | voice_list = [] 36 | 37 | payload_builder = HttpAdapter if use_http else WebsocketAdapter 38 | 39 | if chain_list: 40 | for item in chain_list: 41 | # At 42 | if isinstance(item, At): 43 | chain_data.append({'type': 'At', 'target': item.target or chain.data.user_id}) 44 | 45 | # AtAll 46 | if isinstance(item, AtAll): 47 | chain_data.append({'type': 'AtAll'}) 48 | 49 | # Face 50 | if isinstance(item, Face): 51 | chain_data.append({'type': 'Face', 'faceId': item.face_id}) 52 | 53 | # Text 54 | if isinstance(item, Text): 55 | chain_data.append({'type': 'Plain', 'text': item.content}) 56 | 57 | # Image 58 | if isinstance(item, Image): 59 | target = await item.get() 60 | if is_valid_url(target): 61 | chain_data.append({'type': 'Image', 'url': target}) 62 | else: 63 | chain_data.append( 64 | { 65 | 'type': 'Image', 66 | 'imageId': await get_image_id(api, target, chain.data.message_type), 67 | } 68 | ) 69 | 70 | # Voice 71 | if isinstance(item, Voice): 72 | voice_item = { 73 | 'type': 'Voice', 74 | 'voiceId': await get_voice_id(api, item.file, chain.data.message_type), 75 | } 76 | if chain_only: 77 | voice_list.append(voice_item) 78 | else: 79 | voice_list.append(select_type(chain, api.session, [voice_item], payload_builder)) 80 | 81 | # Html 82 | if isinstance(item, Html): 83 | result = await item.create_html_image() 84 | if result: 85 | chain_data.append( 86 | { 87 | 'type': 'Image', 88 | 'imageId': await get_image_id(api, result, chain.data.message_type), 89 | } 90 | ) 91 | 92 | # Extend 93 | if isinstance(item, Extend): 94 | chain_data.append(item.get()) 95 | 96 | if chain_only: 97 | return chain_data, voice_list 98 | 99 | return select_type(chain, api.session, chain_data, payload_builder), voice_list 100 | 101 | 102 | async def get_image_id(http: MiraiAPI, target: Union[str, bytes], msg_type: str): 103 | if isinstance(target, str): 104 | with open(target, mode='rb') as file: 105 | target = file.read() 106 | 107 | # 在图片里夹点私货,让 Mirai 返回不一样的 ID 108 | target += str(time.time()).encode() 109 | 110 | return await http.upload_image(target, msg_type) 111 | 112 | 113 | async def get_voice_id(http: MiraiAPI, path: str, msg_type: str): 114 | return await http.upload_voice(await silkcoder.async_encode(path, ios_adaptive=True), msg_type) 115 | 116 | 117 | def select_type( 118 | chain: Chain, 119 | session: str, 120 | chain_data: List[dict], 121 | payload_builder: Type[MiraiPostPayload], 122 | ): 123 | reply = None 124 | 125 | if chain_data: 126 | if chain.data.message_type == 'group': 127 | reply = payload_builder.group_message( 128 | session, 129 | chain.data.channel_id, 130 | chain_data, 131 | quote=chain.data.message_id if chain.reference else None, 132 | ) 133 | if chain.data.message_type == 'temp': 134 | reply = payload_builder.temp_message(session, chain.data.user_id, chain.data.channel_id, chain_data) 135 | if chain.data.message_type == 'friend': 136 | reply = payload_builder.friend_message(session, chain.data.user_id, chain_data) 137 | 138 | return reply 139 | -------------------------------------------------------------------------------- /amiyabot/adapters/mirai/forwardMessage.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Optional 2 | from amiyabot.builtin.message import Message 3 | from amiyabot.builtin.messageChain import Chain 4 | 5 | from .api import MiraiAPI 6 | from .builder import build_message_send, MiraiMessageCallback 7 | 8 | 9 | class MiraiForwardMessage: 10 | def __init__(self, data: Message): 11 | self.data = data 12 | self.api: MiraiAPI = data.instance.api 13 | self.node = {'type': 'Forward', 'nodeList': []} 14 | 15 | async def add_message( 16 | self, 17 | chain: Union[Chain, dict], 18 | user_id: Optional[int] = None, 19 | nickname: Optional[str] = None, 20 | time: int = 0, 21 | ): 22 | node = {'time': time, 'senderId': user_id, 'senderName': nickname} 23 | 24 | if isinstance(chain, Chain): 25 | if not chain.data: 26 | source = Message(self.data.instance) 27 | source.user_id = user_id 28 | source.nickname = nickname 29 | source.message_type = 'group' 30 | 31 | chain.data = source 32 | 33 | chain_data, voice_list = await build_message_send(self.api, chain, chain_only=True) 34 | 35 | node['senderId'] = chain.data.user_id 36 | node['senderName'] = chain.data.nickname 37 | 38 | self.node['nodeList'].append({**node, 'messageChain': chain_data}) 39 | for _ in voice_list: 40 | self.node['nodeList'].append({**node, 'messageChain': [{'type': 'Plain', 'text': '[语音]'}]}) 41 | else: 42 | self.node['nodeList'].append({**node, 'messageChain': [chain]}) 43 | 44 | async def add_message_by_id(self, message_id: int): 45 | self.node['nodeList'].append({'messageId': message_id}) 46 | 47 | async def add_message_by_ref(self, message_id: int, target: int): 48 | self.node['nodeList'].append({'messageRef': {'messageId': message_id, 'target': target}}) 49 | 50 | async def send(self): 51 | chain = Chain() 52 | chain.raw_chain = [self.node] 53 | 54 | async with self.data.bot.processing_context(chain, self.data.factory_name): 55 | callback = MiraiMessageCallback( 56 | self.data, 57 | self.data.instance, 58 | await self.api.send_group_message(self.data.channel_id, [self.node]), 59 | ) 60 | 61 | return callback 62 | -------------------------------------------------------------------------------- /amiyabot/adapters/mirai/package.py: -------------------------------------------------------------------------------- 1 | from amiyabot.builtin.message import Event, Message 2 | from amiyabot.adapters import BotAdapterProtocol 3 | 4 | 5 | def package_mirai_message(instance: BotAdapterProtocol, account: str, data: dict): 6 | if 'type' not in data: 7 | return None 8 | 9 | if data['type'] == 'FriendMessage': 10 | msg = Message(instance, data) 11 | msg.message_type = 'friend' 12 | msg.is_direct = True 13 | msg.nickname = data['sender']['nickname'] 14 | 15 | elif data['type'] in ['GroupMessage', 'TempMessage']: 16 | msg = Message(instance, data) 17 | msg.message_type = 'group' if data['type'] == 'GroupMessage' else 'temp' 18 | msg.channel_id = str(data['sender']['group']['id']) 19 | msg.nickname = data['sender']['memberName'] 20 | msg.is_admin = data['sender']['permission'] in ['OWNER', 'ADMINISTRATOR'] 21 | 22 | else: 23 | return Event(instance, data['type'], data) 24 | 25 | msg.user_id = str(data['sender']['id']) 26 | msg.avatar = f'https://q.qlogo.cn/headimg_dl?dst_uin={msg.user_id}&spec=100' 27 | 28 | message_chain = data['messageChain'] 29 | text = '' 30 | 31 | if message_chain: 32 | for chain in message_chain: 33 | if chain['type'] == 'Source': 34 | msg.message_id = chain['id'] 35 | 36 | if chain['type'] == 'At': 37 | if str(chain['target']) == str(account): 38 | msg.is_at = True 39 | else: 40 | msg.at_target.append(chain['target']) 41 | 42 | if chain['type'] == 'Plain': 43 | text += chain['text'].strip() 44 | 45 | if chain['type'] == 'Face': 46 | msg.face.append(chain['faceId']) 47 | 48 | if chain['type'] == 'Image': 49 | msg.image.append(chain['url'].strip()) 50 | 51 | msg.set_text(text) 52 | return msg 53 | -------------------------------------------------------------------------------- /amiyabot/adapters/mirai/payload.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import json 3 | 4 | from typing import List, Optional, Tuple, Union 5 | 6 | 7 | class MiraiPostPayload: 8 | @classmethod 9 | @abc.abstractmethod 10 | def builder( 11 | cls, 12 | command: str, 13 | sub_command: Optional[str] = None, 14 | content: Optional[dict] = None, 15 | options: Optional[dict] = None, 16 | ) -> Tuple[str, Union[dict, str]]: 17 | raise NotImplementedError('builder must be implemented when inheriting class GeneralDefinition.') 18 | 19 | @classmethod 20 | def friend_message(cls, session: str, target_id: str, chains: List[dict]): 21 | return cls.builder( 22 | 'sendFriendMessage', 23 | content={ 24 | 'sessionKey': session, 25 | 'target': target_id, 26 | 'messageChain': chains, 27 | }, 28 | ) 29 | 30 | @classmethod 31 | def group_message( 32 | cls, 33 | session: str, 34 | target_id: str, 35 | chains: List[dict], 36 | quote: Optional[int] = None, 37 | ): 38 | return cls.builder( 39 | 'sendGroupMessage', 40 | options={'quote': quote}, 41 | content={ 42 | 'sessionKey': session, 43 | 'target': target_id, 44 | 'messageChain': chains, 45 | 'quote': quote, 46 | }, 47 | ) 48 | 49 | @classmethod 50 | def temp_message(cls, session: str, target_id: str, group_id: str, chains: List[dict]): 51 | return cls.builder( 52 | 'sendTempMessage', 53 | content={ 54 | 'sessionKey': session, 55 | 'qq': target_id, 56 | 'group': group_id, 57 | 'messageChain': chains, 58 | }, 59 | ) 60 | 61 | @classmethod 62 | def mute(cls, session: str, target_id: str, member_id: str, time: int): 63 | return cls.builder( 64 | 'mute', 65 | content={ 66 | 'sessionKey': session, 67 | 'target': target_id, 68 | 'memberId': member_id, 69 | 'time': time, 70 | }, 71 | ) 72 | 73 | @classmethod 74 | def nudge(cls, session: str, target_id: str, group_id: str): 75 | return cls.builder( 76 | 'sendNudge', 77 | content={ 78 | 'sessionKey': session, 79 | 'target': target_id, 80 | 'subject': group_id, 81 | 'kind': 'Group', 82 | }, 83 | ) 84 | 85 | 86 | class WebsocketAdapter(MiraiPostPayload): 87 | @classmethod 88 | def builder( 89 | cls, 90 | command: str, 91 | sub_command: Optional[str] = None, 92 | content: Optional[dict] = None, 93 | options: Optional[dict] = None, 94 | sync_id: int = 1, 95 | ): 96 | return command, json.dumps( 97 | { 98 | 'syncId': sync_id, 99 | 'command': command, 100 | 'subCommand': sub_command, 101 | 'content': content, 102 | **(options or {}), 103 | }, 104 | ensure_ascii=False, 105 | ) 106 | 107 | 108 | class HttpAdapter(MiraiPostPayload): 109 | @classmethod 110 | def builder( 111 | cls, 112 | command: str, 113 | sub_command: Optional[str] = None, 114 | content: Optional[dict] = None, 115 | options: Optional[dict] = None, 116 | ): 117 | return command, content 118 | -------------------------------------------------------------------------------- /amiyabot/adapters/onebot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmiyaBot/Amiya-Bot-core/5bd2377dd78247500726d117918d074cf6de4bf1/amiyabot/adapters/onebot/__init__.py -------------------------------------------------------------------------------- /amiyabot/adapters/onebot/v11/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import asyncio 3 | import websockets 4 | 5 | from typing import Callable, Optional 6 | from amiyabot.adapters import BotAdapterProtocol, HANDLER_TYPE 7 | from amiyabot.builtin.message import Message 8 | from amiyabot.builtin.messageChain import Chain 9 | from amiyalog import LoggerManager 10 | 11 | from .package import package_onebot11_message 12 | from .builder import build_message_send, OneBot11MessageCallback 13 | from .api import OneBot11API 14 | 15 | log = LoggerManager('OneBot11') 16 | 17 | 18 | def onebot11(host: str, ws_port: int, http_port: int): 19 | def adapter(appid: str, token: str): 20 | return OneBot11Instance(appid, token, host, ws_port, http_port) 21 | 22 | return adapter 23 | 24 | 25 | class OneBot11Instance(BotAdapterProtocol): 26 | def __init__( 27 | self, 28 | appid: str, 29 | token: str, 30 | host: str, 31 | ws_port: int, 32 | http_port: int, 33 | ): 34 | super().__init__(appid, token) 35 | 36 | self.url = f'ws://{host}:{ws_port}/' 37 | self.headers = {'Authorization': f'Bearer {token}'} 38 | 39 | self.connection: Optional[websockets.WebSocketClientProtocol] = None 40 | 41 | self.host = host 42 | self.ws_port = ws_port 43 | self.http_port = http_port 44 | 45 | def __str__(self): 46 | return 'OneBot11' 47 | 48 | @property 49 | def api(self): 50 | return OneBot11API(self.host, self.http_port, self.token) 51 | 52 | async def close(self): 53 | log.info(f'closing {self}(appid {self.appid})...') 54 | self.keep_run = False 55 | 56 | if self.connection: 57 | await self.connection.close() 58 | 59 | async def start(self, handler: HANDLER_TYPE): 60 | while self.keep_run: 61 | await self.keep_connect(handler) 62 | await asyncio.sleep(10) 63 | 64 | async def keep_connect(self, handler: HANDLER_TYPE, package_method: Callable = package_onebot11_message): 65 | mark = f'websocket({self.appid})' 66 | 67 | async with self.get_websocket_connection(mark, self.url, self.headers) as websocket: 68 | if websocket: 69 | log.info(f'{mark} connect successful.') 70 | self.connection = websocket 71 | 72 | while self.keep_run: 73 | message = await websocket.recv() 74 | 75 | if message == b'': 76 | await websocket.close() 77 | log.warning(f'{mark} server already closed this connection.') 78 | return None 79 | 80 | async with log.catch(ignore=[json.JSONDecodeError]): 81 | asyncio.create_task( 82 | handler( 83 | await package_method(self, self.appid, json.loads(message)), 84 | ), 85 | ) 86 | 87 | await websocket.close() 88 | 89 | async def send_chain_message(self, chain: Chain, is_sync: bool = False): 90 | reply, voice_list, cq_codes = await build_message_send(chain) 91 | 92 | res = [] 93 | 94 | for reply_list in [[reply], cq_codes, voice_list]: 95 | for item in reply_list: 96 | if is_sync: 97 | request = await self.api.post('/send_msg', item) 98 | res.append(request) 99 | else: 100 | await self.connection.send(json.dumps({'action': 'send_msg', 'params': item})) 101 | 102 | return [OneBot11MessageCallback(chain.data, self, item) for item in res] 103 | 104 | async def build_active_message_chain(self, chain: Chain, user_id: str, channel_id: str, direct_src_guild_id: str): 105 | data = Message(self) 106 | 107 | data.user_id = user_id 108 | data.channel_id = channel_id 109 | data.message_type = 'group' 110 | 111 | if not channel_id and not user_id: 112 | raise TypeError('send_message() missing argument: "channel_id" or "user_id"') 113 | 114 | if not channel_id and user_id: 115 | data.message_type = 'private' 116 | data.is_direct = True 117 | 118 | message = Chain(data) 119 | message.chain = chain.chain 120 | message.builder = chain.builder 121 | 122 | return message 123 | 124 | async def recall_message(self, message_id: str, data: Optional[Message] = None): 125 | await self.api.post('/delete_msg', {'message_id': message_id}) 126 | -------------------------------------------------------------------------------- /amiyabot/adapters/onebot/v11/builder.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from graiax import silkcoder 4 | from amiyabot.adapters import MessageCallback 5 | from amiyabot.builtin.messageChain import Chain 6 | from amiyabot.builtin.messageChain.element import * 7 | from amiyautils import is_valid_url 8 | from amiyalog import logger as log 9 | 10 | from .api import OneBot11API 11 | from .package import package_onebot11_message 12 | 13 | 14 | class OneBot11MessageCallback(MessageCallback): 15 | async def recall(self): 16 | if not self.response: 17 | log.warning('can not recall message because the response is None.') 18 | return False 19 | 20 | await self.instance.recall_message(self.response.json['data']['message_id']) 21 | 22 | async def get_message(self): 23 | if not self.response: 24 | return None 25 | 26 | api: OneBot11API = self.instance.api 27 | 28 | message_id = self.response.json['data']['message_id'] 29 | 30 | message_res = await api.get_msg(message_id) 31 | message_data = message_res.json 32 | 33 | if message_data: 34 | return await package_onebot11_message( 35 | self.instance, 36 | '', 37 | {'post_type': 'message', **message_data['data']}, 38 | ) 39 | 40 | 41 | async def build_message_send(chain: Chain, chain_only: bool = False): 42 | chain_list = chain.chain 43 | chain_data = [] 44 | voice_list = [] 45 | cq_codes = [] 46 | 47 | if chain.reference and chain.data and chain.data.message_id: 48 | chain_data.append({'type': 'reply', 'data': {'id': chain.data.message_id}}) 49 | 50 | if chain_list: 51 | for item in chain_list: 52 | # At 53 | if isinstance(item, At): 54 | chain_data.append({'type': 'at', 'data': {'qq': item.target or chain.data.user_id}}) 55 | 56 | # AtAll 57 | if isinstance(item, AtAll): 58 | chain_data.append({'type': 'at', 'data': {'qq': 'all'}}) 59 | 60 | # Face 61 | if isinstance(item, Face): 62 | chain_data.append({'type': 'face', 'data': {'id': item.face_id}}) 63 | 64 | # Text 65 | if isinstance(item, Text): 66 | chain_data.append({'type': 'text', 'data': {'text': item.content}}) 67 | 68 | # Image 69 | if isinstance(item, Image): 70 | img = await item.get() 71 | chain_data.append(await append_image(img)) 72 | 73 | # Voice 74 | if isinstance(item, Voice): 75 | voice_item = await append_voice(item.file) 76 | if chain_only: 77 | voice_list.append(voice_item) 78 | else: 79 | voice_list.append(send_msg(chain, [voice_item])) 80 | 81 | # Html 82 | if isinstance(item, Html): 83 | result = await item.create_html_image() 84 | if result: 85 | chain_data.append(await append_image(result)) 86 | 87 | # Extend 88 | if isinstance(item, Extend): 89 | data = item.get() 90 | if isinstance(data, str): 91 | cq_codes.append(send_msg(chain, data)) 92 | else: 93 | chain_data.append(data) 94 | 95 | if chain_only: 96 | return chain_data, voice_list, cq_codes 97 | 98 | return send_msg(chain, chain_data), voice_list, cq_codes 99 | 100 | 101 | async def append_image(img: Union[bytes, str]): 102 | if isinstance(img, bytes): 103 | data = {'file': 'base64://' + base64.b64encode(img).decode()} 104 | elif is_valid_url(img): 105 | data = {'url': img} 106 | else: 107 | data = {'file': img} 108 | 109 | return {'type': 'image', 'data': data} 110 | 111 | 112 | async def append_voice(file: str): 113 | if os.path.exists(file): 114 | file = 'base64://' + base64.b64encode(await silkcoder.async_encode(file, ios_adaptive=True)).decode() 115 | 116 | return {'type': 'record', 'data': {'file': file}} 117 | 118 | 119 | def send_msg(chain: Chain, chain_data: Union[str, list]): 120 | msg_data = { 121 | 'message_type': chain.data.message_type, 122 | 'user_id': chain.data.user_id, 123 | 'group_id': chain.data.channel_id, 124 | 'message': chain_data, 125 | } 126 | msg_data = {k: v for k, v in msg_data.items() if v is not None and v != ''} 127 | 128 | return msg_data 129 | -------------------------------------------------------------------------------- /amiyabot/adapters/onebot/v11/package.py: -------------------------------------------------------------------------------- 1 | from amiyabot.builtin.message import Event, EventList, Message 2 | from amiyabot.adapters import BotAdapterProtocol 3 | 4 | 5 | async def package_onebot11_message(instance: BotAdapterProtocol, account: str, data: dict): 6 | if 'post_type' not in data: 7 | return None 8 | 9 | post_type = data['post_type'] 10 | 11 | if post_type == 'message': 12 | sender: dict = data['sender'] 13 | 14 | if data['message_type'] == 'private': 15 | msg = Message(instance, data) 16 | msg.message_type = 'private' 17 | msg.is_direct = True 18 | msg.nickname = sender.get('nickname') 19 | 20 | elif data['message_type'] == 'group': 21 | msg = Message(instance, data) 22 | msg.message_type = 'group' 23 | msg.channel_id = str(data['group_id']) 24 | msg.nickname = sender.get('card') or sender.get('nickname') 25 | msg.is_admin = sender.get('role') in ['owner', 'admin'] 26 | 27 | else: 28 | return None 29 | else: 30 | event_list = EventList([Event(instance, post_type, data)]) 31 | 32 | if post_type == 'meta_event': 33 | second_type = data['meta_event_type'] 34 | event_list.append(instance, f'meta_event.{second_type}', data) 35 | 36 | if second_type == 'lifecycle': 37 | event_list.append(instance, f'meta_event.{second_type}.' + data['sub_type'], data) 38 | 39 | elif post_type == 'request': 40 | second_type = data['request_type'] 41 | event_list.append(instance, f'request.{second_type}', data) 42 | 43 | elif post_type == 'notice': 44 | second_type = data['notice_type'] 45 | event_list.append(instance, f'notice.{second_type}', data) 46 | 47 | if second_type == 'notify': 48 | event_list.append(instance, f'notice.{second_type}.' + data['sub_type'], data) 49 | 50 | return event_list 51 | 52 | msg.message_id = str(data['message_id']) 53 | msg.user_id = str(data['sender']['user_id']) 54 | msg.avatar = await instance.api.get_user_avatar(data) 55 | 56 | message_chain = data['message'] 57 | text = '' 58 | 59 | if message_chain: 60 | for chain in message_chain: 61 | chain_data = chain['data'] 62 | 63 | if chain['type'] == 'at': 64 | if str(chain_data['qq']) == str(account): 65 | msg.is_at = True 66 | else: 67 | msg.at_target.append(chain_data['qq']) 68 | 69 | if chain['type'] == 'text': 70 | text += chain_data['text'].strip() 71 | 72 | if chain['type'] == 'face': 73 | msg.face.append(chain_data['id']) 74 | 75 | if chain['type'] == 'image': 76 | msg.image.append(chain_data['url'].strip()) 77 | 78 | msg.set_text(text) 79 | return msg 80 | -------------------------------------------------------------------------------- /amiyabot/adapters/onebot/v12/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import asyncio 3 | import websockets 4 | 5 | from typing import Callable, Optional 6 | from amiyabot.adapters import BotAdapterProtocol, HANDLER_TYPE 7 | from amiyabot.builtin.message import Message 8 | from amiyabot.builtin.messageChain import Chain 9 | from amiyalog import LoggerManager 10 | 11 | from .package import package_onebot12_message 12 | from .builder import build_message_send, OneBot12MessageCallback 13 | from .api import OneBot12API 14 | 15 | log = LoggerManager('OneBot12') 16 | 17 | 18 | def onebot12(host: str, ws_port: int, http_port: int): 19 | def adapter(appid: str, token: str): 20 | return OneBot12Instance(appid, token, host, ws_port, http_port) 21 | 22 | return adapter 23 | 24 | 25 | class OneBot12Instance(BotAdapterProtocol): 26 | def __init__( 27 | self, 28 | appid: str, 29 | token: str, 30 | host: str, 31 | ws_port: int, 32 | http_port: int, 33 | ): 34 | super().__init__(appid, token) 35 | 36 | self.url = f'ws://{host}:{ws_port}/' 37 | self.headers = {'Authorization': f'Bearer {token}'} 38 | 39 | self.connection: Optional[websockets.WebSocketClientProtocol] = None 40 | 41 | self.host = host 42 | self.ws_port = ws_port 43 | self.http_port = http_port 44 | 45 | def __str__(self): 46 | return 'OneBot12' 47 | 48 | @property 49 | def api(self): 50 | return OneBot12API(self.host, self.http_port, self.token) 51 | 52 | async def close(self): 53 | log.info(f'closing {self}(appid {self.appid})...') 54 | self.keep_run = False 55 | 56 | if self.connection: 57 | await self.connection.close() 58 | 59 | async def start(self, handler: HANDLER_TYPE): 60 | while self.keep_run: 61 | await self.keep_connect(handler) 62 | await asyncio.sleep(10) 63 | 64 | async def keep_connect(self, handler: HANDLER_TYPE, package_method: Callable = package_onebot12_message): 65 | mark = f'websocket({self.appid})' 66 | 67 | async with self.get_websocket_connection(mark, self.url, self.headers) as websocket: 68 | if websocket: 69 | log.info(f'{mark} connect successful.') 70 | self.connection = websocket 71 | 72 | while self.keep_run: 73 | message = await websocket.recv() 74 | 75 | if message == b'': 76 | await websocket.close() 77 | log.warning(f'{mark} server already closed this connection.') 78 | return None 79 | 80 | async with log.catch(ignore=[json.JSONDecodeError]): 81 | asyncio.create_task( 82 | handler( 83 | await package_method(self, json.loads(message)), 84 | ), 85 | ) 86 | 87 | await websocket.close() 88 | 89 | async def send_chain_message(self, chain: Chain, is_sync: bool = False): 90 | reply = await build_message_send(self.api, chain) 91 | 92 | res = [] 93 | request = await self.api.post('/', self.api.ob12_action('send_message', reply)) 94 | if request: 95 | res.append(request) 96 | 97 | return [OneBot12MessageCallback(chain.data, self, item) for item in res] 98 | 99 | async def build_active_message_chain(self, chain: Chain, user_id: str, channel_id: str, direct_src_guild_id: str): 100 | data = Message(self) 101 | 102 | data.user_id = user_id 103 | data.channel_id = channel_id 104 | data.message_type = 'group' 105 | 106 | if not channel_id and not user_id: 107 | raise TypeError('send_message() missing argument: "channel_id" or "user_id"') 108 | 109 | if not channel_id and user_id: 110 | data.message_type = 'private' 111 | data.is_direct = True 112 | 113 | message = Chain(data) 114 | message.chain = chain.chain 115 | message.builder = chain.builder 116 | 117 | return message 118 | 119 | async def recall_message(self, message_id: str, data: Optional[Message] = None): 120 | await self.api.post('/', self.api.ob12_action('delete_message', {'message_id': message_id})) 121 | -------------------------------------------------------------------------------- /amiyabot/adapters/onebot/v12/api.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from amiyabot.adapters.apiProtocol import BotInstanceAPIProtocol, UnsupportedMethod 3 | from amiyabot.network.httpRequests import http_requests 4 | 5 | 6 | class OneBot12API(BotInstanceAPIProtocol): 7 | def __init__(self, host: str, port: int, token: str): 8 | self.token = token 9 | self.host = f'http://{host}:{port}' 10 | 11 | @property 12 | def headers(self): 13 | return {'Authorization': f'Bearer {self.token}'} 14 | 15 | @staticmethod 16 | def ob12_action(action: str, params: dict): 17 | return {'action': action.strip('/'), 'params': params} 18 | 19 | async def get(self, url: str, params: Optional[dict] = None, *args, **kwargs): 20 | raise UnsupportedMethod('Unsupported "get" method') 21 | 22 | async def post(self, url: str, data: Optional[dict] = None, *args, **kwargs): 23 | return await http_requests.post( 24 | self.host + url, 25 | data, 26 | headers=self.headers, 27 | **kwargs, 28 | ) 29 | 30 | async def request(self, url: str, method: str, *args, **kwargs): 31 | raise UnsupportedMethod(f'Unsupported "{method}" method') 32 | 33 | async def get_file(self, file_id: str, file_type: str = 'url'): 34 | res = await self.post('/', self.ob12_action('get_file', {'file_id': file_id, 'type': file_type})) 35 | if res: 36 | data = res.json 37 | if data['status'] == 'ok': 38 | return data['data'].get(file_type) 39 | -------------------------------------------------------------------------------- /amiyabot/adapters/onebot/v12/builder.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from typing import Callable, Awaitable 4 | from amiyabot.adapters import MessageCallback 5 | from amiyabot.adapters.apiProtocol import BotInstanceAPIProtocol 6 | from amiyabot.builtin.messageChain import Chain 7 | from amiyabot.builtin.messageChain.element import * 8 | from amiyautils import is_valid_url, random_code 9 | from amiyalog import logger as log 10 | 11 | CUSTOM_CHAIN_ITEM = Callable[[CHAIN_ITEM], Awaitable[dict]] 12 | 13 | 14 | class OneBot12MessageCallback(MessageCallback): 15 | async def recall(self): 16 | if not self.response: 17 | log.warning('can not recall message because the response is None.') 18 | return False 19 | 20 | response = self.response.json 21 | 22 | if isinstance(response['data'], dict): 23 | await self.instance.recall_message(response['data']['message_id']) 24 | 25 | async def get_message(self): 26 | return None 27 | 28 | 29 | async def build_message_send(api: BotInstanceAPIProtocol, chain: Chain, custom: Optional[CUSTOM_CHAIN_ITEM] = None): 30 | chain_list = chain.chain 31 | chain_data = [] 32 | 33 | if chain_list: 34 | for item in chain_list: 35 | if custom: 36 | res = await custom(item) 37 | if res: 38 | chain_data.append(res) 39 | continue 40 | 41 | # At 42 | if isinstance(item, At): 43 | chain_data.append({'type': 'mention', 'data': {'user_id': item.target or chain.data.user_id}}) 44 | 45 | # AtAll 46 | if isinstance(item, AtAll): 47 | chain_data.append({'type': 'mention_all', 'data': {}}) 48 | 49 | # Face 50 | if isinstance(item, Face): 51 | ... 52 | 53 | # Text 54 | if isinstance(item, Text): 55 | chain_data.append({'type': 'text', 'data': {'text': item.content}}) 56 | 57 | # Image 58 | if isinstance(item, Image): 59 | img = await item.get() 60 | res = await append_image(api, img) 61 | if res: 62 | chain_data.append(res) 63 | 64 | # Voice 65 | if isinstance(item, Voice): 66 | ... 67 | 68 | # Html 69 | if isinstance(item, Html): 70 | result = await item.create_html_image() 71 | if result: 72 | res = await append_image(api, result) 73 | if res: 74 | chain_data.append(res) 75 | 76 | # Extend 77 | if isinstance(item, Extend): 78 | chain_data.append(item.get()) 79 | 80 | return { 81 | 'detail_type': chain.data.message_type, 82 | 'user_id': chain.data.user_id, 83 | 'group_id': chain.data.channel_id, 84 | 'message': chain_data, 85 | } 86 | 87 | 88 | async def append_image(api: BotInstanceAPIProtocol, img_data: Union[bytes, str]): 89 | if isinstance(img_data, bytes): 90 | data = {'type': 'data', 'data': base64.b64encode(img_data).decode()} 91 | elif is_valid_url(img_data): 92 | data = {'type': 'url', 'url': img_data} 93 | else: 94 | return None 95 | 96 | res = await api.post( 97 | '/', 98 | { 99 | 'action': 'upload_file', 100 | 'params': { 101 | 'name': f'{random_code(20)}.png', 102 | **data, 103 | }, 104 | }, 105 | ) 106 | if res: 107 | return {'type': 'image', 'data': {'file_id': res.json['data']['file_id']}} 108 | -------------------------------------------------------------------------------- /amiyabot/adapters/onebot/v12/package.py: -------------------------------------------------------------------------------- 1 | from amiyabot.builtin.message import Event, EventList, Message 2 | from amiyabot.adapters import BotAdapterProtocol 3 | 4 | from .api import OneBot12API 5 | 6 | 7 | async def package_onebot12_message(instance: BotAdapterProtocol, data: dict): 8 | message_type = data['type'] 9 | 10 | if message_type == 'message': 11 | msg = Message(instance, data) 12 | 13 | if data['detail_type'] == 'private': 14 | msg.is_direct = True 15 | msg.message_type = 'private' 16 | msg.nickname = str(data['user_id']) 17 | 18 | elif data['detail_type'] == 'group': 19 | msg.message_type = 'group' 20 | msg.channel_id = str(data['group_id']) 21 | msg.nickname = str(data['user_id']) 22 | 23 | else: 24 | return None 25 | 26 | msg.message_id = str(data['message_id']) 27 | msg.user_id = str(data['user_id']) 28 | msg.avatar = await instance.api.get_user_avatar(data) 29 | 30 | message_chain = data['message'] 31 | text = '' 32 | 33 | if message_chain: 34 | for chain in message_chain: 35 | chain_data = chain['data'] 36 | 37 | if chain['type'] == 'mention_all': 38 | msg.is_at_all = True 39 | 40 | if chain['type'] == 'mention': 41 | if str(chain_data['user_id']) == str(data['self']['user_id']): 42 | msg.is_at = True 43 | else: 44 | msg.at_target.append(chain_data['user_id']) 45 | 46 | if chain['type'] == 'text': 47 | text += chain_data['text'].strip() 48 | 49 | if chain['type'] == 'image': 50 | msg.image.append(await get_file(instance, chain_data)) 51 | 52 | if chain['type'] == 'file': 53 | msg.files.append(await get_file(instance, chain_data)) 54 | 55 | if chain['type'] == 'voice': 56 | msg.voice = await get_file(instance, chain_data) 57 | 58 | if chain['type'] == 'audio': 59 | msg.audio = await get_file(instance, chain_data) 60 | 61 | if chain['type'] == 'video': 62 | msg.video = await get_file(instance, chain_data) 63 | 64 | msg.set_text(text) 65 | return msg 66 | 67 | event_list = EventList([Event(instance, message_type, data)]) 68 | 69 | if data['detail_type']: 70 | event_list.append(instance, '{type}.{detail_type}'.format(**data), data) 71 | 72 | return event_list 73 | 74 | 75 | async def get_file(instance: BotAdapterProtocol, chain_data: dict): 76 | api: OneBot12API = instance.api 77 | 78 | return await api.get_file(chain_data['file_id']) 79 | -------------------------------------------------------------------------------- /amiyabot/adapters/tencent/__init__.py: -------------------------------------------------------------------------------- 1 | from .qqGuild import QQGuildBotInstance, QQGuildSandboxBotInstance 2 | 3 | 4 | class TencentBotInstance(QQGuildBotInstance): 5 | ... 6 | 7 | 8 | class TencentSandboxBotInstance(QQGuildSandboxBotInstance): 9 | ... 10 | 11 | 12 | # notice = ( 13 | # '"{name}" is deprecated and will be removed in future versions. ' 14 | # 'Please using "from amiyabot.adapters.tencent.qqGuild import {new_name}"' 15 | # ) 16 | # print('\n==== AmiyaBot Warning ======================================') 17 | # print(notice.format(name=TencentBotInstance.__name__, new_name=QQGuildBotInstance.__name__)) 18 | # print(notice.format(name=TencentSandboxBotInstance.__name__, new_name=QQGuildSandboxBotInstance.__name__)) 19 | # print('============================================================\n') 20 | -------------------------------------------------------------------------------- /amiyabot/adapters/tencent/intents.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html#websocket-%E6%96%B9%E5%BC%8F 3 | """ 4 | import enum 5 | 6 | 7 | class IntentsClass(enum.Enum): 8 | @classmethod 9 | def calc(cls): 10 | intent = 0 11 | for item in cls: 12 | intent |= item.value 13 | return intent 14 | 15 | 16 | class CommonIntents(IntentsClass): 17 | GUILDS = 1 << 0 18 | GUILD_MEMBERS = 1 << 1 19 | GUILD_MESSAGE_REACTIONS = 1 << 10 20 | DIRECT_MESSAGE = 1 << 12 21 | INTERACTION = 1 << 26 22 | MESSAGE_AUDIT = 1 << 27 23 | AUDIO_ACTION = 1 << 29 24 | 25 | 26 | class PublicIntents(IntentsClass): 27 | PUBLIC_GUILD_MESSAGES = 1 << 30 28 | 29 | 30 | class PrivateIntents(IntentsClass): 31 | GUILD_MESSAGES = 1 << 9 32 | FORUMS_EVENT = 1 << 28 33 | 34 | 35 | class GroupIntents(IntentsClass): 36 | GROUP_AND_C2C_EVENT = 1 << 25 37 | 38 | 39 | def get_intents(private: bool, name: str) -> int: 40 | if name == 'QQGroup': 41 | return GroupIntents.calc() 42 | 43 | res = CommonIntents.calc() 44 | 45 | if private: 46 | res |= PrivateIntents.calc() 47 | else: 48 | res |= PublicIntents.calc() 49 | 50 | if name == 'QQGlobal': 51 | res |= GroupIntents.calc() 52 | 53 | return res 54 | -------------------------------------------------------------------------------- /amiyabot/adapters/tencent/qqGlobal/__init__.py: -------------------------------------------------------------------------------- 1 | from amiyabot.builtin.messageChain import Chain, ChainBuilder 2 | from amiyabot.adapters.tencent.qqGuild import QQGuildBotInstance 3 | from amiyabot.adapters.tencent.qqGroup import QQGroupBotInstance 4 | 5 | from .package import package_qq_global_message 6 | 7 | 8 | class QQGlobalBotInstance(QQGroupBotInstance): 9 | def __init__( 10 | self, 11 | appid: str, 12 | token: str, 13 | client_secret: str, 14 | default_chain_builder: ChainBuilder, 15 | shard_index: int, 16 | shards: int, 17 | ): 18 | super().__init__(appid, token, client_secret, default_chain_builder, shard_index, shards) 19 | 20 | self.guild = QQGuildBotInstance(appid, token, shard_index, shards) 21 | 22 | def __str__(self): 23 | return 'QQGlobal' 24 | 25 | @property 26 | def package_method(self): 27 | return package_qq_global_message 28 | 29 | async def send_chain_message(self, chain: Chain, is_sync: bool = False): 30 | if not (chain.data.channel_openid or chain.data.user_openid): 31 | return await self.guild.send_chain_message(chain, is_sync) 32 | return await super().send_chain_message(chain, is_sync) 33 | 34 | 35 | qq_global = QQGlobalBotInstance.build_adapter 36 | -------------------------------------------------------------------------------- /amiyabot/adapters/tencent/qqGlobal/package.py: -------------------------------------------------------------------------------- 1 | from amiyabot.adapters import BotAdapterProtocol 2 | from amiyabot.adapters.tencent.qqGroup.package import package_qq_group_message 3 | from amiyabot.adapters.tencent.qqGuild.package import package_qq_guild_message 4 | 5 | 6 | async def package_qq_global_message( 7 | instance: BotAdapterProtocol, 8 | event: str, 9 | message: dict, 10 | is_reference: bool = False, 11 | ): 12 | group_message_created = [ 13 | 'C2C_MESSAGE_CREATE', 14 | 'GROUP_AT_MESSAGE_CREATE', 15 | ] 16 | 17 | if event in group_message_created: 18 | return await package_qq_group_message(instance, event, message, is_reference) 19 | 20 | return await package_qq_guild_message(instance, event, message, is_reference) 21 | -------------------------------------------------------------------------------- /amiyabot/adapters/tencent/qqGroup/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from typing import Optional 4 | from amiyabot.builtin.messageChain import Chain, ChainBuilder 5 | from amiyabot.adapters import HANDLER_TYPE 6 | from amiyabot.adapters.tencent.qqGuild import QQGuildBotInstance 7 | 8 | from .api import QQGroupAPI, log 9 | from .builder import ( 10 | QQGroupMessageCallback, 11 | QQGroupChainBuilder, 12 | QQGroupChainBuilderOptions, 13 | SeqService, 14 | build_message_send, 15 | ) 16 | from .package import package_qq_group_message 17 | 18 | 19 | class QQGroupBotInstance(QQGuildBotInstance): 20 | def __init__( 21 | self, 22 | appid: str, 23 | token: str, 24 | client_secret: str, 25 | default_chain_builder: ChainBuilder, 26 | shard_index: int, 27 | shards: int, 28 | ): 29 | super().__init__(appid, token, shard_index, shards) 30 | 31 | self.__access_token_api = QQGroupAPI(self.appid, self.token, client_secret) 32 | self.__default_chain_builder = default_chain_builder 33 | self.__seq_service = SeqService() 34 | 35 | def __str__(self): 36 | return 'QQGroup' 37 | 38 | @classmethod 39 | def build_adapter( 40 | cls, 41 | client_secret: str, 42 | default_chain_builder: Optional[ChainBuilder] = None, 43 | default_chain_builder_options: QQGroupChainBuilderOptions = QQGroupChainBuilderOptions(), 44 | shard_index: int = 0, 45 | shards: int = 1, 46 | ): 47 | def adapter(appid: str, token: str): 48 | if default_chain_builder: 49 | cb = default_chain_builder 50 | else: 51 | cb = QQGroupChainBuilder(default_chain_builder_options) 52 | 53 | return cls(appid, token, client_secret, cb, shard_index, shards) 54 | 55 | return adapter 56 | 57 | @property 58 | def api(self): 59 | return self.__access_token_api 60 | 61 | @property 62 | def package_method(self): 63 | return package_qq_group_message 64 | 65 | async def start(self, handler: HANDLER_TYPE): 66 | if hasattr(self.__default_chain_builder, 'start'): 67 | self.__default_chain_builder.start() 68 | 69 | if not self.__seq_service.alive: 70 | asyncio.create_task(self.__seq_service.run()) 71 | 72 | await super().start(handler) 73 | 74 | async def close(self): 75 | await self.__seq_service.stop() 76 | await super().close() 77 | 78 | async def send_chain_message(self, chain: Chain, is_sync: bool = False): 79 | if not isinstance(chain.builder, QQGroupChainBuilder): 80 | chain.builder = self.__default_chain_builder 81 | 82 | payloads = await build_message_send(self.api, chain, self.__seq_service) 83 | res = [] 84 | 85 | for payload in payloads: 86 | async with log.catch('post error:', ignore=[asyncio.TimeoutError]): 87 | res.append( 88 | await ( 89 | self.api.post_private_message( 90 | chain.data.user_openid, 91 | payload, 92 | ) 93 | if chain.data.is_direct 94 | else self.api.post_group_message( 95 | chain.data.channel_openid, 96 | payload, 97 | ) 98 | ) 99 | ) 100 | 101 | return [QQGroupMessageCallback(chain.data, self, item) for item in res] 102 | 103 | 104 | qq_group = QQGroupBotInstance.build_adapter 105 | -------------------------------------------------------------------------------- /amiyabot/adapters/tencent/qqGroup/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import requests 4 | 5 | from ..qqGuild.api import QQGuildAPI, log 6 | 7 | 8 | class QQGroupAPI(QQGuildAPI): 9 | def __init__(self, appid: str, token: str, client_secret: str): 10 | super().__init__(appid, token) 11 | 12 | self.appid = appid 13 | self.client_secret = client_secret 14 | self.access_token = '' 15 | self.expires_time = 0 16 | 17 | @property 18 | def headers(self): 19 | if not self.access_token or self.expires_time - time.time() <= 60: 20 | try: 21 | res = requests.post( 22 | url='https://bots.qq.com/app/getAppAccessToken', 23 | data=json.dumps( 24 | { 25 | 'appId': self.appid, 26 | 'clientSecret': self.client_secret, 27 | } 28 | ), 29 | headers={ 30 | 'Content-Type': 'application/json', 31 | }, 32 | timeout=3, 33 | ) 34 | data = json.loads(res.text) 35 | 36 | self.access_token = data['access_token'] 37 | self.expires_time = int(time.time()) + int(data['expires_in']) 38 | 39 | except Exception as e: 40 | log.error(e, desc='accessToken requests error:') 41 | 42 | return { 43 | 'Authorization': f'QQBot {self.access_token}', 44 | 'X-Union-Appid': f'{self.appid}', 45 | } 46 | 47 | @property 48 | def domain(self): 49 | return 'https://api.sgroup.qq.com' 50 | 51 | async def upload_file( 52 | self, 53 | openid: str, 54 | file_type: int, 55 | url: str, 56 | srv_send_msg: bool = False, 57 | is_direct: bool = False, 58 | ): 59 | return await self.post( 60 | f'/v2/users/{openid}/files' if is_direct else f'/v2/groups/{openid}/files', 61 | { 62 | 'file_type': file_type, 63 | 'url': url, 64 | 'srv_send_msg': srv_send_msg, 65 | }, 66 | ) 67 | 68 | async def post_group_message(self, channel_openid: str, payload: dict): 69 | return await self.post(f'/v2/groups/{channel_openid}/messages', payload) 70 | 71 | async def post_private_message(self, user_openid: str, payload: dict): 72 | return await self.post(f'/v2/users/{user_openid}/messages', payload) 73 | -------------------------------------------------------------------------------- /amiyabot/adapters/tencent/qqGroup/package.py: -------------------------------------------------------------------------------- 1 | from amiyabot.builtin.message import Event, Message 2 | from amiyabot.adapters import BotAdapterProtocol 3 | 4 | 5 | async def package_qq_group_message(instance: BotAdapterProtocol, event: str, message: dict, is_reference: bool = False): 6 | message_created = [ 7 | 'C2C_MESSAGE_CREATE', 8 | 'GROUP_AT_MESSAGE_CREATE', 9 | ] 10 | if event in message_created: 11 | data = Message(instance, message) 12 | data.is_direct = event == 'C2C_MESSAGE_CREATE' 13 | data.is_at = True 14 | 15 | data.message_id = message['id'] 16 | data.user_id = message['author']['id'] 17 | 18 | if data.is_direct: 19 | data.user_openid = message['author']['user_openid'] 20 | else: 21 | data.user_openid = message['author']['member_openid'] 22 | data.channel_id = message['group_id'] 23 | data.channel_openid = message['group_openid'] 24 | 25 | if 'attachments' in message: 26 | for item in message['attachments']: 27 | if item['content_type'].startswith('image'): 28 | data.image.append(item['url']) 29 | 30 | if 'content' in message: 31 | data.set_text(message['content']) 32 | 33 | return data 34 | 35 | return Event(instance, event, message) 36 | -------------------------------------------------------------------------------- /amiyabot/adapters/tencent/qqGuild/builder.py: -------------------------------------------------------------------------------- 1 | from amiyabot.adapters import MessageCallback 2 | from amiyabot.builtin.messageChain import Chain 3 | from amiyabot.builtin.messageChain.element import * 4 | 5 | from .api import QQGuildAPI, MessageSendRequest 6 | from .package import package_qq_guild_message 7 | 8 | 9 | class QQGuildMessageCallback(MessageCallback): 10 | async def recall(self): 11 | if not self.response: 12 | log.warning('can not recall message because the response is None.') 13 | return False 14 | 15 | await self.instance.recall_message(self.response.json['id'], self.data) 16 | 17 | async def get_message(self): 18 | if not self.response: 19 | return None 20 | 21 | api: QQGuildAPI = self.instance.api 22 | 23 | response = self.response.json 24 | message = await api.get_message(response['channel_id'], response['id']) 25 | data = message.json['message'] 26 | 27 | if isinstance(data, dict): 28 | return await package_qq_guild_message(self.instance, 'MESSAGE_CREATE', data, True) 29 | 30 | 31 | class MessageSendRequestGroup: 32 | def __init__(self, user_id: str, message_id: str, reference: bool, direct: bool): 33 | self.req_list: List[MessageSendRequest] = [] 34 | 35 | self.text: str = '' 36 | self.user_id: str = user_id 37 | self.message_id: str = message_id 38 | self.reference: bool = reference 39 | self.direct: bool = direct 40 | 41 | def __insert_req(self, content: str = '', image: Optional[Union[str, bytes]] = None, data: Optional[dict] = None): 42 | req = MessageSendRequest( 43 | data={ 44 | 'msg_id': self.message_id, 45 | **(data or {}), 46 | }, 47 | direct=self.direct, 48 | user_id=self.user_id, 49 | ) 50 | 51 | if content: 52 | req.data['content'] = content 53 | 54 | if isinstance(image, str): 55 | req.data['image'] = image 56 | 57 | if isinstance(image, bytes): 58 | req.data['file_image'] = image 59 | req.upload_image = True 60 | 61 | if self.reference: 62 | req.data['message_reference'] = { 63 | 'message_id': self.message_id, 64 | 'ignore_get_message_error': False, 65 | } 66 | 67 | self.req_list.append(req) 68 | 69 | def add_text(self, text: str): 70 | if self.req_list: 71 | req_data = self.req_list[-1].data 72 | 73 | if 'embed' not in req_data and 'ark' not in req_data: 74 | if 'content' not in req_data: 75 | req_data['content'] = '' 76 | 77 | req_data['content'] += text 78 | return None 79 | 80 | self.text += text 81 | 82 | def add_image(self, image: Union[str, bytes]): 83 | self.__insert_req(content=self.text, image=image) 84 | self.text = '' 85 | 86 | def add_data(self, data: Optional[dict]): 87 | self.done() 88 | self.__insert_req(data=data) 89 | 90 | def done(self): 91 | if self.text: 92 | self.__insert_req(content=self.text) 93 | self.text = '' 94 | 95 | 96 | async def build_message_send(chain: Chain, custom_chain: Optional[CHAIN_LIST] = None): 97 | chain_list = custom_chain or chain.chain 98 | 99 | messages = MessageSendRequestGroup( 100 | chain.data.user_id, 101 | chain.data.message_id, 102 | chain.reference, 103 | chain.data.is_direct, 104 | ) 105 | 106 | for item in chain_list: 107 | # At 108 | if isinstance(item, At): 109 | messages.add_text(f'<@{item.target}>') 110 | 111 | # AtAll 112 | if isinstance(item, AtAll): 113 | messages.add_text('<@everyone>') 114 | 115 | # Tag 116 | if isinstance(item, Tag): 117 | messages.add_text(f'<#{item.target}>') 118 | 119 | # Face 120 | if isinstance(item, Face): 121 | messages.add_text(f'') 122 | 123 | # Text 124 | if isinstance(item, Text): 125 | messages.add_text(item.content) 126 | 127 | # Image 128 | if isinstance(item, Image): 129 | messages.add_image(await item.get()) 130 | 131 | # Html 132 | if isinstance(item, Html): 133 | result = await item.create_html_image() 134 | if result: 135 | messages.add_image(result) 136 | 137 | # Embed 138 | if isinstance(item, Embed): 139 | messages.add_data(item.get()) 140 | 141 | # Ark 142 | if isinstance(item, Ark): 143 | messages.add_data(item.get()) 144 | 145 | # Markdown 146 | if isinstance(item, Markdown): 147 | messages.add_data(item.get()) 148 | 149 | messages.done() 150 | 151 | return messages 152 | -------------------------------------------------------------------------------- /amiyabot/adapters/tencent/qqGuild/model.py: -------------------------------------------------------------------------------- 1 | import json 2 | import dataclasses 3 | 4 | from typing import Any, Optional 5 | from dataclasses import dataclass 6 | from websockets.legacy.client import WebSocketClientProtocol 7 | from amiyabot.adapters import HANDLER_TYPE 8 | 9 | 10 | @dataclass 11 | class GateWay: 12 | url: str 13 | shards: int 14 | session_start_limit: dict 15 | 16 | 17 | @dataclass 18 | class ConnectionHandler: 19 | private: bool 20 | gateway: GateWay 21 | message_handler: HANDLER_TYPE 22 | 23 | 24 | @dataclass 25 | class ConnectionModel: 26 | session_id: Optional[str] = None 27 | last_s: Optional[int] = None 28 | reconnect_limit: int = 3 29 | connection: Optional[WebSocketClientProtocol] = None 30 | heartbeat_key: Optional[str] = None 31 | 32 | 33 | @dataclass 34 | class Payload: 35 | op: int 36 | id: Optional[str] = None 37 | d: Optional[Any] = None 38 | s: Optional[int] = None 39 | t: Optional[str] = None 40 | 41 | def to_json(self): 42 | return json.dumps(dataclasses.asdict(self), ensure_ascii=False) 43 | -------------------------------------------------------------------------------- /amiyabot/adapters/tencent/qqGuild/package.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from amiyabot.builtin.message import Event, Message 4 | from amiyabot.adapters import BotAdapterProtocol 5 | 6 | from .api import QQGuildAPI 7 | 8 | ADMIN = ['2', '4', '5'] 9 | 10 | 11 | async def package_qq_guild_message(instance: BotAdapterProtocol, event: str, message: dict, is_reference: bool = False): 12 | message_created = [ 13 | 'MESSAGE_CREATE', 14 | 'AT_MESSAGE_CREATE', 15 | 'DIRECT_MESSAGE_CREATE', 16 | ] 17 | if event in message_created: 18 | if 'bot' in message['author'] and message['author']['bot'] and not is_reference: 19 | return None 20 | 21 | data = get_info(Message(instance, message), message) 22 | data.is_direct = 'direct_message' in message and message['direct_message'] 23 | 24 | api: QQGuildAPI = instance.api 25 | bot = await api.get_me() 26 | 27 | if not data.is_direct: 28 | channel = await api.get_channel(data.channel_id) 29 | if not channel: 30 | return None 31 | 32 | if 'member' in message: 33 | if 'roles' in message['member'] and [n for n in message['member']['roles'] if n in ADMIN]: 34 | data.is_admin = True 35 | 36 | if 'attachments' in message: 37 | for item in message['attachments']: 38 | data.image.append('http://' + item['url']) 39 | 40 | if 'content' in message: 41 | text = message['content'] 42 | 43 | if 'mentions' in message and message['mentions']: 44 | for user in message['mentions']: 45 | text = text.replace('<@!{id}>'.format(**user), '') 46 | 47 | if bot and user['id'] == bot.json['id']: 48 | data.is_at = True 49 | continue 50 | 51 | if user['bot']: 52 | continue 53 | 54 | data.at_target.append(user['id']) 55 | 56 | face_list = re.findall(r'', text) 57 | if face_list: 58 | for fid in face_list: 59 | data.face.append(fid) 60 | 61 | data.set_text(text) 62 | 63 | if 'message_reference' in message: 64 | reference = await api.get_message(message['channel_id'], message['message_reference']['message_id']) 65 | if reference: 66 | reference_data = await package_qq_guild_message(instance, event, reference.json['message'], True) 67 | if reference_data: 68 | data.image += reference_data.image 69 | 70 | return data 71 | 72 | return Event(instance, event, message) 73 | 74 | 75 | def get_info(obj: Message, message: dict): 76 | author = message['author'] 77 | 78 | obj.message_id = message['id'] 79 | obj.user_id = author['id'] 80 | obj.guild_id = message['guild_id'] 81 | obj.src_guild_id = message['src_guild_id'] if 'src_guild_id' in message else message['guild_id'] 82 | obj.channel_id = message['channel_id'] 83 | obj.nickname = author['username'] 84 | obj.avatar = author['avatar'] if 'avatar' in author else None 85 | 86 | return obj 87 | -------------------------------------------------------------------------------- /amiyabot/adapters/test/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from amiyabot.adapters import BotAdapterProtocol, HANDLER_TYPE 4 | from amiyabot.builtin.message import Message, MessageCallback 5 | from amiyabot.builtin.messageChain import Chain 6 | from amiyalog import LoggerManager 7 | 8 | from .builder import build_message_send 9 | from .server import TestServer 10 | 11 | log = LoggerManager('Test') 12 | 13 | 14 | def test_instance(host: str = '127.0.0.1', port: int = 32001): 15 | def adapter(appid: str, _): 16 | return TestInstance(appid, host, port) 17 | 18 | return adapter 19 | 20 | 21 | class TestMessageCallback(MessageCallback): 22 | async def recall(self): 23 | ... 24 | 25 | async def get_message(self) -> Optional[Message]: 26 | ... 27 | 28 | 29 | class TestInstance(BotAdapterProtocol): 30 | def __init__(self, appid: str, host: str, port: int): 31 | super().__init__(appid, '') 32 | 33 | self.host = host 34 | self.port = port 35 | 36 | self.server = TestServer(self, appid, host, port) 37 | 38 | @self.server.app.on_event('startup') 39 | async def startup(): 40 | log.info( 41 | 'The test service has been started. ' 42 | 'Please go to https://console.amiyabot.com/#/test Connect and start testing.' 43 | ) 44 | 45 | def __str__(self): 46 | return 'Testing' 47 | 48 | async def close(self): 49 | ... 50 | 51 | async def start(self, handler: HANDLER_TYPE): 52 | await self.server.run(handler) 53 | 54 | async def build_active_message_chain(self, chain: Chain, user_id: str, channel_id: str, direct_src_guild_id: str): 55 | return chain 56 | 57 | async def send_chain_message(self, chain: Chain, is_sync: bool = False): 58 | reply, voice_list = await build_message_send(chain) 59 | 60 | if reply: 61 | await self.server.send(reply) 62 | 63 | if voice_list: 64 | for voice in voice_list: 65 | await self.server.send(voice) 66 | 67 | return [TestMessageCallback(chain.data, self, None)] 68 | 69 | async def recall_message(self, message_id, data: Optional[Message] = None): 70 | ... 71 | -------------------------------------------------------------------------------- /amiyabot/adapters/test/builder.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import base64 3 | 4 | from amiyabot.builtin.messageChain import Chain 5 | from amiyabot.builtin.messageChain.element import * 6 | 7 | 8 | async def build_message_send(chain: Chain, custom_chain: Optional[CHAIN_LIST] = None): 9 | chain_list = custom_chain or chain.chain 10 | chain_data = [] 11 | voice_list = [] 12 | 13 | if chain.data and chain.data.message_id: 14 | event_id = chain.data.message_id 15 | else: 16 | event_id = str(uuid.uuid4()) 17 | 18 | if chain_list: 19 | for item in chain_list: 20 | # At 21 | if isinstance(item, At): 22 | chain_data.append({'type': 'text', 'data': f'@{chain.data.nickname}'}) 23 | 24 | # AtAll 25 | if isinstance(item, AtAll): 26 | chain_data.append({'type': 'text', 'data': '@All'}) 27 | 28 | # Face 29 | if isinstance(item, Face): 30 | chain_data.append({'type': 'face', 'data': item.face_id}) 31 | 32 | # Text 33 | if isinstance(item, Text): 34 | chain_data.append({'type': 'text', 'data': item.content}) 35 | 36 | # Image 37 | if isinstance(item, Image): 38 | img = await item.get() 39 | chain_data.append({'type': 'image', 'data': await append_image(img)}) 40 | 41 | # Voice 42 | if isinstance(item, Voice): 43 | d, t = await append_voice(item.file) 44 | voice_list.append( 45 | send_msg( 46 | event_id, 47 | [ 48 | { 49 | 'type': 'voice', 50 | 'data': d, 51 | 'file': os.path.basename(item.file), 52 | 'audio_type': f'audio/{t}', 53 | } 54 | ], 55 | ) 56 | ) 57 | 58 | # Html 59 | if isinstance(item, Html): 60 | result = await item.create_html_image() 61 | if result: 62 | chain_data.append({'type': 'image', 'data': await append_image(result)}) 63 | 64 | return send_msg(event_id, chain_data), voice_list 65 | 66 | 67 | async def append_image(img: Union[bytes, str]): 68 | if isinstance(img, bytes): 69 | img = 'data:image/png;base64,' + base64.b64encode(img).decode() 70 | return img 71 | 72 | 73 | async def append_voice(file: str): 74 | _type = os.path.splitext(file)[-1].strip('.') 75 | 76 | with open(file, mode='rb') as vf: 77 | data = vf.read() 78 | 79 | return f'data:audio/{_type};base64,{base64.b64encode(data).decode()}', _type 80 | 81 | 82 | def send_msg(event_id: str, chain_data: list): 83 | return json.dumps( 84 | { 85 | 'event': 'message', 86 | 'event_id': event_id, 87 | 'event_data': chain_data, 88 | }, 89 | ensure_ascii=False, 90 | ) 91 | -------------------------------------------------------------------------------- /amiyabot/adapters/test/server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import base64 3 | import shutil 4 | import asyncio 5 | 6 | from typing import Optional, List 7 | from dataclasses import dataclass 8 | from fastapi import WebSocket, WebSocketDisconnect 9 | from amiyalog import logger as log 10 | from amiyahttp import HttpServer 11 | from amiyautils import random_code, create_dir 12 | from amiyabot.builtin.message import Message, Event 13 | from amiyabot.adapters import BotAdapterProtocol, HANDLER_TYPE 14 | 15 | 16 | @dataclass 17 | class ReceivedMessage: 18 | data: str 19 | websocket: WebSocket 20 | 21 | 22 | class TestServer(HttpServer): 23 | def __init__(self, instance: BotAdapterProtocol, appid: str, host: str, port: int): 24 | super().__init__(host, port) 25 | 26 | create_dir('testTemp') 27 | self.add_static_folder('/testTemp', 'testTemp') 28 | 29 | self.host = host 30 | self.port = port 31 | self.appid = appid 32 | self.instance = instance 33 | self.handler: Optional[HANDLER_TYPE] = None 34 | self.clients: List[WebSocket] = [] 35 | 36 | @self.app.on_event('shutdown') 37 | def clean_temp(): 38 | shutil.rmtree('testTemp') 39 | 40 | @self.app.websocket(f'/{self.appid}') 41 | async def websocket_endpoint(websocket: WebSocket): 42 | await websocket.accept() 43 | 44 | self.clients.append(websocket) 45 | 46 | while True: 47 | try: 48 | asyncio.create_task( 49 | self.handle_message( 50 | ReceivedMessage( 51 | await websocket.receive_text(), 52 | websocket, 53 | ) 54 | ) 55 | ) 56 | except WebSocketDisconnect: 57 | break 58 | except Exception as e: 59 | log.error(e, desc='websocket recv error:') 60 | break 61 | 62 | self.clients.remove(websocket) 63 | 64 | async def run(self, handler: HANDLER_TYPE): 65 | self.handler = handler 66 | 67 | await self.serve() 68 | 69 | async def send(self, data: str): 70 | for item in self.clients: 71 | await item.send_text(data) 72 | 73 | async def handle_message(self, data: ReceivedMessage): 74 | async with log.catch(ignore=[json.JSONDecodeError]): 75 | content = json.loads(data.data) 76 | message = await self.package_message(content['event'], content['event_id'], content['event_data']) 77 | 78 | asyncio.create_task(self.handler(message)) 79 | 80 | async def package_message(self, event: str, event_id: str, message: dict): 81 | if event != 'message': 82 | return Event(self.instance, event, message) 83 | 84 | msg = Message(self.instance, message) 85 | 86 | msg.message_id = event_id 87 | msg.user_id = message['user_id'] 88 | msg.channel_id = message['channel_id'] 89 | msg.message_type = message['message_type'] 90 | msg.nickname = message['nickname'] 91 | msg.is_admin = message['is_admin'] 92 | msg.image = [self.base64_to_temp_url(item) for item in message['images']] 93 | 94 | text = message.get('message', '') 95 | 96 | msg.set_text(text) 97 | return msg 98 | 99 | def base64_to_temp_url(self, base64_string: str): 100 | data = base64_string.split('base64,')[-1] 101 | decoded_data = base64.b64decode(data) 102 | 103 | temp_file_path = f'testTemp/images/{random_code(20)}.png' 104 | create_dir(temp_file_path, is_file=True) 105 | 106 | with open(temp_file_path, 'wb') as temp_file: 107 | temp_file.write(decoded_data) 108 | 109 | return f'http://%s:{self.port}/{temp_file_path}' % ('localhost' if self.host == '0.0.0.0' else self.host) 110 | -------------------------------------------------------------------------------- /amiyabot/builtin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmiyaBot/Amiya-Bot-core/5bd2377dd78247500726d117918d074cf6de4bf1/amiyabot/builtin/__init__.py -------------------------------------------------------------------------------- /amiyabot/builtin/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmiyaBot/Amiya-Bot-core/5bd2377dd78247500726d117918d074cf6de4bf1/amiyabot/builtin/lib/__init__.py -------------------------------------------------------------------------------- /amiyabot/builtin/lib/browserService/__init__.py: -------------------------------------------------------------------------------- 1 | from playwright.async_api import async_playwright, ConsoleMessage, Error as PageError 2 | 3 | from .launchConfig import * 4 | from .pagePool import * 5 | from .pageContext import PageContext 6 | 7 | 8 | class BrowserService: 9 | def __init__(self): 10 | self.playwright: Optional[Playwright] = None 11 | self.browser: Optional[Union[Browser, BrowserContext]] = None 12 | self.config: Optional[BrowserLaunchConfig] = None 13 | self.pool: Optional[PagePool] = None 14 | 15 | self.launched = False 16 | 17 | def __str__(self): 18 | return self.config.name if self.config else 'Not Launched' 19 | 20 | async def launch(self, config: BrowserLaunchConfig): 21 | if self.launched: 22 | return None 23 | 24 | self.launched = True 25 | 26 | log.info('launching browser...') 27 | 28 | self.playwright = await async_playwright().start() 29 | self.browser = await config.launch_browser(self.playwright) 30 | self.config = config 31 | 32 | if self.config.page_pool_size: 33 | self.pool = PagePool(self.browser, config) 34 | 35 | # if not config.browser_name: 36 | # config.browser_name = self.browser._impl_obj._browser_type.name 37 | 38 | log.info(f'{self} launched successful.') 39 | 40 | async def close(self): 41 | await self.browser.close() 42 | await self.playwright.stop() 43 | 44 | log.info(f'{self} closed.') 45 | 46 | async def open_page(self, width: int, height: int): 47 | if self.browser: 48 | size = ViewportSize(width=width, height=height) 49 | 50 | if self.pool: 51 | page_context = await self.pool.acquire_page(size) 52 | else: 53 | page_context = PageContext( 54 | await self.browser.new_page(no_viewport=True, viewport=size), 55 | ) 56 | 57 | if self.config.debug: 58 | page_context.page.once('console', self.__console) 59 | page_context.page.once('pageerror', self.__page_error) 60 | 61 | hook_res = await self.config.on_page_created(page_context.page) 62 | if hook_res: 63 | page_context.page = hook_res 64 | 65 | return page_context 66 | 67 | @staticmethod 68 | async def __console(message: ConsoleMessage): 69 | text = f'console: {message.text}' + '\n at {url}:{lineNumber}:{columnNumber}'.format(**message.location) 70 | 71 | if message.type == 'warning': 72 | log.warning(text) 73 | elif message.type == 'error': 74 | log.error(text) 75 | else: 76 | log.info(text) 77 | 78 | @staticmethod 79 | async def __page_error(error: PageError): 80 | log.error(error.stack) 81 | 82 | 83 | basic_browser_service = BrowserService() 84 | -------------------------------------------------------------------------------- /amiyabot/builtin/lib/browserService/launchConfig.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Optional 2 | from playwright.async_api import Browser, BrowserType, BrowserContext, Page, Playwright 3 | from amiyautils import argv 4 | from amiyalog import LoggerManager 5 | 6 | DEFAULT_WIDTH = argv('browser-width', int) or 1280 7 | DEFAULT_HEIGHT = argv('browser-height', int) or 720 8 | DEFAULT_RENDER_TIME = argv('browser-render-time', int) or 200 9 | BROWSER_PAGE_POOL_SIZE = argv('browser-page-pool-size', int) or 0 10 | BROWSER_LAUNCH_WITH_HEADED = argv('browser-launch-with-headed', bool) 11 | 12 | log = LoggerManager('Browser') 13 | 14 | 15 | class BrowserLaunchConfig: 16 | def __init__(self): 17 | self.browser_type: str = 'chromium' 18 | self.browser_name: [Optional] = None 19 | self.page_pool_size: int = BROWSER_PAGE_POOL_SIZE 20 | self.debug: bool = argv('debug', bool) 21 | 22 | @property 23 | def name(self): 24 | return f'browser({self.browser_name or self.browser_type})' 25 | 26 | async def launch_browser(self, playwright: Playwright) -> Union[Browser, BrowserContext]: 27 | browser: BrowserType = getattr(playwright, self.browser_type) 28 | 29 | return await browser.launch(headless=not BROWSER_LAUNCH_WITH_HEADED) 30 | 31 | async def on_context_created(self, context: BrowserContext) -> Optional[BrowserContext]: 32 | ... 33 | 34 | async def on_page_created(self, page: Page) -> Optional[Page]: 35 | ... 36 | -------------------------------------------------------------------------------- /amiyabot/builtin/lib/browserService/pageContext.py: -------------------------------------------------------------------------------- 1 | from playwright.async_api import Page 2 | 3 | 4 | class PageContext: 5 | def __init__(self, page: Page): 6 | self.page = page 7 | 8 | async def __aenter__(self): 9 | return self.page 10 | 11 | async def __aexit__(self, *args, **kwargs): 12 | await self.page.close() 13 | -------------------------------------------------------------------------------- /amiyabot/builtin/lib/browserService/pagePool.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from playwright.async_api import ViewportSize 4 | from contextlib import asynccontextmanager 5 | 6 | from .launchConfig import * 7 | from .pageContext import PageContext 8 | 9 | 10 | class PagePool: 11 | def __init__(self, browser: Union[Browser, BrowserContext], config: BrowserLaunchConfig): 12 | self.config = config 13 | self.browser = browser 14 | 15 | self.size = 0 16 | self.queuing_num = 0 17 | 18 | self.lock = asyncio.Lock() 19 | self.queue = asyncio.Queue() 20 | 21 | @property 22 | def max_size(self): 23 | return self.config.page_pool_size 24 | 25 | @property 26 | def queue_size(self): 27 | return self.queue.qsize() 28 | 29 | @asynccontextmanager 30 | async def __queuing(self): 31 | self.queuing_num += 1 32 | yield 33 | self.queuing_num -= 1 34 | 35 | async def acquire_page(self, viewport_size: ViewportSize): 36 | log.debug( 37 | f'{self.config.name} -- idle pages: {self.queue_size} opened: {self.size} queuing: {self.queuing_num}' 38 | ) 39 | 40 | created = False 41 | 42 | if self.queue_size == 0: 43 | async with self.lock: 44 | if self.size < self.max_size: 45 | if isinstance(self.browser, BrowserContext): 46 | page = await self.browser.new_page() 47 | else: 48 | context = await self.browser.new_context(no_viewport=True) 49 | hook_res = await self.config.on_context_created(context) 50 | if hook_res: 51 | context = hook_res 52 | 53 | page = await context.new_page() 54 | 55 | created = True 56 | self.size += 1 57 | 58 | log.debug(f'{self.config.name} -- page created. curr size: {self.size}/{self.max_size}') 59 | 60 | if not created: 61 | async with self.__queuing(): 62 | page: Page = await self.queue.get() 63 | 64 | try: 65 | await page.set_viewport_size(viewport_size) 66 | except Exception as e: 67 | log.warning(f'{self.config.name} -- {repr(e)}') 68 | if 'context or browser has been closed' in str(e): 69 | self.size -= 1 70 | return await self.acquire_page(viewport_size) 71 | 72 | return PagePoolContext(page, self) 73 | 74 | async def release_page(self, page: Page): 75 | try: 76 | await page.context.clear_cookies() 77 | await page.evaluate( 78 | ''' 79 | localStorage.clear(); 80 | sessionStorage.clear(); 81 | ''' 82 | ) 83 | await page.goto('about:blank') 84 | await self.queue.put(page) 85 | except Exception as e: 86 | log.warning(f'{self.config.name} -- {repr(e)}') 87 | self.size -= 1 88 | 89 | log.debug(f'{self.config.name} -- page released. idle pages: {self.queue_size}') 90 | 91 | 92 | class PagePoolContext(PageContext): 93 | def __init__(self, page: Page, pool: PagePool): 94 | super().__init__(page) 95 | self.pool = pool 96 | 97 | async def __aexit__(self, *args, **kwargs): 98 | await self.pool.release_page(self.page) 99 | -------------------------------------------------------------------------------- /amiyabot/builtin/lib/eventBus.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | 4 | from typing import Any, Dict, Union, Optional, Callable, Coroutine 5 | 6 | Subscriber = Callable[[Optional[Any]], Union[Coroutine[Any, Any, None], None]] 7 | SubscriberID = int 8 | EventName = str 9 | 10 | 11 | class EventBus: 12 | def __init__(self): 13 | self.__subscriber: Dict[EventName, Dict[SubscriberID, Subscriber]] = dict() 14 | 15 | def publish(self, event_name: EventName, data: Optional[Any] = None): 16 | if event_name in self.__subscriber: 17 | for _, method in self.__subscriber[event_name].items(): 18 | if inspect.iscoroutinefunction(method): 19 | asyncio.create_task(method(data)) 20 | else: 21 | method(data) 22 | 23 | def subscribe(self, event_name: EventName, method: Optional[Subscriber] = None): 24 | if event_name not in self.__subscriber: 25 | self.__subscriber[event_name] = dict() 26 | 27 | if method: 28 | self.__subscriber[event_name][id(method)] = method 29 | return 30 | 31 | def register(func: Subscriber): 32 | self.__subscriber[event_name][id(func)] = func 33 | 34 | return func 35 | 36 | return register 37 | 38 | def unsubscribe(self, event_name: EventName, method: Subscriber): 39 | if event_name in self.__subscriber: 40 | method_id = id(method) 41 | if method_id in self.__subscriber[event_name]: 42 | del self.__subscriber[event_name][method_id] 43 | 44 | 45 | event_bus = EventBus() 46 | -------------------------------------------------------------------------------- /amiyabot/builtin/lib/imageCreator.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import math 4 | 5 | from io import BytesIO 6 | from PIL import Image, ImageDraw, ImageFont 7 | from typing import List, Dict, Tuple, Union, Any, Optional 8 | from dataclasses import dataclass 9 | 10 | cur_file_path = os.path.abspath(__file__) 11 | cur_file_folder = os.path.dirname(cur_file_path) 12 | 13 | 14 | class FontStyle: 15 | file: str = os.path.join(cur_file_folder, '../../_assets/font/HarmonyOS_Sans_SC.ttf') 16 | font_size: int = 15 17 | line_height: int = 16 18 | color: str = '#000000' 19 | bgcolor: str = '#ffffff' 20 | 21 | 22 | @dataclass 23 | class ImageElem: 24 | path: str 25 | size: int 26 | pos: Tuple[int, int] 27 | 28 | 29 | @dataclass 30 | class CharElem: 31 | enter: bool 32 | color: str 33 | text: str 34 | width: int 35 | height: int 36 | 37 | 38 | class TextParser: 39 | def __init__( 40 | self, 41 | text: str, 42 | max_seat: int, 43 | color: str = '#000000', 44 | font_size: int = FontStyle.font_size, 45 | ): 46 | self.font = ImageFont.truetype(FontStyle.file, font_size) 47 | self.text = text 48 | self.color = color 49 | self.max_seat = max_seat 50 | self.width_seat = 0 51 | self.line = 0 52 | 53 | self.char_list: List[CharElem] = [] 54 | 55 | self.__parse() 56 | 57 | def __parse(self): 58 | text = self.text.strip('\n') 59 | search = re.findall(r'\[cl\s(.*?)@#(.*?)\scle]', text) 60 | 61 | color_pos = {0: self.color} 62 | 63 | for item in search: 64 | temp = f'[cl {item[0]}@#{item[1]} cle]' 65 | index = text.index(temp) 66 | color_pos[index] = f'#{item[1]}' 67 | color_pos[index + len(item[0])] = self.color 68 | text = text.replace(temp, item[0], 1) 69 | 70 | length = 0 71 | sub_text = '' 72 | cur_color = self.color 73 | 74 | for idx, char in enumerate(text): 75 | if idx in color_pos: 76 | if cur_color != color_pos[idx] and sub_text: 77 | self.__append_row(cur_color, sub_text, enter=False) 78 | sub_text = '' 79 | cur_color = color_pos[idx] 80 | 81 | length += self.__font_seat(char)[0] 82 | sub_text += char 83 | 84 | self.width_seat = max(self.width_seat, length) 85 | 86 | is_end = idx == len(text) - 1 87 | if length >= self.max_seat or char == '\n' or is_end: 88 | enter = True 89 | if not is_end: 90 | if text[idx + 1] == '\n' and char != '\n': 91 | enter = False 92 | 93 | self.__append_row(cur_color, sub_text, enter=enter) 94 | sub_text = '' 95 | length = 0 96 | 97 | def __append_row(self, color, text, enter=True): 98 | if enter: 99 | self.line += 1 100 | self.char_list.append(CharElem(enter, color, text, *self.__font_seat(text))) 101 | 102 | def __font_seat(self, char): 103 | return self.font.getsize_multiline(char) 104 | 105 | 106 | IMAGES_TYPE = List[Union[ImageElem, Dict[str, Any]]] 107 | 108 | 109 | def create_image( 110 | text: str = '', 111 | width: int = 0, 112 | height: Optional[int] = None, 113 | padding: int = 10, 114 | max_seat: Optional[int] = None, 115 | font_size: int = FontStyle.font_size, 116 | line_height: int = FontStyle.line_height, 117 | color: str = FontStyle.color, 118 | bgcolor: str = FontStyle.bgcolor, 119 | images: Optional[IMAGES_TYPE] = None, 120 | ): 121 | """ 122 | 文字转图片 123 | 124 | 创建一张内容为 hello world 的图片: 125 | create_image('hello world') 126 | 127 | 通过模板让 world 变为红色: 128 | create_image('hello [cl world@#ff0000 cle]') 129 | 130 | 131 | :param text: 主体文本 132 | :param width: 图片宽度 133 | :param height: 图片高度(默认为文本的最大占位) 134 | :param padding: 图片内边距 135 | :param font_size: 文字宽度 136 | :param max_seat: 文字最大占位 137 | :param line_height: 行高 138 | :param color: 文字默认颜色 139 | :param bgcolor: 图片背景色 140 | :param images: 插入的图片列表,内容为 ImageElem 对象 141 | :return: 图片路径 142 | """ 143 | # 计算最大占位 144 | max_seat = max_seat or ((width - padding * 2) if width else math.inf) 145 | 146 | # 解析文本 147 | text_obj = TextParser(text, max_seat, color, font_size) 148 | 149 | # 自适应宽度 150 | width = width or (text_obj.width_seat + padding * 2 + 50) 151 | 152 | # 创建图片 153 | image = Image.new('RGB', (width, height or ((text_obj.line + 2) * line_height)), bgcolor) 154 | draw = ImageDraw.Draw(image) 155 | 156 | row = 0 157 | col = padding 158 | for _, item in enumerate(text_obj.char_list): 159 | draw.text( 160 | (col, padding + row * line_height), 161 | item.text, 162 | font=text_obj.font, 163 | fill=item.color, 164 | ) 165 | col += item.width 166 | if item.enter: 167 | row += 1 168 | col = padding 169 | 170 | if images: 171 | for item in images: 172 | if isinstance(item, dict): 173 | item = ImageElem(**item) 174 | if os.path.exists(item.path) is False: 175 | continue 176 | 177 | img = Image.open(item.path).convert('RGBA') 178 | 179 | pos = [int(n if n >= 0 else width + n) for n in item.pos] 180 | 181 | item_width = int(item.size * (img.width / img.height)) 182 | item_height = item.size 183 | 184 | offset = (item_height - item_width) / 2 185 | if offset: 186 | pos[0] += int(offset) 187 | 188 | img = img.resize(size=(item_width, item_height)) 189 | image.paste(img, box=(pos[0], pos[1]), mask=img) 190 | 191 | container = BytesIO() 192 | image.save(container, format='PNG') 193 | 194 | return container.getvalue() 195 | -------------------------------------------------------------------------------- /amiyabot/builtin/lib/timedTask/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from typing import Optional, Callable 4 | from dataclasses import dataclass 5 | from amiyabot.signalHandler import SignalHandler 6 | 7 | from .scheduler import scheduler 8 | 9 | 10 | @dataclass 11 | class Task: 12 | func: Callable 13 | each: Optional[int] 14 | tag: str 15 | sub_tag: str 16 | run_when_added: bool 17 | kwargs: dict 18 | 19 | 20 | class TasksControl: 21 | @classmethod 22 | def start(cls): 23 | if not scheduler.state: 24 | scheduler.start() 25 | SignalHandler.on_shutdown.append(scheduler.shutdown) 26 | 27 | @classmethod 28 | def add_timed_task(cls, task: Task): 29 | if task.run_when_added: 30 | asyncio.create_task(task.func()) 31 | 32 | if task.each is not None: 33 | scheduler.add_job( 34 | task.func, 35 | id=f'{task.tag}.{task.sub_tag}', 36 | trigger='interval', 37 | seconds=task.each, 38 | **task.kwargs, 39 | ) 40 | else: 41 | scheduler.add_job( 42 | task.func, 43 | id=f'{task.tag}.{task.sub_tag}', 44 | **task.kwargs, 45 | ) 46 | 47 | @classmethod 48 | def remove_task(cls, tag: str, sub_tag: Optional[str] = None): 49 | for job in scheduler.get_jobs(): 50 | job_id: str = job.id 51 | 52 | if sub_tag: 53 | if job_id.startswith(f'{tag}.{sub_tag}'): 54 | job.remove() 55 | else: 56 | if job_id.startswith(tag): 57 | job.remove() 58 | -------------------------------------------------------------------------------- /amiyabot/builtin/lib/timedTask/scheduler.py: -------------------------------------------------------------------------------- 1 | from amiyalog import LoggerManager 2 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 3 | from apscheduler.executors.asyncio import AsyncIOExecutor 4 | from apscheduler.events import * 5 | 6 | log = LoggerManager('Schedule') 7 | 8 | 9 | class Scheduler(AsyncIOScheduler): 10 | options = { 11 | 'executors': { 12 | 'default': AsyncIOExecutor(), 13 | }, 14 | 'job_defaults': { 15 | 'coalesce': False, 16 | 'max_instances': 1, 17 | }, 18 | } 19 | 20 | def event_listener(self, mask): 21 | def register(task): 22 | self.add_listener(task, mask) 23 | return task 24 | 25 | return register 26 | 27 | 28 | scheduler = Scheduler(**Scheduler.options) 29 | 30 | 31 | @scheduler.event_listener(mask=EVENT_JOB_ADDED) 32 | def _(event: JobEvent): 33 | log.debug(f'added timed task: {event.job_id}') 34 | 35 | 36 | @scheduler.event_listener(mask=EVENT_JOB_REMOVED) 37 | def _(event: JobEvent): 38 | log.debug(f'removed timed task: {event.job_id}') 39 | 40 | 41 | @scheduler.event_listener(mask=EVENT_JOB_EXECUTED) 42 | def _(event: JobExecutionEvent): 43 | log.debug(f'timed task executed: {event.job_id}') 44 | -------------------------------------------------------------------------------- /amiyabot/builtin/message/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | import abc 3 | import copy 4 | import asyncio 5 | 6 | from typing import Callable, Optional, Union, List, Tuple, Any 7 | from dataclasses import dataclass 8 | from amiyabot.typeIndexes import T_Chain, T_BotAdapterProtocol 9 | from amiyabot.network.httpRequests import Response 10 | 11 | from .structure import EventStructure, MessageStructure, Verify, File 12 | from .waitEvent import ( 13 | WaitEvent, 14 | WaitEventsBucket, 15 | WaitEventCancel, 16 | WaitEventException, 17 | WaitEventOutOfFocus, 18 | ChannelWaitEvent, 19 | ChannelMessagesItem, 20 | wait_events_bucket, 21 | ) 22 | 23 | WaitReturn = Optional[MessageStructure] 24 | WaitChannelReturn = Optional[ChannelMessagesItem] 25 | MatchReturn = Tuple[bool, int, Any] 26 | 27 | 28 | @dataclass 29 | class Equal: 30 | content: str 31 | 32 | 33 | class Event(EventStructure): 34 | ... 35 | 36 | 37 | class EventList: 38 | def __init__(self, events: Optional[list] = None): 39 | self.events: List[Event] = events or list() 40 | 41 | def __iter__(self): 42 | return iter(self.events) 43 | 44 | def append(self, instance, event_name, data): 45 | self.events.append(Event(instance, event_name, data)) 46 | 47 | 48 | class Message(MessageStructure): 49 | async def send(self, reply: T_Chain) -> Optional['MessageCallbackType']: 50 | async with self.bot.processing_context(reply, self.factory_name): 51 | callbacks: List[MessageCallback] = await self.instance.send_chain_message(reply, is_sync=True) 52 | 53 | if not callbacks: 54 | return None 55 | 56 | return callbacks if len(callbacks) > 1 else callbacks[0] 57 | 58 | async def recall(self): 59 | if self.message_id: 60 | await self.instance.recall_message(self.message_id, self) 61 | 62 | async def wait( 63 | self, 64 | reply=None, 65 | force: bool = False, 66 | max_time: int = 30, 67 | data_filter: Optional[Callable] = None, 68 | level: int = 0, 69 | ) -> WaitReturn: 70 | if self.is_direct: 71 | target_id = f'{self.instance.appid}_{self.guild_id}_{self.user_id}' 72 | else: 73 | target_id = f'{self.instance.appid}_{self.channel_id}_{self.user_id}' 74 | 75 | if reply: 76 | await self.send(reply) 77 | 78 | event: WaitEvent = await wait_events_bucket.set_event(target_id, force, False, level) 79 | asyncio.create_task(event.timer(max_time)) 80 | 81 | while event.check_alive(): 82 | await asyncio.sleep(0) 83 | data = event.get() 84 | if data: 85 | if data_filter: 86 | res = await data_filter(data) 87 | if not res: 88 | event.set(None) 89 | continue 90 | 91 | event.cancel() 92 | 93 | return data 94 | 95 | event.cancel() 96 | 97 | return None 98 | 99 | async def wait_channel( 100 | self, 101 | reply=None, 102 | force: bool = False, 103 | clean: bool = True, 104 | max_time: int = 30, 105 | data_filter: Optional[Callable] = None, 106 | level: int = 0, 107 | ) -> WaitChannelReturn: 108 | if self.is_direct: 109 | raise WaitEventException('direct message not support "wait_channel"') 110 | 111 | target_id = f'{self.instance.appid}_{self.channel_id}' 112 | 113 | if reply: 114 | await self.send(reply) 115 | 116 | if target_id not in wait_events_bucket: 117 | event: ChannelWaitEvent = await wait_events_bucket.set_event(target_id, force, True, level) 118 | asyncio.create_task(event.timer(max_time)) 119 | else: 120 | event: ChannelWaitEvent = wait_events_bucket[target_id] 121 | if event.check_alive(): 122 | event.reset() 123 | if clean: 124 | event.clean() 125 | else: 126 | event.cancel() 127 | event: ChannelWaitEvent = await wait_events_bucket.set_event(target_id, force, True, level) 128 | asyncio.create_task(event.timer(max_time)) 129 | 130 | event.focus(self.message_id) 131 | 132 | while event.check_alive(): 133 | if not event.on_focus(self.message_id): 134 | raise WaitEventOutOfFocus(event, self.message_id) 135 | 136 | await asyncio.sleep(0) 137 | data = event.get() 138 | if data: 139 | if data_filter: 140 | res = await data_filter(data) 141 | if not res: 142 | continue 143 | 144 | event.reset() 145 | 146 | return ChannelMessagesItem(event, data) 147 | 148 | event.cancel() 149 | 150 | return None 151 | 152 | def copy(self): 153 | bot = self.bot 154 | instance = self.instance 155 | 156 | self.bot = None 157 | self.instance = None 158 | 159 | new_data = copy.deepcopy(self) 160 | new_data.bot = bot 161 | new_data.instance = instance 162 | 163 | self.bot = bot 164 | self.instance = instance 165 | 166 | return new_data 167 | 168 | 169 | class MessageMatch: 170 | @staticmethod 171 | def check_str(data: Message, text: str, level: Optional[int] = None) -> MatchReturn: 172 | if text.lower() in data.text.lower(): 173 | return True, level if level is not None else 1, text 174 | return False, 0, None 175 | 176 | @staticmethod 177 | def check_equal(data: Message, text: Equal, level: Optional[int] = None) -> MatchReturn: 178 | if text.content == data.text: 179 | return True, level if level is not None else 1, text 180 | return False, 0, None 181 | 182 | @staticmethod 183 | def check_reg(data: Message, reg: re.Pattern, level: Optional[int] = None) -> MatchReturn: 184 | r = re.search(reg, data.text) 185 | if r: 186 | return ( 187 | True, 188 | level if level is not None else (r.re.groups or 1), 189 | list(r.groups()), 190 | ) 191 | return False, 0, None 192 | 193 | 194 | class MessageCallback: 195 | def __init__(self, data: MessageStructure, instance: T_BotAdapterProtocol, response: Union[Response, Any]): 196 | self.data = data 197 | self.instance = instance 198 | self.response = response 199 | 200 | @abc.abstractmethod 201 | async def recall(self) -> None: 202 | """ 203 | 撤回本条消息 204 | 205 | :return: 206 | """ 207 | raise NotImplementedError 208 | 209 | @abc.abstractmethod 210 | async def get_message(self) -> Optional[Message]: 211 | """ 212 | 获取本条消息的 Message 对象 213 | 214 | :return: 215 | """ 216 | raise NotImplementedError 217 | 218 | 219 | Waiter = Union[WaitEvent, ChannelWaitEvent, None] 220 | EventType = Union[Event, EventList] 221 | MessageCallbackType = Union[MessageCallback, List[MessageCallback]] 222 | -------------------------------------------------------------------------------- /amiyabot/builtin/message/structure.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import time 3 | import jieba 4 | 5 | from typing import Any, List, Union, Optional, Callable 6 | from dataclasses import dataclass 7 | from amiyabot.typeIndexes import * 8 | from amiyautils import remove_punctuation, chinese_to_digits 9 | 10 | 11 | def cut_by_jieba(text: str): 12 | return jieba.lcut(text.lower().replace(' ', '')) 13 | 14 | 15 | class EventStructure: 16 | def __init__(self, instance: T_BotAdapterProtocol, event_name: str, data: dict): 17 | self.instance = instance 18 | self.event_name = event_name 19 | self.data = data 20 | 21 | def __str__(self): 22 | return f'Bot:{self.instance.appid} Event:{self.event_name}' 23 | 24 | 25 | class MessageStructure: 26 | def __init__(self, instance: T_BotAdapterProtocol, message: Optional[dict] = None): 27 | self.bot: Optional[T_BotHandlerFactory] = None 28 | self.instance = instance 29 | 30 | self.factory_name = '' 31 | 32 | self.message = message 33 | self.message_id = '' 34 | self.message_type = '' 35 | 36 | self.face: List[int] = [] 37 | self.image: List[str] = [] 38 | 39 | self.files: List[File] = [] 40 | self.voice = '' 41 | self.audio = '' 42 | self.video = '' 43 | 44 | self.text = '' 45 | self.text_prefix = '' 46 | self.text_digits = '' 47 | self.text_unsigned = '' 48 | self.text_original = '' 49 | self.text_words: List[str] = [] 50 | 51 | self.at_target: List[str] = [] 52 | 53 | self.is_at = False 54 | self.is_at_all = False 55 | self.is_admin = False 56 | self.is_direct = False 57 | 58 | self.user_id = '' 59 | self.user_openid = '' 60 | 61 | self.channel_id = '' 62 | self.channel_openid = '' 63 | 64 | self.guild_id = '' 65 | self.src_guild_id = '' 66 | 67 | self.nickname = '' 68 | self.avatar = '' 69 | 70 | self.verify: Optional[Verify] = None 71 | self.time = int(time.time()) 72 | 73 | def __str__(self): 74 | text = self.text.replace('\n', ' ') 75 | face = ''.join([f'[face:{n}]' for n in self.face]) 76 | image = '[image]' * len(self.image) 77 | 78 | return 'Bot:{bot}{channel} User:{user}{admin}{direct}{nickname}: {message}'.format( 79 | **{ 80 | 'bot': self.instance.appid, 81 | 'channel': f' Channel:{self.channel_id}' if self.channel_id else '', 82 | 'user': self.user_id, 83 | 'admin': '(admin)' if self.is_admin else '', 84 | 'direct': '(direct)' if self.is_direct else '', 85 | 'nickname': f' {self.nickname}' if self.nickname else '', 86 | 'message': text + face + image, 87 | } 88 | ) 89 | 90 | def set_text(self, text: str, set_original: bool = True): 91 | if set_original: 92 | self.text_original = text 93 | 94 | self.text = text.strip() 95 | self.text_convert() 96 | 97 | def text_convert(self): 98 | self.text_digits = chinese_to_digits(self.text) 99 | self.text_unsigned = remove_punctuation(self.text) 100 | 101 | chars = cut_by_jieba(self.text) + cut_by_jieba(self.text_digits) 102 | 103 | words = list(set(chars)) 104 | words = sorted(words, key=chars.index) 105 | 106 | self.text_words = words 107 | 108 | @abc.abstractmethod 109 | async def send(self, reply: T_Chain): 110 | raise NotImplementedError 111 | 112 | @abc.abstractmethod 113 | async def recall(self): 114 | raise NotImplementedError 115 | 116 | @abc.abstractmethod 117 | async def wait( 118 | self, 119 | reply: Optional[T_Chain] = None, 120 | force: bool = False, 121 | max_time: int = 30, 122 | data_filter: Optional[Callable] = None, 123 | level: int = 0, 124 | ): 125 | raise NotImplementedError 126 | 127 | @abc.abstractmethod 128 | async def wait_channel( 129 | self, 130 | reply: Optional[T_Chain] = None, 131 | force: bool = False, 132 | clean: bool = True, 133 | max_time: int = 30, 134 | data_filter: Optional[Callable] = None, 135 | level: int = 0, 136 | ): 137 | raise NotImplementedError 138 | 139 | 140 | class Verify: 141 | def __init__(self, result: bool, weight: Union[int, float] = 0, keypoint: Optional[Any] = None): 142 | self.result = result 143 | self.weight = weight 144 | self.keypoint = keypoint 145 | 146 | self.on_selected: Optional[Callable] = None 147 | 148 | def __bool__(self): 149 | return bool(self.result) 150 | 151 | def __repr__(self): 152 | return f'' 153 | 154 | def set_attrs(self, *attrs: Any): 155 | indexes = [ 156 | 'result', 157 | 'weight', 158 | 'keypoint', 159 | ] 160 | for index, value in zip(indexes, attrs): 161 | setattr(self, index, value) 162 | 163 | 164 | @dataclass 165 | class File: 166 | url: str 167 | filename: str = '' 168 | -------------------------------------------------------------------------------- /amiyabot/builtin/message/waitEvent.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from typing import List, Dict, Union, Optional 4 | from amiyalog import logger as log 5 | 6 | from .structure import MessageStructure 7 | 8 | 9 | class WaitEvent: 10 | def __init__(self, event_id: int, target_id: int, force: bool, level: int): 11 | self.event_id = event_id 12 | self.target_id = target_id 13 | self.force = force 14 | self.level = level 15 | 16 | self.curr_time = 0 17 | 18 | self.data: Optional[MessageStructure] = None 19 | self.type = 'user' 20 | 21 | self.alive = True 22 | 23 | def __repr__(self): 24 | return f'WaitEvent(target_id:{self.target_id} alive:{self.alive})' 25 | 26 | async def timer(self, max_time: int): 27 | while self.alive and self.curr_time < max_time: 28 | await asyncio.sleep(0.2) 29 | self.curr_time += 0.2 30 | self.alive = False 31 | 32 | def reset(self): 33 | self.alive = True 34 | self.curr_time = 0 35 | 36 | def check_alive(self): 37 | if self.target_id not in wait_events_bucket: 38 | raise WaitEventCancel(self, 'This event already deleted.') 39 | 40 | if self.event_id != wait_events_bucket[self.target_id].event_id: 41 | raise WaitEventCancel(self, 'Event id not equal.', del_event=False) 42 | 43 | # if not self.alive: 44 | # WaitEventCancel(self, 'Timeout.') 45 | 46 | return self.alive 47 | 48 | def set(self, data: Optional[MessageStructure]): 49 | self.data = data 50 | 51 | def get(self) -> MessageStructure: 52 | return self.data 53 | 54 | def cancel(self, del_event: bool = True): 55 | self.alive = False 56 | 57 | if del_event: 58 | del wait_events_bucket[self.target_id] 59 | 60 | 61 | class ChannelWaitEvent(WaitEvent): 62 | def __init__(self, *args, **kwargs): 63 | super().__init__(*args, **kwargs) 64 | 65 | self.data: List[MessageStructure] = list() 66 | self.type = 'channel' 67 | self.token = None 68 | 69 | def __repr__(self): 70 | return f'ChannelWaitEvent(target_id:{self.target_id} alive:{self.alive} token:{self.token})' 71 | 72 | def set(self, data: Optional[MessageStructure]): 73 | if data: 74 | self.data.append(data) 75 | 76 | def get(self) -> MessageStructure: 77 | if self.data: 78 | return self.data.pop(0) 79 | 80 | def focus(self, token: str): 81 | self.token = token 82 | 83 | def on_focus(self, token: str): 84 | return self.token == token 85 | 86 | def clean(self): 87 | self.data = list() 88 | 89 | 90 | class ChannelMessagesItem: 91 | def __init__(self, event: ChannelWaitEvent, item: MessageStructure): 92 | self.event = event 93 | self.message = item 94 | 95 | def close_event(self): 96 | self.event.cancel() 97 | 98 | 99 | class WaitEventsBucket: 100 | def __init__(self): 101 | self.id = 0 102 | self.lock = asyncio.Lock() 103 | self.bucket: Dict[Union[int, str], Union[WaitEvent, ChannelWaitEvent]] = {} 104 | 105 | def __contains__(self, item): 106 | return item in self.bucket 107 | 108 | def __getitem__(self, item): 109 | try: 110 | return self.bucket[item] 111 | except KeyError: 112 | return None 113 | 114 | def __delitem__(self, key): 115 | try: 116 | del self.bucket[key] 117 | except KeyError: 118 | pass 119 | 120 | async def __get_id(self): 121 | async with self.lock: 122 | self.id += 1 123 | return self.id 124 | 125 | async def set_event(self, target_id: Union[int, str], force: bool, for_channel: bool, level: int): 126 | event_id = await self.__get_id() 127 | 128 | if for_channel: 129 | event = ChannelWaitEvent(event_id, target_id, force, level) 130 | else: 131 | event = WaitEvent(event_id, target_id, force, level) 132 | 133 | self.bucket[target_id] = event 134 | 135 | return event 136 | 137 | 138 | class WaitEventException(Exception): 139 | def __init__(self, message): 140 | self.message = message 141 | 142 | def __str__(self): 143 | return self.message 144 | 145 | 146 | class WaitEventCancel(Exception): 147 | def __init__(self, event: WaitEvent, reason: str, del_event: bool = True): 148 | self.key = event.target_id 149 | 150 | event.cancel(del_event) 151 | 152 | log.info(f'Wait event cancel -> {event.target_id}({event.event_id}), reason: {reason}') 153 | 154 | def __str__(self): 155 | return f'WaitEventCancel: {self.key}' 156 | 157 | 158 | class WaitEventOutOfFocus(Exception): 159 | def __init__(self, event: ChannelWaitEvent, token: str): 160 | self.token = token 161 | 162 | log.info(f'Wait event out of focus: new token {event.token}, curr {token}') 163 | 164 | def __str__(self): 165 | return f'WaitEventOutOfFocus: {self.token}' 166 | 167 | 168 | wait_events_bucket = WaitEventsBucket() 169 | -------------------------------------------------------------------------------- /amiyabot/builtin/messageChain/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from amiyabot.builtin.message import MessageStructure 4 | from amiyabot.builtin.lib.imageCreator import create_image, IMAGES_TYPE 5 | 6 | from .element import * 7 | 8 | cur_file_path = os.path.abspath(__file__) 9 | cur_file_folder = os.path.dirname(cur_file_path) 10 | 11 | PADDING = 10 12 | IMAGE_WIDTH = 700 13 | MAX_SEAT = IMAGE_WIDTH - PADDING * 2 14 | 15 | 16 | class ChainConfig: 17 | max_length = argv('text-max-length', int) or 100 18 | md_template = os.path.join(cur_file_folder, '../../_assets/markdown/template.html') 19 | 20 | 21 | class Chain: 22 | def __init__( 23 | self, 24 | data: Optional[MessageStructure] = None, 25 | at: bool = True, 26 | reference: bool = False, 27 | chain_builder: Optional[ChainBuilder] = None, 28 | ): 29 | """ 30 | 创建回复消息 31 | 32 | :param data: Message 对象 33 | :param at: 是否 at 用户 34 | :param reference: 是否引用该 Message 对象的消息 35 | """ 36 | self.data = data 37 | self.reference = reference 38 | 39 | self.chain: CHAIN_LIST = [] 40 | self.raw_chain: Optional[Any] = None 41 | 42 | if data and at and not data.is_direct: 43 | self.at(enter=True) 44 | 45 | self._builder = chain_builder or ChainBuilder() 46 | self.use_default_builder = not bool(chain_builder) 47 | 48 | @property 49 | def builder(self): 50 | return self._builder 51 | 52 | @builder.setter 53 | def builder(self, value: ChainBuilder): 54 | self._builder = value 55 | self.use_default_builder = False 56 | 57 | for item in self.chain: 58 | if hasattr(item, 'builder'): 59 | item.builder = value 60 | 61 | def at(self, user: Optional[str] = None, enter: bool = False): 62 | if self.data and self.data.is_direct: 63 | return self 64 | 65 | self.chain.append(At(user or self.data.user_id)) 66 | if enter: 67 | return self.text('\n') 68 | 69 | return self 70 | 71 | def at_all(self): 72 | self.chain.append(AtAll()) 73 | return self 74 | 75 | def tag(self, target: Union[str, int]): 76 | self.chain.append(Tag(target)) 77 | return self 78 | 79 | def face(self, face_id: Union[str, int]): 80 | self.chain.append(Face(face_id)) 81 | return self 82 | 83 | def text(self, text: str, auto_convert: bool = False): 84 | chain = [] 85 | 86 | if re.findall(r'\[cl\s(.*?)@#(.*?)\scle]', text): 87 | return self.text_image(text) 88 | 89 | if text.rstrip('\n') != '': 90 | text = text.rstrip('\n') 91 | 92 | r = re.findall(r'(\[face:(\d+)])', text) 93 | if r: 94 | face = [] 95 | for item in r: 96 | text = text.replace(item[0], ':face') 97 | face.append(item[1]) 98 | 99 | for index, item in enumerate(text.split(':face')): 100 | if item != '': 101 | chain.append(Text(item)) 102 | if index <= len(face) - 1: 103 | chain.append(Face(face[index])) 104 | else: 105 | if auto_convert and len(text) >= ChainConfig.max_length: 106 | self.text_image(text) 107 | else: 108 | chain.append(Text(text)) 109 | 110 | self.chain += chain 111 | 112 | return self 113 | 114 | def text_image( 115 | self, 116 | text: str, 117 | images: Optional[IMAGES_TYPE] = None, 118 | width: Optional[int] = None, 119 | height: Optional[int] = None, 120 | bgcolor: str = '#F5F5F5', 121 | ): 122 | return self.image( 123 | target=create_image( 124 | text, 125 | images=(images or []), 126 | width=width, 127 | height=height, 128 | padding=PADDING, 129 | max_seat=MAX_SEAT, 130 | bgcolor=bgcolor, 131 | ) 132 | ) 133 | 134 | def image(self, target: Optional[Union[str, bytes, List[Union[str, bytes]]]] = None, url: Optional[str] = None): 135 | if url: 136 | self.chain.append(Image(url=url, builder=self.builder)) 137 | else: 138 | if not isinstance(target, list): 139 | target = [target] 140 | 141 | for item in target: 142 | if isinstance(item, str): 143 | if os.path.exists(item): 144 | with open(item, mode='rb') as f: 145 | self.chain.append(Image(content=f.read(), builder=self.builder)) 146 | else: 147 | self.chain.append(Image(content=item, builder=self.builder)) 148 | 149 | return self 150 | 151 | def voice(self, file: str, title: str = 'voice'): 152 | self.chain.append(Voice(file, title, builder=self.builder)) 153 | return self 154 | 155 | def video(self, file: str): 156 | self.chain.append(Video(file, builder=self.builder)) 157 | return self 158 | 159 | def html( 160 | self, 161 | path: str, 162 | data: Optional[Union[dict, list]] = None, 163 | width: int = DEFAULT_WIDTH, 164 | height: int = DEFAULT_HEIGHT, 165 | is_template: bool = True, 166 | render_time: int = DEFAULT_RENDER_TIME, 167 | ): 168 | self.chain.append( 169 | Html( 170 | url=path, 171 | data=data, 172 | width=width, 173 | height=height, 174 | is_file=is_template, 175 | render_time=render_time, 176 | builder=self.builder, 177 | ) 178 | ) 179 | return self 180 | 181 | def markdown( 182 | self, 183 | content: str, 184 | max_width: int = 960, 185 | css_style: str = '', 186 | render_time: int = DEFAULT_RENDER_TIME, 187 | is_dark: bool = False, 188 | ): 189 | return self.html( 190 | ChainConfig.md_template, 191 | width=50, 192 | height=50, 193 | data={ 194 | 'content': content, 195 | 'max_width': max_width, 196 | 'css_style': css_style, 197 | 'is_dark': is_dark, 198 | }, 199 | render_time=render_time, 200 | ) 201 | 202 | def markdown_template( 203 | self, 204 | template_id: str, 205 | params: List[dict], 206 | keyboard: Optional[InlineKeyboard] = None, 207 | keyboard_template_id: Optional[str] = '', 208 | ): 209 | self.chain.append(Markdown(template_id, params, keyboard, keyboard_template_id)) 210 | return self 211 | 212 | def embed(self, title: str, prompt: str, thumbnail: str, fields: List[str]): 213 | self.chain.append(Embed(title, prompt, thumbnail, fields)) 214 | return self 215 | 216 | def ark(self, template_id: int, kv: List[dict]): 217 | self.chain.append(Ark(template_id, kv)) 218 | return self 219 | 220 | def extend(self, data: Any): 221 | self.chain.append(Extend(data)) 222 | return self 223 | -------------------------------------------------------------------------------- /amiyabot/builtin/messageChain/element.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from typing import List, Any 5 | from dataclasses import dataclass 6 | from amiyabot.builtin.lib.browserService import * 7 | from amiyalog import logger as log 8 | 9 | from .keyboard import InlineKeyboard 10 | 11 | 12 | class ChainBuilder: 13 | @classmethod 14 | async def get_image(cls, image: Union[str, bytes]) -> Union[str, bytes]: 15 | return image 16 | 17 | @classmethod 18 | async def get_voice(cls, voice_file: str) -> str: 19 | return voice_file 20 | 21 | @classmethod 22 | async def get_video(cls, video_file: str) -> str: 23 | return video_file 24 | 25 | @classmethod 26 | async def on_page_rendered(cls, page: Page): 27 | ... 28 | 29 | 30 | @dataclass 31 | class At: 32 | target: Union[str, int] 33 | 34 | 35 | @dataclass 36 | class AtAll: 37 | ... 38 | 39 | 40 | @dataclass 41 | class Tag: 42 | target: Union[str, int] 43 | 44 | 45 | @dataclass 46 | class Face: 47 | face_id: Union[str, int] 48 | 49 | 50 | @dataclass 51 | class Text: 52 | content: str 53 | 54 | 55 | @dataclass 56 | class Image: 57 | url: Optional[str] = None 58 | content: Optional[bytes] = None 59 | builder: Optional[ChainBuilder] = None 60 | 61 | async def get(self): 62 | if self.builder: 63 | res = await self.builder.get_image(self.url or self.content) 64 | if res: 65 | return res 66 | return self.url or self.content 67 | 68 | 69 | @dataclass 70 | class Voice: 71 | file: str 72 | title: str 73 | builder: Optional[ChainBuilder] = None 74 | 75 | async def get(self): 76 | if self.builder: 77 | res = await self.builder.get_voice(self.file) 78 | if res: 79 | return res 80 | return self.file 81 | 82 | 83 | @dataclass 84 | class Video: 85 | file: str 86 | builder: Optional[ChainBuilder] = None 87 | 88 | async def get(self): 89 | if self.builder: 90 | res = await self.builder.get_video(self.file) 91 | if res: 92 | return res 93 | return self.file 94 | 95 | 96 | @dataclass 97 | class Html: 98 | url: str 99 | data: Union[list, dict] 100 | is_file: bool = True 101 | render_time: int = DEFAULT_RENDER_TIME 102 | width: int = DEFAULT_WIDTH 103 | height: int = DEFAULT_HEIGHT 104 | builder: Optional[ChainBuilder] = None 105 | 106 | async def create_html_image(self): 107 | async with log.catch('browser service error:'): 108 | page_context = await basic_browser_service.open_page(self.width, self.height) 109 | 110 | if not page_context: 111 | return None 112 | 113 | async with page_context as page: 114 | async with log.catch('html convert error:'): 115 | url = 'file:///' + os.path.abspath(self.url) if self.is_file else self.url 116 | 117 | try: 118 | await page.goto(url) 119 | await page.wait_for_load_state() 120 | except Exception as e: 121 | log.error(e, desc=f'can not goto url {url}. Error:') 122 | return None 123 | 124 | if self.data: 125 | injected = ''' 126 | if ('init' in window) { 127 | init(%s) 128 | } else { 129 | console.warn( 130 | 'Can not execute "window.init(data)" because this function does not exist.' 131 | ) 132 | } 133 | ''' % json.dumps( 134 | self.data 135 | ) 136 | await page.evaluate(injected) 137 | 138 | # 等待渲染 139 | await asyncio.sleep(self.render_time / 1000) 140 | 141 | # 执行钩子 142 | if self.builder: 143 | await self.builder.on_page_rendered(page) 144 | 145 | # 截图 146 | result = await page.screenshot(full_page=True) 147 | 148 | if self.builder: 149 | res = await self.builder.get_image(result) 150 | if res: 151 | result = res 152 | 153 | if result: 154 | return result 155 | 156 | 157 | @dataclass 158 | class Embed: 159 | title: str 160 | prompt: str 161 | thumbnail: str 162 | fields: List[str] 163 | 164 | def get(self): 165 | return { 166 | 'embed': { 167 | 'title': self.title, 168 | 'prompt': self.prompt, 169 | 'thumbnail': {'url': self.thumbnail}, 170 | 'fields': [{'name': item} for item in self.fields], 171 | } 172 | } 173 | 174 | 175 | @dataclass 176 | class Ark: 177 | template_id: int 178 | kv: List[dict] 179 | 180 | def get(self): 181 | return { 182 | 'ark': { 183 | 'template_id': self.template_id, 184 | 'kv': self.kv, 185 | } 186 | } 187 | 188 | 189 | @dataclass 190 | class Markdown: 191 | template_id: str 192 | params: List[dict] 193 | keyboard: Optional[InlineKeyboard] = None 194 | keyboard_template_id: Optional[str] = '' 195 | 196 | def get(self): 197 | data = { 198 | 'markdown': { 199 | 'custom_template_id': self.template_id, 200 | 'params': self.params, 201 | } 202 | } 203 | 204 | if self.keyboard: 205 | data.update({'keyboard': {'content': self.keyboard.dict()}}) 206 | 207 | if self.keyboard_template_id: 208 | data.update({'keyboard': {'id': self.keyboard_template_id}}) 209 | 210 | return data 211 | 212 | 213 | @dataclass 214 | class Extend: 215 | data: Any 216 | 217 | def get(self): 218 | if isinstance(self.data, CQCode): 219 | return self.data.code 220 | return self.data 221 | 222 | 223 | @dataclass 224 | class CQCode: 225 | code: str 226 | 227 | 228 | CHAIN_ITEM = Union[ 229 | At, 230 | AtAll, 231 | Tag, 232 | Face, 233 | Text, 234 | Image, 235 | Voice, 236 | Video, 237 | Html, 238 | Embed, 239 | Ark, 240 | Markdown, 241 | Extend, 242 | ] 243 | CHAIN_LIST = List[CHAIN_ITEM] 244 | -------------------------------------------------------------------------------- /amiyabot/builtin/messageChain/keyboard.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Optional 2 | from dataclasses import dataclass, field, asdict 3 | 4 | 5 | @dataclass 6 | class Permission: 7 | type: int = 2 8 | specify_role_ids: List[str] = field(default_factory=lambda: ['1', '2', '3']) 9 | specify_user_ids: List[str] = field(default_factory=lambda: []) 10 | 11 | 12 | @dataclass 13 | class RenderData: 14 | label: str = '' 15 | visited_label: str = '' 16 | style: int = 0 17 | 18 | 19 | @dataclass 20 | class Action: 21 | type: int = 2 22 | data: str = '' 23 | anchor: int = 0 24 | unsupport_tips: str = '' 25 | 26 | click_limit: int = 10 27 | 28 | reply: bool = False 29 | enter: bool = False 30 | at_bot_show_channel_list: bool = False 31 | 32 | permission: Permission = field(default_factory=Permission) 33 | 34 | 35 | @dataclass 36 | class Button: 37 | id: str 38 | render_data: RenderData = field(default_factory=RenderData) 39 | action: Action = field(default_factory=Action) 40 | 41 | 42 | @dataclass 43 | class Row: 44 | buttons: List[Button] = field(default_factory=list) 45 | 46 | def add_button( 47 | self, 48 | button: Union[str, Button], 49 | render_label: str, 50 | render_visited_label: str = '', 51 | render_style: int = 0, 52 | action_type: int = 2, 53 | action_data: str = '', 54 | action_anchor: int = 0, 55 | action_unsupport_tips: str = '', 56 | action_click_limit: int = 10, 57 | action_reply: bool = False, 58 | action_enter: bool = False, 59 | action_at_bot_show_channel_list: bool = False, 60 | permission_type: int = 2, 61 | permission_specify_role_ids: Optional[List[str]] = None, 62 | permission_specify_user_ids: Optional[List[str]] = None, 63 | ): 64 | if len(self.buttons) >= 5: 65 | raise OverflowError('Create up to 5 buttons per row') 66 | 67 | if isinstance(button, str): 68 | button = Button(button) 69 | 70 | button.render_data.label = render_label 71 | button.render_data.visited_label = render_visited_label or render_label 72 | button.render_data.style = render_style 73 | 74 | button.action.type = action_type 75 | button.action.data = action_data or render_label 76 | button.action.anchor = action_anchor 77 | button.action.unsupport_tips = action_unsupport_tips 78 | button.action.click_limit = action_click_limit 79 | button.action.reply = action_reply 80 | button.action.enter = action_enter 81 | button.action.at_bot_show_channel_list = action_at_bot_show_channel_list 82 | 83 | button.action.permission.type = permission_type 84 | button.action.permission.specify_role_ids = permission_specify_role_ids 85 | button.action.permission.specify_user_ids = permission_specify_user_ids 86 | 87 | self.buttons.append(button) 88 | 89 | return button 90 | 91 | 92 | @dataclass 93 | class InlineKeyboard: 94 | bot_appid: Union[str, int] = '' 95 | rows: List[Row] = field(default_factory=list) 96 | 97 | def add_row(self): 98 | if len(self.rows) >= 5: 99 | raise OverflowError('Create up to 5 rows') 100 | 101 | row = Row() 102 | self.rows.append(row) 103 | 104 | return row 105 | 106 | def dict(self): 107 | return asdict(self) 108 | -------------------------------------------------------------------------------- /amiyabot/database/__init__.py: -------------------------------------------------------------------------------- 1 | import peewee 2 | import pymysql 3 | 4 | from abc import ABC 5 | from typing import List, Any, Optional 6 | from dataclasses import dataclass 7 | from playhouse.migrate import * 8 | from playhouse.shortcuts import ReconnectMixin, model_to_dict 9 | from amiyautils import create_dir, pascal_case_to_snake_case 10 | 11 | 12 | @dataclass 13 | class MysqlConfig: 14 | host: str = '127.0.0.1' 15 | port: int = 3306 16 | user: str = 'root' 17 | password: str = '' 18 | 19 | def dict(self): 20 | return { 21 | 'host': self.host, 22 | 'port': self.port, 23 | 'user': self.user, 24 | 'password': self.password, 25 | } 26 | 27 | 28 | class ReconnectMySQLDatabase(ReconnectMixin, MySQLDatabase, ABC): 29 | ... 30 | 31 | 32 | class ModelClass(Model): 33 | @classmethod 34 | def batch_insert(cls, rows: List[dict], chunk_size: int = 200): 35 | if len(rows) > chunk_size: 36 | for batch in chunked(rows, chunk_size): 37 | cls.insert_many(batch).execute() 38 | else: 39 | cls.insert_many(rows).execute() 40 | 41 | @classmethod 42 | def insert_or_update( 43 | cls, 44 | insert: dict, 45 | update: Optional[dict] = None, 46 | conflict_target: Optional[list] = None, 47 | preserve: Optional[list] = None, 48 | ): 49 | conflict = {'update': update, 'preserve': preserve} 50 | if isinstance(cls._meta.database, ReconnectMySQLDatabase): 51 | conflict['conflict_target'] = conflict_target 52 | 53 | cls.insert(**insert).on_conflict(**conflict).execute() 54 | 55 | 56 | class DatabaseConfigError(Exception): 57 | def __init__(self, value: Any): 58 | self.value = value 59 | 60 | def __str__(self): 61 | return f'Expected MysqlConfig instance, got {self.value} instead' 62 | 63 | 64 | def table(cls: ModelClass) -> Any: 65 | database: Database = cls._meta.database 66 | migrator: SchemaMigrator = SchemaMigrator.from_database(cls._meta.database) 67 | 68 | table_name = pascal_case_to_snake_case(cls.__name__) 69 | 70 | cls._meta.table_name = table_name 71 | cls.create_table() 72 | 73 | description = database.execute_sql(f'select * from `{table_name}` limit 1').description 74 | 75 | model_columns = [f for f, n in cls.__dict__.items() if type(n) in [peewee.FieldAccessor, peewee.ForeignKeyAccessor]] 76 | table_columns = [n[0] for n in description] 77 | 78 | migrate_list = [] 79 | 80 | # 取 AB 差集增加字段 81 | for f in set(model_columns) - set(table_columns): 82 | migrate_list.append(migrator.add_column(table_name, f, getattr(cls, f))) 83 | 84 | # 取 BA 差集删除字段 85 | for f in set(table_columns) - set(model_columns): 86 | migrate_list.append(migrator.drop_column(table_name, f)) 87 | 88 | if migrate_list: 89 | migrate(*tuple(migrate_list)) 90 | 91 | return cls 92 | 93 | 94 | def connect_database(database: str, is_mysql: bool = False, config: Optional[MysqlConfig] = None): 95 | if is_mysql: 96 | if not isinstance(config, MysqlConfig): 97 | raise DatabaseConfigError(config) 98 | 99 | conn = pymysql.connect(**config.dict(), charset='utf8') 100 | cursor = conn.cursor() 101 | cursor.execute(f'CREATE DATABASE IF NOT EXISTS {database} CHARACTER SET utf8;') 102 | cursor.close() 103 | conn.close() 104 | 105 | return ReconnectMySQLDatabase(database, **config.dict()) 106 | 107 | create_dir(database, is_file=True) 108 | return SqliteDatabase(database, pragmas={'timeout': 30}) 109 | 110 | 111 | def convert_model(model, select_model: Optional[peewee.Select] = None) -> dict: 112 | data = {**model_to_dict(model)} 113 | if select_model: 114 | for field in select_model._returning: 115 | if field.name not in data: 116 | data[field.name] = getattr(model, field.name) 117 | 118 | return data 119 | 120 | 121 | def query_to_list(query, select_model: Optional[peewee.Select] = None) -> List[dict]: 122 | return [convert_model(item, select_model) for item in query] 123 | 124 | 125 | def select_for_paginate(select: peewee.ModelSelect, page: int, page_size: int): 126 | return { 127 | 'list': query_to_list( 128 | select.objects().paginate(page=page, paginate_by=page_size), 129 | select_model=select, 130 | ), 131 | 'total': select.count(), 132 | } 133 | -------------------------------------------------------------------------------- /amiyabot/factory/factoryCore.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from itertools import chain 4 | from amiyabot.factory.factoryTyping import * 5 | 6 | 7 | class FactoryCore: 8 | def __init__(self): 9 | self.__container: Dict[str, Union[dict, list]] = { 10 | # 触发词 11 | 'prefix_keywords': list(), 12 | # 响应器 13 | 'event_handlers': dict(), 14 | 'message_handlers': list(), 15 | 'exception_handlers': dict(), 16 | # 消息响应器 ID 字典 17 | 'message_handler_id_map': dict(), 18 | # 生命周期 19 | 'process_event_created': list(), 20 | 'process_message_created': list(), 21 | 'process_message_before_waiter_set': list(), 22 | 'process_message_before_handle': list(), 23 | 'process_message_before_send': list(), 24 | 'process_message_after_send': list(), 25 | 'process_message_after_handle': list(), 26 | # 组设置 27 | 'group_config': dict(), 28 | # 定时任务 29 | 'timed_tasks': list(), 30 | } 31 | 32 | self.plugins: Dict[str, FactoryCore] = dict() 33 | self.factory_name = 'default_factory' 34 | 35 | def get_container(self, key: str) -> Union[dict, list]: 36 | return self.__container[key] 37 | 38 | def get_with_plugins(self, attr_name: Optional[str] = None): 39 | if not attr_name: 40 | attr_name = inspect.getframeinfo(inspect.currentframe().f_back)[2] 41 | 42 | self_attr = self.get_container(attr_name) 43 | attr_type = type(self_attr) 44 | 45 | if attr_type is list: 46 | return self_attr + list(chain(*(getattr(plugin, attr_name) for _, plugin in self.plugins.items()))) 47 | 48 | if attr_type is dict: 49 | value = {**self_attr} 50 | for _, plugin in self.plugins.items(): 51 | plugin_value: Union[dict, list] = getattr(plugin, attr_name) 52 | for k in plugin_value: 53 | if k not in value: 54 | value[k] = plugin_value[k] 55 | else: 56 | value[k] += plugin_value[k] 57 | 58 | if isinstance(value[k], list): 59 | value[k] = list(set(value[k])) 60 | 61 | return value 62 | 63 | @property 64 | def process_event_created(self) -> EventCreatedHandlers: 65 | return self.get_with_plugins() 66 | 67 | @property 68 | def process_message_created(self) -> MessageCreatedHandlers: 69 | return self.get_with_plugins() 70 | 71 | @property 72 | def process_message_before_waiter_set(self) -> BeforeWaiterSetHandlers: 73 | return self.get_with_plugins() 74 | 75 | @property 76 | def process_message_before_handle(self) -> BeforeHandleHandlers: 77 | return self.get_with_plugins() 78 | 79 | @property 80 | def process_message_before_send(self) -> BeforeSendHandlers: 81 | return self.get_with_plugins() 82 | 83 | @property 84 | def process_message_after_send(self) -> AfterSendHandlers: 85 | return self.get_with_plugins() 86 | 87 | @property 88 | def process_message_after_handle(self) -> AfterHandleHandlers: 89 | return self.get_with_plugins() 90 | 91 | def event_created(self, handler: EventCreatedHandlerType): 92 | self.get_container('process_event_created').append(handler) 93 | return handler 94 | 95 | def message_created(self, handler: MessageCreatedHandlerType): 96 | self.get_container('process_message_created').append(handler) 97 | return handler 98 | 99 | def message_before_waiter_set(self, handler: BeforeWaiterSetHandlerType): 100 | self.get_container('process_message_before_waiter_set').append(handler) 101 | return handler 102 | 103 | def message_before_handle(self, handler: BeforeHandleHandlerType): 104 | self.get_container('process_message_before_handle').append(handler) 105 | return handler 106 | 107 | def message_before_send(self, handler: BeforeSendHandlerType): 108 | self.get_container('process_message_before_send').append(handler) 109 | return handler 110 | 111 | def message_after_send(self, handler: AfterSendHandlerType): 112 | self.get_container('process_message_after_send').append(handler) 113 | return handler 114 | 115 | def message_after_handle(self, handler: AfterHandleHandlerType): 116 | self.get_container('process_message_after_handle').append(handler) 117 | return handler 118 | -------------------------------------------------------------------------------- /amiyabot/factory/factoryTyping.py: -------------------------------------------------------------------------------- 1 | import re 2 | import abc 3 | 4 | from typing import Any, Type, Dict, List, Tuple, Union, Optional, Callable, Awaitable 5 | from dataclasses import dataclass 6 | from amiyabot.builtin.messageChain import Chain 7 | from amiyabot.builtin.message import EventType, Message, Equal, Waiter, Verify 8 | from amiyabot.adapters import BotAdapterProtocol 9 | 10 | KeywordsType = Union[str, Equal, re.Pattern, List[Union[str, Equal, re.Pattern]]] 11 | CheckPrefixType = Optional[Union[bool, List[str]]] 12 | 13 | NoneReturn = Awaitable[None] 14 | BoolReturn = Awaitable[Optional[bool]] 15 | ChainReturn = Awaitable[Optional[Chain]] 16 | EventReturn = Awaitable[Optional[EventType]] 17 | MessageReturn = Awaitable[Optional[Message]] 18 | MessageOrBoolReturn = Awaitable[Optional[Union[Message, bool]]] 19 | VerifyResultReturn = Awaitable[Union[bool, Tuple[bool, int], Tuple[bool, int, Any]]] 20 | 21 | FunctionType = Callable[[Message], ChainReturn] 22 | VerifyMethodType = Callable[[Message], VerifyResultReturn] 23 | EventHandlerType = Callable[[EventType, BotAdapterProtocol], NoneReturn] 24 | ExceptionHandlerType = Callable[[Exception, BotAdapterProtocol, Union[Message, EventType]], NoneReturn] 25 | 26 | EventCreatedHandlerType = Callable[[EventType, BotAdapterProtocol], EventReturn] 27 | MessageCreatedHandlerType = Callable[[Message, BotAdapterProtocol], MessageOrBoolReturn] 28 | BeforeWaiterSetHandlerType = Callable[[Message, Waiter, BotAdapterProtocol], MessageReturn] 29 | BeforeHandleHandlerType = Callable[[Message, str, BotAdapterProtocol], BoolReturn] 30 | BeforeSendHandlerType = Callable[[Chain, str, BotAdapterProtocol], ChainReturn] 31 | AfterSendHandlerType = Callable[[Chain, str, BotAdapterProtocol], NoneReturn] 32 | AfterHandleHandlerType = Callable[[Optional[Chain], str, BotAdapterProtocol], NoneReturn] 33 | 34 | 35 | @dataclass 36 | class GroupConfig: 37 | group_id: str 38 | check_prefix: bool = True 39 | allow_direct: bool = False 40 | direct_only: bool = False 41 | 42 | def __str__(self): 43 | return self.group_id 44 | 45 | 46 | @dataclass 47 | class MessageHandlerItem: 48 | function: FunctionType 49 | prefix_keywords: Callable[[], List[str]] 50 | 51 | group_id: Optional[str] = None 52 | group_config: Optional[GroupConfig] = None 53 | keywords: Optional[KeywordsType] = None 54 | allow_direct: Optional[bool] = None 55 | direct_only: bool = False 56 | check_prefix: Optional[CheckPrefixType] = None 57 | custom_verify: Optional[VerifyMethodType] = None 58 | level: Optional[int] = None 59 | 60 | def __repr__(self): 61 | return f'' 62 | 63 | @abc.abstractmethod 64 | async def verify(self, data: Message) -> Verify: 65 | raise NotImplementedError 66 | 67 | @abc.abstractmethod 68 | async def action(self, data: Message) -> Optional[Union[Chain, str]]: 69 | raise NotImplementedError 70 | 71 | 72 | PrefixKeywords = List[str] 73 | EventHandlers = Dict[str, List[EventHandlerType]] 74 | MessageHandlers = List[MessageHandlerItem] 75 | ExceptionHandlers = Dict[Type[Exception], List[ExceptionHandlerType]] 76 | MessageHandlersIDMap = Dict[int, str] 77 | 78 | EventCreatedHandlers = List[EventCreatedHandlerType] 79 | MessageCreatedHandlers = List[MessageCreatedHandlerType] 80 | BeforeWaiterSetHandlers = List[BeforeWaiterSetHandlerType] 81 | BeforeHandleHandlers = List[BeforeHandleHandlerType] 82 | BeforeSendHandlers = List[BeforeSendHandlerType] 83 | AfterSendHandlers = List[AfterSendHandlerType] 84 | AfterHandleHandlers = List[AfterHandleHandlerType] 85 | -------------------------------------------------------------------------------- /amiyabot/factory/implemented.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from typing import List 4 | from dataclasses import dataclass 5 | from amiyautils import remove_prefix_once 6 | from amiyabot.builtin.message import Message, MessageMatch, Verify, Equal 7 | from amiyabot.factory.factoryTyping import MessageHandlerItem, KeywordsType 8 | 9 | 10 | @dataclass 11 | class MessageHandlerItemImpl(MessageHandlerItem): 12 | def __check(self, result: Verify, data: Message, obj: KeywordsType): 13 | methods = { 14 | str: MessageMatch.check_str, 15 | Equal: MessageMatch.check_equal, 16 | re.Pattern: MessageMatch.check_reg, 17 | } 18 | t = type(obj) 19 | 20 | if t in methods: 21 | method = methods[t] 22 | check = result.set_attrs(*method(data, obj, self.level)) 23 | if check: 24 | return check 25 | 26 | elif t is list: 27 | for item in obj: 28 | check = self.__check(result, data, item) 29 | if check: 30 | return check 31 | 32 | return result 33 | 34 | @classmethod 35 | def update_data(cls, data: Message, prefix_keywords: List[str]): 36 | def func(): 37 | text, prefix = remove_prefix_once(data.text, prefix_keywords) 38 | if prefix: 39 | data.text_prefix = prefix 40 | data.set_text(text, set_original=False) 41 | 42 | return func 43 | 44 | async def verify(self, data: Message): 45 | result = Verify(False) 46 | 47 | # 检查是否支持私信 48 | direct_only = self.direct_only or (self.group_config and self.group_config.direct_only) 49 | 50 | if data.is_direct: 51 | if not direct_only: 52 | if self.allow_direct is None: 53 | if not self.group_config or not self.group_config.allow_direct: 54 | return result 55 | 56 | if self.allow_direct is False: 57 | return result 58 | else: 59 | if direct_only: 60 | return result 61 | 62 | # 检查是否包含前缀触发词或被 @ 63 | flag = False 64 | 65 | if self.check_prefix is None: 66 | need_check_prefix = self.group_config.check_prefix if self.group_config else True 67 | else: 68 | need_check_prefix = self.check_prefix 69 | 70 | if need_check_prefix: 71 | if data.is_at: 72 | flag = True 73 | else: 74 | prefix_keywords = need_check_prefix if isinstance(need_check_prefix, list) else self.prefix_keywords() 75 | 76 | if not prefix_keywords: 77 | flag = True 78 | else: 79 | _, prefix = remove_prefix_once(data.text, prefix_keywords) 80 | if prefix: 81 | flag = True 82 | result.on_selected = self.update_data(data, prefix_keywords) 83 | 84 | # 若不通过以上检查,且关键字不为全等句式(Equal) 85 | # 则允许当关键字为列表时,筛选列表内的全等句式继续执行校验,否则校验不通过 86 | if need_check_prefix and not flag and not isinstance(self.keywords, Equal): 87 | equal_filter = [n for n in self.keywords if isinstance(n, Equal)] if isinstance(self.keywords, list) else [] 88 | if equal_filter: 89 | self.keywords = equal_filter 90 | else: 91 | return result 92 | 93 | # 执行自定义校验并修正其返回值 94 | if self.custom_verify: 95 | if result.on_selected: 96 | result.on_selected() 97 | 98 | res = await self.custom_verify(data) 99 | 100 | if isinstance(res, bool) or res is None: 101 | result.result = bool(res) 102 | result.weight = int(bool(res)) 103 | 104 | elif isinstance(res, tuple): 105 | contrast = bool(res[0]), int(bool(res[0])), None 106 | res_len = len(res) 107 | res = (res + contrast[res_len:])[:3] 108 | 109 | result.set_attrs(*res) 110 | 111 | return result 112 | 113 | return self.__check(result, data, self.keywords) 114 | 115 | async def action(self, data: Message): 116 | return await self.function(data) 117 | -------------------------------------------------------------------------------- /amiyabot/handler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmiyaBot/Amiya-Bot-core/5bd2377dd78247500726d117918d074cf6de4bf1/amiyabot/handler/__init__.py -------------------------------------------------------------------------------- /amiyabot/handler/messageHandler.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from typing import Dict 4 | from amiyabot.builtin.message import * 5 | from amiyabot.builtin.messageChain import Chain 6 | from amiyabot.factory import MessageHandlerItem, BotHandlerFactory, EventHandlerType 7 | from amiyalog.manager import LoggerManager, LOG_FILE_SAVE_PATH 8 | 9 | ChoiceRes = Union[MessageHandlerItem, Waiter] 10 | 11 | adapter_log: Dict[str, LoggerManager] = {} 12 | 13 | 14 | async def message_handler(bot: BotHandlerFactory, data: Union[Message, Event, EventList]): 15 | appid = str(bot.appid) 16 | instance = bot.instance 17 | 18 | if appid not in adapter_log: 19 | adapter_log[appid] = LoggerManager( 20 | name=str(instance), 21 | save_path=os.path.join(LOG_FILE_SAVE_PATH, 'bots'), 22 | save_filename=appid, 23 | ) 24 | 25 | _log = adapter_log[appid] 26 | 27 | # 执行事件响应 28 | if not isinstance(data, Message): 29 | # todo 生命周期 - event_created 30 | for method in bot.process_event_created: 31 | data = await method(data, instance) or data 32 | 33 | await event_handler(bot, data, _log) 34 | return None 35 | 36 | _log.info( 37 | data.__str__(), 38 | extra={ 39 | 'message_data': data, 40 | }, 41 | ) 42 | 43 | data.bot = bot 44 | 45 | # todo 生命周期 - message_created 46 | for method in bot.process_message_created: 47 | method_ret = await method(data, instance) 48 | 49 | if method_ret is False: 50 | return None 51 | 52 | if method_ret is not None: 53 | data = method_ret 54 | 55 | # 检查是否存在等待事件 56 | waiter = await find_wait_event(data) 57 | 58 | # 若存在等待事件并且等待事件设置了强制等待,则直接进入事件 59 | if waiter and waiter.force: 60 | # todo 生命周期 - message_before_waiter_set(1) 61 | for method in bot.process_message_before_waiter_set: 62 | data = await method(data, waiter, instance) or data 63 | 64 | waiter.set(data) 65 | return None 66 | 67 | # 选择功能或等待事件 68 | handler = await choice_handlers(data, bot.message_handlers, waiter) 69 | if not handler: 70 | return 71 | 72 | if isinstance(handler, MessageHandlerItem): 73 | factory_name = bot.message_handler_id_map[id(handler.function)] 74 | 75 | data.factory_name = factory_name 76 | 77 | # todo 生命周期 - message_before_handle 78 | flag = True 79 | for method in bot.process_message_before_handle: 80 | res = await method(data, factory_name, instance) 81 | if res is False: 82 | flag = False 83 | if not flag: 84 | return None 85 | 86 | # 执行功能,并取消存在的等待事件 87 | reply = await handler.action(data) 88 | if reply: 89 | if waiter and waiter.type == 'user': 90 | waiter.cancel() 91 | 92 | if isinstance(reply, str): 93 | reply = Chain(data, at=False).text(reply) 94 | 95 | await data.send(reply) 96 | 97 | # todo 生命周期 - message_after_handle 98 | for method in bot.process_message_after_handle: 99 | await method(reply, factory_name, instance) 100 | 101 | return None 102 | 103 | if isinstance(handler, WaitEvent): 104 | # todo 生命周期 - message_before_waiter_set(2) 105 | for method in bot.process_message_before_waiter_set: 106 | data = await method(data, handler, instance) or data 107 | 108 | handler.set(data) 109 | 110 | 111 | async def event_handler(bot: BotHandlerFactory, data: Union[Event, EventList], _log: LoggerManager): 112 | methods = [] 113 | if '__all_event__' in bot.event_handlers: 114 | methods += bot.event_handlers['__all_event__'] 115 | 116 | if isinstance(data, Event): 117 | data = EventList([data]) 118 | 119 | for item in data: 120 | sub_methods: List[EventHandlerType] = [*methods] 121 | 122 | if item.event_name in bot.event_handlers: 123 | sub_methods += bot.event_handlers[item.event_name] 124 | 125 | if sub_methods: 126 | _log.info(item.__str__()) 127 | 128 | for method in sub_methods: 129 | async with _log.catch('event handler error:'): 130 | await method(item, bot.instance) 131 | 132 | 133 | async def choice_handlers(data: Message, handlers: List[MessageHandlerItem], waiter: Waiter) -> Optional[ChoiceRes]: 134 | candidate: List[Tuple[Verify, ChoiceRes]] = [] 135 | 136 | if waiter: 137 | candidate.append((Verify(True, waiter.level), waiter)) 138 | 139 | for item in handlers: 140 | check = await item.verify(data.copy()) 141 | if check: 142 | candidate.append((check, item)) 143 | 144 | if not candidate: 145 | return None 146 | 147 | # 选择排序第一的结果 148 | _sorted = sorted(candidate, key=lambda n: n[0].weight, reverse=True) 149 | selected = _sorted[0] 150 | 151 | # 将 Verify 结果赋值给 Message 152 | data.verify = selected[0] 153 | if data.verify.on_selected: 154 | data.verify.on_selected() 155 | 156 | return selected[1] 157 | 158 | 159 | async def find_wait_event(data: Message) -> Waiter: 160 | waiter = None 161 | 162 | if data.is_direct: 163 | # 私信等待事件 164 | target_id = f'{data.instance.appid}_{data.guild_id}_{data.user_id}' 165 | if target_id in wait_events_bucket: 166 | waiter = wait_events_bucket[target_id] 167 | else: 168 | # 子频道用户等待事件 169 | target_id = f'{data.instance.appid}_{data.channel_id}_{data.user_id}' 170 | if target_id in wait_events_bucket: 171 | waiter = wait_events_bucket[target_id] 172 | 173 | # 子频道全体等待事件 174 | channel_target_id = f'{data.instance.appid}_{data.channel_id}' 175 | if channel_target_id in wait_events_bucket: 176 | channel_waiter = wait_events_bucket[channel_target_id] 177 | 178 | # 如果没有用户事件或全体事件是强制等待的,则覆盖 179 | if not waiter or channel_waiter.force: 180 | waiter = channel_waiter 181 | 182 | # 检查等待事件是否活跃 183 | try: 184 | if waiter and not waiter.check_alive(): 185 | waiter = None 186 | except WaitEventCancel: 187 | waiter = None 188 | 189 | return waiter 190 | -------------------------------------------------------------------------------- /amiyabot/network/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmiyaBot/Amiya-Bot-core/5bd2377dd78247500726d117918d074cf6de4bf1/amiyabot/network/__init__.py -------------------------------------------------------------------------------- /amiyabot/network/download.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import certifi 3 | import aiohttp 4 | import requests 5 | 6 | from io import BytesIO 7 | from typing import Dict, Optional 8 | from amiyalog import logger as log 9 | 10 | default_headers = { 11 | 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) ' 12 | 'AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1' 13 | } 14 | ssl_context = ssl.create_default_context(cafile=certifi.where()) 15 | 16 | 17 | def download_sync( 18 | url: str, 19 | headers: Optional[Dict[str, str]] = None, 20 | stringify: bool = False, 21 | progress: bool = False, 22 | **kwargs, 23 | ): 24 | try: 25 | stream = requests.get(url, headers={**default_headers, **(headers or {})}, stream=True, timeout=30, **kwargs) 26 | container = BytesIO() 27 | 28 | if stream.status_code == 200: 29 | iter_content = stream.iter_content(chunk_size=1024) 30 | if progress and 'content-length' in stream.headers: 31 | iter_content = log.download_progress( 32 | url.split('/')[-1], 33 | max_size=int(stream.headers['content-length']), 34 | chunk_size=1024, 35 | iter_content=iter_content, 36 | ) 37 | for chunk in iter_content: 38 | if chunk: 39 | container.write(chunk) 40 | 41 | content = container.getvalue() 42 | 43 | if stringify: 44 | return str(content, encoding='utf-8') 45 | return content 46 | except requests.exceptions.ConnectionError: 47 | pass 48 | except Exception as e: 49 | log.error(e, desc='download error:') 50 | 51 | 52 | async def download_async(url: str, headers: Optional[Dict[str, str]] = None, stringify: bool = False, **kwargs): 53 | async with log.catch('download error:', ignore=[requests.exceptions.SSLError]): 54 | async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=ssl_context), trust_env=True) as session: 55 | async with session.get(url, headers={**default_headers, **(headers or {})}, **kwargs) as res: 56 | if res.status == 200: 57 | if stringify: 58 | return await res.text() 59 | return await res.read() 60 | -------------------------------------------------------------------------------- /amiyabot/network/httpRequests.py: -------------------------------------------------------------------------------- 1 | import json 2 | import aiohttp 3 | 4 | from typing import Optional, Union, Any 5 | from amiyalog import logger as log 6 | 7 | 8 | class HttpRequests: 9 | success = [200, 204] 10 | async_success = [201, 202, 304023, 304024] 11 | 12 | @classmethod 13 | async def request( 14 | cls, 15 | url: str, 16 | method: str = 'post', 17 | request_name: Optional[str] = None, 18 | ignore_error: bool = False, 19 | **kwargs, 20 | ): 21 | response = Response('') 22 | try: 23 | request_name = (request_name or method).upper() 24 | 25 | async with aiohttp.ClientSession(trust_env=True) as session: 26 | async with session.request(method, url, **kwargs) as res: 27 | response = Response(await res.text()) 28 | response.response = res 29 | 30 | log_text = ( 31 | f'Request <{url}>[{request_name}]. ' 32 | f'Got code {res.status} {res.reason}. ' 33 | f'Response: {response.text}' 34 | ) 35 | log.debug(log_text) 36 | 37 | if res.status not in cls.success + cls.async_success: 38 | if not ignore_error: 39 | log.warning(log_text) 40 | 41 | return response 42 | 43 | except aiohttp.ClientConnectorError as e: 44 | response.error = e 45 | if not ignore_error: 46 | log.error(f'Unable to request <{url}>[{request_name}]') 47 | 48 | except Exception as e: 49 | response.error = e 50 | if not ignore_error: 51 | log.error(e) 52 | 53 | return response 54 | 55 | @classmethod 56 | async def get( 57 | cls, 58 | url: str, 59 | params: Optional[dict] = None, 60 | headers: Optional[dict] = None, 61 | **kwargs, 62 | ): 63 | return await cls.request( 64 | url, 65 | 'get', 66 | params=params, 67 | headers=headers, 68 | **kwargs, 69 | ) 70 | 71 | @classmethod 72 | async def post( 73 | cls, 74 | url: str, 75 | payload: Optional[Union[dict, list]] = None, 76 | headers: Optional[dict] = None, 77 | **kwargs, 78 | ): 79 | _headers = {'Content-Type': 'application/json', **(headers or {})} 80 | _payload = {**(payload or {})} 81 | return await cls.request( 82 | url, 83 | 'post', 84 | request_name='post', 85 | data=json.dumps(_payload), 86 | headers=_headers, 87 | **kwargs, 88 | ) 89 | 90 | @classmethod 91 | async def post_form( 92 | cls, 93 | url: str, 94 | payload: Optional[dict] = None, 95 | headers: Optional[dict] = None, 96 | **kwargs, 97 | ): 98 | _headers = {**(headers or {})} 99 | 100 | data = cls.__build_form_data(payload) 101 | 102 | return await cls.request( 103 | url, 104 | 'post', 105 | 'post-form', 106 | data=data, 107 | headers=_headers, 108 | **kwargs, 109 | ) 110 | 111 | @classmethod 112 | async def post_upload( 113 | cls, 114 | url: str, 115 | file: bytes, 116 | filename: str = 'file', 117 | file_field: str = 'file', 118 | payload: Optional[dict] = None, 119 | headers: Optional[dict] = None, 120 | **kwargs, 121 | ): 122 | _headers = {**(headers or {})} 123 | 124 | data = cls.__build_form_data(payload) 125 | data.add_field(file_field, file, filename=filename, content_type='application/octet-stream') 126 | 127 | return await cls.request( 128 | url, 129 | 'post', 130 | 'post-upload', 131 | data=data, 132 | headers=_headers, 133 | **kwargs, 134 | ) 135 | 136 | @classmethod 137 | def __build_form_data(cls, payload: Optional[dict]): 138 | data = aiohttp.FormData() 139 | 140 | for field, value in (payload or {}).items(): 141 | if value is None: 142 | continue 143 | if type(value) in [dict, list]: 144 | value = json.dumps(value, ensure_ascii=False) 145 | data.add_field(field, value) 146 | 147 | return data 148 | 149 | 150 | class Response(str): 151 | def __init__(self, res_text: str): 152 | self.text = res_text 153 | self.response: Optional[aiohttp.ClientResponse] = None 154 | self.error: Optional[Exception] = None 155 | 156 | @property 157 | def json(self): 158 | try: 159 | return json.loads(self.text) 160 | except json.JSONDecodeError: 161 | return None 162 | 163 | 164 | class ResponseException(Exception): 165 | def __init__(self, code: int, message: str, data: Optional[Any] = None): 166 | self.code = code 167 | self.data = data 168 | self.message = message 169 | 170 | def __str__(self): 171 | return f'[{self.code}] {self.message} -- data: {self.data}' 172 | 173 | 174 | http_requests = HttpRequests 175 | -------------------------------------------------------------------------------- /amiyabot/signalHandler.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import inspect 3 | import asyncio 4 | 5 | from typing import List, Callable 6 | 7 | 8 | class SignalHandler: 9 | on_shutdown: List[Callable] = [] 10 | 11 | @classmethod 12 | def exec_shutdown_handlers(cls): 13 | for action in cls.on_shutdown: 14 | if inspect.iscoroutinefunction(action): 15 | asyncio.create_task(action()) 16 | else: 17 | action() 18 | 19 | 20 | def sigint_handler(*args): 21 | SignalHandler.exec_shutdown_handlers() 22 | # sys.exit(0) 23 | 24 | 25 | signal.signal(signal.SIGINT, sigint_handler) 26 | -------------------------------------------------------------------------------- /amiyabot/typeIndexes.py: -------------------------------------------------------------------------------- 1 | T_BotAdapterProtocol = 'amiyabot.adapters.BotAdapterProtocol' 2 | T_BotHandlerFactory = 'amiyabot.factory.BotHandlerFactory' 3 | T_Chain = 'amiyabot.builtin.messageChain.Chain' 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp~=3.7.4.post0 2 | amiyahttp~=0.0.5 3 | amiyalog~=0.0.1 4 | amiyautils~=0.0.1 5 | apscheduler~=3.10.4 6 | certifi~=2023.7.22 7 | dhash~=1.3 8 | graiax-silkcoder~=0.3.4 9 | jieba~=0.42.1 10 | peewee~=3.14.10 11 | pillow~=9.5.0 12 | playwright~=1.31.1 13 | pymysql~=1.0.2 14 | pyyaml~=6.0 15 | requests~=2.27.1 16 | websockets~=10.1 17 | -------------------------------------------------------------------------------- /scripts/black.sh: -------------------------------------------------------------------------------- 1 | black amiyabot --skip-string-normalization --line-length 120 2 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | twine upload dist/* 2 | -------------------------------------------------------------------------------- /scripts/pylint.sh: -------------------------------------------------------------------------------- 1 | pylint amiyabot --rcfile=pylint.conf 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import random 4 | import setuptools 5 | 6 | from wheel.bdist_wheel import bdist_wheel as _bdist_wheel 7 | from urllib import request 8 | 9 | 10 | def ver_num(_v): 11 | num = int(_v.replace('.', '')) 12 | if num < 1000: 13 | num *= 10 14 | return num 15 | 16 | 17 | def get_new_version(): 18 | pypi = json.loads(request.urlopen('https://pypi.python.org/pypi/amiyabot/json').read()) 19 | v_list = {ver_num(v): v for v in pypi['releases'].keys()} 20 | s_list = sorted(v_list) 21 | latest = v_list[s_list[-1]] 22 | 23 | print(f'latest: {latest}') 24 | 25 | return f'{latest}' 26 | 27 | 28 | # Auto increment the version number. 29 | # 1.0.9 -> 1.1.0 , 1.9.9 -> 2.0.0 but 9.9.9 -> 10.0.0 30 | def incr_version(v): 31 | v = v.split('.') 32 | if len(v) == 3: 33 | if int(v[2]) >= 9: 34 | v[2] = '0' 35 | if int(v[1]) >= 9: 36 | v[1] = '0' 37 | v[0] = str(int(v[0]) + 1) 38 | else: 39 | v[1] = str(int(v[1]) + 1) 40 | else: 41 | v[2] = str(int(v[2]) + 1) 42 | else: 43 | v.append('1') 44 | return '.'.join(v) 45 | 46 | 47 | class CustomBdistWheelCommand(_bdist_wheel): 48 | user_options = _bdist_wheel.user_options + [ 49 | ( 50 | 'auto-increment-version', 51 | None, 52 | 'Auto increment the version number before building with special rule: 1.0.9 -> 1.1.0 , 1.9.9 -> 2.0.0 . However 9.9.9 -> 10.0.0', 53 | ) 54 | ] 55 | 56 | def initialize_options(self): 57 | _bdist_wheel.initialize_options(self) 58 | self.auto_increment_version = False 59 | 60 | def finalize_options(self): 61 | latest_version = get_new_version() 62 | if self.auto_increment_version: 63 | new_version = incr_version(latest_version) 64 | print(f'Auto-incrementing version to: {new_version}') 65 | self.distribution.metadata.version = new_version 66 | else: 67 | new_version = incr_version(latest_version) 68 | release_new = input(f'new?: {new_version} (Y/n)') 69 | 70 | if not (not release_new or release_new.lower() == 'y'): 71 | new_version = input('version: ') 72 | 73 | self.distribution.metadata.version = new_version 74 | 75 | # 加入一个随机数的BuildNumber保证Action可以重复执行 76 | build_number = random.randint(0, 1000) 77 | self.build_number = f'{build_number}' 78 | 79 | _bdist_wheel.finalize_options(self) 80 | 81 | def run(self): 82 | _bdist_wheel.run(self) 83 | 84 | 85 | with open('README.md', mode='r', encoding='utf-8') as md: 86 | description = md.read() 87 | 88 | with open('requirements.txt', mode='r', encoding='utf-8') as req: 89 | requirements = sorted(req.read().lower().strip('\n').split('\n')) 90 | 91 | with open('requirements.txt', mode='w', encoding='utf-8') as req: 92 | req.write('\n'.join(requirements)) 93 | 94 | data_files = [] 95 | for root, dirs, files in os.walk('amiyabot/_assets'): 96 | for item in files: 97 | data_files.append(os.path.join(root, item)) 98 | 99 | setuptools.setup( 100 | name='amiyabot', 101 | version='0.0.1', 102 | author='vivien8261', 103 | author_email='826197021@qq.com', 104 | url='https://www.amiyabot.com', 105 | license='MIT Licence', 106 | description='Python 异步渐进式机器人框架', 107 | long_description=description, 108 | long_description_content_type='text/markdown', 109 | packages=setuptools.find_packages(include=['amiyabot', 'amiyabot.*']), 110 | data_files=[('amiyabot', data_files)], 111 | include_package_data=True, 112 | python_requires='>=3.8', 113 | install_requires=requirements, 114 | cmdclass={ 115 | 'bdist_wheel': CustomBdistWheelCommand, 116 | }, 117 | ) 118 | 119 | # python setup.py bdist_wheel --auto-increment-version 120 | --------------------------------------------------------------------------------