feat: implement channel runtime connectors

This commit is contained in:
2026-06-03 16:22:44 +08:00
parent ee972441f5
commit c3d84b904a
105 changed files with 15621 additions and 322 deletions

View File

@ -0,0 +1 @@
"""Platform channel adapters."""

View File

@ -0,0 +1,138 @@
"""Shared helpers for platform channel adapters."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
@dataclass(slots=True)
class OutboundTarget:
peer_id: str | None
thread_id: str | None = None
peer_type: str = "unknown"
user_id: str | None = None
class PlatformDeliveryError(RuntimeError):
"""Raised when a platform client rejects a delivery."""
def config_bool(config: dict[str, Any], key: str, *, default: bool = False) -> bool:
value = config.get(key)
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return default
def config_list(config: dict[str, Any], key: str) -> list[str]:
value = config.get(key)
if value is None:
return []
if isinstance(value, str):
return [part.strip() for part in value.split(",") if part.strip()]
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
text = str(value).strip()
return [text] if text else []
def chunk_text(text: str, *, max_chars: int) -> list[str]:
if max_chars <= 0:
raise ValueError("max_chars must be positive")
if not text:
return [""]
return [text[index : index + max_chars] for index in range(0, len(text), max_chars)]
def compact_media_summary(media_type: str, *, file_name: str | None = None) -> str:
label = str(media_type or "attachment").strip() or "attachment"
if file_name:
return f"[{label}: {file_name}]"
return f"[{label}]"
def target_from_session_id(session_id: str | None) -> OutboundTarget:
if not session_id:
return OutboundTarget(peer_id=None)
parts = str(session_id).split(":")
if len(parts) < 3:
return OutboundTarget(peer_id=None)
thread_id = parts[3] if len(parts) > 3 and parts[3] else None
return OutboundTarget(peer_id=parts[2] or None, thread_id=thread_id)
def outbound_target(message: OutboundMessage) -> OutboundTarget:
identity = message.channel_identity
if identity is None:
return target_from_session_id(message.session_id)
return OutboundTarget(
peer_id=identity.peer_id,
thread_id=identity.thread_id,
peer_type=identity.peer_type,
user_id=identity.user_id,
)
def mark_unclaimed(message: OutboundMessage) -> None:
message.metadata["delivery_status"] = "unclaimed"
def build_inbound_message(
*,
channel_id: str,
kind: str,
account_id: str,
peer_id: str,
content: str,
message_id: str | None,
peer_type: str,
user_id: str | None = None,
thread_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> InboundMessage:
identity = ChannelIdentity(
channel_id=channel_id,
kind=kind,
account_id=account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
)
return InboundMessage(
channel=channel_id,
content=content,
session_id=identity.session_id(),
user_id=user_id,
message_id=message_id or "",
channel_identity=identity,
metadata=metadata or {},
)
def allowed_by_policy(
*,
policy: str | None,
identifier: str | None,
allowlist: list[str],
default: str = "open",
) -> bool:
effective = (policy or default).strip().lower()
if effective == "disabled":
return False
if effective == "allowlist":
return bool(identifier and identifier in allowlist)
return True

View File

@ -0,0 +1,207 @@
"""Feishu/Lark channel adapter."""
from __future__ import annotations
import json
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 (
build_inbound_message,
chunk_text,
compact_media_summary,
config_bool,
config_list,
mark_unclaimed,
outbound_target,
)
EventRecorder = Callable[..., None]
class FeishuAdapter:
"""Feishu/Lark bot adapter with injectable client support."""
KIND = "feishu"
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 4096)
async def start(self) -> None:
if self._client is not None:
return
if self.mode not in {"websocket", "webhook"}:
raise ValueError(f"Unsupported feishu 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()
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
await client.send_text(receive_id_type="chat_id", receive_id=target.peer_id, text=chunk)
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
event = payload.get("event") if isinstance(payload.get("event"), dict) else payload
message = event.get("message") if isinstance(event.get("message"), dict) else {}
sender = event.get("sender") if isinstance(event.get("sender"), dict) else {}
peer_id = _string_or_none(message.get("chat_id"))
if not peer_id:
return None
message_id = _string_or_none(message.get("message_id"))
message_type = str(message.get("message_type") or "unknown")
chat_type = str(message.get("chat_type") or "unknown")
peer_type = "dm" if chat_type == "p2p" else "group"
user_id = _sender_open_id(sender)
if peer_type == "dm" and not self._dm_allowed(user_id or peer_id):
return None
if peer_type == "group" and not self._group_allowed(peer_id, user_id):
return None
if peer_type == "group" and config_bool(self.config, "requireMentionInGroups", default=False):
if not self._message_mentions_bot(message):
return None
content = self._message_content(message_type, message)
if not content:
return None
metadata = {
"chat_id": peer_id,
"message_id": message_id,
"chat_type": chat_type,
"message_type": message_type,
}
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 _message_content(self, message_type: str, message: dict[str, Any]) -> str:
content = _parse_json_object(message.get("content"))
if message_type == "text":
return str(content.get("text") or "").strip()
file_name = _string_or_none(content.get("file_name") or content.get("name"))
return compact_media_summary(message_type, file_name=file_name)
def _message_mentions_bot(self, message: dict[str, Any]) -> bool:
bot_open_id = _string_or_none(self.config.get("botOpenId"))
if not bot_open_id:
return False
mentions = message.get("mentions")
if not isinstance(mentions, list):
return False
for mention in mentions:
if not isinstance(mention, dict):
continue
mention_id = mention.get("id") if isinstance(mention.get("id"), dict) else {}
if _string_or_none(mention_id.get("open_id")) == bot_open_id:
return True
return False
def _dm_allowed(self, identifier: str | None) -> bool:
allowlist = config_list(self.config, "allowFrom")
if not allowlist:
return True
return bool(identifier and identifier in allowlist)
def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool:
allowlist = config_list(self.config, "groupAllowFrom")
if not allowlist:
return True
return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist))
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("appSecret")
try:
import lark_oapi # noqa: F401
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[feishu] to enable FeishuAdapter") from exc
raise RuntimeError("Feishu 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 _parse_json_object(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if not isinstance(value, str):
return {}
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _sender_open_id(sender: dict[str, Any]) -> str | None:
sender_id = sender.get("sender_id") if isinstance(sender.get("sender_id"), dict) else {}
return _string_or_none(sender_id.get("open_id"))
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,206 @@
"""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

