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