Files
beaver_project/app-instance/backend/beaver/interfaces/channels/platforms/qqbot.py

207 lines
7.0 KiB
Python

"""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