diff --git a/.gitignore b/.gitignore index 4ac91687..a0f8d3d9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,4 @@ botpy.log* /poc /libs/wecom_api/test.py /venv - +/jp-tyo-churros-05.rockchin.top diff --git a/libs/qq_official_api/__init__.py b/libs/qq_official_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/qq_official_api/api.py b/libs/qq_official_api/api.py new file mode 100644 index 00000000..62f252df --- /dev/null +++ b/libs/qq_official_api/api.py @@ -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 diff --git a/libs/qq_official_api/qqofficialevent.py b/libs/qq_official_api/qqofficialevent.py new file mode 100644 index 00000000..41e842f1 --- /dev/null +++ b/libs/qq_official_api/qqofficialevent.py @@ -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: + """ + 消息id,msg_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","") + diff --git a/pkg/core/bootutils/deps.py b/pkg/core/bootutils/deps.py index 44b9e60a..b4a67f35 100644 --- a/pkg/core/bootutils/deps.py +++ b/pkg/core/bootutils/deps.py @@ -28,6 +28,7 @@ required_deps = { "Crypto": "pycryptodome", "lark_oapi": "lark-oapi", "discord": "discord.py", + "cryptography": "cryptography", "gewechat_client": "gewechat-client" } diff --git a/pkg/core/migrations/m026_qqofficial_config.py b/pkg/core/migrations/m026_qqofficial_config.py new file mode 100644 index 00000000..edef36c9 --- /dev/null +++ b/pkg/core/migrations/m026_qqofficial_config.py @@ -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() diff --git a/pkg/core/stages/migrate.py b/pkg/core/stages/migrate.py index 22a8ae0f..16faa53a 100644 --- a/pkg/core/stages/migrate.py +++ b/pkg/core/stages/migrate.py @@ -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): diff --git a/pkg/platform/manager.py b/pkg/platform/manager.py index b45bc488..85302ca4 100644 --- a/pkg/platform/manager.py +++ b/pkg/platform/manager.py @@ -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): diff --git a/pkg/platform/sources/qqofficial.py b/pkg/platform/sources/qqofficial.py new file mode 100644 index 00000000..f41e84db --- /dev/null +++ b/pkg/platform/sources/qqofficial.py @@ -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) + diff --git a/pkg/platform/sources/wecom.py b/pkg/platform/sources/wecom.py index dda22816..38de84e9 100644 --- a/pkg/platform/sources/wecom.py +++ b/pkg/platform/sources/wecom.py @@ -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 ): diff --git a/pkg/utils/image.py b/pkg/utils/image.py index 6f769b26..7a60df2a 100644 --- a/pkg/utils/image.py +++ b/pkg/utils/image.py @@ -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]: diff --git a/requirements.txt b/requirements.txt index b19d6b6b..27349f64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,7 @@ pyjwt pycryptodome lark-oapi discord.py +cryptography gewechat-client # indirect diff --git a/res/announcement_saved.json b/res/announcement_saved.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/res/announcement_saved.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/res/instance_id.json b/res/instance_id.json new file mode 100644 index 00000000..bfd6df4b --- /dev/null +++ b/res/instance_id.json @@ -0,0 +1 @@ +{"host_id": "host_9b4a220d-3bb6-42fc-aec3-41188ce0a41c", "instance_id": "instance_61d8f262-b98a-4165-8e77-85fb6262529e", "instance_create_ts": 1736824678} \ No newline at end of file diff --git a/templates/platform.json b/templates/platform.json index 97a865cf..8676cf99 100644 --- a/templates/platform.json +++ b/templates/platform.json @@ -25,6 +25,14 @@ "direct_message" ] }, + { + "adapter": "qqofficial", + "enable": false, + "appid": "1234567890", + "secret": "xxxxxxx", + "port": 2284, + "token": "abcdefg" + }, { "adapter": "wecom", "enable": false, diff --git a/templates/schema/platform.json b/templates/schema/platform.json index 36e7219b..f2db0f79 100644 --- a/templates/schema/platform.json +++ b/templates/schema/platform.json @@ -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": "用于接入企业微信",