ForcePilot/backend/package/yuxi/channel/extensions/qqbot/monitor.py

304 lines
12 KiB
Python
Raw Normal View History

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