Compare commits
2 Commits
30a57d993c
...
66362780a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66362780a0 | ||
|
|
bdba4ec071 |
5
.env
5
.env
@@ -7,3 +7,8 @@ APIKEY=sk-85880595fc714d63bfd0b025e917bd26#千问apikey
|
||||
|
||||
# 消息回调(ngrok 调通用,由 run-ngrok.sh 自动写入)
|
||||
CALLBACK_BASE_URL=https://dissonant-destinee-nonsensibly.ngrok-free.dev
|
||||
|
||||
# 固定隧道代理(socks5h):不填登录页代理时后端自动用此处,传给 7006
|
||||
TUNNEL_PROXY=218.78.109.253:16816
|
||||
TUNNEL_PROXY_USERNAME=fawbjjkk
|
||||
TUNNEL_PROXY_PASSWORD=5hz6avfb
|
||||
18
.env.example
18
.env.example
@@ -12,6 +12,24 @@ SLIDER_VERIFY_KEY=408449830
|
||||
# 优先 WECHAT_WS_KEY,未设置则使用 KEY(登录参数填的 key)
|
||||
# WECHAT_WS_KEY=HBpEnbtj9BJZ
|
||||
|
||||
# 登录/唤醒时代理(可选):会传给 7006 使用。优先级:登录页填写 > HTTP_PROXY/HTTPS_PROXY > 固定隧道 > KDL API
|
||||
|
||||
# 固定隧道代理(推荐):socks5h + 用户名密码,与 requests 示例一致
|
||||
# 不填登录页代理时,后端自动使用此处配置
|
||||
TUNNEL_PROXY=218.78.109.253:16816
|
||||
TUNNEL_PROXY_USERNAME=fawbjjkk
|
||||
TUNNEL_PROXY_PASSWORD=5hz6avfb
|
||||
|
||||
# 或使用环境变量代理(如 cloudflared 暴露的地址):
|
||||
# ./scripts/expose-proxy-with-cloudflared.sh # 会写 HTTP_PROXY/HTTPS_PROXY
|
||||
# HTTP_PROXY=https://xxx.trycloudflare.com
|
||||
# HTTPS_PROXY=https://xxx.trycloudflare.com
|
||||
|
||||
# 快代理 KDL(可选):未配置隧道/env 时从 API 拉取代理
|
||||
# KDL_PROXY_API_URL=https://kps.kdlapi.com/api/getkps/?secret_id=xxx&signature=xxx&num=1&sep=1
|
||||
# KDL_PROXY_USERNAME=fawbjjkk
|
||||
# KDL_PROXY_PASSWORD=5hz6avfb
|
||||
|
||||
# 消息实时回调(主入口):设置后向 7006 注册 SetCallback,新消息由 7006 POST 到本服务,不再走 WS
|
||||
# 需为 7006 能访问到的公网地址,例如 https://your-domain.com
|
||||
# CALLBACK_BASE_URL=https://your-domain.com
|
||||
|
||||
27
.env.pro
Normal file
27
.env.pro
Normal file
@@ -0,0 +1,27 @@
|
||||
# 前端端口(容器内部固定 3000,这里只是给 Node 用)
|
||||
PORT=3000
|
||||
|
||||
# 后端(本服务)监听端口(容器内部固定 8000)
|
||||
BACKEND_PORT=8000
|
||||
|
||||
# 微信 7006 上游地址(注意和实际部署的 7006 地址保持一致)
|
||||
WECHAT_UPSTREAM_BASE_URL=http://113.44.162.180:7006
|
||||
CHECK_STATUS_BASE_URL=http://113.44.162.180:7006
|
||||
|
||||
# 第三方滑块服务(如果生产环境也要开登录页,则保留并按实际地址修改)
|
||||
SLIDER_VERIFY_BASE_URL=http://113.44.162.180:7765
|
||||
SLIDER_VERIFY_KEY=408449830
|
||||
|
||||
# 消息实时回调(生产用):必须是 7006 能访问到的生产域名
|
||||
# 举例:你的服务对外暴露为 https://wechat-bot.example.com
|
||||
CALLBACK_BASE_URL=http://demo.bimwe.com
|
||||
|
||||
# 千问 / 其他大模型 API Key(生产环境用真实 key)
|
||||
# 建议只保留你实际使用的一种,并确保 key 不泄露
|
||||
QWEN_API_KEY=sk-85880595fc714d63bfd0b025e917bd26
|
||||
|
||||
# 发送消息上游路径(通常保持默认即可,除非你在 7006 侧改了)
|
||||
# SEND_MSG_PATH=/message/SendTextMessage
|
||||
|
||||
# 日志目录(挂载到宿主机的 /app/backend/data/logs,保持默认即可)
|
||||
# LOG_DIR=./backend/data/logs
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -89,6 +89,17 @@ def init_schema(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
""")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_sync_messages_key ON sync_messages(key)")
|
||||
# 回调原始 body 落库,便于回溯与统计
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS callback_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
raw_body TEXT
|
||||
)
|
||||
""")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_callback_log_key ON callback_log(key)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_callback_log_received ON callback_log(received_at)")
|
||||
# 模型配置
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
|
||||
516
backend/main.py
516
backend/main.py
@@ -3,6 +3,14 @@ import html
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# 优先加载项目根目录的 .env(不依赖当前工作目录),使 HTTP_PROXY/HTTPS_PROXY 等生效
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
load_dotenv(os.path.join(_root, ".env"))
|
||||
except ImportError:
|
||||
pass
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -29,7 +37,8 @@ CHECK_STATUS_BASE_URL = os.getenv("CHECK_STATUS_BASE_URL", "http://113.44.162.18
|
||||
# 消息实时回调:设置后 7006 将新消息 POST 到该地址,作为主接收入口(与 SetCallback 一致)
|
||||
CALLBACK_BASE_URL = (os.getenv("CALLBACK_BASE_URL") or "").strip().rstrip("/")
|
||||
SLIDER_VERIFY_BASE_URL = os.getenv("SLIDER_VERIFY_BASE_URL", "http://113.44.162.180:7765").rstrip("/")
|
||||
SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", os.getenv("KEY", "408449830"))
|
||||
# 滑块服务 7765 的 key,与账号 key 无关,默认使用提供方 QQ(使用其公共服务时必填)
|
||||
SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", "408449830")
|
||||
# 发送文本消息:swagger 中为 POST /message/SendTextMessage,body 为 SendMessageModel(MsgItem 数组)
|
||||
SEND_MSG_PATH = (os.getenv("SEND_MSG_PATH") or "/message/SendTextMessage").strip()
|
||||
# 发送图片消息:部分上游为独立接口,或与文本同 path 仅 MsgType 不同(如 3=图片)
|
||||
@@ -228,7 +237,7 @@ async def _register_message_callback(key: str) -> bool:
|
||||
callback_url = f"{CALLBACK_BASE_URL.rstrip('/')}/api/callback/wechat-message"
|
||||
body = {"CallbackURL": callback_url, "Enabled": True}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=10.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=body)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("SetCallback %s key=%s: %s %s", url, key[-4:] if len(key) >= 4 else "****", resp.status_code, resp.text[:200])
|
||||
@@ -340,6 +349,16 @@ class QrCodeRequest(BaseModel):
|
||||
Proxy: Optional[str] = ""
|
||||
IpadOrmac: Optional[str] = ""
|
||||
Check: Optional[bool] = False
|
||||
"""仅当需滑块且为「无数字」时传 True,重新取码用 Mac 设备;其余一律 iPad,传 Mac 易封号。"""
|
||||
force_mac: Optional[bool] = False
|
||||
|
||||
|
||||
class WakeUpRequest(BaseModel):
|
||||
"""唤醒登录(只限扫码登录),仅调用 7006 WakeUpLogin,不拉取二维码。"""
|
||||
key: str
|
||||
Check: Optional[bool] = False
|
||||
IpadOrmac: Optional[str] = "ipad"
|
||||
Proxy: Optional[str] = ""
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -362,6 +381,84 @@ async def api_ws_status() -> dict:
|
||||
return {"connected": is_ws_connected()}
|
||||
|
||||
|
||||
# 代理检测:用当前代理访问测试 URL,验证是否可用
|
||||
PROXY_CHECK_URL = os.getenv("PROXY_CHECK_URL", "https://httpbin.org/ip")
|
||||
|
||||
|
||||
@app.get("/api/check-proxy")
|
||||
async def api_check_proxy(proxy: Optional[str] = Query(None, description="可选,指定要检测的代理 URL(不传则用环境变量/隧道/KDL)")):
|
||||
"""检测代理是否可用:用解析到的代理请求测试页,返回是否成功及来源。"""
|
||||
proxy_url = (proxy or "").strip()
|
||||
source = "query"
|
||||
if not proxy_url:
|
||||
proxy_url = _resolve_proxy("", allow_auto=True)
|
||||
if proxy_url == "__tunnel__":
|
||||
proxy_url = _proxy_from_tunnel()
|
||||
source = "tunnel"
|
||||
logger.info("check-proxy: using tunnel -> %s", "socks5h://***@%s/" % TUNNEL_PROXY)
|
||||
elif proxy_url == "__kdl__":
|
||||
proxy_url = await _proxy_from_kdl()
|
||||
source = "kdl"
|
||||
logger.info("check-proxy: using kdl -> %s", "http://***@%s/" % (proxy_url.split("@", 1)[-1].rstrip("/") if proxy_url else "?"))
|
||||
else:
|
||||
source = "env" if proxy_url else "none"
|
||||
if proxy_url:
|
||||
logger.info("check-proxy: using env/body, len=%s", len(proxy_url))
|
||||
if not proxy_url:
|
||||
return {
|
||||
"ok": False,
|
||||
"source": "none",
|
||||
"error": "未配置代理。请填写代理、或设置 HTTP_PROXY/HTTPS_PROXY、或配置 TUNNEL_PROXY(固定隧道)、或 KDL 代理 API。",
|
||||
}
|
||||
# 脱敏显示(不暴露密码)
|
||||
def _preview(u: str) -> str:
|
||||
if not u or "@" not in u:
|
||||
return u[:50] + "…" if len(u) > 50 else u
|
||||
pre, at = u.rsplit("@", 1)
|
||||
if "://" in pre:
|
||||
scheme = pre.split("://", 1)[0] + "://"
|
||||
rest = pre[len(scheme) :]
|
||||
if ":" in rest:
|
||||
user, _ = rest.split(":", 1)
|
||||
pre = scheme + user + ":***"
|
||||
else:
|
||||
pre = scheme + "***"
|
||||
else:
|
||||
pre = "***"
|
||||
return pre + "@" + (at[:30] + "…" if len(at) > 30 else at)
|
||||
|
||||
preview = _preview(proxy_url)
|
||||
logger.info("check-proxy: source=%s, proxy_preview=%s", source, preview)
|
||||
try:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0, proxy=proxy_url) as client:
|
||||
resp = await client.get(PROXY_CHECK_URL)
|
||||
if resp.status_code == 200:
|
||||
logger.info("check-proxy: ok, status=%s", resp.status_code)
|
||||
return {
|
||||
"ok": True,
|
||||
"source": source,
|
||||
"proxy_preview": preview,
|
||||
"check_url": PROXY_CHECK_URL,
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
logger.warning("check-proxy: fail, status=%s", resp.status_code)
|
||||
return {
|
||||
"ok": False,
|
||||
"source": source,
|
||||
"proxy_preview": preview,
|
||||
"error": f"请求测试页返回 {resp.status_code}",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("check-proxy: exception %s", e)
|
||||
return {
|
||||
"ok": False,
|
||||
"source": source,
|
||||
"proxy_preview": preview,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
def _proxy_from_env() -> str:
|
||||
"""当登录页未填代理时,使用环境变量中的代理(服务器上设置 HTTP_PROXY/HTTPS_PROXY 后生效)。"""
|
||||
return (
|
||||
@@ -371,23 +468,228 @@ def _proxy_from_env() -> str:
|
||||
)
|
||||
|
||||
|
||||
# 固定隧道代理(socks5h + 用户名密码):未配置登录页/环境变量时使用
|
||||
# 格式与 requests 示例一致:socks5h://user:pwd@host:port/
|
||||
TUNNEL_PROXY = (os.getenv("TUNNEL_PROXY") or "").strip() # 例如 218.78.109.253:16816
|
||||
TUNNEL_PROXY_USERNAME = (os.getenv("TUNNEL_PROXY_USERNAME") or "").strip()
|
||||
TUNNEL_PROXY_PASSWORD = (os.getenv("TUNNEL_PROXY_PASSWORD") or "").strip()
|
||||
|
||||
# 快代理 KDL API(可选):隧道未配置时从此接口拉取代理
|
||||
KDL_PROXY_API_URL = (os.getenv("KDL_PROXY_API_URL") or "").strip()
|
||||
KDL_PROXY_USERNAME = (os.getenv("KDL_PROXY_USERNAME") or "").strip()
|
||||
KDL_PROXY_PASSWORD = (os.getenv("KDL_PROXY_PASSWORD") or "").strip()
|
||||
|
||||
# 启动时打印代理配置情况(不打印密码)
|
||||
def _log_proxy_config() -> None:
|
||||
tunnel_ok = bool(TUNNEL_PROXY and TUNNEL_PROXY_USERNAME and TUNNEL_PROXY_PASSWORD)
|
||||
kdl_ok = bool(KDL_PROXY_API_URL and KDL_PROXY_USERNAME and KDL_PROXY_PASSWORD)
|
||||
logger.info(
|
||||
"proxy config: tunnel=%s (TUNNEL_PROXY=%s), kdl=%s (KDL_API=%s)",
|
||||
tunnel_ok,
|
||||
TUNNEL_PROXY or "(empty)",
|
||||
kdl_ok,
|
||||
"set" if KDL_PROXY_API_URL else "(empty)",
|
||||
)
|
||||
|
||||
|
||||
_log_proxy_config()
|
||||
|
||||
|
||||
def _proxy_from_tunnel() -> str:
|
||||
"""使用固定隧道代理,格式 socks5h://user:pwd@host:port/,供 7006 使用。"""
|
||||
if not TUNNEL_PROXY:
|
||||
return ""
|
||||
user = (TUNNEL_PROXY_USERNAME or KDL_PROXY_USERNAME or "").strip()
|
||||
pwd = (TUNNEL_PROXY_PASSWORD or KDL_PROXY_PASSWORD or "").strip()
|
||||
if not user or not pwd:
|
||||
return ""
|
||||
return "socks5h://%(user)s:%(pwd)s@%(proxy)s/" % {
|
||||
"user": user,
|
||||
"pwd": pwd,
|
||||
"proxy": TUNNEL_PROXY,
|
||||
}
|
||||
|
||||
|
||||
async def _proxy_from_kdl() -> str:
|
||||
"""从快代理 API 获取一个代理 IP,格式化为 http://user:pwd@ip:port/ 供 7006 使用。"""
|
||||
if not KDL_PROXY_API_URL or not KDL_PROXY_USERNAME or not KDL_PROXY_PASSWORD:
|
||||
return ""
|
||||
try:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=10.0) as client:
|
||||
resp = await client.get(KDL_PROXY_API_URL)
|
||||
resp.raise_for_status()
|
||||
proxy_ip = (resp.text or "").strip()
|
||||
if not proxy_ip:
|
||||
return ""
|
||||
return "http://%(user)s:%(pwd)s@%(proxy)s/" % {
|
||||
"user": KDL_PROXY_USERNAME,
|
||||
"pwd": KDL_PROXY_PASSWORD,
|
||||
"proxy": proxy_ip,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("KDL proxy fetch failed: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
# 隧道代理若以 http 形式传入(登录页或 KDL 返回),统一改为 socks5h 再传给 7006
|
||||
TUNNEL_PROXY_NORMALIZE_HOST = (os.getenv("TUNNEL_PROXY_NORMALIZE_HOST") or "218.78.109.253:16816").strip()
|
||||
|
||||
|
||||
def _proxy_preview_for_log(proxy: str) -> str:
|
||||
"""代理脱敏,用于日志打印(不暴露密码)。"""
|
||||
if not proxy or not isinstance(proxy, str):
|
||||
return "(empty)"
|
||||
u = proxy.strip()
|
||||
if not u:
|
||||
return "(empty)"
|
||||
if "@" not in u:
|
||||
return u[:50] + "…" if len(u) > 50 else u
|
||||
pre, at = u.rsplit("@", 1)
|
||||
at = at.rstrip("/").split("/")[0].split("?")[0]
|
||||
if "://" in pre:
|
||||
scheme = pre.split("://", 1)[0] + "://"
|
||||
rest = pre[len(scheme):]
|
||||
user = rest.split(":", 1)[0] if ":" in rest else "***"
|
||||
pre = scheme + user + ":***"
|
||||
else:
|
||||
pre = "***"
|
||||
return pre + "@" + (at[:40] + "…" if len(at) > 40 else at) + "/"
|
||||
|
||||
|
||||
def _normalize_proxy_scheme_to_socks5h(proxy: str) -> str:
|
||||
"""若代理是隧道地址但用了 http,改为 socks5h(7006 需 socks5)。"""
|
||||
if not proxy or not isinstance(proxy, str):
|
||||
return proxy
|
||||
p = proxy.strip()
|
||||
if not p.startswith("http://") or "@" not in p:
|
||||
return p
|
||||
try:
|
||||
host_part = p.split("@", 1)[1].rstrip("/").split("/")[0].split("?")[0]
|
||||
except IndexError:
|
||||
return p
|
||||
if host_part != TUNNEL_PROXY and host_part != "218.78.109.253:16816":
|
||||
return p
|
||||
out = "socks5h://" + p[7:]
|
||||
logger.info("proxy normalize: http -> socks5h for tunnel %s", host_part)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_proxy(body_proxy: str, *, allow_auto: bool = True) -> str:
|
||||
"""解析最终传给 7006 的代理:请求体 > 环境变量 > 固定隧道 >(可选)KDL API。"""
|
||||
p = (body_proxy or "").strip()
|
||||
if p:
|
||||
logger.debug("proxy resolve: from body, len=%s", len(p))
|
||||
return p
|
||||
p = _proxy_from_env()
|
||||
if p:
|
||||
logger.debug("proxy resolve: from env (HTTP_PROXY/HTTPS_PROXY), len=%s", len(p))
|
||||
return p
|
||||
if not allow_auto:
|
||||
return ""
|
||||
# 隧道:只要 TUNNEL_PROXY 有值且能凑齐账号密码(含用 KDL_* 兜底)则优先隧道
|
||||
tunnel_user = (TUNNEL_PROXY_USERNAME or KDL_PROXY_USERNAME or "").strip()
|
||||
tunnel_pwd = (TUNNEL_PROXY_PASSWORD or KDL_PROXY_PASSWORD or "").strip()
|
||||
if TUNNEL_PROXY and tunnel_user and tunnel_pwd:
|
||||
logger.info("proxy resolve: auto -> tunnel (socks5h), TUNNEL_PROXY=%s", TUNNEL_PROXY)
|
||||
return "__tunnel__"
|
||||
if KDL_PROXY_API_URL and KDL_PROXY_USERNAME and KDL_PROXY_PASSWORD:
|
||||
logger.info("proxy resolve: auto -> kdl (fetch from API)")
|
||||
return "__kdl__"
|
||||
logger.debug("proxy resolve: no auto proxy configured")
|
||||
return ""
|
||||
|
||||
|
||||
@app.post("/auth/wake")
|
||||
async def wake_up_login(body: WakeUpRequest):
|
||||
"""唤醒登录:仅调用上游 /login/WakeUpLogin(只限扫码登录),不获取二维码。"""
|
||||
key = (body.key or "").strip()
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="key is required")
|
||||
proxy = _resolve_proxy(body.Proxy or "", allow_auto=True)
|
||||
if proxy == "__tunnel__":
|
||||
proxy = _proxy_from_tunnel()
|
||||
if proxy:
|
||||
logger.info("WakeUpLogin: using proxy from tunnel (socks5h), len=%s", len(proxy))
|
||||
elif proxy == "__kdl__":
|
||||
proxy = await _proxy_from_kdl()
|
||||
if proxy:
|
||||
logger.info("WakeUpLogin: using proxy from KDL API, len=%s", len(proxy))
|
||||
if not proxy:
|
||||
logger.info("WakeUpLogin: Proxy 为空,请在 .env 中配置 TUNNEL_PROXY 或 HTTP_PROXY/HTTPS_PROXY 或 KDL,或登录页填写代理")
|
||||
elif proxy not in ("__tunnel__", "__kdl__"):
|
||||
logger.info("WakeUpLogin: using proxy from body/env, len=%s", len(proxy))
|
||||
if proxy in ("__tunnel__", "__kdl__"):
|
||||
proxy = ""
|
||||
proxy = _normalize_proxy_scheme_to_socks5h(proxy)
|
||||
ipad_ormac = (body.IpadOrmac or "").strip() or "ipad"
|
||||
payload = {
|
||||
"Check": body.Check,
|
||||
"IpadOrmac": ipad_ormac,
|
||||
"Proxy": proxy,
|
||||
}
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}/login/WakeUpLogin"
|
||||
logger.info(
|
||||
"WakeUpLogin 请求参数: key=%s, url=%s, Proxy=%s, Check=%s, IpadOrmac=%s",
|
||||
key, url, proxy, body.Check, ipad_ormac,
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=20.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream WakeUpLogin: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"error": "upstream_connect_error", "detail": str(exc)},
|
||||
) from exc
|
||||
if resp.status_code >= 400:
|
||||
body_preview = resp.text[:500]
|
||||
logger.warning("WakeUpLogin bad response: status=%s, body=%s", resp.status_code, body_preview)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"error": "upstream_bad_response", "status_code": resp.status_code, "body": body_preview},
|
||||
)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"ok": True, "text": resp.text[:200]}
|
||||
logger.info("WakeUpLogin success: status=%s", resp.status_code)
|
||||
return data
|
||||
|
||||
|
||||
@app.post("/auth/qrcode")
|
||||
async def get_login_qrcode(body: QrCodeRequest):
|
||||
key = body.key
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="key is required")
|
||||
|
||||
payload = body.dict(exclude={"key"})
|
||||
if not (payload.get("Proxy") or "").strip():
|
||||
env_proxy = _proxy_from_env()
|
||||
if env_proxy:
|
||||
payload["Proxy"] = env_proxy
|
||||
logger.info("GetLoginQrCodeNewDirect: using proxy from env (HTTP_PROXY/HTTPS_PROXY), len=%s", len(env_proxy))
|
||||
proxy = _resolve_proxy(body.Proxy or "", allow_auto=True)
|
||||
if proxy == "__tunnel__":
|
||||
proxy = _proxy_from_tunnel()
|
||||
if proxy:
|
||||
logger.info("GetLoginQrCodeNewDirect: using proxy from tunnel (socks5h), len=%s", len(proxy))
|
||||
elif proxy == "__kdl__":
|
||||
proxy = await _proxy_from_kdl()
|
||||
if proxy:
|
||||
logger.info("GetLoginQrCodeNewDirect: using proxy from KDL API, len=%s", len(proxy))
|
||||
if proxy in ("__tunnel__", "__kdl__"):
|
||||
proxy = ""
|
||||
proxy = _normalize_proxy_scheme_to_socks5h(proxy)
|
||||
if proxy:
|
||||
logger.info("GetLoginQrCodeNewDirect: proxy=yes, force_mac=%s, IpadOrmac=%s", body.force_mac, "mac" if body.force_mac else (body.IpadOrmac or "ipad"))
|
||||
else:
|
||||
logger.info("GetLoginQrCodeNewDirect: proxy=empty(未配置则后端自动读 env/KDL),force_mac=%s", body.force_mac)
|
||||
payload = body.dict(exclude={"key", "force_mac"})
|
||||
payload["Check"] = False
|
||||
payload["IpadOrmac"] = "mac" if body.force_mac else ((body.IpadOrmac or "").strip() or "ipad")
|
||||
payload["Proxy"] = proxy
|
||||
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/GetLoginQrCodeNewDirect"
|
||||
logger.info("GetLoginQrCodeNewDirect: key=%s, payload=%s, url=%s", key, payload, url)
|
||||
logger.info(
|
||||
"GetLoginQrCodeNewDirect 请求参数: key=%s, url=%s, Proxy=%s, Check=%s, IpadOrmac=%s",
|
||||
key, url, proxy, False, payload["IpadOrmac"],
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=20.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream GetLoginQrCodeNewDirect: %s", exc)
|
||||
@@ -452,7 +754,7 @@ async def get_online_status(
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/GetLoginStatus"
|
||||
logger.info("GetLoginStatus: key=%s, url=%s", key, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.get(url, params={"key": key})
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream GetLoginStatus: %s", exc)
|
||||
@@ -545,7 +847,7 @@ async def check_scan_status(
|
||||
url = f"{CHECK_STATUS_BASE_URL}/login/CheckLoginStatus"
|
||||
logger.info("CheckLoginStatus: key=%s, url=%s", key, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.get(url, params={"key": key})
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream CheckLoginStatus: %s", exc)
|
||||
@@ -557,7 +859,12 @@ async def check_scan_status(
|
||||
resp.status_code,
|
||||
body_full[:2000] if len(body_full) > 2000 else body_full,
|
||||
)
|
||||
data = resp.json()
|
||||
try:
|
||||
data = resp.json() if body_full.strip() else {}
|
||||
except Exception:
|
||||
data = {"Code": resp.status_code, "Text": body_full[:500] or "Non-JSON response"}
|
||||
if not isinstance(data, dict):
|
||||
data = {"Code": resp.status_code, "Text": str(data)[:500]}
|
||||
ticket = _extract_clean_ticket(data)
|
||||
if ticket:
|
||||
# data62 使用完整原始数据,来自 GetLoginQrCodeNewDirect 的存储或本次响应的 Data62
|
||||
@@ -635,7 +942,7 @@ async def slider_asset_proxy(path: str):
|
||||
"""代理 7765 的 assets(如 N_jYM_2V.js),避免跨域加载被 CORS 拦截。"""
|
||||
url = f"{SLIDER_VERIFY_BASE_URL.rstrip('/')}/assets/{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code >= 400:
|
||||
raise HTTPException(status_code=resp.status_code, detail=resp.text[:200])
|
||||
@@ -678,7 +985,7 @@ async def api_slider_verify_get(
|
||||
url = SLIDER_VERIFY_BASE_URL.rstrip("/") + "/"
|
||||
params = {"key": key, "data62": (data62 or "").strip(), "original_ticket": ticket_val}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
|
||||
resp = await client.get(url, params=params)
|
||||
# 返回上游的 body;若为 JSON 则解析后返回
|
||||
try:
|
||||
@@ -706,7 +1013,7 @@ async def api_slider_verify_post(body: SliderVerifyBody):
|
||||
url = SLIDER_VERIFY_BASE_URL.rstrip("/") + "/"
|
||||
params = {"key": body.key, "data62": (body.data62 or "").strip(), "original_ticket": ticket_val}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
|
||||
resp = await client.get(url, params=params)
|
||||
try:
|
||||
return resp.json()
|
||||
@@ -966,7 +1273,7 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
# 打印回调原始内容,便于排查结构与字段(截断避免日志过大)
|
||||
# 打印回调原始内容,便于排查(截断避免日志过大)
|
||||
try:
|
||||
logger.info("callback/wechat-message raw body: %s", str(body)[:1000])
|
||||
except Exception:
|
||||
@@ -975,6 +1282,11 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
|
||||
if not k:
|
||||
logger.warning("callback/wechat-message: missing key in query and body")
|
||||
return JSONResponse(content={"ok": False, "error": "missing key"}, status_code=200)
|
||||
# 原始 body 落库,便于回溯与统计
|
||||
try:
|
||||
store.append_callback_log(k, body if isinstance(body, dict) else {"raw": str(body)})
|
||||
except Exception as le:
|
||||
logger.warning("callback_log append failed: %s", le)
|
||||
try:
|
||||
payload: Any = body
|
||||
# 7006 回调当前格式示例:{"key": "...", "message": {...}, "type": "message"}
|
||||
@@ -988,6 +1300,7 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
|
||||
if isinstance(inner, (dict, list)):
|
||||
payload = inner
|
||||
_on_ws_message(k, payload)
|
||||
logger.info("callback message saved to sync_messages, key=%s", k[:8] + "..." if len(k) > 8 else k)
|
||||
except Exception as e:
|
||||
logger.exception("callback/wechat-message key=%s: %s", k[-4:] if len(k) >= 4 else "****", e)
|
||||
return {"ok": True}
|
||||
@@ -997,7 +1310,7 @@ async def _send_message_upstream(key: str, to_user_name: str, content: str) -> d
|
||||
"""调用上游发送文本消息;成功时写入发出记录并返回响应,失败抛 HTTPException。"""
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_MSG_PATH}"
|
||||
payload = {"MsgItem": [{"ToUserName": to_user_name, "MsgType": 1, "TextContent": content}]}
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
if resp.status_code >= 400:
|
||||
body_preview = resp.text[:400] if resp.text else ""
|
||||
@@ -1026,7 +1339,7 @@ async def _send_batch_upstream(key: str, items: List[dict]) -> dict:
|
||||
if not msg_items:
|
||||
raise HTTPException(status_code=400, detail="items 中至少需要一条有效 to_user_name 与 content")
|
||||
payload = {"MsgItem": msg_items}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
if resp.status_code >= 400:
|
||||
body_preview = resp.text[:400] if resp.text else ""
|
||||
@@ -1056,7 +1369,7 @@ async def _send_image_upstream(key: str, to_user_name: str, image_content: str,
|
||||
"AtWxIDList": at_wxid_list or [],
|
||||
}
|
||||
payload = {"MsgItem": [item]}
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
if resp.status_code >= 400:
|
||||
body_preview = resp.text[:400] if resp.text else ""
|
||||
@@ -1128,6 +1441,16 @@ def _log_contact_list_response_structure(raw: dict) -> None:
|
||||
for k, v in list(data.items())[:5]:
|
||||
preview = str(v)[:80] if v is not None else "null"
|
||||
logger.info(" Data.%s: %s", k, preview)
|
||||
# 7006 常见为 Data.ContactList 对象,内挂 contactUsernameList 数组
|
||||
cl = data.get("ContactList") or data.get("contactList")
|
||||
if isinstance(cl, dict):
|
||||
cl_keys = list(cl.keys())
|
||||
logger.info(" Data.ContactList keys: %s", cl_keys)
|
||||
for uk in ("contactUsernameList", "ContactUsernameList", "UserNameList", "userNameList", "usernameList"):
|
||||
arr = cl.get(uk)
|
||||
if isinstance(arr, list):
|
||||
logger.info(" Data.ContactList.%s length=%s, sample=%s", uk, len(arr), arr[:3] if arr else [])
|
||||
break
|
||||
|
||||
|
||||
def _unwrap_wechat_field(v: Any) -> Any:
|
||||
@@ -1215,12 +1538,14 @@ def _normalize_contact_list(raw: Any) -> List[dict]:
|
||||
or data.get("wxcontactList")
|
||||
or data.get("CachedContactList")
|
||||
)
|
||||
# 7006 格式:ContactList 为对象,联系人 id 在 contactUsernameList 字符串数组里
|
||||
# 7006 格式:ContactList 为对象,联系人 id 在 contactUsernameList 等数组里
|
||||
if isinstance(contact_list, dict):
|
||||
username_list = (
|
||||
contact_list.get("contactUsernameList")
|
||||
or contact_list.get("ContactUsernameList")
|
||||
or contact_list.get("UserNameList")
|
||||
or contact_list.get("userNameList")
|
||||
or contact_list.get("usernameList")
|
||||
or []
|
||||
)
|
||||
if isinstance(username_list, list) and username_list:
|
||||
@@ -1241,23 +1566,21 @@ def _normalize_contact_list(raw: Any) -> List[dict]:
|
||||
continue
|
||||
if not isinstance(x, dict):
|
||||
continue
|
||||
wxid = (
|
||||
wxid = _unwrap_wechat_field(
|
||||
x.get("wxid")
|
||||
or x.get("Wxid")
|
||||
or x.get("UserName")
|
||||
or x.get("userName")
|
||||
or x.get("Alias")
|
||||
or ""
|
||||
)
|
||||
remark = (
|
||||
) or ""
|
||||
remark = _unwrap_wechat_field(
|
||||
x.get("remark_name")
|
||||
or x.get("RemarkName")
|
||||
or x.get("NickName")
|
||||
or x.get("nickName")
|
||||
or x.get("DisplayName")
|
||||
or wxid
|
||||
)
|
||||
result.append({"wxid": wxid, "remark_name": remark})
|
||||
) or wxid
|
||||
result.append({"wxid": str(wxid).strip(), "remark_name": str(remark).strip()})
|
||||
return result
|
||||
|
||||
|
||||
@@ -1272,7 +1595,7 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
|
||||
body: dict = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
|
||||
max_rounds = 50
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
|
||||
for round_num in range(max_rounds):
|
||||
resp = await client.post(url, params={"key": key}, json=body)
|
||||
if resp.status_code >= 400:
|
||||
@@ -1282,9 +1605,26 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
|
||||
chunk = _normalize_contact_list(raw)
|
||||
if not chunk and isinstance(raw, dict):
|
||||
chunk = _normalize_contact_list(raw.get("Data") or raw.get("data") or raw)
|
||||
if round_num == 0 and not chunk and isinstance(raw, dict):
|
||||
_log_contact_list_response_structure(raw)
|
||||
for item in chunk:
|
||||
# 首轮无归一化结果时,直接从 Data.ContactList 下任意已知数组键取 id 列表(7006 格式)
|
||||
if not chunk and round_num == 0 and isinstance(raw, dict):
|
||||
data = raw.get("Data") or raw.get("data") or {}
|
||||
if isinstance(data, dict):
|
||||
cl = data.get("ContactList") or data.get("contactList")
|
||||
if isinstance(cl, dict):
|
||||
ul = (
|
||||
cl.get("contactUsernameList")
|
||||
or cl.get("ContactUsernameList")
|
||||
or cl.get("UserNameList")
|
||||
or cl.get("userNameList")
|
||||
or cl.get("usernameList")
|
||||
or []
|
||||
)
|
||||
if isinstance(ul, list) and ul:
|
||||
chunk = [{"wxid": (x if isinstance(x, str) else str(x)), "remark_name": ""} for x in ul]
|
||||
logger.info("GetContactList fallback from Data.ContactList.* list, count=%s", len(chunk))
|
||||
if not chunk:
|
||||
_log_contact_list_response_structure(raw)
|
||||
for item in chunk or []:
|
||||
wxid = (item.get("wxid") or "").strip()
|
||||
if wxid and wxid not in seen:
|
||||
seen.add(wxid)
|
||||
@@ -1306,14 +1646,15 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
|
||||
return usernames
|
||||
|
||||
|
||||
async def _build_contact_index(key: str) -> Dict[str, dict]:
|
||||
async def _build_contact_index(key: str, force_refresh: bool = False) -> Dict[str, dict]:
|
||||
"""
|
||||
通用联系人索引:
|
||||
- 先通过 GetContactList 拿到全部 UserName 列表;
|
||||
- 再通过 /friend/GetContactDetailsList 批量拉取详情;
|
||||
- 构建 name(微信号/昵称/备注) -> 联系人详情 的索引。
|
||||
force_refresh=True 时跳过内存缓存,重新请求上游。
|
||||
"""
|
||||
if key in _contact_index and _contact_index[key]:
|
||||
if not force_refresh and key in _contact_index and _contact_index[key]:
|
||||
return _contact_index[key]
|
||||
|
||||
usernames = await _fetch_all_contact_usernames(key)
|
||||
@@ -1323,83 +1664,68 @@ async def _build_contact_index(key: str) -> Dict[str, dict]:
|
||||
|
||||
url = f"{CHECK_STATUS_BASE_URL.rstrip('/')}/friend/GetContactDetailsList"
|
||||
index: Dict[str, dict] = {}
|
||||
# 小批量遍历,多请求并发调用,直到全部返回(不把 contactUsernameList 整包当 UserNames 一次传)
|
||||
batch_size = 10
|
||||
max_concurrent = 6
|
||||
sem = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
chunk_size = 50
|
||||
for i in range(0, len(usernames), chunk_size):
|
||||
batch = usernames[i : i + chunk_size]
|
||||
body = {
|
||||
"RoomWxIDList": [],
|
||||
"UserNames": batch,
|
||||
}
|
||||
async def fetch_one_batch(client: httpx.AsyncClient, batch: List[str], batch_idx: int) -> List[dict]:
|
||||
body = {"RoomWxIDList": [], "UserNames": batch}
|
||||
async with sem:
|
||||
try:
|
||||
resp = await client.post(url, params={"key": key}, json=body)
|
||||
except Exception as e:
|
||||
logger.warning("GetContactDetailsList batch error: %s", e)
|
||||
continue
|
||||
logger.warning("GetContactDetailsList batch %s error: %s", batch_idx, e)
|
||||
return []
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("GetContactDetailsList %s: %s", resp.status_code, resp.text[:200])
|
||||
continue
|
||||
logger.warning("GetContactDetailsList batch %s %s: %s", batch_idx, resp.status_code, resp.text[:200])
|
||||
return []
|
||||
raw = resp.json()
|
||||
data = raw.get("Data") or raw.get("data") or raw
|
||||
# 打印一次结构,便于排查为何没有解析出联系人详情
|
||||
try:
|
||||
if i == 0:
|
||||
top_keys = list(raw.keys()) if isinstance(raw, dict) else type(raw).__name__
|
||||
data_keys = list(data.keys()) if isinstance(data, dict) else type(data).__name__
|
||||
logger.info(
|
||||
"GetContactDetailsList structure: top_keys=%s, Data_keys=%s, batch_size=%s",
|
||||
top_keys,
|
||||
data_keys,
|
||||
len(batch),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
items = []
|
||||
if isinstance(data, dict):
|
||||
# 7006 GetContactDetailsList 当前结构:Data.contactList 为联系人详情数组
|
||||
items = (
|
||||
data.get("List")
|
||||
or data.get("list")
|
||||
or data.get("ContactDetailsList")
|
||||
or data.get("contacts")
|
||||
or data.get("contactList")
|
||||
or data.get("ContactList") # 7006 可能用大写
|
||||
or []
|
||||
)
|
||||
elif isinstance(data, list):
|
||||
items = data
|
||||
if not isinstance(items, list):
|
||||
# 结构不符时记录一条日志,帮助判断需要从哪里取联系人列表
|
||||
logger.info(
|
||||
"GetContactDetailsList no list items parsed, data_type=%s, sample=%s",
|
||||
type(data).__name__,
|
||||
str(data)[:200],
|
||||
)
|
||||
return []
|
||||
if not items and batch_idx == 0:
|
||||
logger.info("GetContactDetailsList batch 0: data keys=%s, no list parsed", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
|
||||
if batch_idx == 0 and items:
|
||||
sample = items[0]
|
||||
if isinstance(sample, dict):
|
||||
logger.info(
|
||||
"GetContactDetailsList first batch item keys=%s",
|
||||
list(sample.keys()),
|
||||
)
|
||||
return items
|
||||
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
|
||||
batches = [usernames[i : i + batch_size] for i in range(0, len(usernames), batch_size)]
|
||||
tasks = [fetch_one_batch(client, b, i) for i, b in enumerate(batches)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for i, one in enumerate(results):
|
||||
if isinstance(one, BaseException):
|
||||
logger.warning("GetContactDetailsList batch %s exception: %s", i, one)
|
||||
continue
|
||||
# 追加一次示例项日志,便于确认字段名(UserName/NickName/RemarkName 等)
|
||||
try:
|
||||
if i == 0 and items:
|
||||
sample = items[0]
|
||||
if isinstance(sample, dict):
|
||||
logger.info(
|
||||
"GetContactDetailsList first item keys=%s, sample=%s",
|
||||
list(sample.keys()),
|
||||
str(sample)[:200],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
for d in items:
|
||||
for d in one or []:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
# 只保留 bitVal == 3 的联系人(如上游定义的「有效联系人」),其它忽略
|
||||
# 仅当上游明确返回 bitVal 且不为 3 时跳过(未返回或为 3 则保留,避免漏掉联系人)
|
||||
try:
|
||||
bit_val = int(d.get("bitVal") or 0)
|
||||
bv = d.get("bitVal")
|
||||
if bv is not None and int(bv) != 3:
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
bit_val = 0
|
||||
if bit_val != 3:
|
||||
continue
|
||||
|
||||
# 7006 联系人详情字段为 userName/nickName/pyinitial/quanPin 等,内部多为 {'str': 'xxx'} 包装
|
||||
pass
|
||||
wxid = _unwrap_wechat_field(
|
||||
d.get("userName") or d.get("UserName") or d.get("user_name") or d.get("wxid")
|
||||
)
|
||||
@@ -1424,14 +1750,18 @@ async def _build_contact_index(key: str) -> Dict[str, dict]:
|
||||
"quan_pin": quan_pin,
|
||||
"raw": d,
|
||||
}
|
||||
# 主键:wxid
|
||||
index[wxid] = info
|
||||
# 昵称 / 备注作为别名,便于按自然语言查找
|
||||
if nick and nick not in index:
|
||||
index[nick] = info
|
||||
if remark and remark not in index:
|
||||
index[remark] = info
|
||||
|
||||
if usernames and not index:
|
||||
logger.warning(
|
||||
"Contact index empty for key=***%s despite usernames count=%s: GetContactDetailsList may return different structure or all items filtered",
|
||||
key[-4:] if len(key) >= 4 else "****",
|
||||
len(usernames),
|
||||
)
|
||||
_contact_index[key] = index
|
||||
logger.info("Contact index built for key=***%s, size=%s", key[-4:] if len(key) >= 4 else "****", len(index))
|
||||
return index
|
||||
@@ -1484,10 +1814,14 @@ _NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate", "Pr
|
||||
|
||||
|
||||
@app.get("/api/contact-list")
|
||||
async def api_contact_list(key: str = Query(..., description="账号 key")):
|
||||
async def api_contact_list(
|
||||
key: str = Query(..., description="账号 key"),
|
||||
refresh: Optional[str] = Query(None, description="传 1/true/yes 时强制重新拉取,不用内存缓存"),
|
||||
):
|
||||
"""获取全部联系人详情:基于 GetContactList + GetContactDetailsList 构建的通用索引。禁止缓存。"""
|
||||
try:
|
||||
index = await _build_contact_index(key)
|
||||
force_refresh = (refresh or "").lower() in ("1", "true", "yes")
|
||||
index = await _build_contact_index(key, force_refresh=force_refresh)
|
||||
# 只返回去重后的联系人详情(以 wxid 主键)
|
||||
uniques: Dict[str, dict] = {}
|
||||
for name, info in index.items():
|
||||
@@ -1711,7 +2045,7 @@ async def logout(body: LogoutBody):
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/LogOut"
|
||||
logger.info("LogOut: key=%s, url=%s", key, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
|
||||
resp = await client.get(url, params={"key": key})
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream LogOut: %s", exc)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
python-dotenv>=1.0.0
|
||||
httpx==0.27.0
|
||||
websockets>=12.0
|
||||
openai>=1.0.0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据库存储:客户档案、定时问候、商品标签、推送群组/任务、同步消息、模型、AI 回复配置。使用 SQLite,便于增删改查。"""
|
||||
"""数据库存储:客户档案、定时问候、商品标签、推送群组/任务、同步消息、回调原始日志、模型、AI 回复配置。使用 SQLite,便于增删改查。"""
|
||||
import datetime
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
@@ -403,6 +404,32 @@ def append_sent_message(key: str, to_user_name: str, content: str) -> None:
|
||||
append_sync_messages(key, [{"direction": "out", "ToUserName": to_user_name, "Content": content, "CreateTime": int(time.time())}])
|
||||
|
||||
|
||||
def append_callback_log(key: str, raw_body: dict, max_raw_len: int = 51200) -> None:
|
||||
"""将 7006 回调的原始 body 落库,便于回溯与统计。raw_body 序列化后截断,避免单条过大。"""
|
||||
received_at = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
raw_str = json.dumps(raw_body, ensure_ascii=False)
|
||||
if len(raw_str) > max_raw_len:
|
||||
raw_str = raw_str[:max_raw_len] + "...[truncated]"
|
||||
with _LOCK:
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO callback_log (key, received_at, raw_body) VALUES (?,?,?)",
|
||||
(key, received_at, raw_str),
|
||||
)
|
||||
conn.commit()
|
||||
# 每个 key 仅保留最近 2000 条原始回调
|
||||
cur = conn.execute("SELECT id FROM callback_log WHERE key = ? ORDER BY id DESC", (key,))
|
||||
rows = cur.fetchall()
|
||||
if len(rows) > 2000:
|
||||
to_del = [r["id"] for r in rows[2000:]]
|
||||
placeholders = ",".join("?" * len(to_del))
|
||||
conn.execute(f"DELETE FROM callback_log WHERE id IN ({placeholders})", to_del)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------- 模型 ----------
|
||||
def list_models() -> List[Dict]:
|
||||
with _LOCK:
|
||||
|
||||
@@ -37,7 +37,7 @@ async def _run_ws(key: str) -> None:
|
||||
while True:
|
||||
try:
|
||||
_ws_connected = False
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, close_timeout=5) as ws:
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, close_timeout=5, proxy=None) as ws:
|
||||
_ws_connected = True
|
||||
logger.info("WS connected for key=%s", key)
|
||||
while True:
|
||||
|
||||
11
ngrok.yml
Normal file
11
ngrok.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
# ngrok 多隧道配置(免费试用最多 3 个端点)
|
||||
# 与 run.sh --proxy-bridge 配合:单进程同时暴露 8000(回调)和 8899(代理桥接)
|
||||
# authtoken 使用系统默认配置(~/.config/ngrok/ngrok.yml 等),此处不重复配置
|
||||
version: "3"
|
||||
tunnels:
|
||||
callback:
|
||||
proto: http
|
||||
addr: 8000
|
||||
proxy:
|
||||
proto: http
|
||||
addr: 8899
|
||||
@@ -544,12 +544,13 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="proxy">代理(可选)</label>
|
||||
<label for="proxy">代理(可选,不填则由后端自动读取)</label>
|
||||
<input
|
||||
id="proxy"
|
||||
placeholder="socks5://username:password@ipv4:port"
|
||||
placeholder="socks5://ip:port 或 socks5://user:pass@ip:port"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="button" class="secondary" id="btn-check-proxy" style="margin-top: 6px; padding: 4px 10px; font-size: 12px;">检测代理是否正常</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="device">登录设备类型</label>
|
||||
@@ -559,19 +560,15 @@
|
||||
<option value="mac">Mac</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label> </label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="check-proxy" />
|
||||
<label for="check-proxy">修改代理时自动检测可用性</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" id="btn-qrcode">
|
||||
获取登录二维码
|
||||
</button>
|
||||
<button class="secondary" id="btn-qrcode-mac" title="使用 Mac 取码(与上方选择 Mac 后点获取二维码等效)">
|
||||
重新取码(Mac)
|
||||
</button>
|
||||
<button class="secondary" id="btn-wake">
|
||||
唤醒
|
||||
</button>
|
||||
@@ -629,11 +626,11 @@
|
||||
|
||||
<div id="slider-area" style="display: none;">
|
||||
<div class="card" style="max-width: 480px;">
|
||||
<div class="card-title">滑块验证</div>
|
||||
<div class="card-title">滑块验证(无数字时:先点「重新取码(Mac)」,手机停确认页再滑)</div>
|
||||
<div id="slider-app" data-v-app="">
|
||||
<div class="params-section" style="margin-bottom: 12px;">
|
||||
<label class="form-label" for="keyInput">Key:</label>
|
||||
<input type="text" class="form-control" id="keyInput" placeholder="请输入key" style="width:100%;box-sizing:border-box;padding:8px;margin-bottom:8px;border:1px solid var(--border);border-radius:8px;background:rgba(15,23,42,0.6);color:var(--text);">
|
||||
<label class="form-label" for="keyInput">Key (7765 服务方):</label>
|
||||
<input type="text" class="form-control" id="keyInput" placeholder="408449830" style="width:100%;box-sizing:border-box;padding:8px;margin-bottom:8px;border:1px solid var(--border);border-radius:8px;background:rgba(15,23,42,0.6);color:var(--text);">
|
||||
<label class="form-label" for="data62Input">Data62:</label>
|
||||
<input type="text" class="form-control" id="data62Input" placeholder="请输入data62" style="width:100%;box-sizing:border-box;padding:8px;margin-bottom:8px;border:1px solid var(--border);border-radius:8px;background:rgba(15,23,42,0.6);color:var(--text);">
|
||||
<label class="form-label" for="originalTicketInput">Original Ticket:</label>
|
||||
@@ -665,6 +662,7 @@
|
||||
sliderParams: null,
|
||||
sliderScriptLoaded: false,
|
||||
sliderListenersBound: false,
|
||||
qrcodeFetchedAt: 0,
|
||||
};
|
||||
|
||||
function parseSliderUrlParams(sliderUrl) {
|
||||
@@ -755,6 +753,8 @@
|
||||
|
||||
function setLoading(loading) {
|
||||
$('btn-qrcode').disabled = loading;
|
||||
var btnQrcodeMac = $('btn-qrcode-mac');
|
||||
if (btnQrcodeMac) btnQrcodeMac.disabled = loading;
|
||||
var btnWake = $('btn-wake');
|
||||
if (btnWake) btnWake.disabled = loading;
|
||||
$('btn-check-scan').disabled = loading;
|
||||
@@ -765,8 +765,7 @@
|
||||
function getCommonPayload() {
|
||||
const key = $('key').value.trim();
|
||||
const proxy = $('proxy').value.trim();
|
||||
const device = $('device').value;
|
||||
const check = $('check-proxy').checked;
|
||||
const device = ($('device') && $('device').value) || '';
|
||||
if (!key) {
|
||||
alert('请先填写账号唯一标识(key)');
|
||||
return null;
|
||||
@@ -774,8 +773,7 @@
|
||||
return {
|
||||
key,
|
||||
proxy: proxy || undefined,
|
||||
ipadOrMac: device,
|
||||
check,
|
||||
ipadOrMac: device || 'ipad',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -999,16 +997,68 @@
|
||||
return body;
|
||||
}
|
||||
|
||||
async function onGetQrCode() {
|
||||
async function onWake() {
|
||||
const payload = getCommonPayload();
|
||||
if (!payload) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
log('请求登录二维码...');
|
||||
log('唤醒登录(仅扫码登录)...');
|
||||
const body = {
|
||||
key: payload.key,
|
||||
Check: !!payload.check,
|
||||
IpadOrmac: payload.ipadOrMac || 'ipad',
|
||||
Proxy: payload.proxy || '',
|
||||
};
|
||||
const data = await callApi('/auth/wake', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
log('唤醒请求已发送');
|
||||
if (data && typeof data === 'object' && (data.error || data.detail)) {
|
||||
log(JSON.stringify(data.error || data.detail), 'warn');
|
||||
}
|
||||
updateLoginState('已发送唤醒', 'pending', '如需扫码请点击「获取登录二维码」');
|
||||
} catch (e) {
|
||||
log('唤醒失败: ' + (e.message || e), 'error');
|
||||
updateLoginState('唤醒失败', 'offline', e.message || '');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onCheckProxy() {
|
||||
var proxyInput = ($('proxy') && $('proxy').value || '').trim();
|
||||
var url = '/api/check-proxy';
|
||||
if (proxyInput) url += '?proxy=' + encodeURIComponent(proxyInput);
|
||||
log('正在检测代理…' + (proxyInput ? '(使用当前填写的代理)' : '(使用环境变量或 KDL)'));
|
||||
var btn = $('btn-check-proxy');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
var data = await callApi(url);
|
||||
if (data && data.ok) {
|
||||
log('代理正常。来源: ' + (data.source || '') + (data.proxy_preview ? ',代理: ' + data.proxy_preview : ''), 'ok');
|
||||
} else {
|
||||
log('代理不可用: ' + (data && data.error ? data.error : JSON.stringify(data)), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
log('检测代理失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onGetQrCode(forceMac) {
|
||||
const payload = getCommonPayload();
|
||||
if (!payload) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
var deviceLabel = forceMac ? 'Mac' : (payload.ipadOrMac || 'ipad');
|
||||
log(forceMac ? '重新取码(Mac)…' : '请求登录二维码(' + deviceLabel + ')...');
|
||||
const body = {
|
||||
Proxy: payload.proxy || '',
|
||||
IpadOrmac: payload.ipadOrMac || '',
|
||||
Check: !!payload.check,
|
||||
IpadOrmac: forceMac ? 'mac' : (payload.ipadOrMac || 'ipad'),
|
||||
Check: false,
|
||||
force_mac: !!forceMac,
|
||||
};
|
||||
const data = await callApi('/auth/qrcode', {
|
||||
method: 'POST',
|
||||
@@ -1028,18 +1078,26 @@
|
||||
log(line2);
|
||||
if (line3) log(line3);
|
||||
})();
|
||||
state.qrcodeFetchedAt = Date.now();
|
||||
renderQrFromResponse(data);
|
||||
updateLoginState('等待扫码 / 确认中', 'pending', '请在 60 秒内使用微信扫码。');
|
||||
// 仅通过轮询检测扫码状态,不立即查状态(避免沿用上次的「需验证」误弹滑块)
|
||||
if (state.pollingScan) {
|
||||
clearInterval(state.pollingScan);
|
||||
updateLoginState(forceMac ? '已用 Mac 重新取码,请手机停在确认页并完成下方滑块' : '等待扫码 / 确认中', 'pending', forceMac ? 'Data62 已更新,需滑块时请点「打开滑块验证」' : '请在 60 秒内使用微信扫码。');
|
||||
if (forceMac && data) {
|
||||
var newData62 = (data.Data && data.Data.Data62) || data.Data62 || data.data62 || '';
|
||||
if (newData62) {
|
||||
if (!state.sliderParams) state.sliderParams = {};
|
||||
state.sliderParams.data62 = newData62;
|
||||
state.sliderParams.key = state.sliderParams.key || '408449830';
|
||||
state.sliderOpened = true;
|
||||
var reopenWrap = $('slider-reopen-wrap');
|
||||
if (reopenWrap) reopenWrap.style.display = 'block';
|
||||
log('Mac 取码成功,Data62 已更新。上方显示二维码/返回内容;需滑块时请点击「打开滑块验证」。', 'warn');
|
||||
}
|
||||
}
|
||||
if (state.pollingScan) clearInterval(state.pollingScan);
|
||||
setTimeout(() => onCheckScanStatus(true), 2000);
|
||||
state.pollingScan = setInterval(() => {
|
||||
onCheckScanStatus(true);
|
||||
}, 5000);
|
||||
state.pollingScan = setInterval(() => { onCheckScanStatus(true); }, 5000);
|
||||
} catch (e) {
|
||||
log('获取二维码失败: ' + e.message, 'error');
|
||||
log('获取二维码失败: ' + (e.message || e), 'error');
|
||||
updateLoginState('二维码获取失败', 'offline', e.message || '');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -1047,7 +1105,7 @@
|
||||
}
|
||||
|
||||
async function onCheckScanStatus(silent = false) {
|
||||
const payload = getCommonPayload();
|
||||
const payload = getCommonPayload(false);
|
||||
if (!payload) return;
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
@@ -1063,14 +1121,17 @@
|
||||
const d = obj.Data && typeof obj.Data === 'object' ? obj.Data : obj;
|
||||
const stateVal = d.state ?? d.State;
|
||||
|
||||
// 后端返回滑块 path,在当前页右侧替换为滑块区域并自动填充参数
|
||||
// 后端返回滑块 path,在当前页右侧替换为滑块区域并自动填充参数(取码后 15 秒内不自动切,避免未出码就进验证)
|
||||
const sliderUrl = data.slider_url;
|
||||
if (sliderUrl && typeof sliderUrl === 'string') {
|
||||
var allowSliderSwitch = !state.qrcodeFetchedAt || (Date.now() - state.qrcodeFetchedAt) > 15000;
|
||||
if (sliderUrl && typeof sliderUrl === 'string' && allowSliderSwitch) {
|
||||
state.sliderOpened = true;
|
||||
log('需完成滑块验证,已切换到滑块验证区域。', 'warn');
|
||||
const params = parseSliderUrlParams(sliderUrl);
|
||||
if (params) state.sliderParams = params;
|
||||
showSliderAreaAndFill(params);
|
||||
} else if (sliderUrl && !allowSliderSwitch) {
|
||||
log('检测到需验证,但刚取码不久,暂不自动切到滑块,请先扫码或稍后再检测。', 'warn');
|
||||
}
|
||||
|
||||
// 检测状态是否成功登录:state==2 或 Success+已登录 等,成功后自动跳转管理页
|
||||
@@ -1102,11 +1163,14 @@
|
||||
} else {
|
||||
updateLoginState(needVerify || sliderUrl ? '请完成滑块验证' : '正在确认登录状态…', 'pending',
|
||||
needVerify || sliderUrl ? '扫码完成,请完成验证后登录' : '');
|
||||
// 判定到需验证时直接切换验证模块,不等用户点「检测扫码状态」;有 slider_url 已在上方填参,无则先切过去,后续轮询会带参
|
||||
// 判定到需验证时切换验证模块;取码后 15 秒内不自动切,避免未出码就进验证
|
||||
if (needVerify || sliderUrl) {
|
||||
state.sliderOpened = true;
|
||||
if (!sliderUrl) log('需完成滑块验证,已切换到滑块验证区域,等待参数…', 'warn');
|
||||
showSliderAreaAndFill(state.sliderParams || {});
|
||||
var allowSwitch = !state.qrcodeFetchedAt || (Date.now() - state.qrcodeFetchedAt) > 15000;
|
||||
if (allowSwitch) {
|
||||
state.sliderOpened = true;
|
||||
if (!sliderUrl) log('需完成滑块验证,已切换到滑块验证区域,等待参数…', 'warn');
|
||||
showSliderAreaAndFill(state.sliderParams || {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1172,17 +1236,19 @@
|
||||
if ($('slider-area') && $('slider-area').style.display !== 'none') {
|
||||
showQrArea();
|
||||
}
|
||||
onGetQrCode();
|
||||
onGetQrCode(false);
|
||||
});
|
||||
$('btn-qrcode-mac') && $('btn-qrcode-mac').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
onGetQrCode(true);
|
||||
});
|
||||
$('btn-wake') && $('btn-wake').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var device = ($('device') && $('device').value) || '';
|
||||
if (device === 'mac') {
|
||||
if ($('slider-area') && $('slider-area').style.display !== 'none') showQrArea();
|
||||
onGetQrCode();
|
||||
} else {
|
||||
log('唤醒:请先将「登录设备类型」选为 Mac,再点击唤醒以获取二维码。', 'warn');
|
||||
}
|
||||
onWake();
|
||||
});
|
||||
$('btn-check-proxy') && $('btn-check-proxy').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
onCheckProxy();
|
||||
});
|
||||
$('btn-show-slider') && $('btn-show-slider').addEventListener('click', function() {
|
||||
if (state.sliderParams) showSliderAreaAndFill(state.sliderParams);
|
||||
|
||||
@@ -146,11 +146,11 @@
|
||||
<div class="field full">
|
||||
<label>客户标签(从客户档案中选)</label>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
|
||||
<select id="g-tag-select" style="max-width:160px"><option value="">请先登录后加载</option></select>
|
||||
<select id="g-tag-select" style="max-width:200px"><option value="">选择标签…</option></select>
|
||||
<button type="button" class="secondary" id="g-tag-add" style="padding:4px 10px;font-size:12px">添加标签</button>
|
||||
<span id="g-tags-chips" class="tags-chips"></span>
|
||||
</div>
|
||||
<p class="small-label" style="margin-top:4px">在下方「客户档案」中维护的标签会出现在下拉列表;可多选,发送时仅推送给带这些标签的客户。</p>
|
||||
<p class="small-label" style="margin-top:4px">在下方「客户档案」中维护的标签会出现在下拉列表;可多选,发送时仅推送给带这些标签的客户。支持获取联系人列表。</p>
|
||||
</div>
|
||||
<div class="field full"><label>问候语模板(可用 {{name}})</label><textarea id="g-template" rows="2" placeholder="早安,{{name}}!今日上新…"></textarea></div>
|
||||
<div class="field"><label> </label><label><input type="checkbox" id="g-use-qwen" /> 使用千问生成个性化问候</label></div>
|
||||
@@ -165,9 +165,10 @@
|
||||
<label>选择接收人(从好友/客户列表)</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button type="button" class="secondary" id="btn-load-friends" style="padding:4px 10px;font-size:12px">加载联系人</button>
|
||||
<button type="button" class="secondary" id="btn-load-customers-mass" style="padding:4px 10px;font-size:12px" title="从客户档案加载,与联系人合并去重">加载客户</button>
|
||||
<span id="mass-selected-count" class="small-label">已选 0 人</span>
|
||||
</div>
|
||||
<div id="mass-friend-list" style="max-height:140px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:8px;margin-top:6px;background:rgba(15,23,42,0.6)"></div>
|
||||
<div id="mass-friend-list" style="max-height:200px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:8px;margin-top:6px;background:rgba(15,23,42,0.6)"></div>
|
||||
</div>
|
||||
<div class="field full"><label>群发文案</label><textarea id="mass-content" rows="2" placeholder="输入要群发的文字…"></textarea></div>
|
||||
<button type="button" class="primary" id="btn-mass-send">一键群发</button>
|
||||
@@ -211,11 +212,6 @@
|
||||
<span id="ai-reply-status-text">—</span>
|
||||
<button type="button" class="secondary" id="btn-ai-reply-status" style="margin-left:8px;padding:2px 8px;font-size:12px">检查状态</button>
|
||||
</div>
|
||||
<div class="field full" style="margin-bottom:12px">
|
||||
<span class="small-label">消息回调(7006 → 本服务):</span>
|
||||
<span id="callback-status-text">—</span>
|
||||
<button type="button" class="secondary" id="btn-callback-status" style="margin-left:8px;padding:2px 8px;font-size:12px">检查回调</button>
|
||||
</div>
|
||||
<div class="mgmt-form-grid">
|
||||
<div class="field full">
|
||||
<label>超级管理员 wxid(每行一个或逗号分隔)</label>
|
||||
@@ -302,43 +298,27 @@
|
||||
}
|
||||
|
||||
async function loadAiReplyStatus() {
|
||||
const key = $('key').value.trim();
|
||||
const key = getKey();
|
||||
const el = $('ai-reply-status-text');
|
||||
if (!el) return;
|
||||
if (!key) { el.textContent = '请先登录'; return; }
|
||||
if (!key) { el.textContent = '请先登录'; el.style.color = 'var(--muted, #94a3b8)'; return; }
|
||||
el.textContent = '检测中…';
|
||||
try {
|
||||
const data = await callApi('/api/ai-reply-status?key=' + encodeURIComponent(key));
|
||||
el.textContent = data.ok ? '正常(WS 已连接,已配置白名单/超级管理员,已选模型)' : (data.message || '异常');
|
||||
el.style.color = data.ok ? 'var(--success, #22c55e)' : 'var(--muted, #94a3b8)';
|
||||
} catch (e) {
|
||||
el.textContent = '检查失败: ' + (e.message || e);
|
||||
el.style.color = 'var(--danger, #ef4444)';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCallbackStatus() {
|
||||
const key = getKey();
|
||||
const el = $('callback-status-text');
|
||||
if (!el) return;
|
||||
if (!key) { el.textContent = '请先登录'; el.style.color = 'var(--muted)'; return; }
|
||||
el.textContent = '检测中…';
|
||||
el.style.color = 'var(--muted)';
|
||||
el.style.color = 'var(--muted, #94a3b8)';
|
||||
try {
|
||||
const data = await callApi('/api/callback-status?key=' + encodeURIComponent(key));
|
||||
if (!data.configured) {
|
||||
el.textContent = '未配置(未设置 CALLBACK_BASE_URL,使用 WS 拉取消息)';
|
||||
el.textContent = '未配置回调(AI 未接管)';
|
||||
el.style.color = 'var(--muted, #94a3b8)';
|
||||
return;
|
||||
}
|
||||
if (data.registered === true) {
|
||||
el.textContent = '已配置且已向 7006 注册成功,新消息将推送到: ' + (data.callback_url || '');
|
||||
el.textContent = '回调已配置且注册成功(AI 已接管)';
|
||||
el.style.color = 'var(--success, #22c55e)';
|
||||
} else if (data.registered === false) {
|
||||
el.textContent = '已配置但向 7006 注册失败,请检查网络或 7006 服务。回调地址: ' + (data.callback_url || '');
|
||||
el.textContent = '回调已配置但注册失败(AI 未接管)';
|
||||
el.style.color = 'var(--danger, #ef4444)';
|
||||
} else {
|
||||
el.textContent = '已配置,回调地址: ' + (data.callback_url || '');
|
||||
el.textContent = '回调已配置(AI 接管状态未知,请检查 7006)';
|
||||
el.style.color = 'var(--muted, #94a3b8)';
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -403,7 +383,7 @@
|
||||
if (!key) { alert('请先登录'); return; }
|
||||
sel.innerHTML = '<option value="">加载中…</option>';
|
||||
try {
|
||||
const data = await callApi('/api/contact-list?key=' + encodeURIComponent(key), { cache: 'no-store' });
|
||||
const data = await callApi('/api/contact-list?key=' + encodeURIComponent(key) + '&refresh=1', { cache: 'no-store' });
|
||||
const list = data.items || [];
|
||||
lastLoadedContactList = list;
|
||||
if (data.error) {
|
||||
@@ -651,6 +631,28 @@
|
||||
}
|
||||
|
||||
let massSelectedWxids = [];
|
||||
/** 快速群发可选列表(好友+客户合并去重):{ wxid, name, source?: 'friend'|'customer' } */
|
||||
let massContactList = [];
|
||||
|
||||
function renderMassContactList() {
|
||||
const el = $('mass-friend-list');
|
||||
if (!el) return;
|
||||
if (!massContactList.length) {
|
||||
el.innerHTML = '<span class="small-label">请点击「加载联系人」或「加载客户」获取列表。</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = massContactList.map(f => {
|
||||
const wxid = (f.wxid || '').toString();
|
||||
const name = (f.name || wxid).toString();
|
||||
const checked = massSelectedWxids.includes(wxid) ? ' checked' : '';
|
||||
const badge = f.source === 'customer' ? ' <span class="small-label" style="color:var(--muted)">[客户]</span>' : '';
|
||||
return '<label style="display:block;margin:4px 0"><input type="checkbox" class="mass-friend-cb" data-wxid="' + escapeHtml(wxid) + '"' + checked + ' /> ' + escapeHtml(name) + badge + ' <span class="small-label">(' + escapeHtml(wxid.slice(0, 16)) + (wxid.length > 16 ? '…' : '') + ')</span></label>';
|
||||
}).join('');
|
||||
el.querySelectorAll('.mass-friend-cb').forEach(cb => {
|
||||
cb.addEventListener('change', updateMassSelected);
|
||||
});
|
||||
updateMassSelected();
|
||||
}
|
||||
|
||||
async function loadFriendsForMass() {
|
||||
const key = $('key').value.trim();
|
||||
@@ -658,27 +660,55 @@
|
||||
const el = $('mass-friend-list');
|
||||
el.innerHTML = '<span class="small-label">加载中…</span>';
|
||||
try {
|
||||
const data = await callApi('/api/friends?key=' + encodeURIComponent(key), { cache: 'no-store' });
|
||||
const data = await callApi('/api/contact-list?key=' + encodeURIComponent(key) + '&refresh=1', { cache: 'no-store' });
|
||||
const list = data.items || [];
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<span class="small-label">暂无联系人,请先在「客户档案」添加客户。</span>';
|
||||
massContactList = list.map(f => {
|
||||
const wxid = (f.wxid || f.Wxid || f.UserName || '').toString();
|
||||
const name = (f.remark_name || f.RemarkName || f.nick_name || f.NickName || wxid).toString();
|
||||
return { wxid, name, source: 'friend' };
|
||||
}).filter(f => f.wxid);
|
||||
if (!massContactList.length) {
|
||||
el.innerHTML = '<span class="small-label">暂无联系人。可点击「加载客户」从客户档案选择。</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = list.map(f => {
|
||||
const wxid = (f.wxid || f.Wxid || f.UserName || '').toString();
|
||||
const name = (f.remark_name || f.RemarkName || f.NickName || wxid).toString();
|
||||
return '<label style="display:block;margin:4px 0"><input type="checkbox" class="mass-friend-cb" data-wxid="' + escapeHtml(wxid) + '" /> ' + escapeHtml(name) + ' <span class="small-label">(' + escapeHtml(wxid.slice(0, 16)) + '…)</span></label>';
|
||||
}).join('');
|
||||
el.querySelectorAll('.mass-friend-cb').forEach(cb => {
|
||||
cb.addEventListener('change', updateMassSelected);
|
||||
});
|
||||
massSelectedWxids = [];
|
||||
updateMassSelected();
|
||||
renderMassContactList();
|
||||
} catch (e) {
|
||||
el.innerHTML = '<span class="small-label">加载失败: ' + escapeHtml(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomersForMass() {
|
||||
const key = $('key').value.trim();
|
||||
if (!key) { alert('请先登录'); return; }
|
||||
const el = $('mass-friend-list');
|
||||
const wasEmpty = !massContactList.length;
|
||||
if (wasEmpty) el.innerHTML = '<span class="small-label">加载中…</span>';
|
||||
try {
|
||||
const data = await callApi('/api/customers?key=' + encodeURIComponent(key));
|
||||
const list = data.items || [];
|
||||
const existingWxids = new Set(massContactList.map(f => f.wxid));
|
||||
list.forEach(c => {
|
||||
const wxid = (c.wxid || '').toString();
|
||||
if (!wxid) return;
|
||||
if (existingWxids.has(wxid)) return;
|
||||
existingWxids.add(wxid);
|
||||
massContactList.push({
|
||||
wxid,
|
||||
name: (c.remark_name || c.wxid || wxid).toString(),
|
||||
source: 'customer'
|
||||
});
|
||||
});
|
||||
if (!massContactList.length) {
|
||||
el.innerHTML = '<span class="small-label">暂无客户。请先在「客户档案」添加客户,或点击「加载联系人」获取好友列表。</span>';
|
||||
return;
|
||||
}
|
||||
renderMassContactList();
|
||||
} catch (e) {
|
||||
if (wasEmpty) el.innerHTML = '<span class="small-label">加载失败: ' + escapeHtml(e.message) + '</span>';
|
||||
else alert('加载客户失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMassSelected() {
|
||||
const list = $('mass-friend-list');
|
||||
if (!list) return;
|
||||
@@ -853,6 +883,7 @@
|
||||
if ($('g-time')) { $('g-time').addEventListener('change', onGreetingTimeChange); $('g-time').addEventListener('input', onGreetingTimeChange); }
|
||||
})();
|
||||
$('btn-load-friends').addEventListener('click', loadFriendsForMass);
|
||||
$('btn-load-customers-mass') && $('btn-load-customers-mass').addEventListener('click', loadCustomersForMass);
|
||||
$('btn-mass-send').addEventListener('click', doMassSend);
|
||||
$('btn-send-image').addEventListener('click', doSendImage);
|
||||
if ($('img-file') && $('img-file-name')) {
|
||||
@@ -862,7 +893,6 @@
|
||||
}
|
||||
$('btn-ai-reply-save').addEventListener('click', async () => { await saveAiReplyConfig(); loadAiReplyStatus(); });
|
||||
$('btn-ai-reply-status') && $('btn-ai-reply-status').addEventListener('click', loadAiReplyStatus);
|
||||
$('btn-callback-status') && $('btn-callback-status').addEventListener('click', loadCallbackStatus);
|
||||
$('btn-pt-add').addEventListener('click', addProductTag);
|
||||
$('btn-push-group-add').addEventListener('click', createPushGroup);
|
||||
$('btn-push-send').addEventListener('click', doPushSend);
|
||||
|
||||
@@ -42,10 +42,16 @@ fi
|
||||
mkdir -p "${HOST_DATA_DIR}"
|
||||
echo "Data dir (host): ${HOST_DATA_DIR} -> container /app/backend/data"
|
||||
|
||||
ENV_FILE=".env"
|
||||
if [ ! -f "${ENV_FILE}" ]; then
|
||||
echo "Env file ${ENV_FILE} not found, copying from .env.example ..."
|
||||
cp .env.example "${ENV_FILE}"
|
||||
# 优先使用 .env.prod 作为生产环境配置(例如在服务器上单独维护 CALLBACK_BASE_URL 等),
|
||||
# 若不存在则回退到 .env;再没有则从 .env.example 复制一份。
|
||||
if [ -f ".env.prod" ]; then
|
||||
ENV_FILE=".env.prod"
|
||||
else
|
||||
ENV_FILE=".env"
|
||||
if [ ! -f "${ENV_FILE}" ]; then
|
||||
echo "Env file ${ENV_FILE} not found, copying from .env.example ..."
|
||||
cp .env.example "${ENV_FILE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Running container ${CONTAINER_NAME} (frontend :${PORT}, backend :${BACKEND_PORT})..."
|
||||
|
||||
24
run-ngrok.sh
24
run-ngrok.sh
@@ -17,10 +17,10 @@ rm -f "$NGROK_LOG"
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4040/api/tunnels 2>/dev/null | grep -q 200; then
|
||||
echo "检测到 ngrok 已在运行(4040 可访问),直接读取 URL..."
|
||||
else
|
||||
echo "启动 ngrok http 8000(后端需在 8000 端口,可先在本脚本之后另开终端运行 ./run-dev.sh)..."
|
||||
echo "启动 ngrok http 8000(后端需在 8000 端口)..."
|
||||
nohup ngrok http 8000 --log=stdout > "$NGROK_LOG" 2>&1 &
|
||||
NGROK_PID=$!
|
||||
echo "等待 ngrok 就绪(最多 30 秒,并从日志解析 URL)..."
|
||||
echo "等待 ngrok 就绪(最多 30 秒)..."
|
||||
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4040/api/tunnels 2>/dev/null | grep -q 200; then
|
||||
break
|
||||
@@ -73,11 +73,9 @@ import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
tunnels = d.get('tunnels') if isinstance(d, dict) else (d if isinstance(d, list) else [])
|
||||
if not isinstance(tunnels, list):
|
||||
tunnels = []
|
||||
if not isinstance(tunnels, list): tunnels = []
|
||||
for t in tunnels:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
if not isinstance(t, dict): continue
|
||||
u = (t.get('public_url') or t.get('PublicURL') or '').strip()
|
||||
if u.startswith('https://'):
|
||||
print(u.rstrip('/'))
|
||||
@@ -85,17 +83,11 @@ try:
|
||||
else:
|
||||
if tunnels:
|
||||
u = (tunnels[0].get('public_url') or tunnels[0].get('PublicURL') or '').strip()
|
||||
if u:
|
||||
print(u.rstrip('/'))
|
||||
except Exception:
|
||||
pass
|
||||
if u: print(u.rstrip('/'))
|
||||
except Exception: pass
|
||||
" 2>/dev/null)
|
||||
if [ -z "$PUBLIC_URL" ]; then
|
||||
PUBLIC_URL=$(echo "$PAYLOAD" | grep -oE 'https://[a-zA-Z0-9][-a-zA-Z0-9.]*\.(ngrok-free\.app|ngrok\.io|ngrok-app\.com)[^"]*' | head -1 | sed 's|"$||')
|
||||
fi
|
||||
if [ -z "$PUBLIC_URL" ]; then
|
||||
PUBLIC_URL=$(echo "$PAYLOAD" | grep -oE '"public_url"\s*:\s*"https://[^"]+' | sed 's/.*"https:/https:/' | sed 's/"$//' | head -1)
|
||||
fi
|
||||
[ -z "$PUBLIC_URL" ] && PUBLIC_URL=$(echo "$PAYLOAD" | grep -oE 'https://[a-zA-Z0-9][-a-zA-Z0-9.]*\.(ngrok-free\.app|ngrok\.io|ngrok-app\.com)[^"]*' | head -1 | sed 's|"$||')
|
||||
[ -z "$PUBLIC_URL" ] && PUBLIC_URL=$(echo "$PAYLOAD" | grep -oE '"public_url"\s*:\s*"https://[^"]+' | sed 's/.*"https:/https:/' | sed 's/"$//' | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$PUBLIC_URL" ]; then
|
||||
|
||||
65
run.sh
65
run.sh
@@ -1,2 +1,63 @@
|
||||
sh ./run-ngrok.sh
|
||||
sh ./run-dev.sh
|
||||
#!/usr/bin/env bash
|
||||
# 统一启动:可选代理桥接 → ngrok 暴露回调 → 后端+前端
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 用法
|
||||
usage() {
|
||||
echo "用法: ./run.sh [--proxy-bridge]"
|
||||
echo " --proxy-bridge 先启动本地代理桥接(8899→127.0.0.1:7890),便于 7006 通过 ngrok 使用本机代理"
|
||||
echo "无参数时仅: ngrok 暴露 8000 并写入 CALLBACK_BASE_URL → 启动 run-dev.sh"
|
||||
echo ""
|
||||
echo "注意: ngrok 免费版仅 1 个隧道,已用于 8000(回调);代理需另开隧道或 cloudflared 暴露 8899 后填 .env。"
|
||||
}
|
||||
|
||||
USE_PROXY_BRIDGE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--proxy-bridge) USE_PROXY_BRIDGE=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 可选:启动本地代理桥接(8899 → 127.0.0.1:7890)
|
||||
if [ "$USE_PROXY_BRIDGE" = "1" ]; then
|
||||
if [ ! -f "scripts/local_proxy_bridge.py" ]; then
|
||||
echo "未找到 scripts/local_proxy_bridge.py,跳过代理桥接"
|
||||
else
|
||||
PYTHON="python3"
|
||||
if [ -d ".venv" ]; then
|
||||
PYTHON=".venv/bin/python"
|
||||
fi
|
||||
if ! lsof -i :8899 >/dev/null 2>&1; then
|
||||
echo "启动本地代理桥接 :8899 → 127.0.0.1:7890 ..."
|
||||
nohup "$PYTHON" scripts/local_proxy_bridge.py >> /tmp/proxy-bridge.log 2>&1 &
|
||||
echo $! > /tmp/proxy-bridge.pid
|
||||
sleep 1
|
||||
echo " 已启动。若需 7006 走本机代理,需另用 ngrok 付费多隧道或 cloudflared 暴露 8899,将 URL 填入 .env 的 HTTP_PROXY。"
|
||||
else
|
||||
echo "端口 8899 已被占用,跳过代理桥接(可能已在运行)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 退出时清理本脚本启动的代理桥接
|
||||
cleanup_proxy_bridge() {
|
||||
if [ -f /tmp/proxy-bridge.pid ]; then
|
||||
PID=$(cat /tmp/proxy-bridge.pid 2>/dev/null)
|
||||
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
|
||||
kill "$PID" 2>/dev/null || true
|
||||
echo "已停止代理桥接 (PID $PID)"
|
||||
fi
|
||||
rm -f /tmp/proxy-bridge.pid
|
||||
fi
|
||||
}
|
||||
trap cleanup_proxy_bridge EXIT
|
||||
|
||||
# 1) ngrok 暴露 8000,写入 .env 的 CALLBACK_BASE_URL
|
||||
echo ">>> 配置 ngrok 回调地址..."
|
||||
sh ./run-ngrok.sh
|
||||
|
||||
# 2) 启动后端 + 前端(run-dev.sh)
|
||||
echo ">>> 启动后端与前端..."
|
||||
sh ./run-dev.sh
|
||||
|
||||
65
scripts/expose-proxy-with-cloudflared.sh
Executable file
65
scripts/expose-proxy-with-cloudflared.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# 用 cloudflared 暴露本机 8899(代理桥接),将公网 URL 写入 .env 的 HTTP_PROXY/HTTPS_PROXY
|
||||
# 前提:先在本机跑起代理桥接(python scripts/local_proxy_bridge.py)且 8899 可访问
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if ! command -v cloudflared >/dev/null 2>&1; then
|
||||
echo "未检测到 cloudflared。请先安装:"
|
||||
echo " brew install cloudflared # macOS"
|
||||
echo " 或 https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/download-and-install/install-cloudflared/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOG="/tmp/cloudflared-8899.log"
|
||||
rm -f "$LOG"
|
||||
|
||||
echo "启动 cloudflared 隧道 -> http://127.0.0.1:8899 ..."
|
||||
cloudflared tunnel --url http://127.0.0.1:8899 > "$LOG" 2>&1 &
|
||||
CF_PID=$!
|
||||
echo $CF_PID > /tmp/cloudflared-8899.pid
|
||||
|
||||
echo "等待隧道 URL(约 5–15 秒)..."
|
||||
PUBLIC_URL=""
|
||||
for i in $(seq 1 20); do
|
||||
sleep 1
|
||||
if [ -f "$LOG" ] && [ -s "$LOG" ]; then
|
||||
PUBLIC_URL=$(grep -oE 'https://[a-zA-Z0-9][-a-zA-Z0-9.]*\.trycloudflare\.com' "$LOG" 2>/dev/null | head -1)
|
||||
[ -z "$PUBLIC_URL" ] && PUBLIC_URL=$(grep -oE 'https://[^[:space:]]+trycloudflare\.com' "$LOG" 2>/dev/null | head -1)
|
||||
if [ -n "$PUBLIC_URL" ]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PUBLIC_URL" ]; then
|
||||
echo "未从 cloudflared 输出解析到 URL。请查看: cat $LOG"
|
||||
kill $CF_PID 2>/dev/null || true
|
||||
rm -f /tmp/cloudflared-8899.pid
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "隧道地址: $PUBLIC_URL"
|
||||
|
||||
# 写入 .env
|
||||
ENV_FILE=".env"
|
||||
touch "$ENV_FILE"
|
||||
_upsert() {
|
||||
local key="$1" val="$2"
|
||||
if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
sed -i '' "s|^${key}=.*|${key}=${val}|" "$ENV_FILE"
|
||||
else
|
||||
sed -i "s|^${key}=.*|${key}=${val}|" "$ENV_FILE"
|
||||
fi
|
||||
else
|
||||
echo "${key}=${val}" >> "$ENV_FILE"
|
||||
fi
|
||||
}
|
||||
_upsert "HTTP_PROXY" "$PUBLIC_URL"
|
||||
_upsert "HTTPS_PROXY" "$PUBLIC_URL"
|
||||
echo "已写入 $ENV_FILE: HTTP_PROXY / HTTPS_PROXY = $PUBLIC_URL"
|
||||
|
||||
echo ""
|
||||
echo "cloudflared 已在后台运行 (PID $CF_PID)。停止: kill $CF_PID 或 kill \$(cat /tmp/cloudflared-8899.pid)"
|
||||
echo "请重启本项目的后端使代理生效;7006 将经此地址使用你的本机代理(8899->7890)。"
|
||||
148
scripts/local_proxy_bridge.py
Normal file
148
scripts/local_proxy_bridge.py
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
本地代理桥接:在本机起一个 HTTP 代理端口,把请求转发到本机真实代理(如 127.0.0.1:7890)。
|
||||
再用 ngrok 暴露该端口,把 ngrok 公网地址填给 7006 的 Proxy,7006 即可通过你的本地代理出网。
|
||||
|
||||
用法:
|
||||
python scripts/local_proxy_bridge.py
|
||||
# 默认监听 0.0.0.0:8899,上游代理 127.0.0.1:7890(本机 Clash/V2Ray 等)
|
||||
# 再开一个终端: ngrok http 8899 (或 ngrok tcp 8899,则填 http://0.tcp.ngrok.io:端口)
|
||||
# 把 ngrok 生成的公网 URL 填到 .env 的 HTTP_PROXY / HTTPS_PROXY,7006 即可通过你的本机代理出网
|
||||
|
||||
环境变量(可选):
|
||||
PROXY_BRIDGE_LISTEN=0.0.0.0:8899 # 监听地址
|
||||
PROXY_BRIDGE_UPSTREAM=127.0.0.1:7890 # 上游代理(本机 Clash/V2Ray 等)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 可选:把项目根加入 path
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_ROOT = os.path.dirname(_SCRIPT_DIR)
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
def _parse_addr(s: str, default_host: str, default_port: int):
|
||||
s = (s or "").strip()
|
||||
if not s:
|
||||
return default_host, default_port
|
||||
if ":" in s:
|
||||
host, _, port = s.rpartition(":")
|
||||
return host or default_host, int(port) if port else default_port
|
||||
return default_host, int(s) if s.isdigit() else default_port
|
||||
|
||||
|
||||
async def _relay(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
"""双向转发:reader -> writer 直到 EOF。"""
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(65536)
|
||||
if not data:
|
||||
break
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except (ConnectionResetError, BrokenPipeError, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _handle_client(
|
||||
client_reader: asyncio.StreamReader,
|
||||
client_writer: asyncio.StreamWriter,
|
||||
upstream_host: str,
|
||||
upstream_port: int,
|
||||
):
|
||||
"""处理一个客户端连接:把首包(CONNECT/GET 等)转发到上游代理,再双向 relay。"""
|
||||
try:
|
||||
# 读首行 + headers(到 \r\n\r\n)
|
||||
first_line = await client_reader.readline()
|
||||
if not first_line:
|
||||
return
|
||||
header_lines = []
|
||||
while True:
|
||||
line = await client_reader.readline()
|
||||
if line in (b"\r\n", b"\n"):
|
||||
break
|
||||
header_lines.append(line)
|
||||
|
||||
request_head = first_line + b"".join(header_lines) + b"\r\n"
|
||||
|
||||
# 连上游代理
|
||||
try:
|
||||
up_reader, up_writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(upstream_host, upstream_port), timeout=10.0
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[proxy-bridge] upstream connect failed: {e}", flush=True)
|
||||
client_writer.write(
|
||||
b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"
|
||||
b"Upstream proxy connect failed"
|
||||
)
|
||||
await client_writer.drain()
|
||||
client_writer.close()
|
||||
return
|
||||
|
||||
# 把请求头发给上游
|
||||
up_writer.write(request_head)
|
||||
await up_writer.drain()
|
||||
|
||||
# CONNECT 时上游会先回 200 Connection Established,需要把这部分先读完并回给客户端,再双向 relay
|
||||
# 非 CONNECT 时上游直接回响应,也要先读完并回给客户端
|
||||
# 为简单起见:先读上游的响应头(到 \r\n\r\n),转发给客户端,然后双向 relay 剩余 body/隧道
|
||||
up_buf = b""
|
||||
while b"\r\n\r\n" not in up_buf and len(up_buf) < 65536:
|
||||
chunk = await up_reader.read(4096)
|
||||
if not chunk:
|
||||
break
|
||||
up_buf += chunk
|
||||
if up_buf:
|
||||
client_writer.write(up_buf)
|
||||
await client_writer.drain()
|
||||
|
||||
# 双向转发剩余数据(CONNECT 隧道或响应 body)
|
||||
await asyncio.gather(
|
||||
_relay(client_reader, up_writer),
|
||||
_relay(up_reader, client_writer),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[proxy-bridge] handle error: {e}", flush=True)
|
||||
finally:
|
||||
try:
|
||||
client_writer.close()
|
||||
await client_writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _run(listen_host: str, listen_port: int, upstream_host: str, upstream_port: int):
|
||||
server = await asyncio.start_server(
|
||||
lambda r, w: _handle_client(r, w, upstream_host, upstream_port),
|
||||
listen_host,
|
||||
listen_port,
|
||||
)
|
||||
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
|
||||
print(f"[proxy-bridge] listening on {addrs}, upstream={upstream_host}:{upstream_port}", flush=True)
|
||||
print(f"[proxy-bridge] expose with: ngrok http {listen_port}", flush=True)
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
def main():
|
||||
listen_spec = os.environ.get("PROXY_BRIDGE_LISTEN", "0.0.0.0:8899")
|
||||
upstream_spec = os.environ.get("PROXY_BRIDGE_UPSTREAM", "127.0.0.1:7890")
|
||||
listen_host, listen_port = _parse_addr(listen_spec, "0.0.0.0", 8899)
|
||||
upstream_host, upstream_port = _parse_addr(upstream_spec, "127.0.0.1", 7890)
|
||||
asyncio.run(_run(listen_host, listen_port, upstream_host, upstream_port))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user