ForcePilot/backend/package/yuxi/channel/extensions/qqbot/api_client.py
Kris 2ab65f153f feat(channel): 添加 QQ Bot 渠道扩展
新增 QQ Bot 渠道扩展,支持在 Yuxi 平台中集成 QQ 机器人渠道。

包含以下功能模块:
- api_client: QQ API 客户端封装
- api_routes: API 路由管理
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- websocket: WebSocket 实时连接
- credentials: 凭证管理
- token: Token 管理
- outbound: 外发消息管理
- outbound_media: 媒体外发
- streaming: 流式消息处理
- streaming_media: 媒体流处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- pipeline: 消息管道
- pipeline_stages: 管道阶段
- commands: 指令处理
- commands_builtin: 内置指令
- interaction: 交互处理
- approval: 审批流程
- ark: ARK 消息
- audio: 音频处理
- media: 媒体资源
- media_chunked: 分块媒体
- media_tags: 媒体标签
- message_queue: 消息队列
- delivery: 消息送达确认
- reconnect: 重连机制
- typing_keepalive: 输入状态保活
- group_activation: 群激活
- group_gating: 群门控
- group_history: 群历史
- known_users: 已知用户
- ref_index: 引用索引
- tools: Agent 工具集成
- types: 类型定义
2026-05-21 11:35:12 +08:00

392 lines
15 KiB
Python

