ForcePilot/backend/package/yuxi/channel/extensions/telegram/webhook.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

213 lines
6.5 KiB
Python

from __future__ import annotations
import asyncio
import hmac
import logging
import secrets
from fastapi import APIRouter, Header, Request
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook/telegram", tags=["telegram"])
WEBHOOK_MAX_BODY_BYTES = 1_048_576
WEBHOOK_READ_TIMEOUT = 30.0
_webhook_app: object | None = None
_webhook_queue: asyncio.Queue | None = None
_webhook_account_id: str = "default"
_webhook_secret: str = ""
_webhook_token: str = ""
_webhook_registry: dict[str, dict] = {}
def _get_webhook_context(account_id: str) -> dict | None:
return _webhook_registry.get(account_id)
@router.post("")
async def telegram_webhook_receive(
request: Request,
x_telegram_bot_api_secret_token: str | None = Header(None, alias="X-Telegram-Bot-Api-Secret-Token"),
):
ctx = _resolve_webhook_context(x_telegram_bot_api_secret_token)
if ctx is None:
logger.warning("Telegram webhook: no matching account found")
return {"status": "unknown-account"}
expected_secret = ctx.get("secret", "")
if expected_secret and not hmac.compare_digest(x_telegram_bot_api_secret_token or "", expected_secret):
logger.warning("Telegram webhook secret mismatch")
return {"status": "unauthorized"}
try:
body = await request.body()
except Exception:
logger.warning("Telegram webhook body read failed")
return {"status": "ok"}
if len(body) > WEBHOOK_MAX_BODY_BYTES:
logger.warning("Telegram webhook body too large: %d bytes", len(body))
return {"status": "ok"}
import json
try:
update = json.loads(body)
except json.JSONDecodeError:
logger.warning("Telegram webhook invalid JSON")
return {"status": "ok"}
from yuxi.channel.extensions.telegram.monitor import convert_update_to_unified
queue = ctx.get("queue")
account_id = ctx.get("account_id", "default")
unified = convert_update_to_unified(update, account_id)
if unified and queue:
try:
queue.put_nowait(unified)
except asyncio.QueueFull:
logger.warning("Telegram webhook queue full, dropping message")
if "callback_query" in update:
cq_id = update["callback_query"].get("id", "")
if cq_id:
token = ctx.get("token", "")
if token:
from yuxi.channel.extensions.telegram.polling import _answer_callback_quiet
asyncio.create_task(_answer_callback_quiet(token, cq_id))
return {"status": "ok"}
def _resolve_webhook_context(secret_token: str | None) -> dict | None:
if secret_token:
for ctx in _webhook_registry.values():
if ctx.get("secret") and hmac.compare_digest(secret_token, ctx["secret"]):
return ctx
entries = list(_webhook_registry.values())
if len(entries) == 1:
return entries[0]
if _webhook_queue is not None:
return {
"queue": _webhook_queue,
"account_id": _webhook_account_id,
"secret": _webhook_secret,
"token": _webhook_token,
}
return None
async def start_webhook(account: dict, queue: asyncio.Queue) -> None:
global _webhook_app, _webhook_queue, _webhook_account_id, _webhook_secret, _webhook_token
_webhook_queue = queue
_webhook_account_id = account.get("account_id", "default")
_webhook_secret = account.get("webhook_secret", "")
_webhook_token = account.get("token", "")
_webhook_registry[_webhook_account_id] = {
"queue": queue,
"secret": _webhook_secret,
"token": _webhook_token,
}
token = account.get("token", "")
webhook_url = account.get("webhook_url", "")
if not token:
logger.error("Telegram webhook: no token configured")
return
if not webhook_url:
logger.warning("Telegram webhook: no webhook_url configured, skipping setWebhook")
return
import httpx
payload = {"url": webhook_url}
if _webhook_secret:
payload["secret_token"] = _webhook_secret
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
resp = await client.post(
f"https://api.telegram.org/bot{token}/setWebhook",
json=payload,
)
data = resp.json() if resp.content else {}
if data.get("ok"):
logger.info(
"Telegram webhook set for account %s at %s",
_webhook_account_id, webhook_url,
)
else:
logger.error(
"Telegram setWebhook failed for account %s: %s",
_webhook_account_id, data.get("description", "unknown"),
)
except Exception:
logger.exception("Telegram setWebhook network error for account %s", _webhook_account_id)
async def stop_webhook(account: dict) -> None:
global _webhook_app, _webhook_queue, _webhook_account_id, _webhook_secret
token = account.get("token", "")
if not token:
_webhook_queue = None
_webhook_secret = ""
return
import httpx
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
resp = await client.post(
f"https://api.telegram.org/bot{token}/deleteWebhook",
json={"drop_pending_updates": False},
)
data = resp.json() if resp.content else {}
if data.get("ok"):
logger.info("Telegram webhook deleted for account %s", _webhook_account_id)
except Exception:
logger.exception("Telegram deleteWebhook error for account %s", _webhook_account_id)
_webhook_queue = None
_webhook_secret = ""
_webhook_token = ""
_webhook_registry.pop(_webhook_account_id, None)
def verify_webhook_secret(provided: str, expected: str) -> bool:
if not expected:
return True
return hmac.compare_digest(provided or "", expected)
def generate_webhook_secret() -> str:
return secrets.token_urlsafe(32)
async def get_webhook_info(token: str) -> dict | None:
import httpx
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
resp = await client.get(
f"https://api.telegram.org/bot{token}/getWebhookInfo",
)
data = resp.json() if resp.content else {}
if data.get("ok"):
return data.get("result", {})
except Exception:
logger.exception("Telegram getWebhookInfo failed")
return None