245 lines
8.6 KiB
Python
245 lines
8.6 KiB
Python
"""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
|