from __future__ import annotations import asyncio import logging from typing import TYPE_CHECKING, Any if TYPE_CHECKING: import httpx from yuxi.channel.extensions.telegram.errors import MAX_RETRIES, classify_error, is_retryable from yuxi.channel.extensions.telegram.format import markdown_to_telegram_html, split_telegram_html_chunks logger = logging.getLogger(__name__) TELEGRAM_API_BASE = "https://api.telegram.org" class TelegramOutbound: _client: httpx.AsyncClient | None = None _client_lock = asyncio.Lock() @classmethod async def get_client(cls) -> httpx.AsyncClient: import httpx if cls._client is None or cls._client.is_closed: async with cls._client_lock: if cls._client is None or cls._client.is_closed: limits = httpx.Limits( max_keepalive_connections=20, max_connections=100, keepalive_expiry=30.0, ) cls._client = httpx.AsyncClient( timeout=httpx.Timeout(30.0), limits=limits, ) return cls._client @classmethod async def close_client(cls) -> None: if cls._client and not cls._client.is_closed: await cls._client.aclose() cls._client = None delivery_mode = "direct" chunker_mode = "length" text_chunk_limit: int = 4096 poll_max_options: int = 10 supports_poll_duration_seconds = True supports_anonymous_polls = True extract_markdown_images = True presentation_capabilities = None delivery_capabilities = None async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: token = await self._resolve_token(account_id) if not token: logger.error("Telegram send_text: no token resolved") return html = markdown_to_telegram_html(content) chunks = split_telegram_html_chunks(html, self.text_chunk_limit) for chunk in chunks: payload: dict[str, Any] = { "chat_id": target_id, "text": chunk, "parse_mode": "HTML", } if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) await self._api_call(token, "sendMessage", payload) async def send_text_raw( self, target_id: str, html: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> str | None: token = await self._resolve_token(account_id) if not token: logger.error("Telegram send_text_raw: no token resolved") return None payload: dict[str, Any] = { "chat_id": target_id, "text": html, "parse_mode": "HTML", } if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) result = await self._api_call(token, "sendMessage", payload) if result and isinstance(result, dict): return str(result.get("message_id", "")) return None async def send_media( self, target_id: str, media_url: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: token = await self._resolve_token(account_id) if not token: return _SEND_MEDIA_METHODS = { "image": "sendPhoto", "video": "sendVideo", "audio": "sendAudio", "voice": "sendVoice", "file": "sendDocument", "animation": "sendAnimation", } method = _SEND_MEDIA_METHODS.get(media_type, "sendDocument") payload: dict[str, Any] = { "chat_id": target_id, "document" if media_type == "file" else media_type: media_url, } if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) await self._api_call(token, method, payload) async def edit_message( self, target_id: str, message_id: str, content: str, *, thread_id: str | None = None, account_id: str | None = None, ) -> str | None: token = await self._resolve_token(account_id) if not token: return None html = markdown_to_telegram_html(content) if len(html) > self.text_chunk_limit: html = html[: self.text_chunk_limit] payload: dict[str, Any] = { "chat_id": target_id, "message_id": int(message_id), "text": html, "parse_mode": "HTML", } await self._api_call(token, "editMessageText", payload) return message_id async def delete_message(self, target_id: str, message_id: str, account_id: str | None = None) -> None: token = await self._resolve_token(account_id) if not token: return payload = {"chat_id": target_id, "message_id": int(message_id)} await self._api_call(token, "deleteMessage", payload) async def delete_messages( self, target_id: str, message_ids: list[str], account_id: str | None = None, ) -> bool: token = await self._resolve_token(account_id) if not token: return False ids = [int(mid) for mid in message_ids[:100]] payload = {"chat_id": target_id, "message_ids": ids} result = await self._api_call(token, "deleteMessages", payload) return result is not None and isinstance(result, bool) async def delete_message_reaction( self, target_id: str, message_id: str, *, user_id: str | None = None, account_id: str | None = None, ) -> bool: token = await self._resolve_token(account_id) if not token: return False if user_id: payload = {"chat_id": target_id, "message_id": int(message_id), "user_id": int(user_id)} result = await self._api_call(token, "deleteMessageReaction", payload) else: payload = {"chat_id": target_id, "message_id": int(message_id)} result = await self._api_call(token, "deleteAllMessageReactions", payload) return result is not None and isinstance(result, bool) async def send_reaction( self, target_id: str, message_id: str, emoji: str, account_id: str | None = None, ) -> None: token = await self._resolve_token(account_id) if not token: return payload = { "chat_id": target_id, "message_id": int(message_id), "reaction": [{"type": "emoji", "emoji": emoji}], } await self._api_call(token, "setMessageReaction", payload) async def send_typing(self, target_id: str, thread_id: str | None = None, account_id: str | None = None) -> None: token = await self._resolve_token(account_id) if not token: return payload: dict[str, Any] = {"chat_id": target_id, "action": "typing"} if thread_id: payload["message_thread_id"] = int(thread_id) await self._api_call(token, "sendChatAction", payload) async def send_poll( self, target_id: str, question: str, options: list[str], *, is_anonymous: bool = True, allows_multiple_answers: bool = False, open_period: int | None = None, close_date: int | None = None, correct_option_ids: list[int] | None = None, allows_revoting: bool = False, shuffle_options: bool = False, allow_adding_options: bool = False, hide_results_until_closes: bool = False, description: str | None = None, description_parse_mode: str = "HTML", members_only: bool = False, country_codes: list[str] | None = None, option_media: list[dict | None] | None = None, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = { "chat_id": target_id, "question": question, "is_anonymous": is_anonymous, "allows_multiple_answers": allows_multiple_answers, } if option_media: payload["options"] = [ {"text": opt, "media": option_media[i]} if option_media[i] else {"text": opt} for i, opt in enumerate(options) ] else: payload["options"] = options if open_period: payload["open_period"] = open_period if close_date: payload["close_date"] = close_date if correct_option_ids: payload["correct_option_ids"] = correct_option_ids if allows_revoting: payload["allows_revoting"] = True if shuffle_options: payload["shuffle_options"] = True if allow_adding_options: payload["allow_adding_options"] = True if hide_results_until_closes: payload["hide_results_until_closes"] = True if description: payload["description"] = description payload["description_parse_mode"] = description_parse_mode if members_only: payload["members_only"] = True if country_codes: payload["country_codes"] = country_codes if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) result = await self._api_call(token, "sendPoll", payload) return result async def probe(self, token: str, timeout_seconds: float = 2.5) -> dict | None: try: import httpx async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds)) as client: resp = await client.get(f"{TELEGRAM_API_BASE}/bot{token}/getMe") if resp.status_code == 200: data = resp.json() if data.get("ok"): return data.get("result") except Exception: pass return None async def _api_call(self, token: str, method: str, payload: dict) -> dict | None: url = f"{TELEGRAM_API_BASE}/bot{token}/{method}" for attempt in range(MAX_RETRIES): try: client = await self.get_client() resp = await client.post(url, json=payload) data = resp.json() if resp.content else {} if resp.status_code == 200 and data.get("ok"): return data.get("result") kind, description, retry_after = classify_error(resp.status_code, data) if not is_retryable(kind): logger.warning(f"Telegram API {method} failed: {kind} - {description}") return None if retry_after: await asyncio.sleep(retry_after) else: await asyncio.sleep(min(2 ** attempt, 10)) except Exception as e: logger.warning(f"Telegram API {method} network error: {e}") if attempt < MAX_RETRIES - 1: await asyncio.sleep(min(2 ** attempt, 10)) else: return None return None async def _resolve_token(self, account_id: str | None) -> str: from yuxi.channel.extensions.telegram.config import TelegramConfigAdapter adapter = TelegramConfigAdapter() aid = account_id or "default" account = await adapter.resolve_account(aid) return account.get("token", "") def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: return split_telegram_html_chunks(markdown_to_telegram_html(text), limit or self.text_chunk_limit) def sanitize_text(self, text: str, payload: object) -> str: return text def should_skip_plain_text_sanitization(self, payload: object) -> bool: return False async def answer_callback( self, callback_query_id: str, *, text: str | None = None, show_alert: bool = False, cache_time: int = 0, url: str | None = None, account_id: str | None = None, ) -> bool: token = await self._resolve_token(account_id) if not token: return False payload: dict[str, Any] = {"callback_query_id": callback_query_id} if text is not None: payload["text"] = text payload["show_alert"] = show_alert if cache_time: payload["cache_time"] = cache_time if url: payload["url"] = url result = await self._api_call(token, "answerCallbackQuery", payload) return result is not None and isinstance(result, bool) async def send_chat_action( self, target_id: str, action: str = "typing", *, thread_id: str | None = None, account_id: str | None = None, ) -> None: token = await self._resolve_token(account_id) if not token: return valid = { "typing", "upload_photo", "upload_video", "upload_document", "record_voice", "record_video", "record_video_note", "choose_sticker", "find_location", } if action not in valid: action = "typing" payload: dict[str, Any] = {"chat_id": target_id, "action": action} if thread_id: payload["message_thread_id"] = int(thread_id) await self._api_call(token, "sendChatAction", payload) async def send_dice( self, target_id: str, emoji: str = "🎲", *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = {"chat_id": target_id, "emoji": emoji} if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) return await self._api_call(token, "sendDice", payload) async def send_location( self, target_id: str, latitude: float, longitude: float, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = { "chat_id": target_id, "latitude": latitude, "longitude": longitude, } if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) return await self._api_call(token, "sendLocation", payload) async def send_contact( self, target_id: str, phone_number: str, first_name: str, *, last_name: str | None = None, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = { "chat_id": target_id, "phone_number": phone_number, "first_name": first_name, } if last_name: payload["last_name"] = last_name if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) return await self._api_call(token, "sendContact", payload) async def send_venue( self, target_id: str, latitude: float, longitude: float, title: str, address: str, *, foursquare_id: str | None = None, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = { "chat_id": target_id, "latitude": latitude, "longitude": longitude, "title": title, "address": address, } if foursquare_id: payload["foursquare_id"] = foursquare_id if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) return await self._api_call(token, "sendVenue", payload) async def forward_message( self, target_id: str, from_chat_id: str, message_id: int, *, disable_notification: bool = False, protect_content: bool = False, thread_id: str | None = None, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = { "chat_id": target_id, "from_chat_id": from_chat_id, "message_id": message_id, } if thread_id: payload["message_thread_id"] = int(thread_id) if disable_notification: payload["disable_notification"] = True if protect_content: payload["protect_content"] = True return await self._api_call(token, "forwardMessage", payload) async def copy_message( self, target_id: str, from_chat_id: str, message_id: int, *, caption: str | None = None, parse_mode: str = "HTML", reply_to_id: str | None = None, thread_id: str | None = None, disable_notification: bool = False, protect_content: bool = False, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = { "chat_id": target_id, "from_chat_id": from_chat_id, "message_id": message_id, } if caption: payload["caption"] = caption payload["parse_mode"] = parse_mode if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) if disable_notification: payload["disable_notification"] = True if protect_content: payload["protect_content"] = True return await self._api_call(token, "copyMessage", payload) async def send_video_note( self, target_id: str, video_note: str, *, duration: int | None = None, length: int | None = None, thumbnail: str | None = None, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = {"chat_id": target_id, "video_note": video_note} if duration: payload["duration"] = duration if length: payload["length"] = length if thumbnail: payload["thumbnail"] = thumbnail if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) return await self._api_call(token, "sendVideoNote", payload) async def send_media_group( self, target_id: str, media: list[dict], *, disable_notification: bool = False, protect_content: bool = False, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> list[dict] | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = {"chat_id": target_id, "media": media} if reply_to_id: payload["reply_parameters"] = {"message_id": int(reply_to_id)} if thread_id: payload["message_thread_id"] = int(thread_id) if disable_notification: payload["disable_notification"] = True if protect_content: payload["protect_content"] = True result = await self._api_call(token, "sendMediaGroup", payload) return result if isinstance(result, list) else None async def edit_message_reply_markup( self, target_id: str, message_id: str, reply_markup: dict | None = None, *, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = { "chat_id": target_id, "message_id": int(message_id), } if reply_markup is not None: payload["reply_markup"] = reply_markup return await self._api_call(token, "editMessageReplyMarkup", payload) async def edit_message_media( self, target_id: str, message_id: str, media: dict, *, reply_markup: dict | None = None, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = { "chat_id": target_id, "message_id": int(message_id), "media": media, } if reply_markup: payload["reply_markup"] = reply_markup return await self._api_call(token, "editMessageMedia", payload) async def edit_message_caption( self, target_id: str, message_id: str, caption: str, *, parse_mode: str = "HTML", reply_markup: dict | None = None, account_id: str | None = None, ) -> dict | None: token = await self._resolve_token(account_id) if not token: return None payload: dict[str, Any] = { "chat_id": target_id, "message_id": int(message_id), "caption": caption, "parse_mode": parse_mode, } if reply_markup: payload["reply_markup"] = reply_markup return await self._api_call(token, "editMessageCaption", payload) def resolve_target( self, to: str | None = None, *, config: dict | None = None, allow_from: list[str] | None = None, account_id: str | None = None, mode: str | None = None, ) -> tuple[bool, str]: if not to: return False, "target required" return True, to