185 lines
7.0 KiB
Python
185 lines
7.0 KiB
Python
"""
|
|
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://<host>/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> — 解析 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() |