ForcePilot/backend/package/yuxi/channel/extensions/qqbot/monitor.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

304 lines
12 KiB
Python

from __future__ import annotations
import logging
import time
from datetime import datetime, UTC
from typing import Any
from yuxi.channel.extensions.qqbot.api_client import QQBotApiClient
from yuxi.channel.extensions.qqbot.types import (
GatewayEvent,
QQBotAccountConfig,
QQBotAttachment,
QQBotChatType,
QQBotEventType,
QueuedMessage,
SlashCommandResult,
)
from yuxi.channel.message.models import GroupContext, MessageType, PeerInfo, PeerKind, UnifiedMessage
logger = logging.getLogger(__name__)
TARGET_ID_FORMAT_C2C = "qqbot:c2c:{openid}"
TARGET_ID_FORMAT_GROUP = "qqbot:group:{group_openid}"
TARGET_ID_FORMAT_DM = "qqbot:dm:{openid}"
TARGET_ID_FORMAT_CHANNEL = "qqbot:channel:{channel_id}"
class QQBotMonitor:
def __init__(
self,
account: QQBotAccountConfig,
enqueue_fn: Any | None = None,
slash_command_handler: Any | None = None,
interaction_handler: Any | None = None,
):
self._account = account
self._enqueue_fn = enqueue_fn
self._slash_command_handler = slash_command_handler
self._interaction_handler = interaction_handler
async def handle_event(self, event: GatewayEvent, api_client: QQBotApiClient, account: QQBotAccountConfig) -> None:
event_type = event.t or ""
d = event.d or {}
if event_type == QQBotEventType.C2C_MESSAGE_CREATE:
await self._handle_c2c_message(d, event.s)
elif event_type == QQBotEventType.GROUP_AT_MESSAGE_CREATE:
await self._handle_group_at_message(d, event.s)
elif event_type == QQBotEventType.GROUP_MESSAGE_CREATE:
await self._handle_group_message(d, event.s)
elif event_type == QQBotEventType.DIRECT_MESSAGE_CREATE:
await self._handle_dm_message(d, event.s)
elif event_type == QQBotEventType.AT_MESSAGE_CREATE:
await self._handle_at_message(d, event.s)
elif event_type == QQBotEventType.INTERACTION_CREATE:
await self._handle_interaction(d, event.s)
elif event_type == QQBotEventType.FRIEND_ADD:
logger.info("Friend added: openid=%s", d.get("openid", ""))
elif event_type == QQBotEventType.FRIEND_DEL:
logger.info("Friend deleted: openid=%s", d.get("openid", ""))
elif event_type == QQBotEventType.C2C_MSG_RECEIVE:
logger.info("C2C msg receive allowed: openid=%s", d.get("openid", ""))
elif event_type == QQBotEventType.C2C_MSG_REJECT:
logger.info("C2C msg receive rejected: openid=%s", d.get("openid", ""))
elif event_type == QQBotEventType.GROUP_ADD_ROBOT:
logger.info("Robot added to group: group_openid=%s", d.get("group_openid", ""))
elif event_type == QQBotEventType.GROUP_DEL_ROBOT:
logger.info("Robot removed from group: group_openid=%s", d.get("group_openid", ""))
elif event_type == QQBotEventType.GROUP_MSG_RECEIVE:
logger.info("Group msg receive allowed: group_openid=%s", d.get("group_openid", ""))
elif event_type == QQBotEventType.GROUP_MSG_REJECT:
logger.info("Group msg receive rejected: group_openid=%s", d.get("group_openid", ""))
elif event_type in (
QQBotEventType.GUILD_CREATE, QQBotEventType.GUILD_UPDATE, QQBotEventType.GUILD_DELETE,
QQBotEventType.GUILD_MEMBER_ADD, QQBotEventType.GUILD_MEMBER_UPDATE, QQBotEventType.GUILD_MEMBER_REMOVE,
QQBotEventType.CHANNEL_CREATE, QQBotEventType.CHANNEL_UPDATE, QQBotEventType.CHANNEL_DELETE,
QQBotEventType.MESSAGE_AUDIT,
):
logger.debug("Event received: type=%s, data=%s", event_type, _safe_summary(d))
else:
logger.debug("Unhandled event type: %s", event_type)
async def _handle_c2c_message(self, d: dict, seq: int | None) -> None:
msg = self._parse_c2c_message(d, seq)
if msg is None:
return
await self._try_slash_command_or_enqueue(msg)
async def _handle_group_at_message(self, d: dict, seq: int | None) -> None:
msg = self._parse_group_message(d, seq, is_at=True)
if msg is None:
return
await self._try_slash_command_or_enqueue(msg)
async def _handle_group_message(self, d: dict, seq: int | None) -> None:
msg = self._parse_group_message(d, seq, is_at=False)
if msg is None:
return
logger.debug("skipping non-@ group message: msg_id=%s, group=%s, sender=%s",
msg.msg_id, msg.group_openid, msg.sender_id)
async def _handle_dm_message(self, d: dict, seq: int | None) -> None:
msg = self._parse_dm_message(d, seq)
if msg is None:
return
await self._try_slash_command_or_enqueue(msg)
async def _handle_at_message(self, d: dict, seq: int | None) -> None:
msg = self._parse_at_message(d, seq)
if msg is None:
return
await self._try_slash_command_or_enqueue(msg)
async def _handle_interaction(self, d: dict, seq: int | None) -> None:
if self._interaction_handler:
await self._interaction_handler(d)
async def _try_slash_command_or_enqueue(self, msg: QueuedMessage) -> None:
if self._slash_command_handler:
result = await self._slash_command_handler(msg.content, msg)
if result == SlashCommandResult.HANDLED:
return
if result == SlashCommandResult.URGENT:
pass
if self._enqueue_fn:
await self._enqueue_fn(msg)
def _parse_c2c_message(self, d: dict, seq: int | None) -> QueuedMessage | None:
author = d.get("author", {})
content = d.get("content", "").strip()
if not content and not d.get("attachments"):
return None
return QueuedMessage(
msg_id=d.get("id", ""),
msg_seq=seq,
sender_id=author.get("id", ""),
sender_name=author.get("username", ""),
content=content,
event_type=QQBotEventType.C2C_MESSAGE_CREATE,
timestamp=time.time(),
chat_type=QQBotChatType.C2C,
attachments=self._parse_attachments(d.get("attachments", [])),
reply_to_id=d.get("message_reference", {}).get("message_id"),
ref_msg_seq=d.get("message_reference", {}).get("message_seq"),
sender_is_bot=author.get("bot", False),
raw_event=d,
)
def _parse_group_message(self, d: dict, seq: int | None, is_at: bool = False) -> QueuedMessage | None:
author = d.get("author", {})
content = d.get("content", "").strip()
if not content and not d.get("attachments"):
return None
group_openid = d.get("group_openid", d.get("group_id", ""))
return QueuedMessage(
msg_id=d.get("id", ""),
msg_seq=seq,
sender_id=author.get("id", author.get("member_openid", "")),
sender_name=author.get("username", ""),
content=content,
event_type=(
QQBotEventType.GROUP_AT_MESSAGE_CREATE
if is_at
else QQBotEventType.GROUP_MESSAGE_CREATE
),
timestamp=time.time(),
chat_type=QQBotChatType.GROUP,
group_openid=group_openid,
attachments=self._parse_attachments(d.get("attachments", [])),
mentions=d.get("mentions", []),
reply_to_id=d.get("message_reference", {}).get("message_id"),
ref_msg_seq=d.get("message_reference", {}).get("message_seq"),
sender_is_bot=author.get("bot", False),
raw_event=d,
)
def _parse_dm_message(self, d: dict, seq: int | None) -> QueuedMessage | None:
author = d.get("author", {})
content = d.get("content", "").strip()
if not content and not d.get("attachments"):
return None
return QueuedMessage(
msg_id=d.get("id", ""),
msg_seq=seq,
sender_id=author.get("id", ""),
sender_name=author.get("username", ""),
content=content,
event_type=QQBotEventType.DIRECT_MESSAGE_CREATE,
timestamp=time.time(),
chat_type=QQBotChatType.DM,
attachments=self._parse_attachments(d.get("attachments", [])),
reply_to_id=d.get("message_reference", {}).get("message_id"),
ref_msg_seq=d.get("message_reference", {}).get("message_seq"),
sender_is_bot=author.get("bot", False),
raw_event=d,
)
def _parse_at_message(self, d: dict, seq: int | None) -> QueuedMessage | None:
author = d.get("author", {})
content = d.get("content", "").strip()
if not content and not d.get("attachments"):
return None
return QueuedMessage(
msg_id=d.get("id", ""),
msg_seq=seq,
sender_id=author.get("id", ""),
sender_name=author.get("username", ""),
content=content,
event_type=QQBotEventType.AT_MESSAGE_CREATE,
timestamp=time.time(),
chat_type=QQBotChatType.GUILD,
channel_id=d.get("channel_id", ""),
guild_id=d.get("guild_id", ""),
attachments=self._parse_attachments(d.get("attachments", [])),
mentions=d.get("mentions", []),
sender_is_bot=author.get("bot", False),
raw_event=d,
)
def _parse_attachments(self, attachments: list[dict]) -> list[QQBotAttachment]:
result = []
for att in attachments:
result.append(
QQBotAttachment(
url=att.get("url", ""),
content_type=att.get("content_type", ""),
filename=att.get("filename", ""),
size=att.get("size"),
width=att.get("width"),
height=att.get("height"),
)
)
return result
@staticmethod
def to_unified_message(msg: QueuedMessage, account_id: str) -> UnifiedMessage:
if msg.chat_type == QQBotChatType.C2C or msg.chat_type == QQBotChatType.DM:
peer_kind = PeerKind.DIRECT
target_id = TARGET_ID_FORMAT_C2C.format(openid=msg.sender_id)
else:
peer_kind = PeerKind.GROUP
target_id = TARGET_ID_FORMAT_GROUP.format(group_openid=msg.group_openid or "")
group = None
if msg.chat_type == QQBotChatType.GROUP:
group = GroupContext(
id=msg.group_openid,
name=msg.group_openid,
)
elif msg.chat_type == QQBotChatType.GUILD:
group = GroupContext(
id=msg.guild_id,
native_channel_id=msg.channel_id,
)
msg_type = MessageType.TEXT
if msg.attachments:
first_att = msg.attachments[0]
ct = first_att.content_type or ""
if ct.startswith("image/"):
msg_type = MessageType.IMAGE
elif ct.startswith("audio/") or ct.startswith("voice/"):
msg_type = MessageType.VOICE
else:
msg_type = MessageType.FILE
return UnifiedMessage(
msg_id=msg.msg_id,
channel_type="qqbot",
account_id=account_id,
content=msg.content,
sender=PeerInfo(
kind=peer_kind,
id=msg.sender_id,
display_name=msg.sender_name,
is_bot=msg.sender_is_bot,
),
message_type=msg_type,
media_urls=[a.url for a in msg.attachments],
group=group,
timestamp=datetime.fromtimestamp(msg.timestamp, tz=UTC),
raw_payload=msg.raw_event,
metadata={
"chat_type": msg.chat_type.value,
"event_type": msg.event_type,
"group_openid": msg.group_openid,
"channel_id": msg.channel_id,
"guild_id": msg.guild_id,
"target_id": target_id,
},
reply_to_id=msg.reply_to_id,
)
def _safe_summary(d: dict, max_len: int = 200) -> str:
s = str(d)
return s[:max_len] + "..." if len(s) > max_len else s