diff --git a/.gitignore b/.gitignore index c1311a9ff..7745d18ba 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ package-lock.json package.json venv/* packages/python_interpreter/workplace +.venv/* diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 763f6783c..55d8fbd16 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -109,6 +109,16 @@ CONFIG_METADATA_2 = { "enable_group_c2c": True, "enable_guild_direct_message": True, }, + "qq_official_webhook(QQ)": { + "id": "default", + "type": "qq_official_webhook", + "enable": False, + "appid": "", + "secret": "", + "port": 6196, + "enable_group_c2c": True, + "enable_guild_direct_message": True, + }, "aiocqhtp(QQ)": { "id": "default", "type": "aiocqhttp", diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 09629c88e..46118b554 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -24,6 +24,8 @@ class PlatformManager(): from .sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter # noqa: F401 case "qq_official": from .sources.qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter # noqa: F401 + case "qq_official_webhook": + from .sources.qqofficial_webhook.qo_webhook_adapter import QQOfficialWebhookPlatformAdapter # noqa: F401 case "gewechat": from .sources.gewechat.gewechat_platform_adapter import GewechatPlatformAdapter # noqa: F401 case "lark": diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index ef66486bf..b21083c36 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -30,6 +30,9 @@ class QQOfficialMessageEvent(AstrMessageEvent): plain_text, image_base64, image_path = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer) + if not plain_text and not image_base64 and not image_path: + return + ref = None for i in self.send_buffer.chain: if isinstance(i, Reply): diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index de94ab6a4..4548bbf3c 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import botpy import logging import time @@ -28,25 +30,25 @@ class botClient(Client): # 收到群消息 async def on_group_at_message_create(self, message: botpy.message.GroupMessage): - abm = self.platform._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) abm.session_id = abm.sender.user_id if self.platform.unique_session else message.group_openid self._commit(abm) # 收到频道消息 async def on_at_message_create(self, message: botpy.message.Message): - abm = self.platform._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) abm.session_id = abm.sender.user_id if self.platform.unique_session else message.channel_id self._commit(abm) # 收到私聊消息 async def on_direct_message_create(self, message: botpy.message.DirectMessage): - abm = self.platform._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) abm.session_id = abm.sender.user_id self._commit(abm) # 收到 C2C 消息 async def on_c2c_message_create(self, message: botpy.message.C2CMessage): - abm = self.platform._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) abm.session_id = abm.sender.user_id self._commit(abm) @@ -102,7 +104,8 @@ class QQOfficialPlatformAdapter(Platform): "QQ 机器人官方 API 适配器", ) - def _parse_from_qqofficial(self, message: Union[botpy.message.Message, botpy.message.GroupMessage], + @staticmethod + def _parse_from_qqofficial(message: Union[botpy.message.Message, botpy.message.GroupMessage], message_type: MessageType): abm = AstrBotMessage() abm.type = message_type diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py new file mode 100644 index 000000000..ad443df80 --- /dev/null +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -0,0 +1,99 @@ +import botpy +import logging +import asyncio +import botpy.message +import botpy.types +import botpy.types.message + +from botpy import Client +from astrbot.api.platform import Platform, AstrBotMessage, MessageType, PlatformMetadata +from astrbot.api.event import MessageChain +from astrbot.core.platform.astr_message_event import MessageSesion +from .qo_webhook_event import QQOfficialWebhookMessageEvent +from ...register import register_platform_adapter +from .qo_webhook_server import QQOfficialWebhook +from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter + +# remove logger handler +for handler in logging.root.handlers[:]: + logging.root.removeHandler(handler) + +# QQ 机器人官方框架 +class botClient(Client): + def set_platform(self, platform: 'QQOfficialWebhookPlatformAdapter'): + self.platform = platform + + # 收到群消息 + async def on_group_at_message_create(self, message: botpy.message.GroupMessage): + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) + abm.session_id = abm.sender.user_id if self.platform.unique_session else message.group_openid + self._commit(abm) + + # 收到频道消息 + async def on_at_message_create(self, message: botpy.message.Message): + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) + abm.session_id = abm.sender.user_id if self.platform.unique_session else message.channel_id + self._commit(abm) + + # 收到私聊消息 + async def on_direct_message_create(self, message: botpy.message.DirectMessage): + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) + abm.session_id = abm.sender.user_id + self._commit(abm) + + # 收到 C2C 消息 + async def on_c2c_message_create(self, message: botpy.message.C2CMessage): + abm = self.platform._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) + abm.session_id = abm.sender.user_id + self._commit(abm) + + def _commit(self, abm: AstrBotMessage): + self.platform.commit_event(QQOfficialWebhookMessageEvent( + abm.message_str, + abm, + self.platform.meta(), + abm.session_id, + self + )) + +@register_platform_adapter("qq_official_webhook", "QQ 机器人官方 API 适配器(Webhook)") +class QQOfficialWebhookPlatformAdapter(Platform): + + def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None: + super().__init__(event_queue) + + self.config = platform_config + + self.appid = platform_config['appid'] + self.secret = platform_config['secret'] + self.unique_session = platform_settings['unique_session'] + + intents = botpy.Intents( + public_messages=True, + public_guild_messages=True, + direct_message=True + ) + self.client = botClient( + intents=intents, # 已经无用 + bot_log=False, + timeout=20, + ) + self.client.set_platform(self) + + async def send_by_session(self, session: MessageSesion, message_chain: MessageChain): + raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session") + + def meta(self) -> PlatformMetadata: + return PlatformMetadata( + "qq_official_webhook", + "QQ 机器人官方 API 适配器", + ) + + async def run(self): + self.webhook_helper = QQOfficialWebhook( + self.config, + self._event_queue, + self.client + ) + await self.webhook_helper.initialize() + await self.webhook_helper.start_polling() \ No newline at end of file diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py new file mode 100644 index 000000000..2056ab56e --- /dev/null +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py @@ -0,0 +1,18 @@ +import botpy +import botpy.message +import botpy.types +import botpy.types.message +from astrbot.core.utils.io import file_to_base64, download_image_by_url +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.api.message_components import Plain, Image, Reply +from botpy import Client +from botpy.http import Route +from astrbot.api import logger +from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent + + +class QQOfficialWebhookMessageEvent(QQOfficialMessageEvent): + def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, bot: Client): + super().__init__(message_str, message_obj, platform_meta, session_id, bot) + \ No newline at end of file diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py new file mode 100644 index 000000000..f265e7fc0 --- /dev/null +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -0,0 +1,108 @@ +import aiohttp +import quart +import json +import logging +import asyncio +import typing +from botpy import BotAPI, BotHttp, Client, Token, BotWebSocket, ConnectionSession +from astrbot.api import logger +import traceback +from cryptography.hazmat.primitives.asymmetric import ed25519 + +# remove logger handler +for handler in logging.root.handlers[:]: + logging.root.removeHandler(handler) + +class QQOfficialWebhook(): + def __init__(self, config: dict, event_queue: asyncio.Queue, botpy_client: Client): + self.appid = config['appid'] + self.secret = config['secret'] + self.port = config.get("port", 6194) + + if isinstance(self.port, str): + self.port = int(self.port) + + self.http: BotHttp = BotHttp(timeout=300) + self.api: BotAPI = BotAPI(http=self.http) + self.token = Token(self.appid, self.secret) + + self.server = quart.Quart(__name__) + self.server.add_url_rule('/astrbot-qo-webhook/callback', view_func=self.callback, methods=['POST']) + self.client = botpy_client + self.event_queue = event_queue + + async def initialize(self): + logger.info(f"正在登录到 QQ 官方机器人...") + self.user = await self.http.login(self.token) + logger.info(f"已登录 QQ 官方机器人账号: {self.user}") + # 直接注入到 botpy 的 Client,移花接木! + self.client.api = self.api + self.client.http = self.http + + async def bot_connect(): + pass + + self._connection = ConnectionSession( + max_async=1, + connect=bot_connect, + dispatch=self.client.ws_dispatch, + loop=asyncio.get_event_loop(), + api=self.api, + ) + + async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes: + seed = bot_secret + while len(seed) < target_size: + seed *= 2 + return seed[:target_size].encode('utf-8') + + + async def webhook_validation(self, validation_payload: dict): + seed = await self.repeat_seed(self.secret) + private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed) + msg = validation_payload.get("event_ts", "") + validation_payload.get("plain_token", "") + # sign + signature = private_key.sign(msg.encode()).hex() + response = { + "plain_token": validation_payload.get("plain_token"), + "signature": signature + } + return response + + async def callback(self): + msg: dict = await quart.request.json + logger.debug(f"收到 qq_official_webhook 回调: {msg}") + + event = msg.get("t") + opcode = msg.get("op") + data = msg.get("d") + + if opcode == 13: + # validation + signed = await self.webhook_validation(data) + print(signed) + return signed + + if event and opcode == BotWebSocket.WS_DISPATCH_EVENT: + event = msg["t"].lower() + try: + func = self._connection.parser[event] + except KeyError: + logger.error("_parser unknown event %s.", event) + else: + func(msg) + + return {"opcode": 12} + + async def start_polling(self): + await self.server.run_task( + host='0.0.0.0', + port=self.port, + shutdown_trigger=self.shutdown_trigger_placeholder + ) + + async def shutdown_trigger_placeholder(self): + while not self.event_queue.closed: + await asyncio.sleep(1) + logger.info("qq_official_webhook 适配器已关闭。") + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 58c531504..c077496e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,4 +19,5 @@ aiodocker silk-python lark-oapi -ormsgpack \ No newline at end of file +ormsgpack +cryptography \ No newline at end of file