Merge pull request #1016 from wangcham/bugfix-branch

fix: add support for qq official webhook
This commit is contained in:
Junyan Qin (Chin)
2025-02-04 20:49:36 +08:00
committed by GitHub
16 changed files with 749 additions and 18 deletions

2
.gitignore vendored
View File

@@ -37,4 +37,4 @@ botpy.log*
/poc
/libs/wecom_api/test.py
/venv
/jp-tyo-churros-05.rockchin.top

View File

274
libs/qq_official_api/api.py Normal file
View File

@@ -0,0 +1,274 @@
import time
from quart import request
import base64
import binascii
import httpx
from quart import Quart
import xml.etree.ElementTree as ET
from typing import Callable, Dict, Any
from pkg.platform.types import events as platform_events, message as platform_message
import aiofiles
from .qqofficialevent import QQOfficialEvent
import json
import hmac
import base64
import hashlib
import traceback
from cryptography.hazmat.primitives.asymmetric import ed25519
from .qqofficialevent import QQOfficialEvent
def handle_validation(body: dict, bot_secret: str):
# bot正确的secert是32位的此处仅为了适配演示demo
while len(bot_secret) < 32:
bot_secret = bot_secret * 2
bot_secret = bot_secret[:32]
# 实际使用场景中以上三行内容可清除
seed_bytes = bot_secret.encode()
signing_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed_bytes)
msg = body['d']['event_ts'] + body['d']['plain_token']
msg_bytes = msg.encode()
signature = signing_key.sign(msg_bytes)
signature_hex = signature.hex()
response = {
"plain_token": body['d']['plain_token'],
"signature": signature_hex
}
return response
class QQOfficialClient:
def __init__(self, secret: str, token: str, app_id: str):
self.app = Quart(__name__)
self.app.add_url_rule(
"/callback/command",
"handle_callback",
self.handle_callback_request,
methods=["GET", "POST"],
)
self.secret = secret
self.token = token
self.app_id = app_id
self._message_handlers = {
}
self.base_url = "https://api.sgroup.qq.com"
self.access_token = ""
self.access_token_expiry_time = None
async def check_access_token(self):
"""检查access_token是否存在"""
if not self.access_token or await self.is_token_expired():
return False
return bool(self.access_token and self.access_token.strip())
async def get_access_token(self):
"""获取access_token"""
url = "https://bots.qq.com/app/getAppAccessToken"
async with httpx.AsyncClient() as client:
params = {
"appId":self.app_id,
"clientSecret":self.secret,
}
headers = {
"content-type":"application/json",
}
try:
response = await client.post(url,json=params,headers=headers)
if response.status_code == 200:
response_data = response.json()
access_token = response_data.get("access_token")
expires_in = int(response_data.get("expires_in",7200))
self.access_token_expiry_time = time.time() + expires_in - 60
if access_token:
self.access_token = access_token
except Exception as e:
raise Exception(f"获取access_token失败: {e}")
async def handle_callback_request(self):
"""处理回调请求"""
try:
# 读取请求数据
body = await request.get_data()
payload = json.loads(body)
# 验证是否为回调验证请求
if payload.get("op") == 13:
# 生成签名
response = handle_validation(payload, self.secret)
return response
if payload.get("op") == 0:
message_data = await self.get_message(payload)
if message_data:
event = QQOfficialEvent.from_payload(message_data)
await self._handle_message(event)
return {"code": 0, "message": "success"}
except Exception as e:
traceback.print_exc()
return {"error": str(e)}, 400
async def run_task(self, host: str, port: int, *args, **kwargs):
"""启动 Quart 应用"""
await self.app.run_task(host=host, port=port, *args, **kwargs)
def on_message(self, msg_type: str):
"""注册消息类型处理器"""
def decorator(func: Callable[[platform_events.Event], None]):
if msg_type not in self._message_handlers:
self._message_handlers[msg_type] = []
self._message_handlers[msg_type].append(func)
return func
return decorator
async def _handle_message(self, event:QQOfficialEvent):
"""处理消息事件"""
msg_type = event.t
if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]:
await handler(event)
async def get_message(self,msg:dict) -> Dict[str,Any]:
"""获取消息"""
message_data = {
"t": msg.get("t",{}),
"user_openid": msg.get("d",{}).get("author",{}).get("user_openid",{}),
"timestamp": msg.get("d",{}).get("timestamp",{}),
"d_author_id": msg.get("d",{}).get("author",{}).get("id",{}),
"content": msg.get("d",{}).get("content",{}),
"d_id": msg.get("d",{}).get("id",{}),
"id": msg.get("id",{}),
"channel_id": msg.get("d",{}).get("channel_id",{}),
"username": msg.get("d",{}).get("author",{}).get("username",{}),
"guild_id": msg.get("d",{}).get("guild_id",{}),
"member_openid": msg.get("d",{}).get("author",{}).get("openid",{}),
"group_openid": msg.get("d",{}).get("group_openid",{})
}
attachments = msg.get("d", {}).get("attachments", [])
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
image_attachments_type = [attachment['content_type'] for attachment in attachments if await self.is_image(attachment)]
if image_attachments:
message_data["image_attachments"] = image_attachments[0]
message_data["content_type"] = image_attachments_type[0]
else:
message_data["image_attachments"] = None
return message_data
async def is_image(self,attachment:dict) -> bool:
"""判断是否为图片附件"""
content_type = attachment.get("content_type","")
return content_type.startswith("image/")
async def send_private_text_msg(self,user_openid:str,content:str,msg_id:str):
"""发送私聊消息"""
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + "/v2/users/" + user_openid + "/messages"
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"QQBot {self.access_token}",
"Content-Type": "application/json",
}
data = {
"content": content,
"msg_type": 0,
"msg_id": msg_id,
}
response = await client.post(url,headers=headers,json=data)
if response.status_code == 200:
return
else:
raise ValueError(response)
async def send_group_text_msg(self,group_openid:str,content:str,msg_id:str):
"""发送群聊消息"""
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + "/v2/groups/" + group_openid + "/messages"
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"QQBot {self.access_token}",
"Content-Type": "application/json",
}
data = {
"content": content,
"msg_type": 0,
"msg_id": msg_id,
}
response = await client.post(url,headers=headers,json=data)
if response.status_code == 200:
return
else:
raise Exception(response.read().decode())
async def send_channle_group_text_msg(self,channel_id:str,content:str,msg_id:str):
"""发送频道群聊消息"""
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + "/channels/" + channel_id + "/messages"
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"QQBot {self.access_token}",
"Content-Type": "application/json",
}
params = {
"content": content,
"msg_type": 0,
"msg_id": msg_id,
}
response = await client.post(url,headers=headers,json=params)
if response.status_code == 200:
return True
else:
raise Exception(response)
async def send_channle_private_text_msg(self,guild_id:str,content:str,msg_id:str):
"""发送频道私聊消息"""
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + "/dms/" + guild_id + "/messages"
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"QQBot {self.access_token}",
"Content-Type": "application/json",
}
params = {
"content": content,
"msg_type": 0,
"msg_id": msg_id,
}
response = await client.post(url,headers=headers,json=params)
if response.status_code == 200:
return True
else:
raise Exception(response)
async def is_token_expired(self):
"""检查token是否过期"""
if self.access_token_expiry_time is None:
return True
return time.time() > self.access_token_expiry_time