View File

@ -0,0 +1,244 @@
"""Telegram channel adapter."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
from .base import (
build_inbound_message,
chunk_text,
compact_media_summary,
config_bool,
config_list,
mark_unclaimed,
outbound_target,
)
EventRecorder = Callable[..., None]
class TelegramAdapter:
"""Telegram Bot API adapter with injectable client support."""
KIND = "telegram"
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,
application_factory: Callable[[], 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._application_factory = application_factory
self._application: Any | None = None
self.max_message_chars = int(self.config.get("maxMessageChars") or 4096)
async def start(self) -> None:
if self._client is not None:
return
if self.mode == "polling":
self._application = self._build_application()
await self._application.initialize()
await self._application.start()
if getattr(self._application, "updater", None) is not None:
await self._application.updater.start_polling()
self._client = self._application.bot
return
if self.mode == "webhook":
self._client = self._build_bot()
return
raise ValueError(f"Unsupported telegram mode: {self.mode}")
async def stop(self) -> None:
if self._application is None:
return
updater = getattr(self._application, "updater", None)
if updater is not None:
await updater.stop()
await self._application.stop()
await self._application.shutdown()
self._application = None
async def handle_update_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()
kwargs: dict[str, Any] = {"chat_id": target.peer_id}
if target.thread_id:
kwargs["message_thread_id"] = int(target.thread_id) if str(target.thread_id).isdigit() else target.thread_id
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
await client.send_message(**kwargs, text=chunk)
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
data = payload.get("message") or payload.get("edited_message")
if not isinstance(data, dict):
return None
chat = data.get("chat") if isinstance(data.get("chat"), dict) else {}
sender = data.get("from") if isinstance(data.get("from"), dict) else {}
peer_id = _string_or_none(chat.get("id"))
if not peer_id:
return None
chat_type = str(chat.get("type") or "unknown")
peer_type = self._peer_type(chat_type)
user_id = _string_or_none(sender.get("id"))
message_id = _string_or_none(data.get("message_id"))
thread_id = _string_or_none(data.get("message_thread_id"))
content = str(data.get("text") or data.get("caption") 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
if peer_type in {"group", "channel"} and not self._group_allowed(peer_id, user_id):
return None
if peer_type == "dm" and not self._dm_allowed(user_id or peer_id):
return None
if peer_type in {"group", "channel"} and config_bool(self.config, "requireMentionInGroups", default=False):
gated = self._strip_required_mention(content)
if gated is None:
return None
content = gated
metadata = {
"chat_id": peer_id,
"message_id": message_id,
"chat_type": chat_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 _media_entries(self, data: dict[str, Any]) -> list[str]:
entries: list[str] = []
if data.get("photo"):
entries.append(compact_media_summary("photo"))
for media_type in ("document", "audio", "video"):
value = data.get(media_type)
if isinstance(value, dict):
entries.append(compact_media_summary(media_type, file_name=_string_or_none(value.get("file_name"))))
return entries
def _strip_required_mention(self, content: str) -> str | None:
username = str(self.config.get("botUsername") or "").strip().lstrip("@")
if not username:
return None
mention = f"@{username}"
if mention not in content:
return None
return content.replace(mention, "", 1).strip()
def _dm_allowed(self, identifier: str | None) -> bool:
allowlist = config_list(self.config, "allowFrom")
if not allowlist:
return True
return bool(identifier and identifier in allowlist)
def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool:
allowlist = config_list(self.config, "groupAllowFrom")
if not allowlist:
return True
return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist))
def _peer_type(self, chat_type: str) -> str:
if chat_type == "private":
return "dm"
if chat_type in {"group", "supergroup"}:
return "group"
if chat_type == "channel":
return "channel"
return chat_type or "unknown"
def _require_client(self) -> Any:
if self._client is None:
self._client = self._build_bot()
return self._client
def _build_bot(self) -> Any:
token = self._require_secret("botToken")
try:
from telegram import Bot
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc
return Bot(token=token)
def _build_application(self) -> Any:
if self._application_factory is not None:
return self._application_factory()
token = self._require_secret("botToken")
try:
from telegram.ext import Application
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc
async def handle(update: Any, context: Any) -> None:
if hasattr(update, "to_dict"):
await self.handle_update_payload(update.to_dict())
application = Application.builder().token(token).build()
try:
from telegram.ext import MessageHandler, filters
application.add_handler(MessageHandler(filters.ALL, handle))
except Exception:
pass
return application
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

View File

@ -0,0 +1,180 @@
"""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