"""Weixin 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 WeixinAdapter: """Tencent iLink-style Weixin adapter with injectable client support.""" KIND = "weixin" 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 != "polling": raise ValueError(f"Unsupported weixin 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_message_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() context_token = self._context_token(message) for chunk in chunk_text(message.content, max_chars=self.max_message_chars): await client.send_text(peer_id=target.peer_id, text=chunk, context_token=context_token) def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None: sender_id = _string_or_none(payload.get("from") or payload.get("from_user")) room_id = _string_or_none(payload.get("room_id") or payload.get("roomId")) message_id = _string_or_none(payload.get("id") or payload.get("message_id")) message_type = str(payload.get("type") or payload.get("message_type") or "text") if room_id: peer_id = room_id peer_type = "group" user_id = sender_id if not allowed_by_policy( policy=self.config.get("groupPolicy"), identifier=peer_id, allowlist=config_list(self.config, "groupAllowFrom"), default="disabled", ): return None else: peer_id = sender_id peer_type = "dm" user_id = sender_id if not allowed_by_policy( policy=self.config.get("dmPolicy"), identifier=peer_id, allowlist=config_list(self.config, "allowFrom"), default="open", ): return None if not peer_id: return None content = self._content(message_type, payload) if not content: return None metadata = { "message_id": message_id, "message_type": message_type, } context_token = _string_or_none(payload.get("context_token") or payload.get("contextToken")) if context_token: metadata["context_token"] = context_token if room_id: metadata["room_id"] = room_id return build_inbound_message( channel_id=self.channel_id, kind=self.kind, account_id=self.account_id, peer_id=peer_id, peer_type=peer_type, user_id=user_id, message_id=message_id, content=content, metadata=metadata, ) def _content(self, message_type: str, payload: dict[str, Any]) -> str: if message_type == "text": return str(payload.get("text") or payload.get("content") or "").strip() file_name = _string_or_none(payload.get("file_name") or payload.get("filename")) return compact_media_summary(message_type, file_name=file_name) def _context_token(self, message: OutboundMessage) -> str | None: inbound_metadata = message.metadata.get("inbound_metadata") if isinstance(inbound_metadata, dict): value = _string_or_none(inbound_metadata.get("context_token")) if value: return value return _string_or_none(message.metadata.get("context_token")) 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("token") try: import aiohttp # noqa: F401 except ImportError as exc: # pragma: no cover - optional live dependency raise RuntimeError("Install beaver-backend[weixin] to enable WeixinAdapter") from exc raise RuntimeError("Weixin 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