Merge pull request #465 from Soulter/feat-qo-webhook
支持 Webhook 方式接入 QQ 官方机器人平台
This commit is contained in:
@@ -23,3 +23,4 @@ package-lock.json
|
||||
package.json
|
||||
venv/*
|
||||
packages/python_interpreter/workplace
|
||||
.venv/*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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 适配器已关闭。")
|
||||
|
||||
+2
-1
@@ -19,4 +19,5 @@ aiodocker
|
||||
silk-python
|
||||
|
||||
lark-oapi
|
||||
ormsgpack
|
||||
ormsgpack
|
||||
cryptography
|
||||
Reference in New Issue
Block a user