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