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