From e9823c2261ca86ef22f2e31d71eb06a80ca00e49 Mon Sep 17 00:00:00 2001 From: van Date: Thu, 23 Apr 2026 22:06:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo.py | 31 +++ requirements-tg-bridge.txt | 7 + requirements.txt | 2 + run_bridge.bat | 5 + run_login.bat | 5 + tg_bridge/.env.example | 48 ++++ tg_bridge/__init__.py | 3 + tg_bridge/__main__.py | 25 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 277 bytes tg_bridge/__pycache__/app.cpython-312.pyc | Bin 0 -> 12831 bytes .../client_factory.cpython-312.pyc | Bin 0 -> 1202 bytes tg_bridge/__pycache__/config.cpython-312.pyc | Bin 0 -> 5745 bytes .../connection_mode.cpython-312.pyc | Bin 0 -> 1307 bytes .../__pycache__/http_logging.cpython-312.pyc | Bin 0 -> 3183 bytes .../__pycache__/logging_setup.cpython-312.pyc | Bin 0 -> 3321 bytes tg_bridge/__pycache__/proxy.cpython-312.pyc | Bin 0 -> 1817 bytes tg_bridge/app.py | 256 ++++++++++++++++++ tg_bridge/client_factory.py | 23 ++ tg_bridge/config.py | 153 +++++++++++ tg_bridge/connection_mode.py | 29 ++ tg_bridge/http_logging.py | 67 +++++ tg_bridge/logging_setup.py | 66 +++++ tg_bridge/login_cli.py | 49 ++++ tg_bridge/proxy.py | 39 +++ tg_bridge/uvicorn_loop.py | 17 ++ tg_bridge/winloop.py | 15 + 26 files changed, 840 insertions(+) create mode 100644 demo.py create mode 100644 requirements-tg-bridge.txt create mode 100644 requirements.txt create mode 100644 run_bridge.bat create mode 100644 run_login.bat create mode 100644 tg_bridge/.env.example create mode 100644 tg_bridge/__init__.py create mode 100644 tg_bridge/__main__.py create mode 100644 tg_bridge/__pycache__/__init__.cpython-312.pyc create mode 100644 tg_bridge/__pycache__/app.cpython-312.pyc create mode 100644 tg_bridge/__pycache__/client_factory.cpython-312.pyc create mode 100644 tg_bridge/__pycache__/config.cpython-312.pyc create mode 100644 tg_bridge/__pycache__/connection_mode.cpython-312.pyc create mode 100644 tg_bridge/__pycache__/http_logging.cpython-312.pyc create mode 100644 tg_bridge/__pycache__/logging_setup.cpython-312.pyc create mode 100644 tg_bridge/__pycache__/proxy.cpython-312.pyc create mode 100644 tg_bridge/app.py create mode 100644 tg_bridge/client_factory.py create mode 100644 tg_bridge/config.py create mode 100644 tg_bridge/connection_mode.py create mode 100644 tg_bridge/http_logging.py create mode 100644 tg_bridge/logging_setup.py create mode 100644 tg_bridge/login_cli.py create mode 100644 tg_bridge/proxy.py create mode 100644 tg_bridge/uvicorn_loop.py create mode 100644 tg_bridge/winloop.py diff --git a/demo.py b/demo.py new file mode 100644 index 0000000..4dd1188 --- /dev/null +++ b/demo.py @@ -0,0 +1,31 @@ +""" +wxauto 最简单的 demo 测试 +需要先安装: pip install wxauto +确保微信客户端已登录 +""" + +from wxauto import WeChat + +# 初始化微信实例 +print("正在初始化微信...") +wx = WeChat() + +# 发送消息到文件传输助手 +print("正在发送消息...") +wx.SendMsg("你好,这是一条测试消息!", who="文件传输助手") + +# 获取当前聊天窗口的消息 +print("正在获取消息...") +msgs = wx.GetAllMessage() + +# 打印消息信息 +print(f"\n共获取到 {len(msgs)} 条消息:") +for i, msg in enumerate(msgs, 1): + print(f"消息 {i}:") + print(f" 内容: {msg.content}") + print(f" 类型: {msg.type}") + print(f" 发送者: {msg.sender}") + print() + +print("Demo 测试完成!") + diff --git a/requirements-tg-bridge.txt b/requirements-tg-bridge.txt new file mode 100644 index 0000000..26611a6 --- /dev/null +++ b/requirements-tg-bridge.txt @@ -0,0 +1,7 @@ +# Telegram HTTP 转发桥(与 wxauto 无关,可单独安装) +telethon>=1.36.0 +PySocks>=1.7.1 +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +python-dotenv>=1.0.0 +pydantic>=2.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2938872 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +wxauto>=3.9.8.15 + diff --git a/run_bridge.bat b/run_bridge.bat new file mode 100644 index 0000000..fd51516 --- /dev/null +++ b/run_bridge.bat @@ -0,0 +1,5 @@ +@echo off +REM 须用 python -m tg_bridge(已强制 Selector 循环);不要裸 uvicorn tg_bridge.app:app +cd /d "%~dp0" +python -m tg_bridge +if errorlevel 1 pause diff --git a/run_login.bat b/run_login.bat new file mode 100644 index 0000000..0defbd4 --- /dev/null +++ b/run_login.bat @@ -0,0 +1,5 @@ +@echo off +REM 必须在「wx_python」根目录运行(与 tg_bridge 文件夹同级) +cd /d "%~dp0" +python -m tg_bridge.login_cli +if errorlevel 1 pause diff --git a/tg_bridge/.env.example b/tg_bridge/.env.example new file mode 100644 index 0000000..46d10e5 --- /dev/null +++ b/tg_bridge/.env.example @@ -0,0 +1,48 @@ +# https://my.telegram.org 申请 +TELEGRAM_API_ID=12345678 +TELEGRAM_API_HASH=你的api_hash + +# 目标 Bot 的用户名(可带或不带 @)。多个用英文逗号分隔;POST /v1/forward 的 bot 须在此列表内(企微「开」→ AJL05_bot,「慢开」→ QingBaoJuXWsgkbot) +TELEGRAM_BOT_USERNAME=AJL05_bot,QingBaoJuXWsgkbot +# TELEGRAM_BOT_USERNAME=YourBotName + +# Session 持久化(SQLite,一般只需 login_cli 一次)。默认:wx_python 根目录下 tg_bridge.session +# TELEGRAM_SESSION_PATH=D:/data/tg_bridge.session + +# HTTP 监听 +BRIDGE_HOST=0.0.0.0 +BRIDGE_PORT=18080 +# Windows + Telethon:若用手动 uvicorn,必须加(否则会 Proactor+121): +# --loop tg_bridge.uvicorn_loop:selector_loop_factory + +# 可选;设置后 POST /v1/forward 必须带 Bearer 或 X-Bridge-Token +# BRIDGE_TOKEN=随机长字符串 + +# Telegram 连接代理(Clash / v2ray 本地 HTTP 或 SOCKS5,填一种即可;不设则直连) +# TELEGRAM_PROXY_TYPE=http +# TELEGRAM_PROXY_TYPE=socks5 +# TELEGRAM_PROXY_HOST=127.0.0.1 +# TELEGRAM_PROXY_PORT=7890 +# TELEGRAM_PROXY_USER= +# TELEGRAM_PROXY_PASSWORD= +# SOCKS5 若仍超时,可显式关闭代理侧解析域名(默认 socks 已为 false,http 默认为 true) +# TELEGRAM_PROXY_RDNS=false +# Telethon 连接超时(秒),经代理建议 60~120 +# TELEGRAM_CONNECT_TIMEOUT=90 +# TELEGRAM_CONNECTION_RETRIES=5 +# TELEGRAM_RETRY_DELAY=3 +# 传输模式:遇 Windows WinError 121 可改为 tcp_obfuscated 或 tcp_intermediate +# TELEGRAM_CONNECTION=tcp_full +# TELEGRAM_CONNECTION=tcp_obfuscated +# /v1/forward 默认 wait_reply=true 时,等 Bot 回复的最长时间(秒) +# TELEGRAM_BOT_REPLY_TIMEOUT=120 +# Bot 先发「查询中」再发结果时,可默认取第 2 条(请求里 reply_take_nth 可覆盖) +# BOT_REPLY_TAKE_NTH=2 +# 业务日志中附带正文/回复预览的最大字符数(0 或未设置则只记录长度,避免日志含敏感全文) +# BRIDGE_LOG_PREVIEW_CHARS=120 +# 文件日志目录(默认 wx_python/logs);当前写入 tg_bridge.log,每日午夜滚动为 tg_bridge.log.YYYY-MM-DD +# BRIDGE_LOG_DIR=D:/data/tg_bridge_logs +# 保留的滚动日志文件个数(默认 30) +# BRIDGE_LOG_BACKUP_COUNT=30 +# tg_bridge 日志级别:DEBUG / INFO / WARNING … +# BRIDGE_LOG_LEVEL=INFO \ No newline at end of file diff --git a/tg_bridge/__init__.py b/tg_bridge/__init__.py new file mode 100644 index 0000000..7201798 --- /dev/null +++ b/tg_bridge/__init__.py @@ -0,0 +1,3 @@ +"""Telegram 转发桥:HTTP 入站,由个人号向指定 Bot 发送消息。""" + +__version__ = "0.1.0" diff --git a/tg_bridge/__main__.py b/tg_bridge/__main__.py new file mode 100644 index 0000000..c9aac09 --- /dev/null +++ b/tg_bridge/__main__.py @@ -0,0 +1,25 @@ +"""启动 HTTP 服务: python -m tg_bridge""" + +from __future__ import annotations + +import uvicorn + +from tg_bridge.config import Settings +from tg_bridge.winloop import apply_windows_selector_policy + + +def main() -> None: + apply_windows_selector_policy() + s = Settings.load() + # Windows 上 Uvicorn 仍优先 Proactor,须显式 loop_factory,否则 lifespan 里 Telethon WinError 121 + uvicorn.run( + "tg_bridge.app:app", + host=s.bridge_host, + port=s.bridge_port, + log_level="info", + loop="tg_bridge.uvicorn_loop:selector_loop_factory", + ) + + +if __name__ == "__main__": + main() diff --git a/tg_bridge/__pycache__/__init__.cpython-312.pyc b/tg_bridge/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5098b517a8be9fe5436e3b9fee4131f552facc08 GIT binary patch literal 277 zcmX@j%ge<81V>EHW#$6u#~=<2FhUuhd4P=R3@Hpz3@MCJjFn89%%wg+p^(&^)bygn zT!j~V*F5c?_-x_Q_j_h}goFerJZ)Y2eD%!tdwQNv+4y9~swca4J?-EAbi%}EJ?&4| z%~Ej6FHrz$c-heSY+J{(#`TXInyOe0^bGY3{4|+vvB$@!d&dkq? zk6+2~8RVK@mM&H?&iN^+G0CMx#rZ|y`9;|=S&2nunZ+^X74Zd?B^mj7F(v8oNky3{ z>8UaC@tJvPx# literal 0 HcmV?d00001 diff --git a/tg_bridge/__pycache__/app.cpython-312.pyc b/tg_bridge/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..08270489f0c8bef3c6b7f703311f2a95f810f723 GIT binary patch literal 12831 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 literal 0 HcmV?d00001 diff --git a/tg_bridge/__pycache__/client_factory.cpython-312.pyc b/tg_bridge/__pycache__/client_factory.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1acce90a25c24e58d29f5adce644e0784ec7f76c GIT binary patch literal 1202 zcmZuwOG_J36uy(kWb)9)M|}_%v0CU*&~{-d#acuQZggXz5CX%P+$3Y?5$@br0~SKD z#cZ^ti%1Kl&<8D+E=3xlKcG+$7lwk++2EUd^8zpU#9ma$^!CRH0BKousDtc|D&)G=j*Nr?$nDYNb_jr@tvE09>=p zft3n=!eM&D{^r26tBUJq?WO~Jsx0O-x8^A@I2w($a%n{!Z0@B#~% zZoZIh{h$5Htq3+d2}DOGL2-HEO}Y5Jy!L6Y`2OI<^S!mj!yj+=7e5}ZKHD#>9=x6p zpC85X`T`?Zhr>t3<->0u!dTIuk->Lw-UuHQm+UzE%dhMR$Lsms+*8p_+}1pZuRb&4 zsd$p+Iw>KDkpfNPXCN!Zp(;pO2{#6pln|{OWI-x${0#)FWE-ZO=T;M+Gzr| z9Oe`iIj|SF_PJ>@a6NZ_+u!qUc&m3{qjzA_e=9e-?eE+c{0qZN6ZwfvAyg8)OF~{? zhcE9dHMW#m+DpNvtzh>?u)7rM+6wh=g!)UZmkZ-B$IqI)0saJd-TW!d0wzQkVrzli zR1ltaID>Snf1;oGBq@=h1e7GiswYDADcKzk)~f8I9ZD!kJ4)v{y80Zg!Pw)f+pP_- zC&{Y+0#FD0nKmogAa%*8a0rE|%HE&ZNyUkik>D C`%Iny literal 0 HcmV?d00001 diff --git a/tg_bridge/__pycache__/config.cpython-312.pyc b/tg_bridge/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee71c72af5c6a0b9dd0832523d1673289a31b90a GIT binary patch literal 5745 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 literal 0 HcmV?d00001 diff --git a/tg_bridge/__pycache__/logging_setup.cpython-312.pyc b/tg_bridge/__pycache__/logging_setup.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec26bda40b37d5f2fc1fbbab635b672cc4b9a5d3 GIT binary patch literal 3321 zcmbUjZA@F&_1b>6-?OoU4dHVkPFs(dfU|t8Y11g$_zdBzBBUDL)`!D;7(4zz?>#4g zjO1=`%=}9J z#@$P*9b>-QZ894l8Zll_TtdE%Yb*-I;P_vE?zE596He)M2{VM3mqkUyAP zc{D3uxx2n_RlfF)eEoMBs{HWRAhp(``@x8pW4VaLhcAp$ZW#Nsky0X{hf#-+X7eY2%pfU8le7vQh8PMEt{tGp zA|lZAJm@BH23+iPQP}9?!AD8J#$d$f^M`#{9~~Ov1x&SeHg^%X!`=`t5-%KI zR^$SCFw4z~yc8XB4~^hLcE~G;JPQOi$Y0=tf(2x3@QIZ$#L-i;{k>y18gHMzdHPoS zz3Pu^Kdeofk1bNiHi3-#$)Exah!TT8!?-;Lfm{q!Rs`9p=EE3?Og5MEbc6)Wh*rwQ zB0#k7fBK31&VeMV5!o=B~r0U z<@6-uzlnQH8`D1TAL9(1;hs@Ffd+kx2+$m(%70@7bPSTpa~_){a^VWZbn4xxdd4Ve zuS)$E9u;sFRg9V|tYX&Yt^=0x#q_RE)E7yY3aLddCbgEoUQ9A2UiS z%X9N8;LMS8h*YhjF{5gqvvZ~x#TCJQ6whb#aE#`5$7nWp&oMf1K$XnTF@vh(D`%+9 zbEue_J7Y|Y`G1-qUI$gV{2Zh6w!Vgg$#ZZeF~sJo_lWjIBz;HK^%4^FP>s!FR4=;v z#YHr%>qQqeuF@zC*RcHX4-Qg1c)`)vm#ZgzeGd8h75VK4@^7b9^4i;ztB*f&oRI|H z8#?6;b3u?f;OOy(c&=NitYKfXKgefEP|-r)FknEo2x|1-3w6H(NWjf*w;r46T3v< zgUNs2|M}{Bcb6YdE-%bxc2<7>NWOB%F_<}l%8i+vAD!4*-`(2S(!{oRwXlt?-56mC ztcX1>EjB_~<-?MQX@1x*N^ImjF5Sjm|HFp1v#+xaU1vLcu=dFJ)<~wWRl$G@x3RT1 zz0uSzkQcz{A?JRt*e^j8U83X1&izWUCPm&Q{?y^zpTUj;24!FL`e1w1=IpI=hU%Q0 z!+Gksv*Wn)3^WEK=au+@kThz^9fX7i(;+_>_WK5s%D6QIUc9jdHY-0x6RlAZsn9c@N z#M+36jnGH^LL`iJz=8FmB>0B}(hIPOA0C3xM@49AMu8V2!3#Xrv)x@?Jy;()&-n$c z3wejJ4xGZKH@v|p-y{eTLFr$Zh6dM8+8Jh=BSHvTC8#O;x*WthFpLdibYQ?gj0-?E zFJ$>jB4d-6Wbu9_ ze0t?3u@3G8GbG~Bk)=Vb9e__96v^e2G|hleKw@K2+BfOhr1c98u(umN@qNgmINB;e zyGl~ly$S2yJLaUdHr|ppT2sc-zZpx@v~}5DJX14WGvl6iFYY^Z|8Ua&a>{-pVLy?y zpNzLXIo@=Yn|dv2-J7!3B&;WWlL;6R1y7#R;l->g*C#xoonf zOqB^!Wtx61UHIabUS~GM8#YYHR(9iHqR`2?n(pLM11(}X)BBRL=uiq#{w#s<@rPefM81H(kYpP+1DqAL6suE09l5xcA zw^RN}{3%ILB~!u@RiSv7UzP2NxBQ*j{l(U+sG#J(NTaVvmzG~C*w6v?rD%ui`J`xf zOA-3C$lS6|_o+hzql*%tUm@WL4A>yRn@k7`ZdkaqLL=FuIwN7;Rfvr&ygd6^7Moah zAPQ_e%L?$wLYd#IFq;W(_1_Xmiz78i8Vvf+Wqv3_=IttwHz=k-4@osvs3A*3rdkQ4 zmnlDxl2acd!AdrioTE>KB3v}cpAxr8bKzhv1oXf&^E(RG?C H#pwS4+A2@6 literal 0 HcmV?d00001 diff --git a/tg_bridge/__pycache__/proxy.cpython-312.pyc b/tg_bridge/__pycache__/proxy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d52955394f9e7ac359c63939f52879cd32224b6 GIT binary patch literal 1817 zcmZ`(T}&KR6uz@NyF0rK6l<&tY3)eVux4ChfIlq-mk1NvY8A;)97vv)BjLi4P{-ANC0hKKbOiv%5g!dLQPT zd+zzU=bP`IU#qKK2-f{q?uXiK2t6d5^0E{aYO5KPStOxuBvFzjOhqi+7K-TBur)$= z)22>`Z4rC79dw6e8=<=$al40!9fi;?IV|gas={TKzRpj5R2ZGe-@Cc7JeFDhBENK* zBgefs5NQpEWJTxlb0b_7t7qa|c5*m3v#_x|{xCUQAb0UlMpZyjR6U@FR7HcWwMB`8 zU^-=84=KG`kRpDi1^pl^Le$n*V4Xz?)L%Z?C9xaqrA~L55oWwUds>UaMC*;y(9s17tDge{WQ+|j2gT&Gu+V{d4Q}QqRMXr*bcqI{_F zOlPlLHCbVjMid_u6i=t^4^mtU}xqu{LD5_$e=dh$`JQves ztOO!(77b|Hpo%3?1WsqCE@l?LDSUZ1J2_uSju**mCd}MhflHb<1ZK|{hE}qp6I@5U zQw{B(Uwo{XInjJltUm7XQtYm-s7W&QI_%3soJda|- z@L<-H=b!^o)Jt!!Ns)E9+(1)syYF49HySq^zXA1%I0z*_Q6yOjipLFGpRPwWgC@db z#SHvf!>*BQzf_XVv8Tb9S)9mC&*v@-kw4MZ_Ig|UiIz8njuUUbb5iI!+0k}5b`ZQa zmM=j20L3AWB;>hbrjl&-(1vRVd4nd%8#F<|pb7E@Q%2rE22;k~}94d6($9Yn&Kt43s?FwZ1g(hg>FSYgX*+2h(o z7N={|g`RY$Yag3sKV=^ke>rV+_GHT@ix}tm#84vTIGA?VU1Kk^W7OF5Yg*D!{ z%KK9Mu}jpNyYV-7<1eR5XH{(S*cSAGj(cca4-%81Y}m1^$FTC@6XdR12t6_Il?4G4+zmTCNSO4PjAo;D8(lB(wkOnUrZ*w{0b#;6 ue1P~dzKANt!t$&5IoOhUsqKT$rj??o-_dJX)R0B{vgp~r?FXo8Gwxqn)g!q8 literal 0 HcmV?d00001 diff --git a/tg_bridge/app.py b/tg_bridge/app.py new file mode 100644 index 0000000..43f0e53 --- /dev/null +++ b/tg_bridge/app.py @@ -0,0 +1,256 @@ +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 ` 或 `X-Bridge-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 diff --git a/tg_bridge/client_factory.py b/tg_bridge/client_factory.py new file mode 100644 index 0000000..78c41c3 --- /dev/null +++ b/tg_bridge/client_factory.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from telethon import TelegramClient + +from tg_bridge.config import Settings +from tg_bridge.connection_mode import resolve_connection_class +from tg_bridge.proxy import telethon_proxy_from_settings + + +def create_telegram_client(s: Settings) -> TelegramClient: + """创建带代理与连接超时的 TelegramClient(Telethon 默认 timeout=10 经代理易超时)。""" + proxy = telethon_proxy_from_settings(s) + conn = resolve_connection_class(s.connection_mode) + return TelegramClient( + str(s.session_path), + s.api_id, + s.api_hash, + proxy=proxy, + connection=conn, + timeout=s.connect_timeout, + connection_retries=s.connection_retries, + retry_delay=s.retry_delay, + ) diff --git a/tg_bridge/config.py b/tg_bridge/config.py new file mode 100644 index 0000000..9c70854 --- /dev/null +++ b/tg_bridge/config.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from dotenv import load_dotenv + +# 优先从包上级目录(wx_python 根)加载 .env,避免工作目录不在项目根时失效 +_ROOT = Path(__file__).resolve().parent.parent +load_dotenv(_ROOT / ".env") +load_dotenv() + + +def _parse_bot_usernames(raw: str) -> tuple[str, ...]: + """从逗号分隔字符串解析 Bot 用户名(去 @、去空)。""" + parts = [p.strip().lstrip("@") for p in raw.split(",")] + bots = tuple(p for p in parts if p) + if not bots: + raise RuntimeError( + "缺少 TELEGRAM_BOT_USERNAME:填写至少一个 Bot 用户名;多个用英文逗号分隔,不要带 @ 也可" + ) + return bots + + +@dataclass(frozen=True) +class Settings: + api_id: int + api_hash: str + session_path: Path + """已配置的 Bot 用户名列表(顺序:默认目标为第一个)。""" + bot_usernames: tuple[str, ...] + bridge_host: str + bridge_port: int + bridge_token: str | None + # Telethon 连接 Telegram 用的代理(与系统「全局代理」无关,需显式配置) + proxy_type: str | None + proxy_host: str | None + proxy_port: int | None + proxy_user: str | None + proxy_password: str | None + # SOCKS 上 rdns=True 会在代理侧解析域名,部分代理会卡死;默认 False 多为本机解析再连 + proxy_rdns: bool + # Telethon 单次连接超时秒数(默认 10 经代理不够) + connect_timeout: int + connection_retries: int + retry_delay: int + # Telethon Connection 类:tcp_obfuscated 在受限网络 + 代理下常比 tcp_full 更稳 + connection_mode: str + # /v1/forward 在 wait_reply 时等待 Bot 回复的上限(秒) + bot_reply_timeout: float + # wait_reply 时默认取 Bot 第几条连续回复(可被请求体 reply_take_nth 覆盖) + bot_reply_take_nth: int + + @property + def default_bot_username(self) -> str: + return self.bot_usernames[0] + + def resolve_bot_username(self, bot: str | None) -> str: + """将请求里的 bot 解析为已配置的用户名;省略则用列表第一个。""" + if bot is None: + return self.bot_usernames[0] + key = bot.strip().lstrip("@").lower() + by_lower = {b.lower(): b for b in self.bot_usernames} + if key not in by_lower: + raise ValueError( + f"未知 Bot「{bot}」,当前允许: {', '.join(self.bot_usernames)}" + ) + return by_lower[key] + + @staticmethod + def load() -> "Settings": + api_id_raw = os.environ.get("TELEGRAM_API_ID", "").strip() + api_hash = os.environ.get("TELEGRAM_API_HASH", "").strip() + if not api_id_raw or not api_hash: + raise RuntimeError( + "缺少 TELEGRAM_API_ID / TELEGRAM_API_HASH。" + "请到 https://my.telegram.org 申请后写入环境变量或 .env" + ) + bot_raw = os.environ.get("TELEGRAM_BOT_USERNAME", "").strip() + bots = _parse_bot_usernames(bot_raw) + + # 默认可写绝对路径:避免「login 在项目目录、服务从别的工作目录启动」找不到同一份 session + _default_session = (_ROOT / "tg_bridge.session").resolve() + session_raw = os.environ.get("TELEGRAM_SESSION_PATH", "").strip() + session = ( + Path(session_raw).expanduser().resolve() + if session_raw + else _default_session + ) + + token = os.environ.get("BRIDGE_TOKEN", "").strip() or None + host = os.environ.get("BRIDGE_HOST", "0.0.0.0").strip() + port = int(os.environ.get("BRIDGE_PORT", "18080")) + + ptype = os.environ.get("TELEGRAM_PROXY_TYPE", "").strip() or None + phost = os.environ.get("TELEGRAM_PROXY_HOST", "").strip() or None + pport_raw = os.environ.get("TELEGRAM_PROXY_PORT", "").strip() + pport: int | None = int(pport_raw) if pport_raw else None + puser = os.environ.get("TELEGRAM_PROXY_USER", "").strip() or None + ppwd_raw = os.environ.get("TELEGRAM_PROXY_PASSWORD") + ppwd: str | None = None + if puser is not None: + ppwd = ppwd_raw if ppwd_raw is not None else "" + + if ptype and (not phost or not pport): + raise RuntimeError( + "已设置 TELEGRAM_PROXY_TYPE 时,须同时设置 TELEGRAM_PROXY_HOST 与 TELEGRAM_PROXY_PORT" + ) + if (phost or pport_raw) and not ptype: + raise RuntimeError("设置 TELEGRAM_PROXY_HOST / TELEGRAM_PROXY_PORT 时须设置 TELEGRAM_PROXY_TYPE(http|socks5|socks4)") + + rdns_raw = os.environ.get("TELEGRAM_PROXY_RDNS", "").strip().lower() + if rdns_raw in ("1", "true", "yes", "on"): + proxy_rdns = True + elif rdns_raw in ("0", "false", "no", "off"): + proxy_rdns = False + elif not rdns_raw: + # 未配置:HTTP 代理常用远程 DNS;SOCKS 默认本机解析更稳 + proxy_rdns = bool(ptype and str(ptype).lower() in ("http", "https")) + else: + raise RuntimeError("TELEGRAM_PROXY_RDNS 请使用 true/false") + + connect_timeout = int(os.environ.get("TELEGRAM_CONNECT_TIMEOUT", "90")) + connection_retries = int(os.environ.get("TELEGRAM_CONNECTION_RETRIES", "5")) + retry_delay = int(os.environ.get("TELEGRAM_RETRY_DELAY", "3")) + connection_mode = os.environ.get("TELEGRAM_CONNECTION", "tcp_full").strip() or "tcp_full" + bot_reply_timeout = float(os.environ.get("TELEGRAM_BOT_REPLY_TIMEOUT", "120")) + bot_reply_take_nth = int(os.environ.get("BOT_REPLY_TAKE_NTH", "1")) + if bot_reply_take_nth < 1 or bot_reply_take_nth > 20: + raise RuntimeError("BOT_REPLY_TAKE_NTH 须在 1~20 之间") + + return Settings( + api_id=int(api_id_raw), + api_hash=api_hash, + session_path=session, + bot_usernames=bots, + bridge_host=host, + bridge_port=port, + bridge_token=token, + proxy_type=ptype, + proxy_host=phost, + proxy_port=pport, + proxy_user=puser, + proxy_password=ppwd, + proxy_rdns=proxy_rdns, + connect_timeout=connect_timeout, + connection_retries=connection_retries, + retry_delay=retry_delay, + connection_mode=connection_mode, + bot_reply_timeout=bot_reply_timeout, + bot_reply_take_nth=bot_reply_take_nth, + ) diff --git a/tg_bridge/connection_mode.py b/tg_bridge/connection_mode.py new file mode 100644 index 0000000..2a479c1 --- /dev/null +++ b/tg_bridge/connection_mode.py @@ -0,0 +1,29 @@ +"""Telethon Connection 模式(与 Telegram Desktop 使用的传输类似可选用 obfuscated)。""" + +from __future__ import annotations + +from typing import Type + +from telethon.network.connection import ( + Connection, + ConnectionTcpAbridged, + ConnectionTcpFull, + ConnectionTcpIntermediate, + ConnectionTcpObfuscated, +) + + +def resolve_connection_class(mode: str) -> Type[Connection]: + m = (mode or "").strip().lower().replace("-", "_") + if not m or m == "tcp_full": + return ConnectionTcpFull + if m == "tcp_obfuscated": + return ConnectionTcpObfuscated + if m == "tcp_intermediate": + return ConnectionTcpIntermediate + if m == "tcp_abridged": + return ConnectionTcpAbridged + raise ValueError( + f"不支持的 TELEGRAM_CONNECTION={mode!r},可用: " + "tcp_full, tcp_obfuscated, tcp_intermediate, tcp_abridged" + ) diff --git a/tg_bridge/http_logging.py b/tg_bridge/http_logging.py new file mode 100644 index 0000000..5150b6c --- /dev/null +++ b/tg_bridge/http_logging.py @@ -0,0 +1,67 @@ +"""HTTP 访问日志与转发内容摘要(可配置是否打出正文预览)。""" + +from __future__ import annotations + +import logging +import os +import time +from collections.abc import Callable +from typing import Any + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +access_logger = logging.getLogger("tg_bridge.access") + + +def log_preview_max_chars() -> int: + """环境变量 BRIDGE_LOG_PREVIEW_CHARS:>0 时在业务日志中附带截断后的正文/回复预览;未设置或 0 则只打长度。""" + raw = os.environ.get("BRIDGE_LOG_PREVIEW_CHARS", "").strip() + if not raw: + return 0 + try: + return max(0, int(raw)) + except ValueError: + return 0 + + +def preview_for_log(text: str | None, max_chars: int) -> str: + if not text or max_chars <= 0: + return "" + s = text.replace("\r", "").replace("\n", "\\n") + if len(s) <= max_chars: + return s + return s[:max_chars] + "..." + + +class AccessLogMiddleware(BaseHTTPMiddleware): + """记录每条 HTTP 请求的方法、路径、状态码与耗时(不含鉴权头与 Body)。""" + + async def dispatch(self, request: Request, call_next: Callable[[Request], Any]) -> Response: + start = time.perf_counter() + client = request.client.host if request.client else "-" + path = request.url.path + method = request.method + try: + response = await call_next(request) + except Exception: + elapsed_ms = (time.perf_counter() - start) * 1000 + access_logger.exception( + '%s "%s %s" 未捕获异常 %.1fms', + client, + method, + path, + elapsed_ms, + ) + raise + elapsed_ms = (time.perf_counter() - start) * 1000 + access_logger.info( + '%s "%s %s" %s %.1fms', + client, + method, + path, + response.status_code, + elapsed_ms, + ) + return response diff --git a/tg_bridge/logging_setup.py b/tg_bridge/logging_setup.py new file mode 100644 index 0000000..ea8b833 --- /dev/null +++ b/tg_bridge/logging_setup.py @@ -0,0 +1,66 @@ +"""tg_bridge 日志:控制台 + 按日切分的本地文件(午夜滚动,历史文件带日期后缀)。""" + +from __future__ import annotations + +import logging +import logging.handlers +import os +from pathlib import Path + +from dotenv import load_dotenv + +_ROOT = Path(__file__).resolve().parent.parent +load_dotenv(_ROOT / ".env") +load_dotenv() + + +def _parse_log_level(name: str) -> int: + return getattr(logging, name.upper(), logging.INFO) + + +def setup_logging() -> None: + """为 logger ``tg_bridge`` 及其子 logger 配置 StreamHandler + TimedRotatingFileHandler。 + + 当前日志文件:``<目录>/tg_bridge.log``;每日午夜滚动后重命名为 ``tg_bridge.log.YYYY-MM-DD``。 + 重复调用不会重复添加 handler。 + """ + root_tg = logging.getLogger("tg_bridge") + if root_tg.handlers: + return + + log_dir_raw = os.environ.get("BRIDGE_LOG_DIR", "").strip() + log_dir = Path(log_dir_raw).expanduser().resolve() if log_dir_raw else (_ROOT / "logs").resolve() + log_dir.mkdir(parents=True, exist_ok=True) + + backup_raw = os.environ.get("BRIDGE_LOG_BACKUP_COUNT", "30").strip() + try: + backup_count = max(1, int(backup_raw)) + except ValueError: + backup_count = 30 + + level = _parse_log_level(os.environ.get("BRIDGE_LOG_LEVEL", "INFO")) + root_tg.setLevel(level) + + fmt = logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + file_path = log_dir / "tg_bridge.log" + fh = logging.handlers.TimedRotatingFileHandler( + filename=str(file_path), + when="midnight", + interval=1, + backupCount=backup_count, + encoding="utf-8", + delay=True, + ) + fh.suffix = "%Y-%m-%d" + fh.setFormatter(fmt) + + sh = logging.StreamHandler() + sh.setFormatter(fmt) + + root_tg.addHandler(fh) + root_tg.addHandler(sh) + root_tg.propagate = False diff --git a/tg_bridge/login_cli.py b/tg_bridge/login_cli.py new file mode 100644 index 0000000..60141ce --- /dev/null +++ b/tg_bridge/login_cli.py @@ -0,0 +1,49 @@ +"""首次登录 Telegram:生成 session 文件,供 tg_bridge 服务使用。 + +用法(在 wx_python 目录下): + python -m tg_bridge.login_cli + +需已配置 TELEGRAM_API_ID、TELEGRAM_API_HASH、TELEGRAM_SESSION_PATH(可选),见 tg_bridge/.env.example +""" + +from __future__ import annotations + +import asyncio + +from tg_bridge.client_factory import create_telegram_client +from tg_bridge.config import Settings +from tg_bridge.proxy import telethon_proxy_from_settings +from tg_bridge.winloop import apply_windows_selector_policy + + +async def main() -> None: + s = Settings.load() + try: + proxy = telethon_proxy_from_settings(s) + except ValueError as e: + print(e) + return + if proxy: + safe = {**proxy} + if safe.get("password"): + safe["password"] = "***" + print( + f"使用代理: {safe!r} connection={s.connection_mode} " + f"connect_timeout={s.connect_timeout}s" + ) + else: + print(f"未配置代理(直连) connection={s.connection_mode}") + client = create_telegram_client(s) + await client.start() + if not await client.is_user_authorized(): + print("登录未完成") + return + me = await client.get_me() + print(f"已保存会话: {s.session_path}") + print(f"当前账号: id={me.id} username={me.username!r}") + await client.disconnect() + + +if __name__ == "__main__": + apply_windows_selector_policy() + asyncio.run(main()) diff --git a/tg_bridge/proxy.py b/tg_bridge/proxy.py new file mode 100644 index 0000000..a690450 --- /dev/null +++ b/tg_bridge/proxy.py @@ -0,0 +1,39 @@ +"""Telethon 代理配置(传给 TelegramClient 的 proxy 参数)。""" + +from __future__ import annotations + +from typing import Any + +from tg_bridge.config import Settings + + +def telethon_proxy_from_settings(s: Settings) -> dict[str, Any] | None: + """返回 Telethon ``TelegramClient(..., proxy=...)`` 所用的 dict。 + + 键名与 Telethon ``Connection._parse_proxy`` 一致: + ``proxy_type, addr, port, rdns, username, password``(后两项可选)。 + 使用 dict 可避免 PySocks 整数类型与 ``(host, port, rdns)`` 元组长度的歧义。 + + 参考:``telethon/network/connection/connection.py`` 中 ``_proxy_connect``。 + """ + if not s.proxy_type or not s.proxy_host or not s.proxy_port: + return None + + t = s.proxy_type.lower().strip() + if t == "https": + t = "http" + if t not in ("http", "socks5", "socks4"): + raise ValueError( + f"不支持的 TELEGRAM_PROXY_TYPE={s.proxy_type!r},可用: http, socks5, socks4" + ) + + d: dict[str, Any] = { + "proxy_type": t, + "addr": s.proxy_host, + "port": s.proxy_port, + "rdns": s.proxy_rdns, + } + if s.proxy_user is not None: + d["username"] = s.proxy_user + d["password"] = s.proxy_password if s.proxy_password is not None else "" + return d diff --git a/tg_bridge/uvicorn_loop.py b/tg_bridge/uvicorn_loop.py new file mode 100644 index 0000000..a72cf41 --- /dev/null +++ b/tg_bridge/uvicorn_loop.py @@ -0,0 +1,17 @@ +"""Uvicorn 在 Windows 上默认选用 ``ProactorEventLoop``(见 ``uvicorn/loops/asyncio.py``), +与 Telethon 经 SOCKS 连接 Telegram DC 时常触发 ``WinError 121``;login_cli 使用 Selector 故正常。 + +本模块提供给 Uvicorn 的 ``loop_factory``,强制使用 ``SelectorEventLoop``。 + +命令行示例:: + + uvicorn tg_bridge.app:app --host 0.0.0.0 --port 18080 --loop tg_bridge.uvicorn_loop:selector_loop_factory +""" + +from __future__ import annotations + +import asyncio + + +def selector_loop_factory() -> asyncio.AbstractEventLoop: + return asyncio.SelectorEventLoop() diff --git a/tg_bridge/winloop.py b/tg_bridge/winloop.py new file mode 100644 index 0000000..4440dd4 --- /dev/null +++ b/tg_bridge/winloop.py @@ -0,0 +1,15 @@ +"""Windows:经 SOCKS 连接 Telegram 时,Proactor 事件循环易触发 WinError 121(信号灯超时)。 + +在启动任何 asyncio 代码之前调用 ``apply_windows_selector_policy()``。 +参考:asyncio 在 Windows 上默认 ProactorEventLoop 与部分 socket/代理场景不兼容。 +""" + +from __future__ import annotations + +import asyncio +import sys + + +def apply_windows_selector_policy() -> None: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())