View File

@@ -0,0 +1,114 @@
from typing import Dict, Any, Optional
class QQOfficialEvent(dict):
@staticmethod
def from_payload(payload: Dict[str, Any]) -> Optional["QQOfficialEvent"]:
try:
event = QQOfficialEvent(payload)
return event
except KeyError:
return None
@property
def t(self) -> str:
"""
事件类型
"""
return self.get("t", "")
@property
def user_openid(self) -> str:
"""
用户openid
"""
return self.get("user_openid",{})
@property
def timestamp(self) -> str:
"""
时间戳
"""
return self.get("timestamp",{})
@property
def d_author_id(self) -> str:
"""
作者id
"""
return self.get("id",{})
@property
def content(self) -> str:
"""
内容
"""
return self.get("content",'')
@property
def d_id(self) -> str:
"""
d_id
"""
return self.get("d_id",{})
@property
def id(self) -> str:
"""
消息idmsg_id
"""
return self.get("id",{})
@property
def channel_id(self) -> str:
"""
频道id
"""
return self.get("channel_id",{})
@property
def username(self) -> str:
"""
用户名
"""
return self.get("username",{})
@property
def guild_id(self) -> str:
"""
频道id
"""
return self.get("guild_id",{})
@property
def member_openid(self) -> str:
"""
成员openid
"""
return self.get("openid",{})
@property
def attachments(self) -> str:
"""
附件url
"""
url = self.get("image_attachments", "")
if url and not url.startswith("https://"):
url = "https://" + url
return url
@property
def group_openid(self) -> str:
"""
群组id
"""
return self.get("group_openid",{})
@property
def content_type(self) -> str:
"""
文件类型
"""
return self.get("content_type","")

