├── .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 | 
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 |
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 |
--------------------------------------------------------------------------------