From d7c9a8ed29b323453a093c73018949c5d2c36d85 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 11 Feb 2025 11:19:50 +0800 Subject: [PATCH 1/3] chore: webhook server, client --- astrbot/core/config/default.py | 10 +++ astrbot/core/platform/manager.py | 2 + .../qqofficial_webhook/qo_webhook_adapter.py | 64 ++++++++++++++ .../qqofficial_webhook/qo_webhook_server.py | 88 +++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py create mode 100644 astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py 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_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py new file mode 100644 index 000000000..2a5b22d2f --- /dev/null +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -0,0 +1,64 @@ +import botpy +import logging +import time +import asyncio +import os + +from botpy import BotAPI, BotHttp +from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata +from astrbot.api.event import MessageChain +from typing import Union, List +from astrbot.api.message_components import Image, Plain, At +from astrbot.core.platform.astr_message_event import MessageSesion +# from .qqofficial_message_event import QQOfficialMessageEvent +from ...register import register_platform_adapter +from astrbot.core.message.components import BaseMessageComponent +from .qo_webhook_server import QQOfficialWebhook + +# remove logger handler +for handler in logging.root.handlers[:]: + logging.root.removeHandler(handler) + +@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'] + qq_group = platform_config['enable_group_c2c'] + guild_dm = platform_config['enable_guild_direct_message'] + + if qq_group: + self.intents = botpy.Intents( + public_messages=True, + public_guild_messages=True, + direct_message=guild_dm + ) + else: + self.intents = botpy.Intents( + public_guild_messages=True, + direct_message=guild_dm + ) + + + 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 + ) + 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_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py new file mode 100644 index 000000000..f61cc76ff --- /dev/null +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -0,0 +1,88 @@ +import aiohttp +import quart +import json +import asyncio +import typing +from botpy import BotAPI, BotHttp, Client, Token, BotWebSocket, ConnectionSession +from astrbot.api import logger + +class QQOfficialWebhook(): + def __init__(self, config: dict, event_queue: asyncio.Queue): + 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.event_queue = event_queue + + async def initialize(self): + self.user = await self.http.login(self.token) + logger.info(f"已登录 QQ 官方机器人账号: {self.user}") + + async def bot_connect(): + pass + + self._connection = ConnectionSession( + max_async=1, + connect=bot_connect, + dispatch=self.dispatch, + loop=asyncio.get_event_loop(), + api=self.api, + ) + + async def dispatch(self, event: str, *args: typing.Any, **kwargs: typing.Any): + print("dispatch:", locals()) + + async def callback(self): + msg: dict = await quart.request.json + logger.debug(f"收到 qq_official_webhook 回调: {msg}") + + # if await self._is_system_event(msg, ws): + # return + + event = msg.get("t") + opcode = msg.get("op") + event_seq = msg["s"] + # if event_seq > 0: + # self._session["last_seq"] = event_seq + + if event == "READY": + # 心跳检查 + pass + + if event == "RESUMED": + # 心跳检查 + pass + + 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) + + + 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 From d839e729983828e45e0c135b8bd7adf169c1d4c0 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 11 Feb 2025 01:18:25 -0500 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20Webhook=20?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E6=8E=A5=E5=85=A5=20QQ=20=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../qqofficial/qqofficial_platform_adapter.py | 13 +-- .../qqofficial_webhook/qo_webhook_adapter.py | 81 +++++++++++++------ .../qqofficial_webhook/qo_webhook_event.py | 18 +++++ .../qqofficial_webhook/qo_webhook_server.py | 62 +++++++++----- requirements.txt | 3 +- 6 files changed, 128 insertions(+), 50 deletions(-) create mode 100644 astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py 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/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 index 2a5b22d2f..ad443df80 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -1,23 +1,60 @@ import botpy import logging -import time import asyncio -import os +import botpy.message +import botpy.types +import botpy.types.message -from botpy import BotAPI, BotHttp -from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata +from botpy import Client +from astrbot.api.platform import Platform, AstrBotMessage, MessageType, PlatformMetadata from astrbot.api.event import MessageChain -from typing import Union, List -from astrbot.api.message_components import Image, Plain, At from astrbot.core.platform.astr_message_event import MessageSesion -# from .qqofficial_message_event import QQOfficialMessageEvent +from .qo_webhook_event import QQOfficialWebhookMessageEvent from ...register import register_platform_adapter -from astrbot.core.message.components import BaseMessageComponent 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): @@ -30,21 +67,18 @@ class QQOfficialWebhookPlatformAdapter(Platform): self.appid = platform_config['appid'] self.secret = platform_config['secret'] self.unique_session = platform_settings['unique_session'] - qq_group = platform_config['enable_group_c2c'] - guild_dm = platform_config['enable_guild_direct_message'] - - if qq_group: - self.intents = botpy.Intents( - public_messages=True, - public_guild_messages=True, - direct_message=guild_dm - ) - else: - self.intents = botpy.Intents( - public_guild_messages=True, - direct_message=guild_dm - ) + 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") @@ -58,7 +92,8 @@ class QQOfficialWebhookPlatformAdapter(Platform): async def run(self): self.webhook_helper = QQOfficialWebhook( self.config, - self._event_queue + 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 index f61cc76ff..f265e7fc0 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -1,13 +1,20 @@ 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): + 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) @@ -17,17 +24,20 @@ class QQOfficialWebhook(): 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 @@ -35,34 +45,43 @@ class QQOfficialWebhook(): self._connection = ConnectionSession( max_async=1, connect=bot_connect, - dispatch=self.dispatch, + dispatch=self.client.ws_dispatch, loop=asyncio.get_event_loop(), api=self.api, ) - - async def dispatch(self, event: str, *args: typing.Any, **kwargs: typing.Any): - print("dispatch:", locals()) + + 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}") - # if await self._is_system_event(msg, ws): - # return - event = msg.get("t") opcode = msg.get("op") - event_seq = msg["s"] - # if event_seq > 0: - # self._session["last_seq"] = event_seq - - if event == "READY": - # 心跳检查 - pass - - if event == "RESUMED": - # 心跳检查 - pass + 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() @@ -73,6 +92,7 @@ class QQOfficialWebhook(): else: func(msg) + return {"opcode": 12} async def start_polling(self): await self.server.run_task( 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 From b068013343dcb8c811266a800a7d4268593244d2 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 11 Feb 2025 01:25:17 -0500 Subject: [PATCH 3/3] perf: better handle in qq official send --- .../platform/sources/qqofficial/qqofficial_message_event.py | 3 +++ 1 file changed, 3 insertions(+) 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):