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