Compare commits

...

2 Commits

Author SHA1 Message Date
丹尼尔
66362780a0 fix: 新增代理 2026-03-12 13:02:25 +08:00
丹尼尔
bdba4ec071 fix 2026-03-12 11:52:04 +08:00
19 changed files with 8780 additions and 204 deletions

5
.env
View File

@@ -7,3 +7,8 @@ APIKEY=sk-85880595fc714d63bfd0b025e917bd26#千问apikey
# 消息回调ngrok 调通用,由 run-ngrok.sh 自动写入) # 消息回调ngrok 调通用,由 run-ngrok.sh 自动写入)
CALLBACK_BASE_URL=https://dissonant-destinee-nonsensibly.ngrok-free.dev 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

View File

@@ -12,6 +12,24 @@ SLIDER_VERIFY_KEY=408449830
# 优先 WECHAT_WS_KEY未设置则使用 KEY登录参数填的 key # 优先 WECHAT_WS_KEY未设置则使用 KEY登录参数填的 key
# WECHAT_WS_KEY=HBpEnbtj9BJZ # 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 注册 SetCallback新消息由 7006 POST 到本服务,不再走 WS
# 需为 7006 能访问到的公网地址,例如 https://your-domain.com # 需为 7006 能访问到的公网地址,例如 https://your-domain.com
# CALLBACK_BASE_URL=https://your-domain.com # CALLBACK_BASE_URL=https://your-domain.com

27
.env.pro Normal file
View 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.

View File

