This commit is contained in:
丹尼尔
2026-03-12 11:52:04 +08:00
parent 30a57d993c
commit bdba4ec071
19 changed files with 5690 additions and 191 deletions

2
.env
View File

@@ -7,3 +7,5 @@ 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
HTTP_PROXY=https://arab-examines-proposal-las.trycloudflare.com
HTTPS_PROXY=https://arab-examines-proposal-las.trycloudflare.com

View File

@@ -12,6 +12,13 @@ SLIDER_VERIFY_KEY=408449830
# 优先 WECHAT_WS_KEY未设置则使用 KEY登录参数填的 key # 优先 WECHAT_WS_KEY未设置则使用 KEY登录参数填的 key
# WECHAT_WS_KEY=HBpEnbtj9BJZ # WECHAT_WS_KEY=HBpEnbtj9BJZ
# 登录/唤醒时代理(可选):该值会传给 7006由 7006 使用。必须为 7006 能访问的公网地址
# 快速方式:先运行代理桥接 + cloudflared 暴露,自动写入本文件:
# python3 scripts/local_proxy_bridge.py # 终端1
# ./scripts/expose-proxy-with-cloudflared.sh # 终端2会写 HTTP_PROXY/HTTPS_PROXY
# HTTP_PROXY=https://xxx.trycloudflare.com
# HTTPS_PROXY=https://xxx.trycloudflare.com
# 消息实时回调(主入口):设置后向 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")
@@ -371,23 +390,74 @@ def _proxy_from_env() -> str:
) )
@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 = (body.Proxy or "").strip()
if not proxy:
proxy = _proxy_from_env()
if proxy:
logger.info("WakeUpLogin: using proxy from env (HTTP_PROXY/HTTPS_PROXY), len=%s", len(proxy))
else:
logger.info("WakeUpLogin: Proxy 为空,请在 .env 中设置 HTTP_PROXY/HTTPS_PROXY或登录页填写代理后重试")
payload = {
"Check": body.Check,
"IpadOrmac": "ipad",
"Proxy": proxy,
}
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}/login/WakeUpLogin"
logger.info("WakeUpLogin: key=%s, payload=%s, url=%s", key, payload, url)
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 = (body.Proxy or "").strip()
if not (payload.get("Proxy") or "").strip(): if not proxy:
env_proxy = _proxy_from_env() proxy = _proxy_from_env()
if env_proxy: if not proxy:
payload["Proxy"] = env_proxy raise HTTPException(
logger.info("GetLoginQrCodeNewDirect: using proxy from env (HTTP_PROXY/HTTPS_PROXY), len=%s", len(env_proxy)) status_code=400,
detail="必须配置代理Proxy。服务器在香港不上代理必封号请填写 socks5 代理后再取码。",
)
payload = body.dict(exclude={"key", "force_mac"})
payload["Check"] = False
payload["IpadOrmac"] = "mac" if body.force_mac else "ipad"
payload["Proxy"] = proxy
logger.info("GetLoginQrCodeNewDirect: proxy=yes, force_mac=%s, IpadOrmac=%s", body.force_mac, payload["IpadOrmac"])
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, payload=%s, url=%s", key, payload, url)
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 +522,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 +615,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 +627,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 +710,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 +753,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 +781,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 +1041,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 +1050,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 +1068,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 +1078,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 +1107,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 +1137,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 +1209,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 +1306,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 +1334,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 +1363,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 +1373,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 +1414,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 +1432,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 +1518,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 +1582,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 +1813,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,39 +544,32 @@
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="proxy">代理(可选)</label> <label for="proxy">代理 <span>*</span></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"
/> />
<div class="small-label" style="margin-top: 4px;">socks5 代理,必填。服务器在香港,不上代理必封号。</div>
</div> </div>
<div class="field"> <div class="field">
<label for="device">登录设备类型</label> <label>登录方式</label>
<select id="device"> <div class="small-label" style="padding: 8px 0;">取码用 iPad仅当「无数字」需滑块时用一次「重新取码(Mac)」</div>
<option value="">自动</option>
<option value="ipad">iPad</option>
<option value="mac">Mac</option>
</select>
</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>
<div class="actions"> <div class="actions">
<button class="primary" id="btn-qrcode"> <button class="primary" id="btn-qrcode">
获取登录二维码 1. 获取登录二维码
</button>
<button class="secondary" id="btn-qrcode-mac" title="仅当需滑块且手机无数字验证码时使用,取码后手机显示 Mac再去滑块">
重新取码(Mac)
</button> </button>
<button class="secondary" id="btn-wake"> <button class="secondary" id="btn-wake">
唤醒 唤醒
</button> </button>
<button class="secondary" id="btn-check-scan"> <button class="secondary" id="btn-check-scan" title="state=2 为登录成功;若手机显示 Mac 登录,多点击几次会自动切 iPad">
检测扫码状态 2. 检测扫码状态
</button> </button>
<button class="secondary" id="btn-online"> <button class="secondary" id="btn-online">
获取在线状态 获取在线状态
@@ -629,11 +622,12 @@
<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 class="small-label" style="margin-bottom: 8px;">Key 为 7765 服务方 QQ(408449830),与账号 key 无关。Data62=取码返回Ticket=检测扫码状态返回(无乱码)。</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>
@@ -755,6 +749,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;
@@ -762,20 +758,21 @@
$('btn-logout').disabled = loading; $('btn-logout').disabled = loading;
} }
function getCommonPayload() { function getCommonPayload(requireProxy) {
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 check = $('check-proxy').checked;
if (!key) { if (!key) {
alert('请先填写账号唯一标识key'); alert('请先填写账号唯一标识key');
return null; return null;
} }
if (requireProxy && !proxy) {
alert('请先填写 socks5 代理。服务器在香港,不上代理必封号。');
return null;
}
return { return {
key, key,
proxy: proxy || undefined, proxy: proxy || undefined,
ipadOrMac: device, ipadOrMac: 'ipad',
check,
}; };
} }
@@ -999,16 +996,46 @@
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 onGetQrCode(forceMac) {
const payload = getCommonPayload(true);
if (!payload) return;
setLoading(true);
try {
log(forceMac ? '重新取码(Mac),用于无数字时走滑块…' : '请求登录二维码(iPad)...');
const body = { const body = {
Proxy: payload.proxy || '', Proxy: payload.proxy || '',
IpadOrmac: payload.ipadOrMac || '', IpadOrmac: forceMac ? 'mac' : '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',
@@ -1029,17 +1056,22 @@
if (line3) log(line3); if (line3) log(line3);
})(); })();
renderQrFromResponse(data); renderQrFromResponse(data);
updateLoginState('等待扫码 / 确认中', 'pending', '请在 60 秒内使用微信扫码。'); updateLoginState(forceMac ? '已用 Mac 重新取码,请手机停在确认页并完成下方滑块' : '等待扫码 / 确认中', 'pending', forceMac ? '滑块 Key=408449830Data62 已更新' : '请在 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';
showSliderAreaAndFill(state.sliderParams);
log('已用新 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 +1079,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 {
@@ -1172,17 +1204,15 @@
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();
onGetQrCode();
} else {
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

@@ -211,11 +211,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 +297,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 +382,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) {
@@ -862,7 +841,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()