ForcePilot/backend/package/yuxi/channel/extensions/telegram/outbound.py
Kris 8a3601250e feat(channel): 添加 Telegram 渠道扩展
新增 Telegram 渠道扩展,支持在 Yuxi 平台中集成 Telegram 即时通讯渠道。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- polling: 长轮询模式
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- actions: 动作处理
- inline_keyboard: 内联键盘
- native_commands: 原生指令
- chat: 聊天管理
- delivery: 消息送达确认
- media: 媒体资源处理
- profile: 用户资料
- reactions: 表情反应
- sticker: 贴纸处理
- types: 类型定义
2026-05-21 11:48:09 +08:00

589 lines
22 KiB
Python

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