新增 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: 类型定义
392 lines
15 KiB
Python
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
|