@@ -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)") 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(""" cur.execute("""
CREATE TABLE IF NOT EXISTS models ( CREATE TABLE IF NOT EXISTS models (

View File

@@ -3,6 +3,14 @@ import html
import logging import logging
import os import os
from contextlib import asynccontextmanager 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 logging.handlers import RotatingFileHandler
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional 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 一致) # 消息实时回调:设置后 7006 将新消息 POST 到该地址,作为主接收入口(与 SetCallback 一致)
CALLBACK_BASE_URL = (os.getenv("CALLBACK_BASE_URL") or "").strip().rstrip("/") 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_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/SendTextMessagebody 为 SendMessageModelMsgItem 数组) # 发送文本消息swagger 中为 POST /message/SendTextMessagebody 为 SendMessageModelMsgItem 数组)
SEND_MSG_PATH = (os.getenv("SEND_MSG_PATH") or "/message/SendTextMessage").strip() SEND_MSG_PATH = (os.getenv("SEND_MSG_PATH") or "/message/SendTextMessage").strip()
# 发送图片消息:部分上游为独立接口,或与文本同 path 仅 MsgType 不同(如 3=图片) # 发送图片消息:部分上游为独立接口,或与文本同 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" callback_url = f"{CALLBACK_BASE_URL.rstrip('/')}/api/callback/wechat-message"
body = {"CallbackURL": callback_url, "Enabled": True} body = {"CallbackURL": callback_url, "Enabled": True}
try: 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) resp = await client.post(url, params={"key": key}, json=body)
if resp.status_code >= 400: 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]) 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] = "" Proxy: Optional[str] = ""
IpadOrmac: Optional[str] = "" IpadOrmac: Optional[str] = ""
Check: Optional[bool] = False 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") @app.middleware("http")
@@ -362,6 +381,84 @@ async def api_ws_status() -> dict:
return {"connected": is_ws_connected()} 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: def _proxy_from_env() -> str:
"""当登录页未填代理时,使用环境变量中的代理(服务器上设置 HTTP_PROXY/HTTPS_PROXY 后生效)。""" """当登录页未填代理时,使用环境变量中的代理(服务器上设置 HTTP_PROXY/HTTPS_PROXY 后生效)。"""
return ( 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改为 socks5h7006 需 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") @app.post("/auth/qrcode")
async def get_login_qrcode(body: QrCodeRequest): async def get_login_qrcode(body: QrCodeRequest):
key = body.key key = body.key
if not key: if not key:
raise HTTPException(status_code=400, detail="key is required") raise HTTPException(status_code=400, detail="key is required")
payload = body.dict(exclude={"key"}) proxy = _resolve_proxy(body.Proxy or "", allow_auto=True)
if not (payload.get("Proxy") or "").strip(): if proxy == "__tunnel__":
env_proxy = _proxy_from_env() proxy = _proxy_from_tunnel()
if env_proxy: if proxy:
payload["Proxy"] = env_proxy logger.info("GetLoginQrCodeNewDirect: using proxy from tunnel (socks5h), len=%s", len(proxy))
logger.info("GetLoginQrCodeNewDirect: using proxy from env (HTTP_PROXY/HTTPS_PROXY), len=%s", len(env_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/KDLforce_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" 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: 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) resp = await client.post(url, params={"key": key}, json=payload)
except Exception as exc: except Exception as exc:
logger.exception("Error calling upstream GetLoginQrCodeNewDirect: %s", 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" url = f"{WECHAT_UPSTREAM_BASE_URL}/login/GetLoginStatus"
logger.info("GetLoginStatus: key=%s, url=%s", key, url) logger.info("GetLoginStatus: key=%s, url=%s", key, url)
try: 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}) resp = await client.get(url, params={"key": key})
except Exception as exc: except Exception as exc:
logger.exception("Error calling upstream GetLoginStatus: %s", 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" url = f"{CHECK_STATUS_BASE_URL}/login/CheckLoginStatus"
logger.info("CheckLoginStatus: key=%s, url=%s", key, url) logger.info("CheckLoginStatus: key=%s, url=%s", key, url)
try: 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}) resp = await client.get(url, params={"key": key})
except Exception as exc: except Exception as exc:
logger.exception("Error calling upstream CheckLoginStatus: %s", exc) logger.exception("Error calling upstream CheckLoginStatus: %s", exc)
@@ -557,7 +859,12 @@ async def check_scan_status(
resp.status_code, resp.status_code,
body_full[:2000] if len(body_full) > 2000 else body_full, 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) ticket = _extract_clean_ticket(data)
if ticket: if ticket:
# data62 使用完整原始数据,来自 GetLoginQrCodeNewDirect 的存储或本次响应的 Data62 # data62 使用完整原始数据,来自 GetLoginQrCodeNewDirect 的存储或本次响应的 Data62
@@ -635,7 +942,7 @@ async def slider_asset_proxy(path: str):
"""代理 7765 的 assets如 N_jYM_2V.js避免跨域加载被 CORS 拦截。""" """代理 7765 的 assets如 N_jYM_2V.js避免跨域加载被 CORS 拦截。"""
url = f"{SLIDER_VERIFY_BASE_URL.rstrip('/')}/assets/{path}" url = f"{SLIDER_VERIFY_BASE_URL.rstrip('/')}/assets/{path}"
try: 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) resp = await client.get(url)
if resp.status_code >= 400: if resp.status_code >= 400:
raise HTTPException(status_code=resp.status_code, detail=resp.text[:200]) 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("/") + "/" url = SLIDER_VERIFY_BASE_URL.rstrip("/") + "/"
params = {"key": key, "data62": (data62 or "").strip(), "original_ticket": ticket_val} params = {"key": key, "data62": (data62 or "").strip(), "original_ticket": ticket_val}
try: 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) resp = await client.get(url, params=params)
# 返回上游的 body若为 JSON 则解析后返回 # 返回上游的 body若为 JSON 则解析后返回
try: try:
@@ -706,7 +1013,7 @@ async def api_slider_verify_post(body: SliderVerifyBody):
url = SLIDER_VERIFY_BASE_URL.rstrip("/") + "/" url = SLIDER_VERIFY_BASE_URL.rstrip("/") + "/"
params = {"key": body.key, "data62": (body.data62 or "").strip(), "original_ticket": ticket_val} params = {"key": body.key, "data62": (body.data62 or "").strip(), "original_ticket": ticket_val}
try: 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) resp = await client.get(url, params=params)
try: try:
return resp.json() return resp.json()
@@ -966,7 +1273,7 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
body = await request.json() body = await request.json()
except Exception: except Exception:
body = {} body = {}
# 打印回调原始内容,便于排查结构与字段(截断避免日志过大) # 打印回调原始内容,便于排查(截断避免日志过大)
try: try:
logger.info("callback/wechat-message raw body: %s", str(body)[:1000]) logger.info("callback/wechat-message raw body: %s", str(body)[:1000])
except Exception: except Exception:
@@ -975,6 +1282,11 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
if not k: if not k:
logger.warning("callback/wechat-message: missing key in query and body") logger.warning("callback/wechat-message: missing key in query and body")
return JSONResponse(content={"ok": False, "error": "missing key"}, status_code=200) 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: try:
payload: Any = body payload: Any = body
# 7006 回调当前格式示例:{"key": "...", "message": {...}, "type": "message"} # 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)): if isinstance(inner, (dict, list)):
payload = inner payload = inner
_on_ws_message(k, payload) _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: except Exception as e:
logger.exception("callback/wechat-message key=%s: %s", k[-4:] if len(k) >= 4 else "****", e) logger.exception("callback/wechat-message key=%s: %s", k[-4:] if len(k) >= 4 else "****", e)
return {"ok": True} return {"ok": True}
@@ -997,7 +1310,7 @@ async def _send_message_upstream(key: str, to_user_name: str, content: str) -> d
"""调用上游发送文本消息;成功时写入发出记录并返回响应,失败抛 HTTPException。""" """调用上游发送文本消息;成功时写入发出记录并返回响应,失败抛 HTTPException。"""
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_MSG_PATH}" url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_MSG_PATH}"
payload = {"MsgItem": [{"ToUserName": to_user_name, "MsgType": 1, "TextContent": content}]} 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) resp = await client.post(url, params={"key": key}, json=payload)
if resp.status_code >= 400: if resp.status_code >= 400:
body_preview = resp.text[:400] if resp.text else "" 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: if not msg_items:
raise HTTPException(status_code=400, detail="items 中至少需要一条有效 to_user_name 与 content") raise HTTPException(status_code=400, detail="items 中至少需要一条有效 to_user_name 与 content")
payload = {"MsgItem": msg_items} 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) resp = await client.post(url, params={"key": key}, json=payload)
if resp.status_code >= 400: if resp.status_code >= 400:
body_preview = resp.text[:400] if resp.text else "" 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 [], "AtWxIDList": at_wxid_list or [],
} }
payload = {"MsgItem": [item]} 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) resp = await client.post(url, params={"key": key}, json=payload)
if resp.status_code >= 400: if resp.status_code >= 400:
body_preview = resp.text[:400] if resp.text else "" 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]: for k, v in list(data.items())[:5]:
preview = str(v)[:80] if v is not None else "null" preview = str(v)[:80] if v is not None else "null"
logger.info(" Data.%s: %s", k, preview) 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: 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("wxcontactList")
or data.get("CachedContactList") or data.get("CachedContactList")
) )
# 7006 格式ContactList 为对象,联系人 id 在 contactUsernameList 字符串数组里 # 7006 格式ContactList 为对象,联系人 id 在 contactUsernameList 数组里
if isinstance(contact_list, dict): if isinstance(contact_list, dict):
username_list = ( username_list = (
contact_list.get("contactUsernameList") contact_list.get("contactUsernameList")
or 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 contact_list.get("usernameList")
or [] or []
) )
if isinstance(username_list, list) and username_list: if isinstance(username_list, list) and username_list:
@@ -1241,23 +1566,21 @@ def _normalize_contact_list(raw: Any) -> List[dict]:
continue continue
if not isinstance(x, dict): if not isinstance(x, dict):
continue continue
wxid = ( wxid = _unwrap_wechat_field(
x.get("wxid") x.get("wxid")
or x.get("Wxid") or x.get("Wxid")
or x.get("UserName") or x.get("UserName")
or x.get("userName") or x.get("userName")
or x.get("Alias") or x.get("Alias")
or "" ) or ""
) remark = _unwrap_wechat_field(
remark = (
x.get("remark_name") x.get("remark_name")
or x.get("RemarkName") or x.get("RemarkName")
or x.get("NickName") or x.get("NickName")
or x.get("nickName") or x.get("nickName")
or x.get("DisplayName") or x.get("DisplayName")
or wxid ) or wxid
) result.append({"wxid": str(wxid).strip(), "remark_name": str(remark).strip()})
result.append({"wxid": wxid, "remark_name": remark})
return result return result
@@ -1272,7 +1595,7 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
body: dict = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0} body: dict = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
max_rounds = 50 max_rounds = 50
try: 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): for round_num in range(max_rounds):
resp = await client.post(url, params={"key": key}, json=body) resp = await client.post(url, params={"key": key}, json=body)
if resp.status_code >= 400: if resp.status_code >= 400:
@@ -1282,9 +1605,26 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
chunk = _normalize_contact_list(raw) chunk = _normalize_contact_list(raw)
if not chunk and isinstance(raw, dict): if not chunk and isinstance(raw, dict):
chunk = _normalize_contact_list(raw.get("Data") or raw.get("data") or raw) chunk = _normalize_contact_list(raw.get("Data") or raw.get("data") or raw)
if round_num == 0 and not chunk and isinstance(raw, dict): # 首轮无归一化结果时,直接从 Data.ContactList 下任意已知数组键取 id 列表7006 格式)
_log_contact_list_response_structure(raw) if not chunk and round_num == 0 and isinstance(raw, dict):
for item in chunk: 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() wxid = (item.get("wxid") or "").strip()
if wxid and wxid not in seen: if wxid and wxid not in seen:
seen.add(wxid) seen.add(wxid)
@@ -1306,14 +1646,15 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
return usernames 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 列表; - 先通过 GetContactList 拿到全部 UserName 列表;
- 再通过 /friend/GetContactDetailsList 批量拉取详情; - 再通过 /friend/GetContactDetailsList 批量拉取详情;
- 构建 name(微信号/昵称/备注) -> 联系人详情 的索引。 - 构建 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] return _contact_index[key]
usernames = await _fetch_all_contact_usernames(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" url = f"{CHECK_STATUS_BASE_URL.rstrip('/')}/friend/GetContactDetailsList"
index: Dict[str, dict] = {} 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: async def fetch_one_batch(client: httpx.AsyncClient, batch: List[str], batch_idx: int) -> List[dict]:
chunk_size = 50 body = {"RoomWxIDList": [], "UserNames": batch}
for i in range(0, len(usernames), chunk_size): async with sem:
batch = usernames[i : i + chunk_size]
body = {
"RoomWxIDList": [],
"UserNames": batch,
}
try: try:
resp = await client.post(url, params={"key": key}, json=body) resp = await client.post(url, params={"key": key}, json=body)
except Exception as e: except Exception as e:
logger.warning("GetContactDetailsList batch error: %s", e) logger.warning("GetContactDetailsList batch %s error: %s", batch_idx, e)
continue return []
if resp.status_code >= 400: if resp.status_code >= 400:
logger.warning("GetContactDetailsList %s: %s", resp.status_code, resp.text[:200]) logger.warning("GetContactDetailsList batch %s %s: %s", batch_idx, resp.status_code, resp.text[:200])
continue return []
raw = resp.json() raw = resp.json()
data = raw.get("Data") or raw.get("data") or raw 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 = [] items = []
if isinstance(data, dict): if isinstance(data, dict):
# 7006 GetContactDetailsList 当前结构Data.contactList 为联系人详情数组
items = ( items = (
data.get("List") data.get("List")
or data.get("list") or data.get("list")
or data.get("ContactDetailsList") or data.get("ContactDetailsList")
or data.get("contacts") or data.get("contacts")
or data.get("contactList") or data.get("contactList")
or data.get("ContactList") # 7006 可能用大写
or [] or []
) )
elif isinstance(data, list): elif isinstance(data, list):
items = data items = data
if not isinstance(items, list): if not isinstance(items, list):
# 结构不符时记录一条日志,帮助判断需要从哪里取联系人列表 return []
logger.info( if not items and batch_idx == 0:
"GetContactDetailsList no list items parsed, data_type=%s, sample=%s", logger.info("GetContactDetailsList batch 0: data keys=%s, no list parsed", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
type(data).__name__, if batch_idx == 0 and items:
str(data)[:200], 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 continue
# 追加一次示例项日志便于确认字段名UserName/NickName/RemarkName 等) for d in one or []:
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:
if not isinstance(d, dict): if not isinstance(d, dict):
continue continue
# 只保留 bitVal == 3 的联系人(如上游定义的「有效联系人」),其它忽略 # 仅当上游明确返回 bitVal 且不为 3 时跳过(未返回或为 3 则保留,避免漏掉联系人)
try: 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): except (TypeError, ValueError):
bit_val = 0 pass
if bit_val != 3:
continue
# 7006 联系人详情字段为 userName/nickName/pyinitial/quanPin 等,内部多为 {'str': 'xxx'} 包装
wxid = _unwrap_wechat_field( wxid = _unwrap_wechat_field(
d.get("userName") or d.get("UserName") or d.get("user_name") or d.get("wxid") 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, "quan_pin": quan_pin,
"raw": d, "raw": d,
} }
# 主键wxid
index[wxid] = info index[wxid] = info
# 昵称 / 备注作为别名,便于按自然语言查找
if nick and nick not in index: if nick and nick not in index:
index[nick] = info index[nick] = info
if remark and remark not in index: if remark and remark not in index:
index[remark] = info 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 _contact_index[key] = index
logger.info("Contact index built for key=***%s, size=%s", key[-4:] if len(key) >= 4 else "****", len(index)) logger.info("Contact index built for key=***%s, size=%s", key[-4:] if len(key) >= 4 else "****", len(index))
return index return index
@@ -1484,10 +1814,14 @@ _NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate", "Pr
@app.get("/api/contact-list") @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 构建的通用索引。禁止缓存。""" """获取全部联系人详情:基于 GetContactList + GetContactDetailsList 构建的通用索引。禁止缓存。"""
try: 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 主键) # 只返回去重后的联系人详情(以 wxid 主键)
uniques: Dict[str, dict] = {} uniques: Dict[str, dict] = {}
for name, info in index.items(): for name, info in index.items():
@@ -1711,7 +2045,7 @@ async def logout(body: LogoutBody):
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/LogOut" url = f"{WECHAT_UPSTREAM_BASE_URL}/login/LogOut"
logger.info("LogOut: key=%s, url=%s", key, url) logger.info("LogOut: key=%s, url=%s", key, url)
try: 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}) resp = await client.get(url, params={"key": key})
except Exception as exc: except Exception as exc:
logger.exception("Error calling upstream LogOut: %s", exc) logger.exception("Error calling upstream LogOut: %s", exc)