from __future__ import annotations
import logging
from typing import Any
import httpx
from yuxi.channel.extensions.qqbot.api_routes import (
API_BASE_URL,
GATEWAY_URL,
c2c_input_notify_url,
c2c_message_url,
c2c_messages_url,
c2c_stream_messages_url,
channel_message_url,
channel_messages_url,
channel_url,
dm_messages_url,
generate_url_link_url,
group_files_url,
group_message_url,
group_messages_url,
interaction_url,
resource_url,
)
from yuxi.channel.extensions.qqbot.errors import QQBotError, QQBotErrorCode
from yuxi.channel.extensions.qqbot.token import TokenManager
from yuxi.channel.extensions.qqbot.types import (
QQBotAttachment,
QQBotChatType,
)
logger = logging.getLogger(__name__)
class QQBotApiClient:
def __init__(self, token_manager: TokenManager):
self._token_manager = token_manager
self._http_client: httpx.AsyncClient | None = None
def _get_client(self) -> httpx.AsyncClient:
if self._http_client is None:
self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
return self._http_client
async def _auth_headers(self) -> dict[str, str]:
token = await self._token_manager.get_token()
return {
"Authorization": f"QQBot {token}",
"Content-Type": "application/json",
"X-Union-Appid": self._token_manager.app_id,
}
async def _request(
self, method: str, url: str, json_data: dict | None = None, files: dict | None = None
) -> dict[str, Any]:
client = self._get_client()
headers = await self._auth_headers()
if files:
headers.pop("Content-Type", None)
try:
if files:
resp = await client.request(method, url, headers=headers, files=files)
else:
resp = await client.request(method, url, headers=headers, json=json_data)
resp.raise_for_status()
return resp.json() if resp.content else {}
except httpx.HTTPStatusError as e:
body = {}
try:
body = e.response.json()
except Exception:
pass
code = body.get("code", e.response.status_code)
message = body.get("message", str(e))
raise QQBotError(
QQBotErrorCode.SEND_FAILED, f"API error [{code}]: {message}", retryable=e.response.status_code >= 500
) from e
except httpx.RequestError as e:
raise QQBotError(QQBotErrorCode.NETWORK_ERROR, str(e), retryable=True) from e
async def get_gateway_url(self) -> str:
data = await self._request("GET", GATEWAY_URL)
return data.get("url", "")
async def send_c2c_message(
self,
openid: str,
content: str,
msg_type: int = 0,
msg_seq: int | None = None,
msg_id: str | None = None,
markdown: dict | None = None,
keyboard: dict | None = None,
media: dict | None = None,
ark: dict | None = None,
event_id: str | None = None,
message_reference: dict | None = None,
is_wakeup: bool = False,
) -> dict:
payload: dict[str, Any] = {}
if content:
payload["content"] = content
if msg_type is not None:
payload["msg_type"] = msg_type
if msg_seq is not None:
payload["msg_seq"] = msg_seq
if msg_id:
payload["msg_id"] = msg_id
if markdown:
payload["markdown"] = markdown
if keyboard:
payload["keyboard"] = keyboard
if media:
payload["media"] = media
if ark:
payload["ark"] = ark
if event_id:
payload["event_id"] = event_id
if message_reference:
payload["message_reference"] = message_reference
if is_wakeup:
payload["is_wakeup"] = True
return await self._request("POST", c2c_messages_url(openid), json_data=payload)
async def send_group_message(
self,
group_openid: str,
content: str,
msg_type: int = 0,
msg_seq: int | None = None,
msg_id: str | None = None,
markdown: dict | None = None,
keyboard: dict | None = None,
media: dict | None = None,
ark: dict | None = None,
event_id: str | None = None,
message_reference: dict | None = None,
) -> dict:
payload: dict[str, Any] = {"content": content, "msg_type": msg_type}
if msg_seq is not None:
payload["msg_seq"] = msg_seq
if msg_id:
payload["msg_id"] = msg_id
if markdown:
payload["markdown"] = markdown
if keyboard:
payload["keyboard"] = keyboard
if media:
payload["media"] = media
if ark:
payload["ark"] = ark
if event_id:
payload["event_id"] = event_id
if message_reference:
payload["message_reference"] = message_reference
return await self._request("POST", group_messages_url(group_openid), json_data=payload)
async def send_c2c_markdown(
self,
openid: str,
markdown_content: str = "",
msg_seq: int | None = None,
msg_id: str | None = None,
keyboard: dict | None = None,
custom_template_id: str | None = None,
params: list[str] | None = None,
message_reference: dict | None = None,
) -> dict:
payload: dict[str, Any] = {"msg_type": 2}
if custom_template_id and params:
payload["markdown"] = {"custom_template_id": custom_template_id, "params": params}
elif markdown_content:
payload["markdown"] = {"content": markdown_content}
if msg_seq is not None:
payload["msg_seq"] = msg_seq
if msg_id:
payload["msg_id"] = msg_id
if keyboard:
payload["keyboard"] = keyboard
if message_reference:
payload["message_reference"] = message_reference
return await self._request("POST", c2c_messages_url(openid), json_data=payload)
async def send_group_markdown(
self,
group_openid: str,
markdown_content: str = "",
msg_seq: int | None = None,
msg_id: str | None = None,
keyboard: dict | None = None,
custom_template_id: str | None = None,
params: list[str] | None = None,
message_reference: dict | None = None,
) -> dict:
payload: dict[str, Any] = {"msg_type": 2}
if custom_template_id and params:
payload["markdown"] = {"custom_template_id": custom_template_id, "params": params}
elif markdown_content:
payload["markdown"] = {"content": markdown_content}
if msg_seq is not None:
payload["msg_seq"] = msg_seq
if msg_id:
payload["msg_id"] = msg_id
if keyboard:
payload["keyboard"] = keyboard
if message_reference:
payload["message_reference"] = message_reference
return await self._request("POST", group_messages_url(group_openid), json_data=payload)
async def start_c2c_stream(self, openid: str) -> dict:
payload: dict[str, Any] = {"content": "", "msg_type": 0}
data = await self._request("POST", c2c_stream_messages_url(openid), json_data=payload)
return data
async def send_c2c_stream_chunk(
self, openid: str, stream_id: str, text: str, msg_seq: int | None = None
) -> dict:
payload: dict[str, Any] = {
"stream": {"state": 1, "id": stream_id, "content": text},
}
if msg_seq is not None:
payload["msg_seq"] = msg_seq
return await self._request("POST", c2c_stream_messages_url(openid), json_data=payload)
async def complete_c2c_stream(self, openid: str, stream_id: str) -> dict:
payload = {"stream": {"state": 10, "id": stream_id}}
return await self._request("POST", c2c_stream_messages_url(openid), json_data=payload)
async def abort_c2c_stream(self, openid: str, stream_id: str) -> dict:
payload = {"stream": {"state": 12, "id": stream_id}}
return await self._request("POST", c2c_stream_messages_url(openid), json_data=payload)
async def send_input_notify(self, openid: str) -> dict:
payload = {"msg_type": 6}
return await self._request("POST", c2c_input_notify_url(openid), json_data=payload)
async def ack_interaction(self, interaction_id: str, code: int = 0) -> dict:
return await self._request("PUT", interaction_url(interaction_id), json_data={"code": code})
async def delete_c2c_message(self, openid: str, message_id: str) -> dict:
return await self._request("DELETE", c2c_message_url(openid, message_id))
async def delete_group_message(self, group_openid: str, message_id: str) -> dict:
return await self._request("DELETE", group_message_url(group_openid, message_id))
async def delete_channel_message(self, channel_id: str, message_id: str) -> dict:
return await self._request("DELETE", channel_message_url(channel_id, message_id))
async def update_channel(self, channel_id: str, channel_data: dict) -> dict:
return await self._request("PATCH", channel_url(channel_id), json_data=channel_data)
async def delete_channel(self, channel_id: str) -> dict:
return await self._request("DELETE", channel_url(channel_id))
async def get_guild_announces(self, guild_id: str) -> dict:
from yuxi.channel.extensions.qqbot.api_routes import guild_announces_url
return await self._request("GET", guild_announces_url(guild_id))
async def create_guild_announce(self, guild_id: str, announce_data: dict) -> dict:
from yuxi.channel.extensions.qqbot.api_routes import guild_announces_url
return await self._request("POST", guild_announces_url(guild_id), json_data=announce_data)
async def get_guild_schedules(self, guild_id: str) -> dict:
from yuxi.channel.extensions.qqbot.api_routes import guild_schedules_url
return await self._request("GET", guild_schedules_url(guild_id))
async def create_guild_schedule(self, guild_id: str, schedule_data: dict) -> dict:
from yuxi.channel.extensions.qqbot.api_routes import guild_schedules_url
return await self._request("POST", guild_schedules_url(guild_id), json_data=schedule_data)
async def send_c2c_ark(self, openid: str, ark: dict, msg_seq: int | None = None) -> dict:
payload: dict[str, Any] = {"msg_type": 3, "ark": ark}
if msg_seq is not None:
payload["msg_seq"] = msg_seq
return await self._request("POST", c2c_messages_url(openid), json_data=payload)
async def send_group_ark(self, group_openid: str, ark: dict, msg_seq: int | None = None) -> dict:
payload: dict[str, Any] = {"msg_type": 3, "ark": ark}
if msg_seq is not None:
payload["msg_seq"] = msg_seq
return await self._request("POST", group_messages_url(group_openid), json_data=payload)
async def send_channel_message(
self,
channel_id: str,
content: str,
msg_type: int = 0,
markdown: dict | None = None,
keyboard: dict | None = None,
) -> dict:
payload: dict[str, Any] = {"content": content, "msg_type": msg_type}
if markdown:
payload["markdown"] = markdown
if keyboard:
payload["keyboard"] = keyboard
return await self._request("POST", channel_messages_url(channel_id), json_data=payload)
async def send_dm_message(
self,
guild_id: str,
content: str,
msg_type: int = 0,
msg_id: str | None = None,
markdown: dict | None = None,
) -> dict:
payload: dict[str, Any] = {"content": content, "msg_type": msg_type}
if msg_id:
payload["msg_id"] = msg_id
if markdown:
payload["markdown"] = markdown
return await self._request("POST", dm_messages_url(guild_id), json_data=payload)
async def send_c2c_embed(self, openid: str, embed: dict, msg_id: str | None = None) -> dict:
payload: dict[str, Any] = {"msg_type": 4, "embed": embed}
if msg_id:
payload["msg_id"] = msg_id
return await self._request("POST", c2c_messages_url(openid), json_data=payload)
async def send_group_embed(self, group_openid: str, embed: dict, msg_id: str | None = None) -> dict:
payload: dict[str, Any] = {"msg_type": 4, "embed": embed}
if msg_id:
payload["msg_id"] = msg_id
return await self._request("POST", group_messages_url(group_openid), json_data=payload)
async def generate_url_link(self) -> dict:
return await self._request("POST", generate_url_link_url())
async def get_me(self) -> dict:
return await self._request("GET", f"{API_BASE_URL}/users/@me")
async def get_my_guilds(self) -> dict:
return await self._request("GET", f"{API_BASE_URL}/users/@me/guilds")
async def get_guild(self, guild_id: str) -> dict:
return await self._request("GET", f"{API_BASE_URL}/guilds/{guild_id}")
async def get_guild_channels(self, guild_id: str) -> dict:
return await self._request("GET", f"{API_BASE_URL}/guilds/{guild_id}/channels")
async def get_guild_members(self, guild_id: str, after: str | None = None, limit: int = 100) -> dict:
url = f"{API_BASE_URL}/guilds/{guild_id}/members"
if after:
url += f"?after={after}"
if limit != 100:
sep = "&" if after else "?"
url += f"{sep}limit={limit}"
return await self._request("GET", url)
async def create_guild_channel(self, guild_id: str, channel_data: dict) -> dict:
from yuxi.channel.extensions.qqbot.api_routes import guild_channels_url
return await self._request("POST", guild_channels_url(guild_id), json_data=channel_data)
async def upload_media(
self, target_id: str, file_data: bytes, filename: str, file_type: int, chat_type: QQBotChatType
) -> QQBotAttachment:
client = self._get_client()
headers = await self._auth_headers()
headers.pop("Content-Type", None)
if chat_type == QQBotChatType.C2C:
url = resource_url(target_id, file_type, url_type=0, srv_send_msg=False)
else:
url = group_files_url(target_id, file_type)
try:
resp = await client.post(
url,
headers=headers,
files={"file": (filename, file_data)},
)
resp.raise_for_status()
data = resp.json()
file_info = data.get("file_info", "")
return QQBotAttachment(
url=data.get("url", file_info),
content_type=data.get("content_type", ""),
filename=filename,
size=len(file_data),
file_info=file_info,
ttl=data.get("ttl"),
)
except httpx.HTTPStatusError as e:
raise QQBotError(
QQBotErrorCode.MEDIA_UPLOAD_FAILED, f"Media upload failed: {e}", retryable=True
) from e
async def close(self) -> None:
if self._http_client:
await self._http_client.aclose()
self._http_client = None