from __future__ import annotations import asyncio import hashlib import time from collections.abc import AsyncIterator from typing import Any, Literal from telegram import BotCommand, BotCommandScope, Update from telegram.error import TelegramError, InvalidToken, RetryAfter, Forbidden, BadRequest, TimedOut, NetworkError from telegram.ext import Application, ApplicationBuilder, MessageHandler, filters from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelNotConnectedError, ) from yuxi.channels.models import ( Attachment, ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, ChatType, DeliveryResult, EventType, HealthStatus, MentionsInfo, MessageType, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger from .send import send_with_retry, send_stream_edit from yuxi.channels.sdk.chunking import ChunkConfig, chunk_message as sdk_chunk_message from yuxi.channels.sdk.format import markdown_to_html as sdk_markdown_to_html from yuxi.channels.sdk.format import strip_html_tags as sdk_strip_html_tags from yuxi.channels.capabilities import ChannelCapabilities, TTSVoiceCapabilities, TTSCapabilities from yuxi.channels.meta import ChannelMeta from .accounts.token import resolve_bot_token from .connect.allowed_updates import get_allowed_updates from .connect.polling_liveness import PollingLiveness from .connect.update_offset_store import UpdateOffsetStore from .reactions.reaction_level import ReactionLevelController from .reactions.reaction_notifications import ReactionNotifications from .tools.sendchataction_401_backoff import SendChatAction401Backoff from .tools.request_timeouts import resolve_global_timeout from .debounce import MessageDebouncer from .thread_persistence import ThreadPersistence from .topics.auto_topic_label import suggest_icon_color from .topics.topic_name_cache import TopicNameCache def _build_command_scope(scope_config: str | dict) -> BotCommandScope | None: """Build a BotCommandScope from configuration.""" if isinstance(scope_config, str): if scope_config == "default": return None if scope_config == "all_private_chats": return BotCommandScope(type="all_private_chats") if scope_config == "all_group_chats": return BotCommandScope(type="all_group_chats") if scope_config == "all_chat_administrators": return BotCommandScope(type="all_chat_administrators") return None if isinstance(scope_config, dict): scope_type = scope_config.get("type", "default") if scope_type == "default": return None return BotCommandScope( type=scope_type, chat_id=scope_config.get("chat_id"), user_id=scope_config.get("user_id"), ) return None @register_builtin_adapter(aliases=["tg"]) class TelegramAdapter(BaseChannelAdapter): channel_id = "telegram" channel_type = ChannelType.TELEGRAM text_chunk_limit = 4096 supports_markdown = False supports_streaming = True streaming_modes = ["off", "partial", "block", "progress"] max_media_size_mb = 100 capabilities = ChannelCapabilities( chat_types=["direct", "group", "channel", "thread"], polls=True, reactions=True, edit=True, unsend=True, reply=True, threads=True, media=True, native_commands=True, pin=True, unpin=True, group_management=True, supports_markdown=False, supports_streaming=True, streaming_modes=["off", "partial", "block", "progress"], text_chunk_limit=4096, max_media_size_mb=100, tts=TTSCapabilities(voice=TTSVoiceCapabilities(synthesis_target="voice-note", enabled=True)), block_streaming=True, ) meta = ChannelMeta( id="telegram", label="Telegram", markdown_capable=False, ) def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._enabled = self.config.get("enabled", True) self._history_limit = self.config.get("history_limit", 50) self._application: Application | None = None self._monitor_mode: Literal["polling", "webhook"] = "polling" self._bot_info: dict[str, Any] | None = None self._token_hash: str = "" self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60) self._polling_task: asyncio.Task | None = None self._dedup_set: dict[str, float] = {} self._dedup_ttl = 300 cfg_size = self.config.get("max_media_size_mb") if cfg_size is not None: self.max_media_size_mb = int(cfg_size) self._liveness = PollingLiveness(max_idle_seconds=self.config.get("polling_stall_threshold_ms", 120000) / 1000) self._offset_store = UpdateOffsetStore() self._reaction_level_ctrl = ReactionLevelController(self.config) self._reaction_notifications = ReactionNotifications(self.config) self._chat_action_backoff = SendChatAction401Backoff() self._topic_name_cache = TopicNameCache() self._sent_message_cache: dict[str, dict[str, Any]] = {} self._sent_message_cache_ttl = self.config.get("sent_message_cache_ttl", 3600) self._dedup_lock = asyncio.Lock() self._cache_lock = asyncio.Lock() self._silent_error_replies = self.config.get("silent_error_replies", False) self._trusted_local_file_roots: list[str] = self.config.get("trusted_local_file_roots", []) self._config_writes = self.config.get("config_writes", True) self._commands_native = ( self.config.get("commands", {}).get("native", "auto") if isinstance(self.config.get("commands"), dict) else "auto" ) self._commands_native_skills = ( self.config.get("commands", {}).get("native_skills", "auto") if isinstance(self.config.get("commands"), dict) else "auto" ) self._debouncer = MessageDebouncer( max_entries=self.config.get("debounce_max_entries", 1000), ttl_seconds=self.config.get("debounce_ttl_seconds", 300), ) self._thread_persistence = ThreadPersistence( storage_path=self.config.get("thread_persistence_path"), ) async def connect(self) -> None: if self._status == ChannelStatus.CONNECTED: return if not self._enabled: self._status = ChannelStatus.DISABLED logger.info( f"[Telegram] Channel '{self.config.get('name', self.channel_id)}' is disabled, skipping connect" ) return self._status = ChannelStatus.CONNECTING logger.info(f"[Telegram] Starting channel '{self.config.get('name', self.channel_id)}'") token = resolve_bot_token(self.config) if not token: raise ChannelAuthenticationError() builder = ApplicationBuilder().token(token) api_root = self.config.get("api_root") if api_root: builder.base_url(f"{api_root.rstrip('/')}/bot") proxy = self.config.get("proxy") if proxy: builder.proxy_url(proxy) connect_timeout = resolve_global_timeout(self.config) builder.connect_timeout(connect_timeout) builder.read_timeout(connect_timeout) builder.write_timeout(connect_timeout) network_cfg = self.config.get("network") or {} if network_cfg: pool_size = network_cfg.get("connectionPoolSize", 1) builder.connection_pool_size(pool_size) http_ver = network_cfg.get("httpVersion", "1.1") builder.http_version(http_ver) dns_order = network_cfg.get("dnsResultOrder") if dns_order: from telegram.request import HTTPXRequest import socket httpx_kwargs: dict[str, Any] = {} if dns_order == "ipv4first": httpx_kwargs["socket_options"] = [(socket.AF_INET, None)] elif dns_order == "ipv6first": httpx_kwargs["socket_options"] = [(socket.AF_INET6, None)] if httpx_kwargs: request = HTTPXRequest( connection_pool_size=pool_size, proxy_url=proxy, connect_timeout=connect_timeout, read_timeout=connect_timeout, write_timeout=connect_timeout, http_version=http_ver, httpx_kwargs=httpx_kwargs, ) builder.request(request) builder.get_updates_request(request) auto_select = network_cfg.get("autoSelectFamily", True) logger.debug(f"[Telegram] Network: autoSelectFamily={auto_select}, dnsResultOrder={dns_order}") self._application = builder.concurrent_updates(False).build() try: bot_info = await self._application.bot.get_me() except InvalidToken: raise ChannelAuthenticationError() except (NetworkError, TimedOut) as e: raise ChannelNotConnectedError() from e self._bot_info = { "id": bot_info.id, "username": bot_info.username, "name": f"{bot_info.first_name} {bot_info.last_name or ''}".strip(), } self._token_hash = hashlib.sha256(token.encode()).hexdigest()[:8] logger.info(f"[Telegram] Bot verified: @{bot_info.username} (ID: {bot_info.id})") await self._register_handlers() await self._register_commands() webhook_url = self.config.get("webhook_url") if webhook_url: await self._start_webhook() self._monitor_mode = "webhook" else: await self._start_polling() self._monitor_mode = "polling" self._status = ChannelStatus.CONNECTED logger.info(f"[Telegram] Channel started in {self._monitor_mode} mode") async def disconnect(self) -> None: if self._status == ChannelStatus.DISCONNECTED: return logger.info(f"[Telegram] Stopping channel '{self.config.get('name', self.channel_id)}'") if self._polling_task and not self._polling_task.done(): self._polling_task.cancel() try: await self._polling_task except asyncio.CancelledError: pass self._polling_task = None if self._application: if self._monitor_mode == "polling": try: await self._application.updater.stop() except Exception: pass try: await self._application.stop() await self._application.shutdown() except Exception as e: logger.warning(f"[Telegram] Error during shutdown: {e}") self._application = None self._status = ChannelStatus.DISCONNECTED async def send(self, response: ChannelResponse) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") chat_id = response.identity.channel_chat_id payload = self.format_outbound(response) async def _do_send(): return await send_with_retry(self._application.bot, chat_id, payload, self.config) try: result = await self._circuit_breaker.call(_do_send) if result and hasattr(result, "message_id"): now = time.monotonic() async with self._cache_lock: self._sent_message_cache[str(result.message_id)] = { "chat_id": chat_id, "msg_id": str(result.message_id), "cached_at": now, } expired = [ k for k, v in self._sent_message_cache.items() if now - v["cached_at"] > self._sent_message_cache_ttl ] for k in expired: del self._sent_message_cache[k] return result except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except (TelegramError, Exception) as e: return DeliveryResult(success=False, error=str(e)) async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") max_bytes = self.max_media_size_mb * 1024 * 1024 if isinstance(data, (bytes, bytearray)) and len(data) > max_bytes: return DeliveryResult(success=False, error=f"Media exceeds {self.max_media_size_mb}MB limit") _send_map = { "image": self._application.bot.send_photo, "video": self._application.bot.send_video, "audio": self._application.bot.send_audio, "file": self._application.bot.send_document, "animation": self._application.bot.send_animation, "sticker": self._application.bot.send_sticker, } send_func = _send_map.get(media_type) if send_func is None: return DeliveryResult(success=False, error=f"Unsupported media type: {media_type}") max_retries = 3 for attempt in range(max_retries): try: msg = await send_func(chat_id=chat_id, **{media_type if media_type != "file" else "document": data}) return DeliveryResult(success=True, message_id=str(msg.message_id)) except RetryAfter as e: await asyncio.sleep(e.retry_after) except Forbidden: return DeliveryResult(success=False, error="Bot blocked by user") except (TimedOut, NetworkError, OSError): if attempt == max_retries - 1: return DeliveryResult(success=False, error="Media send failed after retries") delay = 1.0 * (2**attempt) await asyncio.sleep(delay) except Exception as e: return DeliveryResult(success=False, error=str(e)) return DeliveryResult(success=False, error="Media send failed after retries") async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: html = sdk_markdown_to_html(content) await self._application.bot.edit_message_text( chat_id=chat_id, message_id=int(msg_id), text=html, parse_mode="HTML", ) return DeliveryResult(success=True, message_id=msg_id) except BadRequest: try: clean_text = sdk_strip_html_tags(sdk_markdown_to_html(content)) await self._application.bot.edit_message_text( chat_id=chat_id, message_id=int(msg_id), text=clean_text, ) return DeliveryResult(success=True, message_id=msg_id) except Exception as e: return DeliveryResult(success=False, error=str(e)) except RetryAfter as e: return DeliveryResult(success=False, error=f"Rate limited, retry after {e.retry_after}s") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: if finished: return await self.edit_message(chat_id, msg_id, chunk) return await send_stream_edit(self._application, chat_id, msg_id, chunk, self.config) async def edit_message_caption(self, chat_id: str, msg_id: str, caption: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: html = sdk_markdown_to_html(caption) await self._application.bot.edit_message_caption( chat_id=chat_id, message_id=int(msg_id), caption=html, parse_mode="HTML", ) return DeliveryResult(success=True, message_id=msg_id) except BadRequest: try: clean = sdk_strip_html_tags(sdk_markdown_to_html(caption)) await self._application.bot.edit_message_caption( chat_id=chat_id, message_id=int(msg_id), caption=clean, ) return DeliveryResult(success=True, message_id=msg_id) except Exception as e: return DeliveryResult(success=False, error=str(e)) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def edit_message_reply_markup(self, chat_id: str, msg_id: str, reply_markup: Any = None) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.edit_message_reply_markup( chat_id=chat_id, message_id=int(msg_id), reply_markup=reply_markup, ) return DeliveryResult(success=True, message_id=msg_id) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.delete_message(chat_id=chat_id, message_id=int(msg_id)) return DeliveryResult(success=True, message_id=msg_id) except Forbidden: return DeliveryResult(success=False, error="Bot lacks delete permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") if not self._reaction_level_ctrl.should_react("message.received"): return DeliveryResult(success=True, message_id=msg_id, metadata={"reaction_skipped": True}) try: await self._application.bot.set_message_reaction( chat_id=chat_id, message_id=int(msg_id), reaction=[{"type": "emoji", "emoji": emoji}], ) return DeliveryResult(success=True, message_id=msg_id) except BadRequest as e: if "REACTION_INVALID" in str(e): return DeliveryResult(success=True, message_id=msg_id, metadata={"reaction_rejected": True}) return DeliveryResult(success=False, error=str(e)) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_chat_action(self, chat_id: str, action: str = "typing") -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") success = await self._chat_action_backoff.execute(self._application.bot, chat_id, action) if success: return DeliveryResult(success=True) return DeliveryResult(success=False, error="sendChatAction skipped (backoff active)") async def pin_message(self, chat_id: str, msg_id: str, disable_notification: bool = False) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.pin_chat_message( chat_id=chat_id, message_id=int(msg_id), disable_notification=disable_notification, ) return DeliveryResult(success=True, message_id=msg_id) except Forbidden: return DeliveryResult(success=False, error="Bot lacks pin permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def unpin_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.unpin_chat_message( chat_id=chat_id, message_id=int(msg_id), ) return DeliveryResult(success=True, message_id=msg_id) except Forbidden: return DeliveryResult(success=False, error="Bot lacks unpin permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def unpin_all_messages(self, chat_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.unpin_all_chat_messages(chat_id=chat_id) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks unpin permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def forward_message(self, from_chat_id: str, to_chat_id: str, msg_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: result = await self._application.bot.forward_message( chat_id=to_chat_id, from_chat_id=from_chat_id, message_id=int(msg_id), ) return DeliveryResult(success=True, message_id=str(result.message_id)) except Forbidden: return DeliveryResult(success=False, error="Bot lacks forward permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_media_group(self, chat_id: str, media: list[dict[str, Any]]) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: from telegram import InputMediaPhoto, InputMediaVideo, InputMediaAudio, InputMediaDocument input_media = [] for item in media: media_type = item.get("type", "photo") media_data = item.get("media", "") caption = item.get("caption") kwargs = {} if caption: kwargs["caption"] = caption kwargs["parse_mode"] = "HTML" if media_type == "photo": input_media.append(InputMediaPhoto(media=media_data, **kwargs)) elif media_type == "video": input_media.append(InputMediaVideo(media=media_data, **kwargs)) elif media_type == "audio": input_media.append(InputMediaAudio(media=media_data, **kwargs)) elif media_type == "document": input_media.append(InputMediaDocument(media=media_data, **kwargs)) if not input_media: return DeliveryResult(success=False, error="No valid media in group") messages = await self._application.bot.send_media_group( chat_id=chat_id, media=input_media, ) msg_ids = [str(m.message_id) for m in messages] return DeliveryResult(success=True, message_id=msg_ids[0], metadata={"message_ids": msg_ids}) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_location(self, chat_id: str, latitude: float, longitude: float) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: msg = await self._application.bot.send_location( chat_id=chat_id, latitude=latitude, longitude=longitude, ) return DeliveryResult(success=True, message_id=str(msg.message_id)) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_contact( self, chat_id: str, phone_number: str, first_name: str, last_name: str = "" ) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: msg = await self._application.bot.send_contact( chat_id=chat_id, phone_number=phone_number, first_name=first_name, last_name=last_name or None, ) return DeliveryResult(success=True, message_id=str(msg.message_id)) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def create_forum_topic( self, chat_id: str, name: str, icon_color: int | None = None, icon_custom_emoji_id: str | None = None ) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: if not name or name.strip() == "": name = "New Topic" if icon_color is None: icon_color = suggest_icon_color(name) kwargs = {"name": name, "icon_color": icon_color} if icon_custom_emoji_id is not None: kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id result = await self._application.bot.create_forum_topic( chat_id=chat_id, **kwargs, ) thread_id = str(result.message_thread_id) self._topic_name_cache.set( thread_id, { "name": result.name, "icon_color": icon_color, }, ) return DeliveryResult( success=True, message_id=None, metadata={ "message_thread_id": thread_id, "name": result.name, }, ) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_dice(self, chat_id: str, emoji: str = "\U0001f3b2") -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: msg = await self._application.bot.send_dice(chat_id=chat_id, emoji=emoji) return DeliveryResult(success=True, message_id=str(msg.message_id)) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def edit_forum_topic( self, chat_id: str, topic_id: str, name: str | None = None, icon_custom_emoji_id: str | None = None ) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: kwargs = {"message_thread_id": int(topic_id)} if name is not None: kwargs["name"] = name if icon_custom_emoji_id is not None: kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id await self._application.bot.edit_forum_topic(chat_id=chat_id, **kwargs) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def close_forum_topic(self, chat_id: str, topic_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.close_forum_topic( chat_id=chat_id, message_thread_id=int(topic_id), ) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def reopen_forum_topic(self, chat_id: str, topic_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.reopen_forum_topic( chat_id=chat_id, message_thread_id=int(topic_id), ) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def delete_forum_topic(self, chat_id: str, topic_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.delete_forum_topic( chat_id=chat_id, message_thread_id=int(topic_id), ) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def kick_member(self, chat_id: str, user_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.ban_chat_member(chat_id=chat_id, user_id=int(user_id)) await self._application.bot.unban_chat_member(chat_id=chat_id, user_id=int(user_id)) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks kick permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def ban_member(self, chat_id: str, user_id: str, until_date: int | None = None) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: kwargs = {} if until_date is not None: kwargs["until_date"] = until_date await self._application.bot.ban_chat_member(chat_id=chat_id, user_id=int(user_id), **kwargs) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks ban permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def unban_member(self, chat_id: str, user_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.unban_chat_member(chat_id=chat_id, user_id=int(user_id)) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks unban permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def mute_member(self, chat_id: str, user_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: from telegram import ChatPermissions await self._application.bot.restrict_chat_member( chat_id=chat_id, user_id=int(user_id), permissions=ChatPermissions(can_send_messages=False), ) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks mute permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def unmute_member(self, chat_id: str, user_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: from telegram import ChatPermissions await self._application.bot.restrict_chat_member( chat_id=chat_id, user_id=int(user_id), permissions=ChatPermissions( can_send_messages=True, can_send_media_messages=True, can_send_polls=True, can_send_other_messages=True, can_add_web_page_previews=True, ), ) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks unmute permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def set_chat_permissions(self, chat_id: str, permissions: dict[str, bool]) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: from telegram import ChatPermissions await self._application.bot.set_chat_permissions( chat_id=chat_id, permissions=ChatPermissions(**permissions), ) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks permission management") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def set_chat_photo(self, chat_id: str, photo: Any) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.set_chat_photo(chat_id=chat_id, photo=photo) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks permission to set chat photo") except BadRequest as e: return DeliveryResult(success=False, error=f"Invalid photo: {e}") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def delete_chat_photo(self, chat_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.delete_chat_photo(chat_id=chat_id) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks permission to delete chat photo") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def rename_group(self, chat_id: str, title: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.set_chat_title(chat_id=chat_id, title=title) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks permission to rename group") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def set_chat_description(self, chat_id: str, description: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.set_chat_description(chat_id=chat_id, description=description) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks permission to set description") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def promote_admin( self, chat_id: str, user_id: str, can_change_info: bool = False, can_post_messages: bool = False, can_edit_messages: bool = False, can_delete_messages: bool = False, can_invite_users: bool = False, can_restrict_members: bool = False, can_pin_messages: bool = False, can_promote_members: bool = False, can_manage_chat: bool = False, can_manage_video_chats: bool = False, can_manage_topics: bool = False, is_anonymous: bool = False, ) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: from telegram import ChatAdministratorRights rights = ChatAdministratorRights( is_anonymous=is_anonymous, can_manage_chat=can_manage_chat, can_delete_messages=can_delete_messages, can_manage_video_chats=can_manage_video_chats, can_restrict_members=can_restrict_members, can_promote_members=can_promote_members, can_change_info=can_change_info, can_invite_users=can_invite_users, can_post_messages=can_post_messages, can_edit_messages=can_edit_messages, can_pin_messages=can_pin_messages, can_manage_topics=can_manage_topics, ) await self._application.bot.promote_chat_member( chat_id=chat_id, user_id=int(user_id), rights=rights, ) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks promote permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def demote_admin(self, chat_id: str, user_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: from telegram import ChatAdministratorRights await self._application.bot.promote_chat_member( chat_id=chat_id, user_id=int(user_id), rights=ChatAdministratorRights(), ) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot lacks demote permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def create_chat_invite_link( self, chat_id: str, name: str | None = None, member_limit: int | None = None ) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: kwargs = {} if name is not None: kwargs["name"] = name if member_limit is not None: kwargs["member_limit"] = member_limit result = await self._application.bot.create_chat_invite_link(chat_id=chat_id, **kwargs) return DeliveryResult( success=True, metadata={"invite_link": result.invite_link, "name": result.name}, ) except Forbidden: return DeliveryResult(success=False, error="Bot lacks invite link permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def export_chat_invite_link(self, chat_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: invite_link = await self._application.bot.export_chat_invite_link(chat_id=chat_id) return DeliveryResult(success=True, metadata={"invite_link": invite_link}) except Forbidden: return DeliveryResult(success=False, error="Bot lacks invite link permission") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def leave_chat(self, chat_id: str) -> DeliveryResult: if not self._application: return DeliveryResult(success=False, error="Application not initialized") try: await self._application.bot.leave_chat(chat_id=chat_id) return DeliveryResult(success=True) except Forbidden: return DeliveryResult(success=False, error="Bot cannot leave this chat") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def receive(self) -> AsyncIterator[ChannelMessage]: if False: yield # type: ignore[misc] def normalize_inbound(self, raw: Update) -> ChannelMessage: if raw.message is None: return self._make_passthrough_event(raw) msg = raw.message chat = msg.chat user = msg.from_user debounce_key = self._debouncer.build_debounce_key(raw.to_dict()) if debounce_key and self._debouncer.is_duplicate(debounce_key): return self._make_skip_event(raw) chat_id = str(chat.id) user_id = str(user.id) if user else "unknown" msg_id = str(msg.message_id) if chat.type == "channel": chat_id = self._normalize_channel_id(chat_id) chat_type = ChatType.DIRECT if chat.type in ("group", "supergroup"): chat_type = ChatType.GROUP elif chat.type == "channel": chat_type = ChatType.GUILD_CHANNEL event_type = EventType.MESSAGE_RECEIVED if msg.new_chat_members: event_type = EventType.MEMBER_JOINED elif msg.left_chat_member: event_type = EventType.MEMBER_LEFT elif msg.pinned_message: event_type = EventType.SYSTEM_EVENT elif msg.forum_topic_created: event_type = EventType.SYSTEM_EVENT metadata_extra = {"forum_topic_created": True, "forum_topic_name": msg.forum_topic_created.name} elif msg.forum_topic_closed: event_type = EventType.SYSTEM_EVENT metadata_extra = {"forum_topic_closed": True} elif msg.forum_topic_reopened: event_type = EventType.SYSTEM_EVENT metadata_extra = {"forum_topic_reopened": True} else: metadata_extra = {} content = msg.text or msg.caption or "" message_type = MessageType.TEXT if msg.text and self._is_bot_command(msg): message_type = MessageType.COMMAND attachments = self._extract_attachments(msg) if not content and attachments: content = "(Media)" mentions = self._extract_mentions(msg) extracted_urls = self._extract_urls(msg) metadata = { "telegram_chat_type": chat.type, "is_topic_message": msg.is_topic_message, } metadata.update(metadata_extra) if msg.message_thread_id: metadata["thread_id"] = str(msg.message_thread_id) cached = self._topic_name_cache.get(str(msg.message_thread_id)) if cached: metadata["thread_name"] = cached.get("name", "") if chat.username: metadata["chat_username"] = chat.username if chat.title: metadata["chat_title"] = chat.title if msg.reply_to_message: metadata["reply_to_message_id"] = str(msg.reply_to_message.message_id) if msg.forward_origin: fwd = msg.forward_origin fwd_info = {"type": fwd.type} if getattr(fwd, "sender_user", None): fwd_info["sender_user_id"] = str(fwd.sender_user.id) if getattr(fwd, "sender_chat", None): fwd_info["sender_chat_id"] = str(fwd.sender_chat.id) if fwd.sender_chat.title: fwd_info["sender_chat_title"] = fwd.sender_chat.title metadata["forward_info"] = fwd_info return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=user_id, channel_chat_id=chat_id, channel_message_id=msg_id, ), event_type=event_type, message_type=message_type, chat_type=chat_type, content=content, attachments=attachments, mentions=mentions, extracted_urls=extracted_urls, reply_to_message_id=metadata.get("reply_to_message_id"), metadata=metadata, timestamp=msg.date, ) @staticmethod def _is_bot_command(msg) -> bool: try: from telegram import MessageEntity except ImportError: return bool(msg.text and msg.text.startswith("/")) if msg.entities: return any(e.type == MessageEntity.BOT_COMMAND for e in msg.entities) return bool(msg.text and msg.text.startswith("/")) def _extract_attachments(self, msg) -> list[Attachment]: attachments = [] if msg.photo: largest = msg.photo[-1] attachments.append( Attachment( type="image", file_id=largest.file_id, mime_type="image/jpeg", ) ) if msg.document: attachments.append( Attachment( type="file", file_id=msg.document.file_id, filename=msg.document.file_name, mime_type=msg.document.mime_type, size_bytes=msg.document.file_size or 0, ) ) if msg.video: attachments.append( Attachment( type="video", file_id=msg.video.file_id, mime_type=msg.video.mime_type, ) ) if msg.audio: attachments.append( Attachment( type="audio", file_id=msg.audio.file_id, mime_type=msg.audio.mime_type, ) ) if msg.voice: attachments.append( Attachment( type="audio", file_id=msg.voice.file_id, mime_type=msg.voice.mime_type, ) ) if msg.sticker: attachments.append( Attachment( type="sticker", file_id=msg.sticker.file_id, ) ) if msg.animation: attachments.append( Attachment( type="animation", file_id=msg.animation.file_id, filename=msg.animation.file_name, mime_type=msg.animation.mime_type, size_bytes=msg.animation.file_size or 0, ) ) if msg.video_note: attachments.append( Attachment( type="video_note", file_id=msg.video_note.file_id, size_bytes=msg.video_note.file_size or 0, ) ) if msg.location: attachments.append( Attachment( type="location", metadata={ "latitude": msg.location.latitude, "longitude": msg.location.longitude, }, ) ) if msg.contact: attachments.append( Attachment( type="contact", metadata={ "phone_number": msg.contact.phone_number, "first_name": msg.contact.first_name, "last_name": msg.contact.last_name, "user_id": str(msg.contact.user_id) if msg.contact.user_id else None, }, ) ) if msg.dice: attachments.append( Attachment( type="dice", metadata={"emoji": msg.dice.emoji, "value": msg.dice.value}, ) ) if msg.poll: attachments.append( Attachment( type="poll", metadata={ "poll_id": msg.poll.id, "question": msg.poll.question, "options": [opt.text for opt in msg.poll.options], }, ) ) if msg.venue: attachments.append( Attachment( type="venue", metadata={ "title": msg.venue.title, "address": msg.venue.address, "latitude": msg.venue.location.latitude, "longitude": msg.venue.location.longitude, }, ) ) if msg.media_group_id: for att in attachments: if att.type in ("image", "video", "audio"): att.metadata["media_group_id"] = msg.media_group_id return attachments def _extract_mentions(self, msg) -> MentionsInfo | None: if not msg.entities or not msg.text: return None mentioned_ids: list[str] = [] bot_username = (self._bot_info or {}).get("username", "") for entity in msg.entities: if entity.type == "mention": mention_text = msg.text[entity.offset : entity.offset + entity.length] mentioned_ids.append(mention_text) elif entity.type == "text_mention" and entity.user: mentioned_ids.append(str(entity.user.id)) if not mentioned_ids: return None bot_mentioned = f"@{bot_username}" in mentioned_ids if bot_username else False return MentionsInfo( mentioned_user_ids=mentioned_ids, is_bot_mentioned=bot_mentioned, raw_text=msg.text, ) def _extract_urls(self, msg) -> list[str]: if not msg.entities or not msg.text: return [] urls: list[str] = [] for entity in msg.entities: if entity.type == "url": url = msg.text[entity.offset : entity.offset + entity.length] urls.append(url) elif entity.type == "text_link": urls.append(entity.url) return urls def _make_passthrough_event(self, update: Update) -> ChannelMessage: if update.edited_message: msg = update.edited_message chat_id = str(msg.chat.id) if msg.chat else "unknown" user_id = str(msg.from_user.id) if msg.from_user else "unknown" return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=user_id, channel_chat_id=chat_id, ), event_type=EventType.MESSAGE_UPDATED, message_type=MessageType.TEXT, chat_type=ChatType.DIRECT, content="(passthrough)", ) if update.callback_query: cb = update.callback_query return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=str(cb.from_user.id), channel_chat_id=str(cb.message.chat.id) if cb.message else "unknown", ), event_type=EventType.CARD_ACTION, message_type=MessageType.TEXT, chat_type=ChatType.DIRECT, content=cb.data or "", ) if update.my_chat_member: mcm = update.my_chat_member chat_id = str(mcm.chat.id) event = ( EventType.BOT_ADDED if mcm.new_chat_member.status in ("member", "administrator") else EventType.BOT_REMOVED ) return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=str(mcm.from_user.id), channel_chat_id=chat_id, ), event_type=event, message_type=MessageType.TEXT, chat_type=ChatType.GROUP if mcm.chat.type in ("group", "supergroup") else ChatType.DIRECT, content=f"Bot {mcm.new_chat_member.status} in chat {chat_id}", metadata={"chat_type": mcm.chat.type, "new_status": mcm.new_chat_member.status}, ) if update.chat_member: cm = update.chat_member chat_id = str(cm.chat.id) event = ( EventType.MEMBER_JOINED if cm.new_chat_member.status in ("member", "administrator") and cm.old_chat_member.status not in ("member", "administrator") else EventType.MEMBER_LEFT if cm.new_chat_member.status in ("left", "kicked") else EventType.SYSTEM_EVENT ) return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=str(cm.new_chat_member.user.id), channel_chat_id=chat_id, ), event_type=event, message_type=MessageType.TEXT, chat_type=ChatType.GROUP if cm.chat.type in ("group", "supergroup") else ChatType.DIRECT, content=f"Member status change in chat {chat_id}", metadata={ "chat_type": cm.chat.type, "old_status": cm.old_chat_member.status, "new_status": cm.new_chat_member.status, }, ) if update.poll: p = update.poll return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="unknown", channel_chat_id="unknown", ), event_type=EventType.SYSTEM_EVENT, message_type=MessageType.POLL, chat_type=ChatType.DIRECT, content=f"Poll {p.id}: {p.question}", metadata={"poll_id": p.id, "is_closed": p.is_closed}, ) if update.poll_answer: pa = update.poll_answer return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=str(pa.user.id) if pa.user else "unknown", channel_chat_id="unknown", ), event_type=EventType.SYSTEM_EVENT, message_type=MessageType.POLL, chat_type=ChatType.DIRECT, content=f"Poll answer for {pa.poll_id}", metadata={"poll_id": pa.poll_id, "option_ids": pa.option_ids}, ) if update.message_reaction: mr = update.message_reaction bot_username = (self._bot_info or {}).get("username", "") reactor = mr.user if not self._reaction_notifications.should_notify( bot_username, f"@{reactor.username}" if reactor and reactor.username else "" ): return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=str(mr.user.id) if mr.user else "unknown", channel_chat_id=str(mr.chat.id), ), event_type=EventType.MESSAGE_RECEIVED, message_type=MessageType.TEXT, chat_type=ChatType.GROUP if mr.chat.type in ("group", "supergroup") else ChatType.DIRECT, content="(reaction update - filtered)", metadata={"_filtered": True}, ) return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=str(mr.user.id) if mr.user else "unknown", channel_chat_id=str(mr.chat.id), ), event_type=EventType.SYSTEM_EVENT, message_type=MessageType.TEXT, chat_type=ChatType.GROUP if mr.chat.type in ("group", "supergroup") else ChatType.DIRECT, content="(reaction update)", metadata={ "message_id": str(mr.message_id), "old_reaction": [str(r) for r in (mr.old_reaction or [])], "new_reaction": [str(r) for r in (mr.new_reaction or [])], }, ) if update.message_reaction_count: mrc = update.message_reaction_count return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="unknown", channel_chat_id=str(mrc.chat.id), ), event_type=EventType.SYSTEM_EVENT, message_type=MessageType.TEXT, chat_type=ChatType.GROUP if mrc.chat.type in ("group", "supergroup") else ChatType.DIRECT, content="(reaction count update)", metadata={ "message_id": str(mrc.message_id), "reactions": [ {"type": str(r.type), "emoji": getattr(r, "emoji", None), "count": r.total_count} for r in (mrc.reactions or []) ], }, ) if update.chat_join_request: cjr = update.chat_join_request return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=str(cjr.from_user.id), channel_chat_id=str(cjr.chat.id), ), event_type=EventType.SYSTEM_EVENT, message_type=MessageType.TEXT, chat_type=ChatType.GROUP, content=f"Join request from user {cjr.from_user.id} in chat {cjr.chat.id}", metadata={ "chat_type": cjr.chat.type, "chat_title": getattr(cjr.chat, "title", None), "user_id": str(cjr.from_user.id), "user_name": cjr.from_user.full_name, "bio": getattr(cjr, "bio", None), "invite_link": getattr(cjr, "invite_link", None), }, ) return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="unknown", channel_chat_id="unknown", ), event_type=EventType.MESSAGE_RECEIVED, message_type=MessageType.TEXT, chat_type=ChatType.DIRECT, content="(unknown update)", ) def format_outbound(self, response: ChannelResponse) -> dict[str, Any]: html = sdk_markdown_to_html(response.content) chunks = sdk_chunk_message(html, ChunkConfig(limit=self.text_chunk_limit)) main_text = chunks[0] if chunks else html payload: dict[str, Any] = { "chat_id": response.identity.channel_chat_id, "text": main_text, "parse_mode": "HTML", } thread_id = response.metadata.get("thread_id") if thread_id: payload["message_thread_id"] = thread_id reply_mode = self.config.get("reply_to_mode", "first") if reply_mode == "off": pass elif reply_mode in ("first", "all", "batched"): if response.reply_to_message_id: payload["reply_to_message_id"] = int(response.reply_to_message_id) if reply_mode == "all" and response.identity.channel_message_id: payload["reply_to_message_id"] = int(response.identity.channel_message_id) quote_text = response.metadata.get("quote_text") or self.config.get("quote_text") if quote_text and response.reply_to_message_id: try: from telegram import ReplyParameters payload["reply_parameters"] = ReplyParameters( message_id=int(response.reply_to_message_id), quote=quote_text[:256], ) except ImportError: pass if self.config.get("silent") or response.metadata.get("silent"): payload["disable_notification"] = True link_preview = self.config.get("link_preview", True) payload["link_preview_options"] = {"is_disabled": not link_preview} if response.message_type == MessageType.IMAGE and response.attachments: force_doc = self.config.get("force_document", False) payload.pop("text", None) if force_doc: payload["document"] = response.attachments[0].url or response.attachments[0].file_id else: payload["photo"] = response.attachments[0].url or response.attachments[0].file_id payload["caption"] = main_text elif response.message_type == MessageType.FILE and response.attachments: payload.pop("text", None) payload["document"] = response.attachments[0].url or response.attachments[0].file_id payload["caption"] = main_text reply_markup = self._build_reply_markup(response) if reply_markup is not None: payload["reply_markup"] = reply_markup return payload def _build_reply_markup(self, response: ChannelResponse) -> Any: markup_cfg = response.metadata.get("reply_markup") if not markup_cfg: return None markup_type = markup_cfg.get("type", "keyboard") if markup_type == "force_reply": from telegram import ForceReply return ForceReply( input_field_placeholder=markup_cfg.get("input_field_placeholder", ""), selective=markup_cfg.get("selective", False), ) if markup_type == "keyboard": from telegram import KeyboardButton, ReplyKeyboardMarkup keyboard_rows = markup_cfg.get("keyboard", []) if not keyboard_rows: return None rows = [] for row in keyboard_rows: buttons = [] for btn in row: if isinstance(btn, str): buttons.append(KeyboardButton(text=btn)) elif isinstance(btn, dict): kwargs: dict[str, Any] = {"text": btn["text"]} if btn.get("request_contact"): kwargs["request_contact"] = True if btn.get("request_location"): kwargs["request_location"] = True if btn.get("request_poll"): from telegram import KeyboardButtonPollType kwargs["request_poll"] = KeyboardButtonPollType(type=btn["request_poll"].get("type")) if btn.get("web_app"): from telegram import WebAppInfo kwargs["web_app"] = WebAppInfo(url=btn["web_app"]["url"]) buttons.append(KeyboardButton(**kwargs)) rows.append(buttons) return ReplyKeyboardMarkup( keyboard=rows, resize_keyboard=markup_cfg.get("resize_keyboard", True), one_time_keyboard=markup_cfg.get("one_time_keyboard", False), input_field_placeholder=markup_cfg.get("input_field_placeholder", ""), selective=markup_cfg.get("selective", False), is_persistent=markup_cfg.get("is_persistent", True), ) return None async def health_check(self) -> HealthStatus: if not self._application: if not self._enabled: return HealthStatus(status="degraded", metadata={"reason": "channel_disabled"}) return HealthStatus(status="unhealthy", last_error="Application not initialized") try: bot_info = await self._application.bot.get_me() liveness_status = self._liveness.get_status() return HealthStatus( status="degraded" if not liveness_status["healthy"] else "healthy", metadata={ "bot_id": bot_info.id, "bot_username": bot_info.username, "monitor_mode": self._monitor_mode, "adapter_status": self._status.value, "liveness": liveness_status, "backoff_active": not self._chat_action_backoff.can_send(), }, last_connected_at=utc_now_naive(), ) except InvalidToken: return HealthStatus(status="unhealthy", last_error="Invalid bot token") except (NetworkError, TimedOut) as e: return HealthStatus(status="degraded", last_error=str(e)) except Exception as e: return HealthStatus(status="unhealthy", last_error=str(e)) async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: if not self._application: return {} try: chat = await self._application.bot.get_chat(chat_id=int(channel_user_id)) return { "id": chat.id, "username": chat.username, "first_name": chat.first_name, "last_name": chat.last_name, "type": chat.type, } except Exception: return {} async def download_media(self, file_id: str) -> bytes: if not self._application: raise ChannelNotConnectedError() tg_file = await self._application.bot.get_file(file_id) max_bytes = self.max_media_size_mb * 1024 * 1024 if tg_file.file_size and tg_file.file_size > max_bytes: raise ValueError(f"File size {tg_file.file_size} bytes exceeds {self.max_media_size_mb}MB limit") return await tg_file.download_as_bytearray() def is_local_file_path_trusted(self, file_path: str) -> bool: if not self._trusted_local_file_roots: return False from pathlib import Path resolved = Path(file_path).resolve() return any(resolved.is_relative_to(Path(root).resolve()) for root in self._trusted_local_file_roots) async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool: secret = self.config.get("webhook_secret", "") if not secret: return True token = headers.get("X-Telegram-Bot-Api-Secret-Token", "") return token == secret @property def history_limit(self) -> int: return self._history_limit @property def enabled(self) -> bool: return self._enabled @property def silent_error_replies(self) -> bool: return self._silent_error_replies @property def trusted_local_file_roots(self) -> list[str]: return self._trusted_local_file_roots @property def config_writes(self) -> bool: return self._config_writes def _get_commands_native(self) -> str: commands_cfg = self.config.get("commands", {}) if isinstance(commands_cfg, dict): return commands_cfg.get("native", self._commands_native) return self._commands_native def _get_commands_native_skills(self) -> str: commands_cfg = self.config.get("commands", {}) if isinstance(commands_cfg, dict): return commands_cfg.get("native_skills", self._commands_native_skills) return self._commands_native_skills async def _register_handlers(self) -> None: if not self._application: return async def handle_update(update: Update, _context): if self._message_handler is None: return update_key = f"u:{update.update_id}" now = time.monotonic() async with self._dedup_lock: if update_key in self._dedup_set: return self._dedup_set[update_key] = now expired = [k for k, v in self._dedup_set.items() if now - v > self._dedup_ttl] for k in expired: del self._dedup_set[k] self._liveness.record_activity(update.update_id) self._offset_store.set_offset(self._token_hash, update.update_id) if update.callback_query: try: await update.callback_query.answer() except Exception: pass channel_msg = self.normalize_inbound(update) await self._message_handler(channel_msg) self._application.add_handler(MessageHandler(filters.ALL, handle_update)) async def _register_commands(self) -> None: if not self._application: return native_commands = self._get_commands_native() if native_commands == "never": return commands_config = self.config.get("commands", []) if isinstance(commands_config, dict): cmd_entries = commands_config.get("commands", []) elif isinstance(commands_config, list): cmd_entries = commands_config else: cmd_entries = [] if not cmd_entries and native_commands != "always": return if isinstance(commands_config[0], (str, int)): scoped = False scoped_configs = cmd_entries # type: ignore[arg-type] elif isinstance(commands_config[0], dict): scoped = True scoped_configs = [commands_config] if "scope" in commands_config else commands_config else: scoped = False scoped_configs = cmd_entries # type: ignore[arg-type] for entry in scoped_configs: if scoped: cmd_list = entry.get("commands", []) scope_config = entry.get("scope", "default") else: cmd_list = [{"command": c, "description": c} if isinstance(c, str) else c for c in commands_config] scope_config = "default" break commands = [BotCommand(c["command"], c["description"]) for c in cmd_list] scope = _build_command_scope(scope_config) if scope: await self._application.bot.set_my_commands(commands, scope=scope) else: await self._application.bot.set_my_commands(commands) async def _start_polling(self) -> None: if not self._application: return allowed_updates = get_allowed_updates(self.config) await self._application.bot.delete_webhook(drop_pending_updates=True) await self._application.initialize() await self._application.start() poll_interval = self.config.get("poll_interval", 1.0) saved_offset = self._offset_store.get_offset(self._token_hash) if saved_offset > 0: logger.info(f"[Telegram] Resuming from saved offset: {saved_offset}") async def _poll(): poll_params: dict[str, Any] = { "poll_interval": poll_interval, "timeout": self.config.get("poll_timeout", 10), "allowed_updates": allowed_updates, } if saved_offset > 0: poll_params["offset"] = saved_offset + 1 try: await self._application.updater.start_polling(**poll_params) except asyncio.CancelledError: pass except Exception as e: logger.error(f"[Telegram] Polling error, marking channel as error: {e}") self._status = ChannelStatus.ERROR async def _monitor_liveness(): check_interval = self.config.get("polling_liveness_interval", 60) while True: await asyncio.sleep(check_interval) if not self._liveness.is_healthy(): logger.warning( f"[Telegram] Polling liveness check failed, " f"idle for {self._liveness.get_status()['last_activity_ago']:.1f}s" ) if self._status == ChannelStatus.CONNECTED: self._status = ChannelStatus.RECONNECTING else: if self._status == ChannelStatus.RECONNECTING: self._status = ChannelStatus.CONNECTED async def _persist_offset(): while True: await asyncio.sleep(30) status = self._liveness.get_status() if status["last_update_id"] > 0: self._offset_store.set_offset(self._token_hash, status["last_update_id"]) self._polling_task = asyncio.create_task(_poll()) asyncio.create_task(_monitor_liveness()) asyncio.create_task(_persist_offset()) async def _start_webhook(self) -> None: if not self._application: return webhook_url = self.config["webhook_url"] webhook_path = self.config.get("webhook_path", "/telegram-webhook") webhook_secret = self.config.get("webhook_secret", "") allowed_updates = get_allowed_updates(self.config) full_url = f"{webhook_url.rstrip('/')}{webhook_path}" webhook_params: dict[str, Any] = { "url": full_url, "drop_pending_updates": True, "allowed_updates": allowed_updates, } if webhook_secret: webhook_params["secret_token"] = webhook_secret cert_path = self.config.get("webhook_cert_path") if cert_path: try: with open(cert_path, "rb") as f: webhook_params["certificate"] = f.read() logger.info(f"[Telegram] Using custom SSL cert from {cert_path}") except FileNotFoundError: logger.warning(f"[Telegram] Webhook cert file not found: {cert_path}") except Exception as e: logger.warning(f"[Telegram] Failed to read webhook cert: {e}") await self._application.bot.set_webhook(**webhook_params) await self._application.initialize() await self._application.start() logger.info(f"[Telegram] Webhook set to {full_url}") async def pre_connect(self) -> dict: token = resolve_bot_token(self.config) if not token: return {"status": "error", "message": "Missing bot_token"} try: builder = ApplicationBuilder().token(token) api_root = self.config.get("api_root") if api_root: builder.base_url(f"{api_root.rstrip('/')}/bot") app = builder.build() bot_info = await app.bot.get_me() await app.shutdown() return { "status": "ok", "bot_id": bot_info.id, "bot_username": bot_info.username, } except InvalidToken: return {"status": "error", "message": "Invalid bot token (401 Unauthorized)"} except Exception as e: return {"status": "error", "message": str(e)} def _normalize_channel_id(self, chat_id: str) -> str: if chat_id.startswith("-100"): return chat_id[4:] return chat_id def _make_skip_event(self, raw: Update) -> ChannelMessage: msg = raw.message chat = msg.chat if msg else None chat_id = str(chat.id) if chat else "0" return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="", channel_chat_id=chat_id, channel_message_id="0", ), event_type=EventType.SYSTEM_EVENT, message_type=MessageType.TEXT, chat_type=ChatType.DIRECT, content="", metadata={"skip": True}, )