新增 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: 类型定义
589 lines
22 KiB
Python
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
|