View File

@@ -1,5 +1,6 @@
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.0 uvicorn[standard]==0.30.0
python-dotenv>=1.0.0
httpx==0.27.0 httpx==0.27.0
websockets>=12.0 websockets>=12.0
openai>=1.0.0 openai>=1.0.0

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""数据库存储:客户档案、定时问候、商品标签、推送群组/任务、同步消息、模型、AI 回复配置。使用 SQLite便于增删改查。""" """数据库存储:客户档案、定时问候、商品标签、推送群组/任务、同步消息、回调原始日志、模型、AI 回复配置。使用 SQLite便于增删改查。"""
import datetime
import json import json
import threading import threading
import time 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())}]) 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]: def list_models() -> List[Dict]:
with _LOCK: with _LOCK:

View File

@@ -37,7 +37,7 @@ async def _run_ws(key: str) -> None:
while True: while True:
try: try:
_ws_connected = False _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 _ws_connected = True
logger.info("WS connected for key=%s", key) logger.info("WS connected for key=%s", key)
while True: while True:

11
ngrok.yml Normal file
View 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

View File

@@ -544,12 +544,13 @@
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="proxy">代理(可选)</label> <label for="proxy">代理(可选,不填则由后端自动读取</label>
<input <input
id="proxy" id="proxy"
placeholder="socks5://username:password@ipv4:port" placeholder="socks5://ip:port 或 socks5://user:pass@ip:port"
autocomplete="off" autocomplete="off"
/> />
<button type="button" class="secondary" id="btn-check-proxy" style="margin-top: 6px; padding: 4px 10px; font-size: 12px;">检测代理是否正常</button>
</div> </div>
<div class="field"> <div class="field">
<label for="device">登录设备类型</label> <label for="device">登录设备类型</label>
@@ -559,19 +560,15 @@
<option value="mac">Mac</option> <option value="mac">Mac</option>
</select> </select>
</div> </div>
<div class="field">
<label>&nbsp;</label>
<div class="checkbox-row">
<input type="checkbox" id="check-proxy" />
<label for="check-proxy">修改代理时自动检测可用性</label>
</div>
</div>
</div> </div>
<div class="actions"> <div class="actions">
<button class="primary" id="btn-qrcode"> <button class="primary" id="btn-qrcode">
获取登录二维码 获取登录二维码
</button> </button>
<button class="secondary" id="btn-qrcode-mac" title="使用 Mac 取码(与上方选择 Mac 后点获取二维码等效)">
重新取码(Mac)
</button>
<button class="secondary" id="btn-wake"> <button class="secondary" id="btn-wake">
唤醒 唤醒
</button> </button>
@@ -629,11 +626,11 @@
<div id="slider-area" style="display: none;"> <div id="slider-area" style="display: none;">
<div class="card" style="max-width: 480px;"> <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 id="slider-app" data-v-app="">
<div class="params-section" style="margin-bottom: 12px;"> <div class="params-section" style="margin-bottom: 12px;">
<label class="form-label" for="keyInput">Key:</label> <label class="form-label" for="keyInput">Key (7765 服务方):</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);"> <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> <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);"> <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> <label class="form-label" for="originalTicketInput">Original Ticket:</label>
@@ -665,6 +662,7 @@
sliderParams: null, sliderParams: null,
sliderScriptLoaded: false, sliderScriptLoaded: false,
sliderListenersBound: false, sliderListenersBound: false,
qrcodeFetchedAt: 0,
}; };
function parseSliderUrlParams(sliderUrl) { function parseSliderUrlParams(sliderUrl) {
@@ -755,6 +753,8 @@
function setLoading(loading) { function setLoading(loading) {
$('btn-qrcode').disabled = loading; $('btn-qrcode').disabled = loading;
var btnQrcodeMac = $('btn-qrcode-mac');
if (btnQrcodeMac) btnQrcodeMac.disabled = loading;
var btnWake = $('btn-wake'); var btnWake = $('btn-wake');
if (btnWake) btnWake.disabled = loading; if (btnWake) btnWake.disabled = loading;
$('btn-check-scan').disabled = loading; $('btn-check-scan').disabled = loading;
@@ -765,8 +765,7 @@
function getCommonPayload() { function getCommonPayload() {
const key = $('key').value.trim(); const key = $('key').value.trim();
const proxy = $('proxy').value.trim(); const proxy = $('proxy').value.trim();
const device = $('device').value; const device = ($('device') && $('device').value) || '';
const check = $('check-proxy').checked;
if (!key) { if (!key) {
alert('请先填写账号唯一标识key'); alert('请先填写账号唯一标识key');
return null; return null;
@@ -774,8 +773,7 @@
return { return {
key, key,
proxy: proxy || undefined, proxy: proxy || undefined,
ipadOrMac: device, ipadOrMac: device || 'ipad',
check,
}; };
} }
@@ -999,16 +997,68 @@
return body; return body;
} }
async function onGetQrCode() { async function onWake() {
const payload = getCommonPayload(); const payload = getCommonPayload();
if (!payload) return; if (!payload) return;
setLoading(true); setLoading(true);
try { 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 = { const body = {
Proxy: payload.proxy || '', Proxy: payload.proxy || '',
IpadOrmac: payload.ipadOrMac || '', IpadOrmac: forceMac ? 'mac' : (payload.ipadOrMac || 'ipad'),
Check: !!payload.check, Check: false,
force_mac: !!forceMac,
}; };
const data = await callApi('/auth/qrcode', { const data = await callApi('/auth/qrcode', {
method: 'POST', method: 'POST',
@@ -1028,18 +1078,26 @@
log(line2); log(line2);
if (line3) log(line3); if (line3) log(line3);
})(); })();
state.qrcodeFetchedAt = Date.now();
renderQrFromResponse(data); renderQrFromResponse(data);
updateLoginState('等待扫码 / 确认中', 'pending', '请在 60 秒内使用微信扫码。'); updateLoginState(forceMac ? '已用 Mac 重新取码,请手机停在确认页并完成下方滑块' : '等待扫码 / 确认中', 'pending', forceMac ? 'Data62 已更新,需滑块时请点「打开滑块验证」' : '请在 60 秒内使用微信扫码。');
// 仅通过轮询检测扫码状态,不立即查状态(避免沿用上次的「需验证」误弹滑块) if (forceMac && data) {
if (state.pollingScan) { var newData62 = (data.Data && data.Data.Data62) || data.Data62 || data.data62 || '';
clearInterval(state.pollingScan); 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); setTimeout(() => onCheckScanStatus(true), 2000);
state.pollingScan = setInterval(() => { state.pollingScan = setInterval(() => { onCheckScanStatus(true); }, 5000);
onCheckScanStatus(true);
}, 5000);
} catch (e) { } catch (e) {
log('获取二维码失败: ' + e.message, 'error'); log('获取二维码失败: ' + (e.message || e), 'error');
updateLoginState('二维码获取失败', 'offline', e.message || ''); updateLoginState('二维码获取失败', 'offline', e.message || '');
} finally { } finally {
setLoading(false); setLoading(false);
@@ -1047,7 +1105,7 @@
} }
async function onCheckScanStatus(silent = false) { async function onCheckScanStatus(silent = false) {
const payload = getCommonPayload(); const payload = getCommonPayload(false);
if (!payload) return; if (!payload) return;
if (!silent) setLoading(true); if (!silent) setLoading(true);
try { try {
@@ -1063,14 +1121,17 @@
const d = obj.Data && typeof obj.Data === 'object' ? obj.Data : obj; const d = obj.Data && typeof obj.Data === 'object' ? obj.Data : obj;
const stateVal = d.state ?? d.State; const stateVal = d.state ?? d.State;
// 后端返回滑块 path在当前页右侧替换为滑块区域并自动填充参数 // 后端返回滑块 path在当前页右侧替换为滑块区域并自动填充参数(取码后 15 秒内不自动切,避免未出码就进验证)
const sliderUrl = data.slider_url; 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; state.sliderOpened = true;
log('需完成滑块验证,已切换到滑块验证区域。', 'warn'); log('需完成滑块验证,已切换到滑块验证区域。', 'warn');
const params = parseSliderUrlParams(sliderUrl); const params = parseSliderUrlParams(sliderUrl);
if (params) state.sliderParams = params; if (params) state.sliderParams = params;
showSliderAreaAndFill(params); showSliderAreaAndFill(params);
} else if (sliderUrl && !allowSliderSwitch) {
log('检测到需验证,但刚取码不久,暂不自动切到滑块,请先扫码或稍后再检测。', 'warn');
} }
// 检测状态是否成功登录state==2 或 Success+已登录 等,成功后自动跳转管理页 // 检测状态是否成功登录state==2 或 Success+已登录 等,成功后自动跳转管理页
@@ -1102,11 +1163,14 @@
} else { } else {
updateLoginState(needVerify || sliderUrl ? '请完成滑块验证' : '正在确认登录状态…', 'pending', updateLoginState(needVerify || sliderUrl ? '请完成滑块验证' : '正在确认登录状态…', 'pending',
needVerify || sliderUrl ? '扫码完成,请完成验证后登录' : ''); needVerify || sliderUrl ? '扫码完成,请完成验证后登录' : '');
// 判定到需验证时直接切换验证模块,不等用户点「检测扫码状态」;有 slider_url 已在上方填参,无则先切过去,后续轮询会带参 // 判定到需验证时切换验证模块;取码后 15 秒内不自动切,避免未出码就进验证
if (needVerify || sliderUrl) { if (needVerify || sliderUrl) {
state.sliderOpened = true; var allowSwitch = !state.qrcodeFetchedAt || (Date.now() - state.qrcodeFetchedAt) > 15000;
if (!sliderUrl) log('需完成滑块验证,已切换到滑块验证区域,等待参数…', 'warn'); if (allowSwitch) {
showSliderAreaAndFill(state.sliderParams || {}); state.sliderOpened = true;
if (!sliderUrl) log('需完成滑块验证,已切换到滑块验证区域,等待参数…', 'warn');
showSliderAreaAndFill(state.sliderParams || {});
}
} }
} }
} else { } else {
@@ -1172,17 +1236,19 @@
if ($('slider-area') && $('slider-area').style.display !== 'none') { if ($('slider-area') && $('slider-area').style.display !== 'none') {
showQrArea(); 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) { $('btn-wake') && $('btn-wake').addEventListener('click', function(e) {
e.preventDefault(); e.preventDefault();
var device = ($('device') && $('device').value) || ''; onWake();
if (device === 'mac') { });
if ($('slider-area') && $('slider-area').style.display !== 'none') showQrArea(); $('btn-check-proxy') && $('btn-check-proxy').addEventListener('click', function(e) {
onGetQrCode(); e.preventDefault();
} else { onCheckProxy();
log('唤醒:请先将「登录设备类型」选为 Mac再点击唤醒以获取二维码。', 'warn');
}
}); });
$('btn-show-slider') && $('btn-show-slider').addEventListener('click', function() { $('btn-show-slider') && $('btn-show-slider').addEventListener('click', function() {
if (state.sliderParams) showSliderAreaAndFill(state.sliderParams); if (state.sliderParams) showSliderAreaAndFill(state.sliderParams);

View File

@@ -146,11 +146,11 @@
<div class="field full"> <div class="field full">
<label>客户标签(从客户档案中选)</label> <label>客户标签(从客户档案中选)</label>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;"> <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> <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> <span id="g-tags-chips" class="tags-chips"></span>
</div> </div>
<p class="small-label" style="margin-top:4px">在下方「客户档案」中维护的标签会出现在下拉列表;可多选,发送时仅推送给带这些标签的客户。</p> <p class="small-label" style="margin-top:4px">在下方「客户档案」中维护的标签会出现在下拉列表;可多选,发送时仅推送给带这些标签的客户。支持获取联系人列表。</p>
</div> </div>
<div class="field full"><label>问候语模板(可用 {{name}}</label><textarea id="g-template" rows="2" placeholder="早安,{{name}}!今日上新…"></textarea></div> <div class="field full"><label>问候语模板(可用 {{name}}</label><textarea id="g-template" rows="2" placeholder="早安,{{name}}!今日上新…"></textarea></div>
<div class="field"><label>&nbsp;</label><label><input type="checkbox" id="g-use-qwen" /> 使用千问生成个性化问候</label></div> <div class="field"><label>&nbsp;</label><label><input type="checkbox" id="g-use-qwen" /> 使用千问生成个性化问候</label></div>
@@ -165,9 +165,10 @@
<label>选择接收人(从好友/客户列表)</label> <label>选择接收人(从好友/客户列表)</label>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;"> <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-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> <span id="mass-selected-count" class="small-label">已选 0 人</span>
</div> </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>
<div class="field full"><label>群发文案</label><textarea id="mass-content" rows="2" placeholder="输入要群发的文字…"></textarea></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> <button type="button" class="primary" id="btn-mass-send">一键群发</button>
@@ -211,11 +212,6 @@
<span id="ai-reply-status-text"></span> <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> <button type="button" class="secondary" id="btn-ai-reply-status" style="margin-left:8px;padding:2px 8px;font-size:12px">检查状态</button>
</div> </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="mgmt-form-grid">
<div class="field full"> <div class="field full">
<label>超级管理员 wxid每行一个或逗号分隔</label> <label>超级管理员 wxid每行一个或逗号分隔</label>
@@ -302,43 +298,27 @@
} }
async function loadAiReplyStatus() { async function loadAiReplyStatus() {
const key = $('key').value.trim(); const key = getKey();
const el = $('ai-reply-status-text'); const el = $('ai-reply-status-text');
if (!el) return; if (!el) return;
if (!key) { el.textContent = '请先登录'; return; } if (!key) { el.textContent = '请先登录'; el.style.color = 'var(--muted, #94a3b8)'; return; }
el.textContent = '检测中…'; el.textContent = '检测中…';
try { el.style.color = 'var(--muted, #94a3b8)';
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)';
try { try {
const data = await callApi('/api/callback-status?key=' + encodeURIComponent(key)); const data = await callApi('/api/callback-status?key=' + encodeURIComponent(key));
if (!data.configured) { if (!data.configured) {
el.textContent = '未配置(未设置 CALLBACK_BASE_URL使用 WS 拉取消息'; el.textContent = '未配置回调AI 未接管';
el.style.color = 'var(--muted, #94a3b8)'; el.style.color = 'var(--muted, #94a3b8)';
return; return;
} }
if (data.registered === true) { if (data.registered === true) {
el.textContent = '已配置且已向 7006 注册成功,新消息将推送到: ' + (data.callback_url || ''); el.textContent = '回调已配置且注册成功AI 已接管)';
el.style.color = 'var(--success, #22c55e)'; el.style.color = 'var(--success, #22c55e)';
} else if (data.registered === false) { } else if (data.registered === false) {
el.textContent = '已配置但向 7006 注册失败,请检查网络或 7006 服务。回调地址: ' + (data.callback_url || ''); el.textContent = '回调已配置但注册失败AI 未接管)';
el.style.color = 'var(--danger, #ef4444)'; el.style.color = 'var(--danger, #ef4444)';
} else { } else {
el.textContent = '已配置,回调地址: ' + (data.callback_url || ''); el.textContent = '回调已配置AI 接管状态未知,请检查 7006';
el.style.color = 'var(--muted, #94a3b8)'; el.style.color = 'var(--muted, #94a3b8)';
} }
} catch (e) { } catch (e) {
@@ -403,7 +383,7 @@
if (!key) { alert('请先登录'); return; } if (!key) { alert('请先登录'); return; }
sel.innerHTML = '<option value="">加载中…</option>'; sel.innerHTML = '<option value="">加载中…</option>';
try { 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 || []; const list = data.items || [];
lastLoadedContactList = list; lastLoadedContactList = list;
if (data.error) { if (data.error) {
@@ -651,6 +631,28 @@
} }
let massSelectedWxids = []; 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() { async function loadFriendsForMass() {
const key = $('key').value.trim(); const key = $('key').value.trim();
@@ -658,27 +660,55 @@
const el = $('mass-friend-list'); const el = $('mass-friend-list');
el.innerHTML = '<span class="small-label">加载中…</span>'; el.innerHTML = '<span class="small-label">加载中…</span>';
try { 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 || []; const list = data.items || [];
if (!list.length) { massContactList = list.map(f => {
el.innerHTML = '<span class="small-label">暂无联系人,请先在「客户档案」添加客户。</span>'; 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; return;
} }
el.innerHTML = list.map(f => { renderMassContactList();
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();
} catch (e) { } catch (e) {
el.innerHTML = '<span class="small-label">加载失败: ' + escapeHtml(e.message) + '</span>'; 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() { function updateMassSelected() {
const list = $('mass-friend-list'); const list = $('mass-friend-list');
if (!list) return; if (!list) return;
@@ -853,6 +883,7 @@
if ($('g-time')) { $('g-time').addEventListener('change', onGreetingTimeChange); $('g-time').addEventListener('input', onGreetingTimeChange); } if ($('g-time')) { $('g-time').addEventListener('change', onGreetingTimeChange); $('g-time').addEventListener('input', onGreetingTimeChange); }
})(); })();
$('btn-load-friends').addEventListener('click', loadFriendsForMass); $('btn-load-friends').addEventListener('click', loadFriendsForMass);
$('btn-load-customers-mass') && $('btn-load-customers-mass').addEventListener('click', loadCustomersForMass);
$('btn-mass-send').addEventListener('click', doMassSend); $('btn-mass-send').addEventListener('click', doMassSend);
$('btn-send-image').addEventListener('click', doSendImage); $('btn-send-image').addEventListener('click', doSendImage);
if ($('img-file') && $('img-file-name')) { if ($('img-file') && $('img-file-name')) {
@@ -862,7 +893,6 @@
} }
$('btn-ai-reply-save').addEventListener('click', async () => { await saveAiReplyConfig(); loadAiReplyStatus(); }); $('btn-ai-reply-save').addEventListener('click', async () => { await saveAiReplyConfig(); loadAiReplyStatus(); });
$('btn-ai-reply-status') && $('btn-ai-reply-status').addEventListener('click', 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-pt-add').addEventListener('click', addProductTag);
$('btn-push-group-add').addEventListener('click', createPushGroup); $('btn-push-group-add').addEventListener('click', createPushGroup);
$('btn-push-send').addEventListener('click', doPushSend); $('btn-push-send').addEventListener('click', doPushSend);

View File

@@ -42,10 +42,16 @@ fi
mkdir -p "${HOST_DATA_DIR}" mkdir -p "${HOST_DATA_DIR}"
echo "Data dir (host): ${HOST_DATA_DIR} -> container /app/backend/data" echo "Data dir (host): ${HOST_DATA_DIR} -> container /app/backend/data"
ENV_FILE=".env" # 优先使用 .env.prod 作为生产环境配置(例如在服务器上单独维护 CALLBACK_BASE_URL 等),
if [ ! -f "${ENV_FILE}" ]; then # 若不存在则回退到 .env再没有则从 .env.example 复制一份。
echo "Env file ${ENV_FILE} not found, copying from .env.example ..." if [ -f ".env.prod" ]; then
cp .env.example "${ENV_FILE}" 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 fi
echo "Running container ${CONTAINER_NAME} (frontend :${PORT}, backend :${BACKEND_PORT})..." echo "Running container ${CONTAINER_NAME} (frontend :${PORT}, backend :${BACKEND_PORT})..."

View File

@@ -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 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..." echo "检测到 ngrok 已在运行4040 可访问),直接读取 URL..."
else else
echo "启动 ngrok http 8000后端需在 8000 端口,可先在本脚本之后另开终端运行 ./run-dev.sh..." echo "启动 ngrok http 8000后端需在 8000 端口)..."
nohup ngrok http 8000 --log=stdout > "$NGROK_LOG" 2>&1 & nohup ngrok http 8000 --log=stdout > "$NGROK_LOG" 2>&1 &
NGROK_PID=$! 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 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 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 break
@@ -73,11 +73,9 @@ import sys, json
try: try:
d = json.load(sys.stdin) d = json.load(sys.stdin)
tunnels = d.get('tunnels') if isinstance(d, dict) else (d if isinstance(d, list) else []) tunnels = d.get('tunnels') if isinstance(d, dict) else (d if isinstance(d, list) else [])
if not isinstance(tunnels, list): if not isinstance(tunnels, list): tunnels = []
tunnels = []
for t in tunnels: for t in tunnels:
if not isinstance(t, dict): if not isinstance(t, dict): continue
continue
u = (t.get('public_url') or t.get('PublicURL') or '').strip() u = (t.get('public_url') or t.get('PublicURL') or '').strip()
if u.startswith('https://'): if u.startswith('https://'):
print(u.rstrip('/')) print(u.rstrip('/'))
@@ -85,17 +83,11 @@ try:
else: else:
if tunnels: if tunnels:
u = (tunnels[0].get('public_url') or tunnels[0].get('PublicURL') or '').strip() u = (tunnels[0].get('public_url') or tunnels[0].get('PublicURL') or '').strip()
if u: if u: print(u.rstrip('/'))
print(u.rstrip('/')) except Exception: pass
except Exception:
pass
" 2>/dev/null) " 2>/dev/null)
if [ -z "$PUBLIC_URL" ]; then [ -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|"$||')
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
PUBLIC_URL=$(echo "$PAYLOAD" | grep -oE '"public_url"\s*:\s*"https://[^"]+' | sed 's/.*"https:/https:/' | sed 's/"$//' | head -1)
fi
fi fi
if [ -z "$PUBLIC_URL" ]; then if [ -z "$PUBLIC_URL" ]; then

65
run.sh
View File

@@ -1,2 +1,63 @@
sh ./run-ngrok.sh #!/usr/bin/env bash
sh ./run-dev.sh # 统一启动:可选代理桥接 → 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

View 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约 515 秒)..."
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)。"

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
本地代理桥接:在本机起一个 HTTP 代理端口,把请求转发到本机真实代理(如 127.0.0.1:7890
再用 ngrok 暴露该端口,把 ngrok 公网地址填给 7006 的 Proxy7006 即可通过你的本地代理出网。
用法:
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_PROXY7006 即可通过你的本机代理出网
环境变量(可选):
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()