View File

@@ -28,6 +28,7 @@ required_deps = {
"Crypto": "pycryptodome",
"lark_oapi": "lark-oapi",
"discord": "discord.py",
"cryptography": "cryptography",
"gewechat_client": "gewechat-client"
}

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from .. import migration
@migration.migration_class("qqofficial-config", 26)
class QQOfficialConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'qqofficial':
return False
return True
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters'].append({
"adapter": "qqofficial",
"enable": False,
"appid": "",
"secret": "",
"port": 2284,
"token": ""
})
await self.ap.platform_cfg.dump_config()

View File

@@ -9,7 +9,7 @@ from ..migrations import m005_deepseek_cfg_completion, m006_vision_config, m007_
from ..migrations import m010_ollama_requester_config, m011_command_prefix_config, m012_runner_config, m013_http_api_config, m014_force_delay_config
from ..migrations import m015_gitee_ai_config, m016_dify_service_api, m017_dify_api_timeout_params, m018_xai_config, m019_zhipuai_config
from ..migrations import m020_wecom_config, m021_lark_config, m022_lmstudio_config, m023_siliconflow_config, m024_discord_config, m025_gewechat_config
from ..migrations import m026_qqofficial_config
@stage.stage_class("MigrationStage")
class MigrationStage(stage.BootingStage):

View File

@@ -7,6 +7,8 @@ import logging
import asyncio
import traceback
from .sources import qqofficial
# FriendMessage, Image, MessageChain, Plain
from ..platform import adapter as msadapter
@@ -37,7 +39,7 @@ class PlatformManager:
async def initialize(self):
from .sources import nakuru, aiocqhttp, qqbotpy, wecom, lark, discord, gewechat
from .sources import nakuru, aiocqhttp, qqbotpy, qqofficial, wecom, lark, discord, gewechat
async def on_friend_message(event: platform_events.FriendMessage, adapter: msadapter.MessageSourceAdapter):

View File

