""" Telegram 渠道实现 — ChannelAdapter 的 POC 实例 任务 #935 · 2026-08-09 依赖:httpx(项目已用)。无 NoneBot2 / python-telegram-bot,纯 httpx 直调 Bot API。 - Bot API:https://core.telegram.org/bots/api - 单消息 4096 字符上限(自动分段) - Webhook 注册:setWebhook """ from __future__ import annotations import asyncio import logging from typing import Any import httpx from app.core.channel_adapter import ChannelAdapter, IncomingMessage log = logging.getLogger(__name__) TELEGRAM_API_BASE = "https://api.telegram.org" class TelegramChannel(ChannelAdapter): """Telegram Bot 渠道。 启动: - 调 setWebhook,把 https:///app/api/channel/telegram 注册给 Bot - 调 deleteWebhook 清掉旧 webhook,避免双收 出站: - sendMessage API,长度超过 4096 自动分段 - MarkdownV2 特殊字符需转义;POC 暂不启用 parse_mode(由 LLM 输出纯文本更安全) 入站: - webhook 端点接收 update,转 IncomingMessage,交给 router.dispatch """ channel_id = "telegram" def __init__( self, *, bot_token: str, webhook_url: str, webhook_secret: str = "", ) -> None: self.bot_token = bot_token self.webhook_url = webhook_url self.webhook_secret = webhook_secret self._api = f"{TELEGRAM_API_BASE}/bot{bot_token}" self._client: httpx.AsyncClient | None = None self._running = False async def start(self) -> None: self._client = httpx.AsyncClient(timeout=15.0) # 1) 清掉旧 webhook,避免双收 try: await self._client.post(f"{self._api}/deleteWebhook", json={"drop_pending_updates": True}) except Exception as e: log.warning("[telegram] deleteWebhook failed: %s", e) # 2) 注册新 webhook payload: dict[str, Any] = { "url": self.webhook_url, "allowed_updates": ["message"], "drop_pending_updates": False, } if self.webhook_secret: payload["secret_token"] = self.webhook_secret try: r = await self._client.post(f"{self._api}/setWebhook", json=payload) j = r.json() if r.content else {} if not j.get("ok"): raise RuntimeError(f"setWebhook failed: {j}") log.info("[telegram] webhook registered: %s", self.webhook_url) except Exception as e: log.warning("[telegram] start failed: %s", e, exc_info=True) self._running = True async def stop(self) -> None: self._running = False if self._client: try: await self._client.post(f"{self._api}/deleteWebhook", json={}) except Exception: pass await self._client.aclose() self._client = None async def send(self, *, user_id: str, content: str) -> None: """出站单条。user_id 在这里是 chat_id。""" if not self._client: log.warning("[telegram] send called but client not initialized") return # Telegram 单消息 4096 字符上限,长文本按 4000 分段(留 buffer) chunk_size = 4000 for i in range(0, max(len(content), 1), chunk_size): piece = content[i:i + chunk_size] try: r = await self._client.post( f"{self._api}/sendMessage", json={"chat_id": user_id, "text": piece}, ) if not r.is_success: log.warning("[telegram] sendMessage failed: %d %s", r.status_code, r.text[:200]) except Exception as e: log.warning("[telegram] send failed: %s", e, exc_info=True) async def receive_update(self, payload: dict) -> None: """webhook 端点调用此方法。""" msg = payload.get("message") or payload.get("edited_message") if not msg: return chat = msg.get("chat") or {} chat_type = chat.get("type", "private") is_group = chat_type in ("group", "supergroup") text = msg.get("text") or msg.get("caption") or "" # 去除 /command 后传给 LLM if text.startswith("/"): parts = text.split(maxsplit=1) cmd = parts[0].split("@")[0] # 去除 @botname 后缀 if cmd == "/bind": # /bind 由 binding_router 处理,不在这里 await self._handle_bind(chat_id=str(chat.get("id")), args=parts[1] if len(parts) > 1 else "") return text = parts[1] if len(parts) > 1 else "" if not text: return incoming = IncomingMessage( channel="telegram", platform_user_id=str(chat.get("id")), text=text, is_group=is_group, group_id=str(chat.get("id")) if is_group else None, raw=msg, ) if not self.router: log.warning("[telegram] router not bound; drop") return try: reply = await self.router.dispatch(incoming) except Exception as e: log.exception("[telegram] dispatch failed: %s", e) reply = "(系统繁忙,请稍后再试)" await self.send(user_id=incoming.platform_user_id, content=reply) async def _handle_bind(self, *, chat_id: str, args: str) -> None: """/bind — 解析 token,写 im_channel_routes,绑定 platform_user_id ↔ user_id。""" from sqlalchemy.orm import Session from app.core.db import SessionLocal from app.models import IMChannelRoute, User token = (args or "").strip() if not token: await self.send(user_id=chat_id, content="用法:/bind <你在七虎AI后台生成的绑定码>") return db: Session = SessionLocal() try: # token = base64(user_id) 简化方案;生产可换 JWT + 有效期 import base64 try: user_id = base64.urlsafe_b64decode(token.encode()).decode() except Exception: await self.send(user_id=chat_id, content="绑定码无效") return user = db.query(User).filter(User.id == user_id).first() if user is None: await self.send(user_id=chat_id, content="绑定码无效或用户不存在") return existing = db.query(IMChannelRoute).filter( IMChannelRoute.channel == "telegram", IMChannelRoute.platform_user_id == chat_id, ).first() if existing: existing.internal_user_id = user.id else: db.add(IMChannelRoute(channel="telegram", platform_user_id=chat_id, internal_user_id=user.id)) db.commit() await self.send(user_id=chat_id, content=f"✅ 绑定成功!你现在可以与我对话。") except Exception as e: log.exception("[telegram.bind] failed: %s", e) await self.send(user_id=chat_id, content="绑定失败,请稍后再试") finally: db.close()