新增 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: 类型定义
226 lines
7.2 KiB
Python
226 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def _get_offset_dir() -> str:
|
|
data_dir = os.environ.get(
|
|
"DATA_DIR",
|
|
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "data"),
|
|
)
|
|
offset_dir = os.path.join(data_dir, "channels", "telegram", "offsets")
|
|
os.makedirs(offset_dir, exist_ok=True)
|
|
return offset_dir
|
|
|
|
|
|
_OFFSET_DIR = _get_offset_dir()
|
|
|
|
TELEGRAM_API_BASE = "https://api.telegram.org"
|
|
|
|
|
|
async def _answer_callback_quiet(token: str, callback_query_id: str) -> None:
|
|
import httpx
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
|
await client.post(
|
|
f"{TELEGRAM_API_BASE}/bot{token}/answerCallbackQuery",
|
|
json={"callback_query_id": callback_query_id},
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _ensure_ptb():
|
|
try:
|
|
import telegram # noqa: F401
|
|
except ImportError:
|
|
raise ImportError(
|
|
"python-telegram-bot>=21.0 is required for Telegram channel. "
|
|
"Install with: pip install python-telegram-bot[job-queue]"
|
|
)
|
|
|
|
|
|
def _pid_alive(pid: int) -> bool:
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _acquire_polling_lease(lease_key: str) -> bool:
|
|
os.makedirs(_OFFSET_DIR, exist_ok=True)
|
|
lease_path = os.path.join(_OFFSET_DIR, f".telegram_polling_lease_{lease_key}")
|
|
|
|
if os.path.exists(lease_path):
|
|
try:
|
|
with open(lease_path) as f:
|
|
stale = json.load(f)
|
|
stale_pid = stale.get("pid", 0)
|
|
if stale_pid and _pid_alive(stale_pid):
|
|
logger.warning(
|
|
"Telegram polling lease held by PID %d for key %s, refusing to start",
|
|
stale_pid, lease_key,
|
|
)
|
|
return False
|
|
logger.info("Telegram polling lease stale (PID %d dead), re-acquiring", stale_pid)
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
try:
|
|
with open(lease_path, "w") as f:
|
|
json.dump({"pid": os.getpid(), "key": lease_key, "created_at": int(time.time())}, f)
|
|
logger.info("Telegram polling lease acquired: key=%s pid=%d", lease_key, os.getpid())
|
|
return True
|
|
except OSError:
|
|
logger.error("Failed to write Telegram polling lease file: %s", lease_path)
|
|
return False
|
|
|
|
|
|
def _release_polling_lease(lease_key: str) -> None:
|
|
lease_path = os.path.join(_OFFSET_DIR, f".telegram_polling_lease_{lease_key}")
|
|
try:
|
|
os.remove(lease_path)
|
|
logger.info("Telegram polling lease released: key=%s", lease_key)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class TelegramPolling:
|
|
def __init__(self):
|
|
self._running = False
|
|
self._app = None
|
|
self._task: asyncio.Task | None = None
|
|
self._offset: int = 0
|
|
self._last_update_time: float = 0.0
|
|
self._lease_key: str | None = None
|
|
|
|
async def start(self, account: dict, queue: asyncio.Queue, abort_event: asyncio.Event) -> None:
|
|
_ensure_ptb()
|
|
from telegram import Update
|
|
from telegram.ext import Application, ContextTypes
|
|
|
|
token = account.get("token", "")
|
|
if not token:
|
|
logger.error("Telegram polling: no token configured")
|
|
return
|
|
|
|
account_id = account.get("account_id", "default")
|
|
|
|
from yuxi.channel.extensions.telegram.config import TelegramConfigAdapter
|
|
|
|
lease_key = TelegramConfigAdapter.token_fingerprint(token)
|
|
if not _acquire_polling_lease(lease_key):
|
|
logger.error("Telegram polling lease conflict for account %s, refusing to start", account_id)
|
|
return
|
|
|
|
self._lease_key = lease_key
|
|
self._offset = _load_offset(account_id)
|
|
self._last_update_time = time.monotonic()
|
|
|
|
self._app = Application.builder().token(token).build()
|
|
|
|
async def handle_update(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
from yuxi.channel.extensions.telegram.monitor import convert_update_to_unified
|
|
|
|
self._last_update_time = time.monotonic()
|
|
update_id = update.update_id
|
|
|
|
update_dict = update.to_dict()
|
|
unified = convert_update_to_unified(update_dict, account_id)
|
|
if unified:
|
|
try:
|
|
queue.put_nowait(unified)
|
|
except asyncio.QueueFull:
|
|
logger.warning("Telegram message queue full, dropping message")
|
|
|
|
if "callback_query" in update_dict:
|
|
cq_id = update_dict["callback_query"].get("id", "")
|
|
if cq_id:
|
|
try:
|
|
await _answer_callback_quiet(token, cq_id)
|
|
except Exception:
|
|
pass
|
|
|
|
_persist_offset(account_id, update_id + 1)
|
|
|
|
self._app.add_handler(
|
|
__import__("telegram.ext").ext.MessageHandler(
|
|
__import__("telegram.ext").ext.filters.ALL, handle_update
|
|
)
|
|
)
|
|
|
|
self._running = True
|
|
logger.info("Telegram polling started for account %s", account_id)
|
|
|
|
try:
|
|
await self._app.initialize()
|
|
await self._app.start()
|
|
await self._app.updater.start_polling(
|
|
allowed_updates=[
|
|
"message", "edited_message", "callback_query",
|
|
"my_chat_member", "poll", "poll_answer", "message_reaction",
|
|
],
|
|
)
|
|
|
|
while self._running and not abort_event.is_set():
|
|
if time.monotonic() - self._last_update_time > 120:
|
|
logger.warning(
|
|
"Telegram polling watchdog for account %s (no updates in 120s)",
|
|
account_id,
|
|
)
|
|
await asyncio.sleep(30)
|
|
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
logger.exception("Telegram polling error for account %s", account_id)
|
|
finally:
|
|
await self._stop_app()
|
|
|
|
async def stop(self) -> None:
|
|
self._running = False
|
|
await self._stop_app()
|
|
if self._lease_key:
|
|
_release_polling_lease(self._lease_key)
|
|
self._lease_key = None
|
|
|
|
async def _stop_app(self) -> None:
|
|
if self._app:
|
|
try:
|
|
if self._app.updater:
|
|
await self._app.updater.stop()
|
|
await self._app.stop()
|
|
await self._app.shutdown()
|
|
except Exception:
|
|
logger.debug("Error during PTB app shutdown", exc_info=True)
|
|
self._app = None
|
|
|
|
|
|
def _offset_path(account_id: str) -> str:
|
|
os.makedirs(_OFFSET_DIR, exist_ok=True)
|
|
return os.path.join(_OFFSET_DIR, f".telegram_offset_{account_id}")
|
|
|
|
|
|
def _load_offset(account_id: str) -> int:
|
|
try:
|
|
with open(_offset_path(account_id)) as f:
|
|
data = json.load(f)
|
|
return data.get("offset", 0)
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
return 0
|
|
|
|
|
|
def _persist_offset(account_id: str, offset: int) -> None:
|
|
try:
|
|
with open(_offset_path(account_id), "w") as f:
|
|
json.dump({"offset": offset, "updated_at": int(time.time())}, f)
|
|
except OSError:
|
|
pass
|