初始化
This commit is contained in:
31
demo.py
Normal file
31
demo.py
Normal file
@@ -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 测试完成!")
|
||||||
|
|
||||||
7
requirements-tg-bridge.txt
Normal file
7
requirements-tg-bridge.txt
Normal file
@@ -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
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
wxauto>=3.9.8.15
|
||||||
|
|
||||||
5
run_bridge.bat
Normal file
5
run_bridge.bat
Normal file
@@ -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
|
||||||
5
run_login.bat
Normal file
5
run_login.bat
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
REM 必须在「wx_python」根目录运行(与 tg_bridge 文件夹同级)
|
||||||
|
cd /d "%~dp0"
|
||||||
|
python -m tg_bridge.login_cli
|
||||||
|
if errorlevel 1 pause
|
||||||
48
tg_bridge/.env.example
Normal file
48
tg_bridge/.env.example
Normal file
@@ -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
|
||||||
3
tg_bridge/__init__.py
Normal file
3
tg_bridge/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Telegram 转发桥:HTTP 入站,由个人号向指定 Bot 发送消息。"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
25
tg_bridge/__main__.py
Normal file
25
tg_bridge/__main__.py
Normal file
@@ -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()
|
||||||
BIN
tg_bridge/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
tg_bridge/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tg_bridge/__pycache__/app.cpython-312.pyc
Normal file
BIN
tg_bridge/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tg_bridge/__pycache__/client_factory.cpython-312.pyc
Normal file
BIN
tg_bridge/__pycache__/client_factory.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tg_bridge/__pycache__/config.cpython-312.pyc
Normal file
BIN
tg_bridge/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tg_bridge/__pycache__/connection_mode.cpython-312.pyc
Normal file
BIN
tg_bridge/__pycache__/connection_mode.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tg_bridge/__pycache__/http_logging.cpython-312.pyc
Normal file
BIN
tg_bridge/__pycache__/http_logging.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tg_bridge/__pycache__/logging_setup.cpython-312.pyc
Normal file
BIN
tg_bridge/__pycache__/logging_setup.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tg_bridge/__pycache__/proxy.cpython-312.pyc
Normal file
BIN
tg_bridge/__pycache__/proxy.cpython-312.pyc
Normal file
Binary file not shown.
256
tg_bridge/app.py
Normal file
256
tg_bridge/app.py
Normal file
@@ -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 <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
|
||||||
23
tg_bridge/client_factory.py
Normal file
23
tg_bridge/client_factory.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
153
tg_bridge/config.py
Normal file
153
tg_bridge/config.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
29
tg_bridge/connection_mode.py
Normal file
29
tg_bridge/connection_mode.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
67
tg_bridge/http_logging.py
Normal file
67
tg_bridge/http_logging.py
Normal file
@@ -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
|
||||||
66
tg_bridge/logging_setup.py
Normal file
66
tg_bridge/logging_setup.py
Normal file
@@ -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
|
||||||
49
tg_bridge/login_cli.py
Normal file
49
tg_bridge/login_cli.py
Normal file
@@ -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())
|
||||||
39
tg_bridge/proxy.py
Normal file
39
tg_bridge/proxy.py
Normal file
@@ -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
|
||||||
17
tg_bridge/uvicorn_loop.py
Normal file
17
tg_bridge/uvicorn_loop.py
Normal file
@@ -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()
|
||||||
15
tg_bridge/winloop.py
Normal file
15
tg_bridge/winloop.py
Normal file
@@ -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())
|
||||||
Reference in New Issue
Block a user