"""QQ Bot channel adapter.""" from __future__ import annotations from collections.abc import Callable from typing import Any from beaver.foundation.events import InboundMessage, OutboundMessage from beaver.interfaces.channels.base import ChannelInboundSink from .base import ( allowed_by_policy, build_inbound_message, chunk_text, compact_media_summary, config_list, mark_unclaimed, outbound_target, ) EventRecorder = Callable[..., None] class QQBotAdapter: """QQ Bot API adapter with injectable client support.""" KIND = "qqbot" def __init__( self, *, channel_id: str, kind: str, mode: str, account_id: str, display_name: str | None, inbound_sink: ChannelInboundSink, secrets: dict[str, Any] | None = None, config: dict[str, Any] | None = None, event_recorder: EventRecorder | None = None, client: Any | None = None, ) -> None: self.channel_id = channel_id self.kind = kind self.mode = mode self.account_id = account_id self.display_name = display_name self.inbound_sink = inbound_sink self.secrets = secrets or {} self.config = config or {} self.event_recorder = event_recorder self._client = client self.max_message_chars = int(self.config.get("maxMessageChars") or 2000) async def start(self) -> None: if self._client is not None: return if self.mode != "websocket": raise ValueError(f"Unsupported qqbot mode: {self.mode}") self._client = self._build_client() async def stop(self) -> None: close = getattr(self._client, "close", None) if close is not None: result = close() if hasattr(result, "__await__"): await result async def handle_event_payload(self, payload: dict[str, Any]) -> None: message = self._normalize_payload(payload) if message is None: return await self.inbound_sink.accept_inbound(message) async def send(self, message: OutboundMessage) -> None: target = outbound_target(message) if not target.peer_id: mark_unclaimed(message) return client = self._require_client() platform_message_id = message.channel_identity.message_id if message.channel_identity else None for chunk in chunk_text(message.content, max_chars=self.max_message_chars): await client.send_text( peer_type=target.peer_type, peer_id=target.peer_id, content=chunk, message_id=platform_message_id, ) def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None: event_type = str(payload.get("t") or payload.get("type") or "") data = payload.get("d") if isinstance(payload.get("d"), dict) else payload author = data.get("author") if isinstance(data.get("author"), dict) else {} route = self._route(event_type, data, author) if route is None: return None peer_id, peer_type, user_id, thread_id = route if peer_type == "dm": if not allowed_by_policy( policy=self.config.get("dmPolicy"), identifier=user_id or peer_id, allowlist=config_list(self.config, "allowFrom"), default="open", ): return None elif peer_type == "group": if not allowed_by_policy( policy=self.config.get("groupPolicy"), identifier=peer_id, allowlist=config_list(self.config, "groupAllowFrom"), default="open", ): return None message_id = _string_or_none(data.get("id")) content = str(data.get("content") or "").strip() media_entries = self._media_entries(data) if media_entries: content = "\n".join([part for part in [content, *media_entries] if part]).strip() if not content: return None metadata = { "event_type": event_type, "message_id": message_id, "peer_type": peer_type, } if media_entries: metadata["media"] = media_entries return build_inbound_message( channel_id=self.channel_id, kind=self.kind, account_id=self.account_id, peer_id=peer_id, thread_id=thread_id, peer_type=peer_type, user_id=user_id, message_id=message_id, content=content, metadata=metadata, ) def _route( self, event_type: str, data: dict[str, Any], author: dict[str, Any], ) -> tuple[str, str, str | None, str | None] | None: if event_type == "C2C_MESSAGE_CREATE": peer_id = _string_or_none(author.get("user_openid")) if not peer_id: return None return peer_id, "dm", peer_id, None if event_type == "GROUP_AT_MESSAGE_CREATE": peer_id = _string_or_none(data.get("group_openid")) if not peer_id: return None return peer_id, "group", _string_or_none(author.get("member_openid")), None if data.get("guild_id") and data.get("channel_id"): peer_id = _string_or_none(data.get("channel_id")) if not peer_id: return None return peer_id, "channel", _string_or_none(author.get("id")), _string_or_none(data.get("guild_id")) return None def _media_entries(self, data: dict[str, Any]) -> list[str]: entries: list[str] = [] attachments = data.get("attachments") if not isinstance(attachments, list): return entries for attachment in attachments: if not isinstance(attachment, dict): continue media_type = str(attachment.get("content_type") or attachment.get("type") or "attachment") entries.append(compact_media_summary(media_type, file_name=_string_or_none(attachment.get("filename")))) return entries def _require_client(self) -> Any: if self._client is None: self._client = self._build_client() return self._client def _build_client(self) -> Any: self._require_secret("appId") self._require_secret("clientSecret") try: import aiohttp # noqa: F401 except ImportError as exc: # pragma: no cover - optional live dependency raise RuntimeError("Install beaver-backend[qqbot] to enable QQBotAdapter") from exc raise RuntimeError("QQBot live client is not configured for direct construction") def _require_secret(self, key: str) -> str: value = self.secrets.get(key) if not value: raise ValueError(f"{key} is required") return str(value) def _string_or_none(value: Any) -> str | None: if value is None: return None text = str(value).strip() return text or None