@@ -0,0 +1,256 @@
from __future__ import annotations
import typing
import asyncio
import traceback
import time
import datetime
import aiocqhttp
import aiohttp
from pkg.platform.adapter import MessageSourceAdapter
from pkg.platform.types import events as platform_events, message as platform_message
from pkg.core import app
from .. import adapter
from ...pipeline.longtext.strategies import forward
from ...core import app
from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ...command.errors import ParamNotEnoughError
from libs.qq_official_api.api import QQOfficialClient
from libs.qq_official_api.qqofficialevent import QQOfficialEvent
from ...utils import image
class QQOfficialMessageConverter(adapter.MessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain):
content_list = []
#只实现了发文字
for msg in message_chain:
if type(msg) is platform_message.Plain:
content_list.append({
"type":"text",
"content":msg.text,
})
return content_list
@staticmethod
async def target2yiri(message:str,message_id:str,pic_url:str,content_type):
yiri_msg_list = []
yiri_msg_list.append(
platform_message.Source(id=message_id,time=datetime.datetime.now())
)
if pic_url is not None:
base64_url = await image.get_qq_official_image_base64(pic_url=pic_url,content_type=content_type)
yiri_msg_list.append(
platform_message.Image(base64=base64_url)
)
message = ''
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
class QQOfficialEventConverter(adapter.EventConverter):
@staticmethod
async def yiri2target(event:platform_events.MessageEvent) -> QQOfficialEvent:
return event.source_platform_object
@staticmethod
async def target2yiri(event:QQOfficialEvent):
"""
QQ官方消息转换为LB对象
"""
yiri_chain = await QQOfficialMessageConverter.target2yiri(
message=event.content,message_id=event.d_id,pic_url=event.attachments,content_type=event.content_type
)
if event.t == 'C2C_MESSAGE_CREATE':
friend = platform_entities.Friend(
id = event.user_openid,
nickname = event.t,
remark = "",
)
return platform_events.FriendMessage(
sender = friend,message_chain = yiri_chain,time = event.timestamp,
source_platform_object=event
)
if event.t == 'DIRECT_MESSAGE_CREATE':
friend = platform_entities.Friend(
id = event.guild_id,
nickname = event.t,
remark = "",
)
return platform_events.FriendMessage(
sender = friend,message_chain = yiri_chain,
source_platform_object=event
)
if event.t == 'GROUP_AT_MESSAGE_CREATE':
yiri_chain.insert(0, platform_message.At(target="justbot"))
sender = platform_entities.GroupMember(
id = event.group_openid,
member_name= event.t,
permission= 'MEMBER',
group = platform_entities.Group(
id = 0,
name = 'MEMBER',
permission= platform_entities.Permission.Member
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0
)
time = event.timestamp
return platform_events.GroupMessage(
sender = sender,
message_chain=yiri_chain,
time = time,
source_platform_object=event
)
if event.t =='AT_MESSAGE_CREATE':
yiri_chain.insert(0, platform_message.At(target="justbot"))
sender = platform_entities.GroupMember(
id = event.channel_id,
member_name=event.t,
permission= 'MEMBER',
group = platform_entities.Group(
id = 0,
name = 'MEMBER',
permission=platform_entities.Permission.Member
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0
)
time = event.timestamp,
return platform_events.GroupMessage(
sender =sender,
message_chain = yiri_chain,
time = time,
source_platform_object=event
)
@adapter.adapter_class("qqofficial")
class QQOfficialAdapter(adapter.MessageSourceAdapter):
bot:QQOfficialClient
ap:app.Application
config:dict
bot_account_id:str
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config:dict, ap:app.Application):
self.config = config
self.ap = ap
required_keys = [
"appid",
"secret",
]
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise ParamNotEnoughError("QQ官方机器人缺少相关配置项请查看文档或联系管理员")
self.bot = QQOfficialClient(
app_id=config["appid"],
secret=config["secret"],
token=config["token"],
)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
qq_official_event = await QQOfficialEventConverter.yiri2target(
message_source,
)
content_list = await QQOfficialMessageConverter.yiri2target(message)
#私聊消息
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
for content in content_list:
if content["type"] == 'text':
await self.bot.send_private_text_msg(qq_official_event.user_openid,content['content'],qq_official_event.d_id)
#群聊消息
if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
for content in content_list:
if content["type"] == 'text':
await self.bot.send_group_text_msg(qq_official_event.group_openid,content['content'],qq_official_event.d_id)
#频道群聊
if qq_official_event.t == 'AT_MESSAGE_CREATE':
for content in content_list:
if content["type"] == 'text':
await self.bot.send_channle_group_text_msg(qq_official_event.channel_id,content['content'],qq_official_event.d_id)
#频道私聊
if qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
for content in content_list:
if content["type"] == 'text':
await self.bot.send_channle_private_text_msg(qq_official_event.guild_id,content['content'],qq_official_event.d_id)
async def send_message(
self, target_type: str, target_id: str, message: platform_message.MessageChain
):
pass
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, adapter.MessageSourceAdapter], None
],
):
async def on_message(event:QQOfficialEvent):
self.bot_account_id = "justbot"
try:
return await callback(
await self.event_converter.target2yiri(event),self
)
except:
traceback.print_exc()
if event_type == platform_events.FriendMessage:
self.bot.on_message("DIRECT_MESSAGE_CREATE")(on_message)
self.bot.on_message("C2C_MESSAGE_CREATE")(on_message)
elif event_type == platform_events.GroupMessage:
self.bot.on_message("GROUP_AT_MESSAGE_CREATE")(on_message)
self.bot.on_message("AT_MESSAGE_CREATE")(on_message)
async def run_async(self):
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await self.bot.run_task(
host='0.0.0.0',
port=self.config["port"],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
return False
def unregister_listener(
self,
event_type: type,
callback: typing.Callable[[platform_events.Event, MessageSourceAdapter], None],
):
return super().unregister_listener(event_type, callback)

View File

@@ -29,17 +29,6 @@ class WecomMessageConverter(adapter.MessageConverter):
):
content_list = []
[
{
"type": "text",
"content": "text",
},
{
"type": "image",
"media_id": "media_id",
}
]
for msg in message_chain:
if type(msg) is platform_message.Plain:
content_list.append({
@@ -83,7 +72,7 @@ class WecomMessageConverter(adapter.MessageConverter):
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
yiri_msg_list.append(platform_message.Image(base64=f"data:image/{image_format};base64,{image_base64}"))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
@@ -208,7 +197,7 @@ class WecomeAdapter(adapter.MessageSourceAdapter):
await self.bot.send_private_msg(Wecom_event.user_id, Wecom_event.agent_id, content["content"])
elif content["type"] == "image":
await self.bot.send_image(Wecom_event.user_id, Wecom_event.agent_id, content["media_id"])
async def send_message(
self, target_type: str, target_id: str, message: platform_message.MessageChain
):

View File

@@ -6,6 +6,7 @@ import ssl
import aiohttp
import PIL.Image
import httpx
async def get_wecom_image_base64(pic_url: str) -> tuple[str, str]:
"""
@@ -30,7 +31,19 @@ async def get_wecom_image_base64(pic_url: str) -> tuple[str, str]:
image_base64 = base64.b64encode(image_data).decode('utf-8')
return image_base64, image_format
async def get_qq_official_image_base64(pic_url:str,content_type:str) -> tuple[str,str]:
"""
下载QQ官方图片
并且转换为base64格式
"""
async with httpx.AsyncClient() as client:
response = await client.get(pic_url)
response.raise_for_status() # 确保请求成功
image_data = response.content
base64_data = base64.b64encode(image_data).decode('utf-8')
return f"data:{content_type};base64,{base64_data}"
def get_qq_image_downloadable_url(image_url: str) -> tuple[str, dict]:

View File

@@ -27,6 +27,7 @@ pyjwt
pycryptodome
lark-oapi
discord.py
cryptography
gewechat-client
# indirect

View File

@@ -0,0 +1 @@
[]

1
res/instance_id.json Normal file
View File

@@ -0,0 +1 @@
{"host_id": "host_9b4a220d-3bb6-42fc-aec3-41188ce0a41c", "instance_id": "instance_61d8f262-b98a-4165-8e77-85fb6262529e", "instance_create_ts": 1736824678}

View File

@@ -25,6 +25,14 @@
"direct_message"
]
},
{
"adapter": "qqofficial",
"enable": false,
"appid": "1234567890",
"secret": "xxxxxxx",
"port": 2284,
"token": "abcdefg"
},
{
"adapter": "wecom",
"enable": false,

View File

@@ -83,7 +83,7 @@
}
},
{
"title": "qq-botpy 适配器",
"title": "qq-botpy 适配器WebSocket",
"description": "用于接入 QQ 官方机器人 API",
"properties": {
"adapter": {
@@ -122,6 +122,47 @@
}
}
},
{
"title": "QQ 官方适配器WebHook",
"description": "用于接入 QQ 官方机器人 API",
"properties": {
"adapter": {
"type": "string",
"const": "qqofficial"
},
"enable": {
"type": "boolean",
"default": false,
"description": "是否启用此适配器",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"appid": {
"type": "string",
"default": "",
"description": "申请到的QQ官方机器人的appid"
},
"secret": {
"type": "string",
"default": "",
"description": "申请到的QQ官方机器人的secret"
},
"port": {
"type": "integer",
"default": 2284,
"description": "监听的端口"
},
"token": {
"type": "string",
"default": "",
"description": "申请到的QQ官方机器人的token"
}
}
},
{
"title": "企业微信适配器",
"description": "用于接入企业微信",