Files
tg_python/tg_bridge/app.py
2026-04-23 22:06:19 +08:00

257 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
# 策略对纯 asyncio 有效Uvicorn 在 win32 上会改用 Proactor 类作为 loop_factory
# 与下文无关。请用 ``python -m tg_bridge`` 或 ``uvicorn ... --loop tg_bridge.uvicorn_loop:selector_loop_factory``。
from tg_bridge.winloop import apply_windows_selector_policy
apply_windows_selector_policy()
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Annotated, Any
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from telethon import TelegramClient
from telethon.errors import RPCError
from tg_bridge.client_factory import create_telegram_client
from tg_bridge.config import Settings
from tg_bridge.http_logging import AccessLogMiddleware, log_preview_max_chars, preview_for_log
from tg_bridge.logging_setup import setup_logging
setup_logging()
logger = logging.getLogger(__name__)
_settings: Settings | None = None
_client: TelegramClient | None = None
# 与 Bot 一问一答必须串行,否则 Conversation 会串话或 AlreadyInConversationError
_telegram_bridge_lock = asyncio.Lock()
security = HTTPBearer(auto_error=False)
def _extract_bot_reply_text(message: Any) -> str:
if message is None:
return ""
raw = getattr(message, "text", None) or getattr(message, "message", None) or ""
return raw.strip() if isinstance(raw, str) else ""
def _get_settings() -> Settings:
global _settings
if _settings is None:
_settings = Settings.load()
return _settings
async def _ensure_client() -> TelegramClient:
if _client is None:
raise HTTPException(status_code=503, detail="Telegram 客户端未初始化")
return _client
def _verify_bridge_auth(
creds: HTTPAuthorizationCredentials | None,
x_bridge_token: str | None,
) -> None:
s = _get_settings()
expected = s.bridge_token
if not expected:
return
if creds and creds.scheme.lower() == "bearer" and creds.credentials == expected:
return
if x_bridge_token and x_bridge_token == expected:
return
raise HTTPException(status_code=401, detail="无效或未提供鉴权")
@asynccontextmanager
async def _lifespan(app: FastAPI):
global _client
s = _get_settings()
if s.bridge_token:
logger.info("已启用 BRIDGE_TOKEN请求需携带 Bearer 或 X-Bridge-Token")
else:
logger.warning("未设置 BRIDGE_TOKEN/v1/forward 对同网段可达者开放,生产环境请设置")
if s.proxy_type and s.proxy_host and s.proxy_port:
logger.info(
"Telegram 使用代理: type=%s host=%s port=%s rdns=%s connection=%s timeout=%ss",
s.proxy_type,
s.proxy_host,
s.proxy_port,
s.proxy_rdns,
s.connection_mode,
s.connect_timeout,
)
_client = create_telegram_client(s)
await _client.connect()
if not await _client.is_user_authorized():
await _client.disconnect()
_client = None
raise RuntimeError(
"Telegram 会话未授权。请在 wx_python 目录执行: python -m tg_bridge.login_cli"
)
me = await _client.get_me()
logger.info("Telegram 已连接: user_id=%s username=%s", me.id, me.username)
yield
if _client:
await _client.disconnect()
_client = None
app = FastAPI(title="tg_bridge", version="0.1.0", lifespan=_lifespan)
app.add_middleware(AccessLogMiddleware)
class ForwardBody(BaseModel):
"""转发到 Telegram Bot 的请求体。"""
text: str = Field(..., min_length=1, description="要发送的文本")
bot: str | None = Field(
None,
description="目标 Bot 用户名(与 .env 中配置的某一个一致);省略则发往列表中的第一个",
)
context: str | None = Field(
None,
description="可选,如企业微信 UserID会加在正文前便于区分来源",
)
wait_reply: bool = Field(
True,
description="为 true 时等待 Bot 下一条文本回复,并放入 reply_text企业微信桥接常用",
)
reply_timeout_sec: int | None = Field(
None,
ge=5,
le=600,
description="等待回复超时秒数,默认使用服务端 TELEGRAM_BOT_REPLY_TIMEOUT",
)
reply_take_nth: int | None = Field(
None,
ge=1,
le=20,
description="取 Bot 连续回复的第几条1=第一条2=第二条)。省略则用服务端 BOT_REPLY_TAKE_NTH",
)
class ForwardResponse(BaseModel):
ok: bool = True
detail: str = "sent"
reply_text: str | None = Field(
None,
description="Bot 回复正文;仅 wait_reply 为 true 且收到消息时可能有值",
)
async def _auth_dep(
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
x_bridge_token: Annotated[str | None, Header(alias="X-Bridge-Token")] = None,
):
_verify_bridge_auth(creds, x_bridge_token)
@app.get("/health")
async def health():
s = _get_settings()
c = _client
authorized = False
if c:
try:
authorized = await c.is_user_authorized()
except Exception:
authorized = False
return {
"status": "ok",
"telegram_authorized": authorized,
"bot": s.default_bot_username,
"bots": list(s.bot_usernames),
"telegram_proxy": bool(s.proxy_type and s.proxy_host and s.proxy_port),
"telegram_proxy_rdns": s.proxy_rdns,
"telegram_connect_timeout_sec": s.connect_timeout,
"telegram_connection": s.connection_mode,
"bot_reply_timeout_sec": s.bot_reply_timeout,
"default_bot_reply_take_nth": s.bot_reply_take_nth,
}
@app.post("/v1/forward", response_model=ForwardResponse)
async def forward_message(
body: ForwardBody,
_: Annotated[None, Depends(_auth_dep)],
):
"""将 text 用当前登录的 Telegram 个人号发给配置的 Bot。
若设置 BRIDGE_TOKEN请求头需 `Authorization: Bearer <token>` 或 `X-Bridge-Token: <token>`。
"""
s = _get_settings()
client = await _ensure_client()
try:
target_bot = s.resolve_bot_username(body.bot)
except ValueError as e:
logger.warning("forward 请求被拒绝: %s", e)
raise HTTPException(status_code=400, detail=str(e)) from e
payload = body.text.strip()
if body.context and body.context.strip():
payload = f"[wx:{body.context.strip()}]\n{payload}"
prev_n = log_preview_max_chars()
preview_part = (
f" text_preview={preview_for_log(payload, prev_n)!r}" if prev_n else ""
)
logger.info(
"forward 请求: bot=%s wait_reply=%s text_len=%d context=%s timeout_sec=%s take_nth=%s%s",
target_bot,
body.wait_reply,
len(payload),
bool(body.context and body.context.strip()),
body.reply_timeout_sec,
body.reply_take_nth,
preview_part,
)
reply_text: str | None = None
try:
if body.wait_reply:
rt = float(body.reply_timeout_sec or s.bot_reply_timeout)
nth = body.reply_take_nth if body.reply_take_nth is not None else s.bot_reply_take_nth
# 多条回复时,每条独立算超时;总会话上限略放大避免 total_timeout 先触发
total_rt = rt * float(nth) + 15.0
async with _telegram_bridge_lock:
async with client.conversation(
target_bot,
exclusive=True,
timeout=rt,
total_timeout=total_rt,
) as conv:
await conv.send_message(payload)
response = None
for _ in range(nth):
response = await conv.get_response(timeout=rt)
reply_text = _extract_bot_reply_text(response) if response is not None else None
reply_text = reply_text or None
else:
await client.send_message(target_bot, payload)
except asyncio.TimeoutError:
logger.warning("forward: bot=%s 等待 Bot 回复超时", target_bot)
raise HTTPException(status_code=504, detail="等待 Bot 回复超时") from None
except RPCError as e:
logger.exception("Telegram RPC 失败")
raise HTTPException(status_code=502, detail=str(e)) from e
except Exception as e:
logger.exception("发送失败")
raise HTTPException(status_code=500, detail=str(e)) from e
out = ForwardResponse(reply_text=reply_text)
rlen = len(out.reply_text or "")
if prev_n and out.reply_text:
logger.info(
"forward 响应: reply_len=%d reply_preview=%r",
rlen,
preview_for_log(out.reply_text, prev_n),
)
else:
logger.info("forward 响应: reply_len=%d", rlen)
return out