feat: implement channel runtime connectors
This commit is contained in:
@ -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
|
||||
Reference in New Issue
Block a user