181 lines
6.1 KiB
Python
181 lines
6.1 KiB
Python
"""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
|