fix: bug
This commit is contained in:
3
.env
3
.env
@@ -4,3 +4,6 @@ WS_KEY=HBpEnbtj9BJZ
|
||||
SLIDER_VERIFY_KEY=408449830
|
||||
APIKEY=sk-85880595fc714d63bfd0b025e917bd26#千问apikey
|
||||
# 962516e4-60eb-4a26-a5a3-44e21adcf7bc #豆包
|
||||
|
||||
# 消息回调(ngrok 调通用,由 run-ngrok.sh 自动写入)
|
||||
CALLBACK_BASE_URL=https://dissonant-destinee-nonsensibly.ngrok-free.dev
|
||||
|
||||
@@ -12,6 +12,10 @@ SLIDER_VERIFY_KEY=408449830
|
||||
# 优先 WECHAT_WS_KEY,未设置则使用 KEY(登录参数填的 key)
|
||||
# WECHAT_WS_KEY=HBpEnbtj9BJZ
|
||||
|
||||
# 消息实时回调(主入口):设置后向 7006 注册 SetCallback,新消息由 7006 POST 到本服务,不再走 WS
|
||||
# 需为 7006 能访问到的公网地址,例如 https://your-domain.com
|
||||
# CALLBACK_BASE_URL=https://your-domain.com
|
||||
|
||||
# 千问 API Key(用于个性化问候等),优先 QWEN_API_KEY,其次 APIKEY
|
||||
QWEN_API_KEY=sk-xxx
|
||||
# 或使用阿里云 DashScope:DASHSCOPE_API_KEY=sk-xxx
|
||||
|
||||
80
DEBUG.md
Normal file
80
DEBUG.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 本地调试说明
|
||||
|
||||
## 1. 启动开发环境
|
||||
|
||||
```bash
|
||||
./run-dev.sh
|
||||
```
|
||||
|
||||
- 前端:<http://localhost:3000>(管理页、登录等)
|
||||
- 后端:<http://localhost:8000>(API)
|
||||
- `.env` 会被自动加载,可配置 `WECHAT_UPSTREAM_BASE_URL`、`KEY` 等
|
||||
|
||||
## 2. 账号 key 与消息接收
|
||||
|
||||
- 登录页使用的 **key** 需要与后端「消息接收」使用同一 key,否则收不到该账号消息。
|
||||
- 在 `.env` 中设置 **`KEY=你的key`**(或 `WECHAT_WS_KEY=你的key`、`WS_KEY=你的key`),与登录时填的 key 一致即可。
|
||||
|
||||
## 3. 消息接收:WS 与回调两种方式
|
||||
|
||||
### 不设 CALLBACK_BASE_URL(默认,适合本地)
|
||||
|
||||
- 不配置 `CALLBACK_BASE_URL` 时,后端用 **WebSocket** 连接 7006 的 GetSyncMsg 拉取消息。
|
||||
- 7006 若拒绝 WS,日志会出现 `WS disconnected ... server rejected WebSocket`,消息可能收不到,但不影响登录、发消息、联系人等接口调试。
|
||||
|
||||
### 使用回调(7006 主动 POST 到本机)
|
||||
|
||||
- 7006 在公网,无法直接访问你本机的 `localhost`,需要先用 **ngrok** 把本机 8000 暴露到公网,再设回调。
|
||||
|
||||
**一键用 ngrok 调通(推荐):**
|
||||
|
||||
1. **先配置 ngrok**(仅首次):ngrok 需登录并配置 authtoken,否则会报 `ERR_NGROK_4018`。
|
||||
- 打开 https://dashboard.ngrok.com/get-started/your-authtoken 复制 token
|
||||
- 执行:`ngrok config add-authtoken <你的token>`
|
||||
2. **终端 1**:执行
|
||||
```bash
|
||||
./run-ngrok.sh
|
||||
```
|
||||
脚本会启动 `ngrok http 8000`,并把得到的公网隧道地址自动写入 `.env` 的 `CALLBACK_BASE_URL`(不会把 signup/dashboard 等链接误写成回调地址)。
|
||||
|
||||
2. **终端 2**:启动服务
|
||||
```bash
|
||||
./run-dev.sh
|
||||
```
|
||||
后端启动时会读取 `CALLBACK_BASE_URL` 并向 7006 注册 SetCallback,新消息会由 7006 POST 到你的 ngrok 地址。
|
||||
|
||||
3. 打开 <http://localhost:3000> 登录,用同一 key 收发消息即可验证回调是否生效。
|
||||
|
||||
**手动步骤(不用脚本时):**
|
||||
|
||||
1. 终端运行 `ngrok http 8000`,记下输出的 **https** 地址(如 `https://xxxx.ngrok-free.app`)。
|
||||
2. 在 `.env` 中增加或修改:`CALLBACK_BASE_URL=https://xxxx.ngrok-free.app`。
|
||||
3. 执行 `./run-dev.sh`(若已在运行则重启)。之后新消息由 7006 POST 到 `https://xxxx.ngrok-free.app/api/callback/wechat-message`,不再走 WS。
|
||||
|
||||
## 4. 手动模拟回调(不暴露公网也可测接口)
|
||||
|
||||
不跑隧道时,可直接用 curl 测回调接口是否正常:
|
||||
|
||||
```bash
|
||||
# 替换成你的 key
|
||||
curl -X POST 'http://localhost:8000/api/callback/wechat-message?key=HBpEnbtj9BJZ' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"MsgList":[{"FromUserName":"wxid_test","Content":"你好","MsgType":1}]}'
|
||||
```
|
||||
|
||||
返回 `{"ok":true}` 且后端无报错即表示回调入口和 `_on_ws_message` 逻辑正常。
|
||||
|
||||
## 5. 查看日志
|
||||
|
||||
- 后端:`backend/data/logs/app.log`(或控制台)
|
||||
- 搜索 `SetCallback`、`callback/wechat-message`、`WS disconnected` 等便于排查消息接收问题。
|
||||
|
||||
## 6. 只调试后端 API
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
cd "$(dirname "$0")"
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
再用浏览器或 Postman 访问 <http://localhost:8000/docs> 调试接口。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
658
backend/main.py
658
backend/main.py
@@ -5,13 +5,14 @@ import os
|
||||
from contextlib import asynccontextmanager
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
try:
|
||||
@@ -25,21 +26,23 @@ except ImportError:
|
||||
|
||||
WECHAT_UPSTREAM_BASE_URL = os.getenv("WECHAT_UPSTREAM_BASE_URL", "http://localhost:8080").rstrip("/")
|
||||
CHECK_STATUS_BASE_URL = os.getenv("CHECK_STATUS_BASE_URL", "http://113.44.162.180:7006").rstrip("/")
|
||||
# 消息实时回调:设置后 7006 将新消息 POST 到该地址,作为主接收入口(与 SetCallback 一致)
|
||||
CALLBACK_BASE_URL = (os.getenv("CALLBACK_BASE_URL") or "").strip().rstrip("/")
|
||||
SLIDER_VERIFY_BASE_URL = os.getenv("SLIDER_VERIFY_BASE_URL", "http://113.44.162.180:7765").rstrip("/")
|
||||
SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", os.getenv("KEY", "408449830"))
|
||||
# 发送文本消息:swagger 中为 POST /message/SendTextMessage,body 为 SendMessageModel(MsgItem 数组)
|
||||
SEND_MSG_PATH = (os.getenv("SEND_MSG_PATH") or "/message/SendTextMessage").strip()
|
||||
# 发送图片消息:部分上游为独立接口,或与文本同 path 仅 MsgType 不同(如 3=图片)
|
||||
SEND_IMAGE_PATH = (os.getenv("SEND_IMAGE_PATH") or "").strip() or SEND_MSG_PATH
|
||||
# 联系人列表:7006 为 POST /friend/GetContactList,body 传 CurrentChatRoomContactSeq/CurrentWxcontactSeq=0
|
||||
CONTACT_LIST_PATH = (os.getenv("CONTACT_LIST_PATH") or os.getenv("FRIEND_LIST_PATH") or "/friend/GetContactList").strip()
|
||||
FRIEND_LIST_PATH = (os.getenv("FRIEND_LIST_PATH") or CONTACT_LIST_PATH).strip()
|
||||
# 图片消息 MsgType:部分上游为 0,常见为 3
|
||||
IMAGE_MSG_TYPE = int(os.getenv("IMAGE_MSG_TYPE", "3"))
|
||||
|
||||
# 按 key 缓存取码结果与 Data62,供后续步骤使用
|
||||
qrcode_store: dict = {}
|
||||
|
||||
# 按 key 缓存联系人索引:name(微信号/昵称/备注) -> 联系人详情
|
||||
_contact_index: Dict[str, Dict[str, dict]] = {}
|
||||
|
||||
_LOG_FMT = "%(asctime)s [%(levelname)s] %(name)s - %(message)s"
|
||||
logging.basicConfig(level=logging.INFO, format=_LOG_FMT)
|
||||
# 日志落盘:写入 data/logs/app.log,便于排查(可按 LOG_DIR 覆盖目录)
|
||||
@@ -69,14 +72,18 @@ def _allowed_ai_reply(key: str, from_user: str) -> bool:
|
||||
return False
|
||||
cfg = store.get_ai_reply_config(key)
|
||||
if not cfg:
|
||||
logger.debug("AI reply skipped: no config for key=%s (请在管理页「AI 回复设置」保存超级管理员/白名单)", key[:8])
|
||||
return False
|
||||
super_admins = set(cfg.get("super_admin_wxids") or [])
|
||||
whitelist = set(cfg.get("whitelist_wxids") or [])
|
||||
return from_user.strip() in super_admins or from_user.strip() in whitelist
|
||||
allowed = from_user.strip() in super_admins or from_user.strip() in whitelist
|
||||
if not allowed:
|
||||
logger.debug("AI reply skipped: from_user=%s not in whitelist/super_admin for key=%s", from_user[:20], key[:8])
|
||||
return allowed
|
||||
|
||||
|
||||
async def _ai_takeover_reply(key: str, from_user: str, content: str) -> None:
|
||||
"""收到他人消息时由 AI 接管:生成回复并发送。"""
|
||||
"""收到他人消息时由 AI 接管:根据指令生成回复或调用内置动作(如代发消息)。"""
|
||||
if not from_user or not content or not content.strip():
|
||||
return
|
||||
try:
|
||||
@@ -95,33 +102,92 @@ async def _ai_takeover_reply(key: str, from_user: str, content: str) -> None:
|
||||
break
|
||||
if not context or context[-1].get("role") != "user":
|
||||
context.append({"role": "user", "content": content})
|
||||
text = await llm_chat(context)
|
||||
if text and text.strip():
|
||||
await _send_message_upstream(key, from_user, text.strip())
|
||||
logger.info("AI takeover replied to %s: %s", from_user[:20], text.strip()[:50])
|
||||
|
||||
# Function Call 风格:让模型选择是直接回复,还是发起“代发消息”等动作
|
||||
system_prompt = (
|
||||
"你是微信客服助手,负责根据用户消息决定是直接回复,还是调用内置动作。"
|
||||
"只用 JSON 回复,格式如下(不要多余文本):\n"
|
||||
"{\n"
|
||||
' \"type\": \"reply\" | \"send_message\",\n'
|
||||
' \"reply\"?: \"直接回复给当前联系人的内容(当 type=reply 时必填)\",\n'
|
||||
' \"target_wxid\"?: \"要代发消息的对象 wxid(当 type=send_message 时必填,可以是当前联系人或其他 wxid)\",\n'
|
||||
' \"content\"?: \"要代发的消息内容(当 type=send_message 时必填)\"\n'
|
||||
"}\n"
|
||||
"注意:\n"
|
||||
"1)如果用户只是正常聊天,优先使用 type=reply;\n"
|
||||
"2)只有当用户明确指令你“帮我给某人发消息/转告/通知 xxx”时,才使用 type=send_message;\n"
|
||||
"3)严禁凭空编造 target_wxid,若无法确定对方 wxid,请继续使用 type=reply 询问用户;\n"
|
||||
"4)不要用自然语言解释 JSON,只输出 JSON 本身。"
|
||||
)
|
||||
messages = [{"role": "system", "content": system_prompt}, *context]
|
||||
|
||||
raw = await llm_chat(messages)
|
||||
if not raw or not raw.strip():
|
||||
return
|
||||
|
||||
action = None
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
action = _json.loads(raw)
|
||||
except Exception:
|
||||
# 回退为普通文本回复
|
||||
reply_text = raw.strip()
|
||||
await _send_message_upstream(key, from_user, reply_text)
|
||||
logger.info("AI takeover replied (fallback) to %s: %s", from_user[:20], reply_text[:50])
|
||||
return
|
||||
|
||||
if not isinstance(action, dict) or "type" not in action:
|
||||
return
|
||||
|
||||
action_type = str(action.get("type") or "").strip()
|
||||
if action_type == "send_message":
|
||||
target = str(action.get("target_wxid") or "").strip()
|
||||
msg = str(action.get("content") or "").strip()
|
||||
if target and msg:
|
||||
# 先按“昵称/备注/微信号”解析成真正的 wxid
|
||||
real_wxid = await _resolve_contact_username(key, target)
|
||||
if not real_wxid:
|
||||
# 回一条提示给当前联系人,说明未找到该联系人
|
||||
warn = f"未找到名为「{target}」的联系人,请确认称呼或直接提供对方微信号。"
|
||||
await _send_message_upstream(key, from_user, warn)
|
||||
logger.info("AI send_message resolve failed for %s (key=***%s)", target, key[-4:] if len(key) >= 4 else "****")
|
||||
return
|
||||
await _send_message_upstream(key, real_wxid, msg)
|
||||
logger.info("AI function-call send_message to %s (raw=%s): %s", real_wxid[:20], target[:20], msg[:50])
|
||||
elif action_type == "reply":
|
||||
reply_text = str(action.get("reply") or "").strip()
|
||||
if reply_text:
|
||||
await _send_message_upstream(key, from_user, reply_text)
|
||||
logger.info("AI takeover replied to %s: %s", from_user[:20], reply_text[:50])
|
||||
except Exception as e:
|
||||
logger.exception("AI takeover reply error (from=%s): %s", from_user, e)
|
||||
|
||||
|
||||
def _on_ws_message(key: str, data: dict) -> None:
|
||||
"""GetSyncMsg 收到数据时:写入 store;若为他人消息则 AI 接管对话。"""
|
||||
msg_list = data.get("MsgList") or data.get("List") or data.get("msgList")
|
||||
if isinstance(msg_list, list) and msg_list:
|
||||
store.append_sync_messages(key, msg_list)
|
||||
for m in msg_list:
|
||||
if _is_self_sent(m):
|
||||
continue
|
||||
from_user = (m.get("FromUserName") or m.get("from") or "").strip()
|
||||
content = (m.get("Content") or m.get("content") or "").strip()
|
||||
msg_type = m.get("MsgType") or m.get("msgType")
|
||||
if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"): # 仅文本触发 AI
|
||||
if not _allowed_ai_reply(key, from_user):
|
||||
def _on_ws_message(key: str, data: Any) -> None:
|
||||
"""GetSyncMsg / 回调 收到数据时:写入 store;若为他人消息则 AI 接管对话。"""
|
||||
# 1)上游典型结构:{"MsgList": [...]} / {"List": [...]} / {"msgList": [...]}
|
||||
if isinstance(data, dict):
|
||||
msg_list = data.get("MsgList") or data.get("List") or data.get("msgList")
|
||||
if isinstance(msg_list, list) and msg_list:
|
||||
store.append_sync_messages(key, msg_list)
|
||||
for m in msg_list:
|
||||
if _is_self_sent(m):
|
||||
continue
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
|
||||
except RuntimeError:
|
||||
pass
|
||||
elif isinstance(data, list):
|
||||
from_user = (m.get("FromUserName") or m.get("from") or "").strip()
|
||||
content = (m.get("Content") or m.get("content") or "").strip()
|
||||
msg_type = m.get("MsgType") or m.get("msgType")
|
||||
if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"): # 仅文本触发 AI
|
||||
if not _allowed_ai_reply(key, from_user):
|
||||
continue
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
|
||||
except RuntimeError:
|
||||
pass
|
||||
return
|
||||
|
||||
# 2)如果 data 本身就是列表(例如回调已归一化为 [normalized_msg])
|
||||
if isinstance(data, list):
|
||||
store.append_sync_messages(key, data)
|
||||
for m in data:
|
||||
if not isinstance(m, dict) or _is_self_sent(m):
|
||||
@@ -136,21 +202,42 @@ def _on_ws_message(key: str, data: dict) -> None:
|
||||
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
store.append_sync_messages(key, [data])
|
||||
m = data if isinstance(data, dict) else {}
|
||||
if not _is_self_sent(m):
|
||||
from_user = (m.get("FromUserName") or m.get("from") or "").strip()
|
||||
content = (m.get("Content") or m.get("content") or "").strip()
|
||||
msg_type = m.get("MsgType") or m.get("msgType")
|
||||
if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"):
|
||||
if not _allowed_ai_reply(key, from_user):
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
|
||||
except RuntimeError:
|
||||
pass
|
||||
return
|
||||
|
||||
# 3)兜底:单条 dict / 其它类型
|
||||
store.append_sync_messages(key, [data])
|
||||
m = data if isinstance(data, dict) else {}
|
||||
if not _is_self_sent(m):
|
||||
from_user = (m.get("FromUserName") or m.get("from") or "").strip()
|
||||
content = (m.get("Content") or m.get("content") or "").strip()
|
||||
msg_type = m.get("MsgType") or m.get("msgType")
|
||||
if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"):
|
||||
if not _allowed_ai_reply(key, from_user):
|
||||
return
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
async def _register_message_callback(key: str) -> bool:
|
||||
"""向 7006 注册消息回调:POST /message/SetCallback?key=xxx,使 7006 将新消息推送到本服务。"""
|
||||
if not CALLBACK_BASE_URL or not key:
|
||||
return False
|
||||
url = f"{CHECK_STATUS_BASE_URL.rstrip('/')}/message/SetCallback"
|
||||
callback_url = f"{CALLBACK_BASE_URL.rstrip('/')}/api/callback/wechat-message"
|
||||
body = {"CallbackURL": callback_url, "Enabled": True}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=body)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("SetCallback %s key=%s: %s %s", url, key[-4:] if len(key) >= 4 else "****", resp.status_code, resp.text[:200])
|
||||
return False
|
||||
logger.info("SetCallback registered for key=***%s, CallbackURL=%s", key[-4:] if len(key) >= 4 else "****", callback_url)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("SetCallback error key=%s: %s", key[-4:] if len(key) >= 4 else "****", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _run_greeting_scheduler() -> None:
|
||||
@@ -219,7 +306,15 @@ async def _run_greeting_scheduler() -> None:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
set_message_callback(_on_ws_message)
|
||||
asyncio.create_task(start_ws_sync())
|
||||
_callback_key = (os.getenv("WECHAT_WS_KEY") or os.getenv("KEY") or os.getenv("WS_KEY") or "").strip()
|
||||
if CALLBACK_BASE_URL and _callback_key:
|
||||
ok = await _register_message_callback(_callback_key)
|
||||
if ok:
|
||||
logger.info("消息接收已切换为实时回调入口,不再启动 WS GetSyncMsg")
|
||||
else:
|
||||
asyncio.create_task(start_ws_sync())
|
||||
else:
|
||||
asyncio.create_task(start_ws_sync())
|
||||
scheduler = asyncio.create_task(_run_greeting_scheduler())
|
||||
yield
|
||||
scheduler.cancel()
|
||||
@@ -847,6 +942,57 @@ async def api_list_messages(key: str = Query(..., description="账号 key"), lim
|
||||
return {"items": store.list_sync_messages(key, limit=limit)}
|
||||
|
||||
|
||||
@app.get("/api/callback-status")
|
||||
async def api_callback_status(key: Optional[str] = Query(None, description="账号 key,传入时会向 7006 重新注册 SetCallback 并返回是否成功")):
|
||||
"""检查消息回调配置:是否配置了 CALLBACK_BASE_URL、回调地址,以及(传入 key 时)向 7006 注册是否成功。"""
|
||||
callback_url = ""
|
||||
if CALLBACK_BASE_URL:
|
||||
callback_url = f"{CALLBACK_BASE_URL.rstrip('/')}/api/callback/wechat-message"
|
||||
registered: Optional[bool] = None
|
||||
if key and key.strip():
|
||||
k = key.strip()
|
||||
registered = await _register_message_callback(k)
|
||||
return {
|
||||
"configured": bool(CALLBACK_BASE_URL),
|
||||
"callback_url": callback_url or None,
|
||||
"registered": registered,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/callback/wechat-message")
|
||||
async def api_callback_wechat_message(request: Request, key: Optional[str] = Query(None, description="账号 key(7006 回调时可能带在 query)")):
|
||||
"""7006 消息实时回调入口:与 SetCallback 配合,收到新消息时 7006 POST 到此地址,与 WS GetSyncMsg 同结构,统一走 _on_ws_message 处理。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
# 打印回调原始内容,便于排查结构与字段(截断避免日志过大)
|
||||
try:
|
||||
logger.info("callback/wechat-message raw body: %s", str(body)[:1000])
|
||||
except Exception:
|
||||
pass
|
||||
k = (key or (body.get("key") or body.get("Key") or "") or "").strip()
|
||||
if not k:
|
||||
logger.warning("callback/wechat-message: missing key in query and body")
|
||||
return JSONResponse(content={"ok": False, "error": "missing key"}, status_code=200)
|
||||
try:
|
||||
payload: Any = body
|
||||
# 7006 回调当前格式示例:{"key": "...", "message": {...}, "type": "message"}
|
||||
# 优先按回调 message 结构归一化,再回退到 Data/data 解包。
|
||||
if isinstance(body, dict) and body.get("message"):
|
||||
normalized = _normalize_callback_message(body)
|
||||
if normalized:
|
||||
payload = [normalized]
|
||||
elif isinstance(body, dict):
|
||||
inner = body.get("Data") or body.get("data")
|
||||
if isinstance(inner, (dict, list)):
|
||||
payload = inner
|
||||
_on_ws_message(k, payload)
|
||||
except Exception as e:
|
||||
logger.exception("callback/wechat-message key=%s: %s", k[-4:] if len(k) >= 4 else "****", e)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _send_message_upstream(key: str, to_user_name: str, content: str) -> dict:
|
||||
"""调用上游发送文本消息;成功时写入发出记录并返回响应,失败抛 HTTPException。"""
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_MSG_PATH}"
|
||||
@@ -968,9 +1114,93 @@ async def api_send_image(body: SendImageBody):
|
||||
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
|
||||
|
||||
|
||||
def _log_contact_list_response_structure(raw: dict) -> None:
|
||||
"""首轮无数据时打印上游响应结构,便于排查为何数据未解析到。"""
|
||||
keys_top = list(raw.keys()) if isinstance(raw, dict) else []
|
||||
data = raw.get("Data") or raw.get("data")
|
||||
keys_data = list(data.keys()) if isinstance(data, dict) else (type(data).__name__ if data is not None else "None")
|
||||
logger.info(
|
||||
"GetContactList response structure (no items extracted): top_level_keys=%s, Data_keys=%s",
|
||||
keys_top,
|
||||
keys_data,
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
for k, v in list(data.items())[:5]:
|
||||
preview = str(v)[:80] if v is not None else "null"
|
||||
logger.info(" Data.%s: %s", k, preview)
|
||||
|
||||
|
||||
def _unwrap_wechat_field(v: Any) -> Any:
|
||||
"""上游字段有时为 {'str': 'xxx'} 或 {'len': 0} 这种包装,这里尝试取出内部值。"""
|
||||
if isinstance(v, dict):
|
||||
if "str" in v:
|
||||
return v.get("str")
|
||||
if "len" in v and len(v) == 1:
|
||||
return v.get("len")
|
||||
return v
|
||||
|
||||
|
||||
def _normalize_callback_message(raw: dict) -> dict:
|
||||
"""
|
||||
将 7006 回调的 message 结构统一为与 WS GetSyncMsg 类似的消息字典,
|
||||
便于复用 _on_ws_message / 实时消息面板的展示与 AI 接管逻辑。
|
||||
|
||||
示例 raw:
|
||||
{
|
||||
"key": "HBpEnbtj9BJZ",
|
||||
"message": {
|
||||
"msg_id": 126545176,
|
||||
"from_user_name": {"str": "zhang499142409"},
|
||||
"to_user_name": {"str": "wxid_xxx"},
|
||||
"msg_type": 1,
|
||||
"content": {"str": "测试"},
|
||||
...
|
||||
},
|
||||
"type": "message"
|
||||
}
|
||||
"""
|
||||
msg = raw.get("message") or raw
|
||||
if not isinstance(msg, dict):
|
||||
return {}
|
||||
from_user = _unwrap_wechat_field(msg.get("from_user_name") or msg.get("FromUserName"))
|
||||
to_user = _unwrap_wechat_field(msg.get("to_user_name") or msg.get("ToUserName"))
|
||||
content = _unwrap_wechat_field(msg.get("content") or msg.get("Content"))
|
||||
msg_type = msg.get("msg_type") or msg.get("MsgType")
|
||||
create_time = msg.get("create_time") or msg.get("CreateTime")
|
||||
# 回放到统一结构,字段名尽量与 WS GetSyncMsg 一致
|
||||
normalized = {
|
||||
"MsgId": msg.get("msg_id") or msg.get("MsgId") or msg.get("new_msg_id"),
|
||||
"FromUserName": from_user or "",
|
||||
"ToUserName": to_user or "",
|
||||
"Content": content or "",
|
||||
"MsgType": msg_type,
|
||||
"CreateTime": create_time,
|
||||
}
|
||||
# 附带原始字段,便于调试 / 扩展展示
|
||||
for k, v in msg.items():
|
||||
if k in (
|
||||
"msg_id",
|
||||
"MsgId",
|
||||
"new_msg_id",
|
||||
"from_user_name",
|
||||
"FromUserName",
|
||||
"to_user_name",
|
||||
"ToUserName",
|
||||
"content",
|
||||
"Content",
|
||||
"msg_type",
|
||||
"MsgType",
|
||||
"create_time",
|
||||
"CreateTime",
|
||||
):
|
||||
continue
|
||||
normalized[k] = v
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_contact_list(raw: Any) -> List[dict]:
|
||||
"""将上游 GetContactList 多种返回格式统一为 [ { wxid, remark_name, ... } ]。"""
|
||||
items = []
|
||||
items: Any = []
|
||||
if isinstance(raw, list):
|
||||
items = raw
|
||||
elif isinstance(raw, dict):
|
||||
@@ -978,20 +1208,37 @@ def _normalize_contact_list(raw: Any) -> List[dict]:
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
items = (
|
||||
contact_list = (
|
||||
data.get("ContactList")
|
||||
or data.get("contactList")
|
||||
or data.get("WxcontactList")
|
||||
or data.get("wxcontactList")
|
||||
or data.get("CachedContactList")
|
||||
or data.get("List")
|
||||
or data.get("list")
|
||||
or data.get("items")
|
||||
or []
|
||||
)
|
||||
items = items or raw.get("items") or raw.get("list") or raw.get("List") or []
|
||||
# 7006 格式:ContactList 为对象,联系人 id 在 contactUsernameList 字符串数组里
|
||||
if isinstance(contact_list, dict):
|
||||
username_list = (
|
||||
contact_list.get("contactUsernameList")
|
||||
or contact_list.get("ContactUsernameList")
|
||||
or contact_list.get("UserNameList")
|
||||
or []
|
||||
)
|
||||
if isinstance(username_list, list) and username_list:
|
||||
items = [{"wxid": (x if isinstance(x, str) else str(x)), "remark_name": (x if isinstance(x, str) else str(x))} for x in username_list]
|
||||
else:
|
||||
items = []
|
||||
else:
|
||||
items = contact_list if isinstance(contact_list, list) else (
|
||||
data.get("List") or data.get("list") or data.get("items")
|
||||
or data.get("Friends") or data.get("friends")
|
||||
or data.get("MemberList") or data.get("memberList") or []
|
||||
)
|
||||
items = items or raw.get("items") or raw.get("list") or raw.get("List") or raw.get("ContactList") or raw.get("WxcontactList") or []
|
||||
result = []
|
||||
for x in items:
|
||||
if isinstance(x, str):
|
||||
result.append({"wxid": x, "remark_name": x})
|
||||
continue
|
||||
if not isinstance(x, dict):
|
||||
continue
|
||||
wxid = (
|
||||
@@ -1010,44 +1257,260 @@ def _normalize_contact_list(raw: Any) -> List[dict]:
|
||||
or x.get("DisplayName")
|
||||
or wxid
|
||||
)
|
||||
result.append({"wxid": wxid, "remark_name": remark, **{k: v for k, v in x.items() if k not in ("wxid", "Wxid", "remark_name", "RemarkName")}})
|
||||
result.append({"wxid": wxid, "remark_name": remark})
|
||||
return result
|
||||
|
||||
|
||||
# 上游 GetContactList 请求体:CurrentChatRoomContactSeq、CurrentWxcontactSeq 传 0 表示拉取全量
|
||||
GET_CONTACT_LIST_BODY = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
|
||||
async def _fetch_all_contact_usernames(key: str) -> List[str]:
|
||||
"""
|
||||
调用 /friend/GetContactList 拉取全部联系人,返回去重后的 UserName 列表。
|
||||
仅用于后续调用 GetContactDetailsList 获取详情。
|
||||
"""
|
||||
url = f"{CHECK_STATUS_BASE_URL.rstrip('/')}/friend/GetContactList"
|
||||
usernames: List[str] = []
|
||||
seen: set = set()
|
||||
body: dict = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
|
||||
max_rounds = 50
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
for round_num in range(max_rounds):
|
||||
resp = await client.post(url, params={"key": key}, json=body)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("GetContactList(round=%s) %s: %s", round_num + 1, resp.status_code, resp.text[:200])
|
||||
break
|
||||
raw = resp.json()
|
||||
chunk = _normalize_contact_list(raw)
|
||||
if not chunk and isinstance(raw, dict):
|
||||
chunk = _normalize_contact_list(raw.get("Data") or raw.get("data") or raw)
|
||||
if round_num == 0 and not chunk and isinstance(raw, dict):
|
||||
_log_contact_list_response_structure(raw)
|
||||
for item in chunk:
|
||||
wxid = (item.get("wxid") or "").strip()
|
||||
if wxid and wxid not in seen:
|
||||
seen.add(wxid)
|
||||
usernames.append(wxid)
|
||||
next_chat, next_wx = _next_contact_seq(raw)
|
||||
if next_chat == 0 and next_wx == 0:
|
||||
break
|
||||
if next_chat == body.get("CurrentChatRoomContactSeq") and next_wx == body.get("CurrentWxcontactSeq"):
|
||||
break
|
||||
body = {
|
||||
"CurrentChatRoomContactSeq": next_chat or body.get("CurrentChatRoomContactSeq", 0),
|
||||
"CurrentWxcontactSeq": next_wx or body.get("CurrentWxcontactSeq", 0),
|
||||
}
|
||||
if not body["CurrentChatRoomContactSeq"] and not body["CurrentWxcontactSeq"]:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("GetContactList usernames error: %s", e)
|
||||
logger.info("GetContactList usernames total=%s", len(usernames))
|
||||
return usernames
|
||||
|
||||
|
||||
async def _build_contact_index(key: str) -> Dict[str, dict]:
|
||||
"""
|
||||
通用联系人索引:
|
||||
- 先通过 GetContactList 拿到全部 UserName 列表;
|
||||
- 再通过 /friend/GetContactDetailsList 批量拉取详情;
|
||||
- 构建 name(微信号/昵称/备注) -> 联系人详情 的索引。
|
||||
"""
|
||||
if key in _contact_index and _contact_index[key]:
|
||||
return _contact_index[key]
|
||||
|
||||
usernames = await _fetch_all_contact_usernames(key)
|
||||
if not usernames:
|
||||
_contact_index[key] = {}
|
||||
return _contact_index[key]
|
||||
|
||||
url = f"{CHECK_STATUS_BASE_URL.rstrip('/')}/friend/GetContactDetailsList"
|
||||
index: Dict[str, dict] = {}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
chunk_size = 50
|
||||
for i in range(0, len(usernames), chunk_size):
|
||||
batch = usernames[i : i + chunk_size]
|
||||
body = {
|
||||
"RoomWxIDList": [],
|
||||
"UserNames": batch,
|
||||
}
|
||||
try:
|
||||
resp = await client.post(url, params={"key": key}, json=body)
|
||||
except Exception as e:
|
||||
logger.warning("GetContactDetailsList batch error: %s", e)
|
||||
continue
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("GetContactDetailsList %s: %s", resp.status_code, resp.text[:200])
|
||||
continue
|
||||
raw = resp.json()
|
||||
data = raw.get("Data") or raw.get("data") or raw
|
||||
# 打印一次结构,便于排查为何没有解析出联系人详情
|
||||
try:
|
||||
if i == 0:
|
||||
top_keys = list(raw.keys()) if isinstance(raw, dict) else type(raw).__name__
|
||||
data_keys = list(data.keys()) if isinstance(data, dict) else type(data).__name__
|
||||
logger.info(
|
||||
"GetContactDetailsList structure: top_keys=%s, Data_keys=%s, batch_size=%s",
|
||||
top_keys,
|
||||
data_keys,
|
||||
len(batch),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
items = []
|
||||
if isinstance(data, dict):
|
||||
# 7006 GetContactDetailsList 当前结构:Data.contactList 为联系人详情数组
|
||||
items = (
|
||||
data.get("List")
|
||||
or data.get("list")
|
||||
or data.get("ContactDetailsList")
|
||||
or data.get("contacts")
|
||||
or data.get("contactList")
|
||||
or []
|
||||
)
|
||||
elif isinstance(data, list):
|
||||
items = data
|
||||
if not isinstance(items, list):
|
||||
# 结构不符时记录一条日志,帮助判断需要从哪里取联系人列表
|
||||
logger.info(
|
||||
"GetContactDetailsList no list items parsed, data_type=%s, sample=%s",
|
||||
type(data).__name__,
|
||||
str(data)[:200],
|
||||
)
|
||||
continue
|
||||
# 追加一次示例项日志,便于确认字段名(UserName/NickName/RemarkName 等)
|
||||
try:
|
||||
if i == 0 and items:
|
||||
sample = items[0]
|
||||
if isinstance(sample, dict):
|
||||
logger.info(
|
||||
"GetContactDetailsList first item keys=%s, sample=%s",
|
||||
list(sample.keys()),
|
||||
str(sample)[:200],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
for d in items:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
# 只保留 bitVal == 3 的联系人(如上游定义的「有效联系人」),其它忽略
|
||||
try:
|
||||
bit_val = int(d.get("bitVal") or 0)
|
||||
except (TypeError, ValueError):
|
||||
bit_val = 0
|
||||
if bit_val != 3:
|
||||
continue
|
||||
|
||||
# 7006 联系人详情字段为 userName/nickName/pyinitial/quanPin 等,内部多为 {'str': 'xxx'} 包装
|
||||
wxid = _unwrap_wechat_field(
|
||||
d.get("userName") or d.get("UserName") or d.get("user_name") or d.get("wxid")
|
||||
)
|
||||
wxid = (wxid or "").strip()
|
||||
if not wxid:
|
||||
continue
|
||||
nick = _unwrap_wechat_field(d.get("nickName") or d.get("NickName") or d.get("nick_name")) or ""
|
||||
nick = str(nick).strip()
|
||||
remark = _unwrap_wechat_field(
|
||||
d.get("remark") or d.get("RemarkName") or d.get("remark_name")
|
||||
) or ""
|
||||
remark = str(remark).strip()
|
||||
pyinitial = _unwrap_wechat_field(d.get("pyinitial") or d.get("pyInitial") or d.get("PYInitial")) or ""
|
||||
pyinitial = str(pyinitial).strip()
|
||||
quan_pin = _unwrap_wechat_field(d.get("quanPin") or d.get("QuanPin") or d.get("fullPinyin")) or ""
|
||||
quan_pin = str(quan_pin).strip()
|
||||
info = {
|
||||
"wxid": wxid,
|
||||
"remark_name": remark or nick or wxid,
|
||||
"nick_name": nick,
|
||||
"pyinitial": pyinitial,
|
||||
"quan_pin": quan_pin,
|
||||
"raw": d,
|
||||
}
|
||||
# 主键:wxid
|
||||
index[wxid] = info
|
||||
# 昵称 / 备注作为别名,便于按自然语言查找
|
||||
if nick and nick not in index:
|
||||
index[nick] = info
|
||||
if remark and remark not in index:
|
||||
index[remark] = info
|
||||
|
||||
_contact_index[key] = index
|
||||
logger.info("Contact index built for key=***%s, size=%s", key[-4:] if len(key) >= 4 else "****", len(index))
|
||||
return index
|
||||
|
||||
|
||||
async def _resolve_contact_username(key: str, name: str) -> Optional[str]:
|
||||
"""
|
||||
将用户提到的“昵称/备注/微信号”解析成真正的 wxid(UserName)。
|
||||
返回 wxid,找不到则返回 None。
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
idx = await _build_contact_index(key)
|
||||
info = idx.get(name.strip())
|
||||
if info and isinstance(info, dict):
|
||||
wxid = (info.get("wxid") or info.get("UserName") or "").strip()
|
||||
return wxid or None
|
||||
return None
|
||||
|
||||
|
||||
# 上游 GetContactList:传 0 拉首页,响应可能带 NextWxcontactSeq/NextChatRoomContactSeq 表示还有后续页,需循环拉取全量
|
||||
def _next_contact_seq(raw: dict) -> tuple:
|
||||
"""从上游响应中解析下一页的 seq,返回 (next_chatroom_seq, next_wxcontact_seq)。无下一页则返回 (0, 0)。"""
|
||||
def _int(v, default: int = 0) -> int:
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
data = raw.get("Data") or raw.get("data") or raw
|
||||
chatroom_seq = 0
|
||||
wxcontact_seq = 0
|
||||
if isinstance(data, dict):
|
||||
chatroom_seq = _int(data.get("NextChatRoomContactSeq") or data.get("CurrentChatRoomContactSeq"), 0)
|
||||
wxcontact_seq = _int(data.get("NextWxcontactSeq") or data.get("CurrentWxcontactSeq"), 0)
|
||||
for k in ("NextChatRoomContactSeq", "NextWxcontactSeq"):
|
||||
v = raw.get(k)
|
||||
if v is not None:
|
||||
if "ChatRoom" in k:
|
||||
chatroom_seq = _int(v, 0)
|
||||
else:
|
||||
wxcontact_seq = _int(v, 0)
|
||||
return (chatroom_seq, wxcontact_seq)
|
||||
|
||||
|
||||
# 联系人列表等接口禁止缓存,避免 304 导致前端拿到旧数据
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate", "Pragma": "no-cache"}
|
||||
|
||||
|
||||
@app.get("/api/contact-list")
|
||||
async def api_contact_list(key: str = Query(..., description="账号 key")):
|
||||
"""获取全部联系人:POST 上游,body 为 CurrentChatRoomContactSeq/CurrentWxcontactSeq=0,key 走 query。"""
|
||||
base = WECHAT_UPSTREAM_BASE_URL.rstrip("/")
|
||||
path = CONTACT_LIST_PATH if CONTACT_LIST_PATH.startswith("/") else f"/{CONTACT_LIST_PATH}"
|
||||
url = f"{base}{path}"
|
||||
"""获取全部联系人详情:基于 GetContactList + GetContactDetailsList 构建的通用索引。禁止缓存。"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
url,
|
||||
params={"key": key},
|
||||
json=GET_CONTACT_LIST_BODY,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("GetContactList %s: %s", resp.status_code, resp.text[:200])
|
||||
return {"items": [], "error": resp.text[:200]}
|
||||
raw = resp.json()
|
||||
# 日志便于确认 7006 返回结构(不打印完整列表)
|
||||
if isinstance(raw, dict):
|
||||
data = raw.get("Data") or raw.get("data")
|
||||
data_keys = list(data.keys()) if isinstance(data, dict) else getattr(data, "__name__", type(data).__name__)
|
||||
logger.info("GetContactList response keys: raw=%s, Data=%s", list(raw.keys()), data_keys)
|
||||
items = _normalize_contact_list(raw)
|
||||
if not items and isinstance(raw, dict):
|
||||
items = _normalize_contact_list(raw.get("Data") or raw.get("data") or raw)
|
||||
logger.info("GetContactList normalized items count: %s", len(items))
|
||||
return {"items": items}
|
||||
index = await _build_contact_index(key)
|
||||
# 只返回去重后的联系人详情(以 wxid 主键)
|
||||
uniques: Dict[str, dict] = {}
|
||||
for name, info in index.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
wxid = (info.get("wxid") or "").strip()
|
||||
if not wxid or wxid in uniques:
|
||||
continue
|
||||
uniques[wxid] = {
|
||||
"wxid": wxid,
|
||||
# 显示时优先用昵称,其次备注,最后用 wxid
|
||||
"nick_name": info.get("nick_name") or "",
|
||||
"remark_name": info.get("remark_name") or info.get("nick_name") or wxid,
|
||||
"pyinitial": info.get("pyinitial") or "",
|
||||
"quanPin": info.get("quan_pin") or "",
|
||||
}
|
||||
items = list(uniques.values())
|
||||
logger.info("api_contact_list key=***%s -> %s contacts", key[-4:] if len(key) >= 4 else "****", len(items))
|
||||
return JSONResponse(content={"items": items}, headers=_NO_CACHE_HEADERS)
|
||||
except Exception as e:
|
||||
logger.warning("GetContactList error: %s", e)
|
||||
return {"items": [], "error": str(e)}
|
||||
# 打印完整异常与 key,便于排查加载联系人报错
|
||||
logger.exception("contact-list error for key=***%s: %s", key[-4:] if len(key) >= 4 else "****", e)
|
||||
return JSONResponse(content={"items": [], "error": str(e)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@app.get("/api/friends")
|
||||
@@ -1092,6 +1555,33 @@ async def api_update_ai_reply_config(body: AIReplyConfigUpdate):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/ai-reply-status")
|
||||
async def api_ai_reply_status(key: str = Query(..., description="账号 key")):
|
||||
"""检查 AI 模型接管是否正常:WS 连接、是否配置白名单/超级管理员、是否有当前模型。"""
|
||||
ws_ok = is_ws_connected()
|
||||
cfg = store.get_ai_reply_config(key)
|
||||
super_list = cfg.get("super_admin_wxids") or [] if cfg else []
|
||||
white_list = cfg.get("whitelist_wxids") or [] if cfg else []
|
||||
has_allow_list = bool(super_list or white_list)
|
||||
model = store.get_current_model()
|
||||
has_model = bool(model and model.get("api_key"))
|
||||
ok = ws_ok and has_allow_list and has_model
|
||||
return {
|
||||
"ok": ok,
|
||||
"ws_connected": ws_ok,
|
||||
"has_ai_reply_config": bool(cfg),
|
||||
"has_whitelist_or_super_admin": has_allow_list,
|
||||
"super_admin_count": len(super_list),
|
||||
"whitelist_count": len(white_list),
|
||||
"has_current_model": has_model,
|
||||
"message": "正常" if ok else (
|
||||
"未连接消息同步(WS)" if not ws_ok else
|
||||
"请在「AI 回复设置」添加并保存超级管理员或白名单" if not has_allow_list else
|
||||
"请在「模型管理」添加并选中当前模型" if not has_model else "未知"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------- 模型管理(多模型切换,API Key 按模型配置) ----------
|
||||
class ModelCreate(BaseModel):
|
||||
name: str
|
||||
@@ -1235,3 +1725,9 @@ async def logout(body: LogoutBody):
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
|
||||
# 静态页面目录:与 Node 一致,直接访问后端时也可访问所有静态页
|
||||
_PUBLIC_DIR = os.path.join(os.path.dirname(__file__), "..", "public")
|
||||
if os.path.isdir(_PUBLIC_DIR):
|
||||
app.mount("/", StaticFiles(directory=_PUBLIC_DIR, html=True), name="static")
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ logger = logging.getLogger("wechat-backend.ws_sync")
|
||||
WS_BASE_URL = os.getenv("WECHAT_WS_BASE_URL", "").rstrip("/") or os.getenv("CHECK_STATUS_BASE_URL", "http://113.44.162.180:7006").rstrip("/").replace("http://", "ws://").replace("https://", "wss://")
|
||||
# 与 7006 GetSyncMsg 建立连接时使用的 key,必须与登录页使用的账号 key 一致,否则收不到该账号的消息
|
||||
# 优先读取 WECHAT_WS_KEY,未设置时使用 KEY(与登录参数一致)
|
||||
DEFAULT_KEY = (os.getenv("WECHAT_WS_KEY") or os.getenv("KEY") or "").strip() or "HBpEnbtj9BJZ"
|
||||
DEFAULT_KEY = (os.getenv("WECHAT_WS_KEY") or os.getenv("KEY") or os.getenv("WS_KEY") or "").strip() or "HBpEnbtj9BJZ"
|
||||
|
||||
try:
|
||||
import websockets
|
||||
|
||||
@@ -221,9 +221,18 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 后端已按时间倒序(最新在前)返回,这里保持顺序即可
|
||||
// 后端已按时间倒序(最新在前)返回
|
||||
const data = await callApi('/api/messages?key=' + encodeURIComponent(key) + '&limit=80');
|
||||
const list = data.items || [];
|
||||
let list = data.items || [];
|
||||
// 仅展示对话类消息:文本消息或我发出的消息,过滤掉系统通知、非会话类型
|
||||
list = list.filter(m => {
|
||||
const t = m.MsgType ?? m.msgType;
|
||||
const dir = m.direction;
|
||||
const content = (m.Content || m.content || '').toString().trim();
|
||||
if (dir === 'out') return true;
|
||||
if (t === 1 || t === '1') return !!content;
|
||||
return false;
|
||||
});
|
||||
$('message-list').innerHTML = list.length ? list.map(m => {
|
||||
const isOut = m.direction === 'out';
|
||||
const fromLabel = isOut ? ('我 → ' + (m.ToUserName || '')) : (m.FromUserName || m.from || m.MsgId || '-').toString().slice(0, 32);
|
||||
@@ -263,6 +272,29 @@
|
||||
$('btn-refresh-msg').addEventListener('click', loadMessages);
|
||||
$('btn-send-msg').addEventListener('click', sendMessage);
|
||||
loadMessages();
|
||||
// 定时轮询实时消息,配合回调可及时看到新消息
|
||||
(function autoPollMessages() {
|
||||
const INTERVAL_MS = 2000;
|
||||
let timer = null;
|
||||
function start() {
|
||||
if (timer) return;
|
||||
timer = setInterval(() => {
|
||||
// 页面不可见时可跳过,减少无谓请求
|
||||
if (document.hidden) return;
|
||||
loadMessages();
|
||||
}, INTERVAL_MS);
|
||||
}
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) stop(); else start();
|
||||
});
|
||||
start();
|
||||
})();
|
||||
|
||||
(function wsStatusCheck() {
|
||||
let wasConnected = false;
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
<div class="small-label" style="margin-top:6px">从联系人选择(多选可批量填入)</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px">
|
||||
<button type="button" class="secondary" id="btn-load-contact-list" style="padding:4px 10px;font-size:12px">加载联系人</button>
|
||||
<button type="button" class="secondary" id="btn-batch-import-remark" style="padding:4px 10px;font-size:12px" title="将当前已加载的联系人全部导入为客户,备注名使用 wxid">批量导入自动备注</button>
|
||||
<select id="c-wxid-select" multiple style="min-width:200px;min-height:80px;max-height:120px"></select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,7 +175,14 @@
|
||||
<div class="small-label" style="margin-top:16px;margin-bottom:8px">发送图片消息(快捷方式)</div>
|
||||
<div class="mgmt-form-grid">
|
||||
<div class="field"><label>接收人 wxid</label><input id="img-to-user" placeholder="zhang499142409" /></div>
|
||||
<div class="field full"><label>图片内容(base64 或 URL)</label><input id="img-content" placeholder="图片 base64 或图片 URL" /></div>
|
||||
<div class="field full">
|
||||
<label>图片内容(base64 / URL / 本地图片)</label>
|
||||
<input id="img-content" placeholder="粘贴 base64、图片 URL,或从下方选择本地图片" />
|
||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<input type="file" id="img-file" accept="image/*" style="font-size:12px" />
|
||||
<span id="img-file-name" class="small-label" style="color:var(--muted)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field full"><label>附带文字(可选)</label><input id="img-text" placeholder="可选" /></div>
|
||||
<button type="button" class="primary" id="btn-send-image">发送图片</button>
|
||||
</div>
|
||||
@@ -198,6 +206,16 @@
|
||||
</div>
|
||||
<div id="panel-ai-reply" class="mgmt-panel">
|
||||
<p class="small-label" style="margin-bottom:12px">分级处理:仅<strong>超级管理员</strong>与<strong>白名单</strong>中的联系人会收到 AI 自动回复,其他消息一律不回复。</p>
|
||||
<div class="field full" style="margin-bottom:12px">
|
||||
<span class="small-label">AI 接管状态:</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>
|
||||
</div>
|
||||
<div class="field full" style="margin-bottom:12px">
|
||||
<span class="small-label">消息回调(7006 → 本服务):</span>
|
||||
<span id="callback-status-text">—</span>
|
||||
<button type="button" class="secondary" id="btn-callback-status" style="margin-left:8px;padding:2px 8px;font-size:12px">检查回调</button>
|
||||
</div>
|
||||
<div class="mgmt-form-grid">
|
||||
<div class="field full">
|
||||
<label>超级管理员 wxid(每行一个或逗号分隔)</label>
|
||||
@@ -256,7 +274,7 @@
|
||||
const tab = document.querySelector('.mgmt-tab[data-panel="' + panelId + '"]');
|
||||
if (tab) tab.classList.add('active');
|
||||
if (panelId === 'panel-greeting') loadCustomerTagsForGreeting();
|
||||
if (panelId === 'panel-ai-reply') loadAiReplyConfig();
|
||||
if (panelId === 'panel-ai-reply') { loadAiReplyConfig(); loadAiReplyStatus(); loadCallbackStatus(); }
|
||||
}
|
||||
|
||||
function _parseWxidLines(ta) {
|
||||
@@ -283,6 +301,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAiReplyStatus() {
|
||||
const key = $('key').value.trim();
|
||||
const el = $('ai-reply-status-text');
|
||||
if (!el) return;
|
||||
if (!key) { el.textContent = '请先登录'; return; }
|
||||
el.textContent = '检测中…';
|
||||
try {
|
||||
const data = await callApi('/api/ai-reply-status?key=' + encodeURIComponent(key));
|
||||
el.textContent = data.ok ? '正常(WS 已连接,已配置白名单/超级管理员,已选模型)' : (data.message || '异常');
|
||||
el.style.color = data.ok ? 'var(--success, #22c55e)' : 'var(--muted, #94a3b8)';
|
||||
} catch (e) {
|
||||
el.textContent = '检查失败: ' + (e.message || e);
|
||||
el.style.color = 'var(--danger, #ef4444)';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCallbackStatus() {
|
||||
const key = getKey();
|
||||
const el = $('callback-status-text');
|
||||
if (!el) return;
|
||||
if (!key) { el.textContent = '请先登录'; el.style.color = 'var(--muted)'; return; }
|
||||
el.textContent = '检测中…';
|
||||
el.style.color = 'var(--muted)';
|
||||
try {
|
||||
const data = await callApi('/api/callback-status?key=' + encodeURIComponent(key));
|
||||
if (!data.configured) {
|
||||
el.textContent = '未配置(未设置 CALLBACK_BASE_URL,使用 WS 拉取消息)';
|
||||
el.style.color = 'var(--muted, #94a3b8)';
|
||||
return;
|
||||
}
|
||||
if (data.registered === true) {
|
||||
el.textContent = '已配置且已向 7006 注册成功,新消息将推送到: ' + (data.callback_url || '');
|
||||
el.style.color = 'var(--success, #22c55e)';
|
||||
} else if (data.registered === false) {
|
||||
el.textContent = '已配置但向 7006 注册失败,请检查网络或 7006 服务。回调地址: ' + (data.callback_url || '');
|
||||
el.style.color = 'var(--danger, #ef4444)';
|
||||
} else {
|
||||
el.textContent = '已配置,回调地址: ' + (data.callback_url || '');
|
||||
el.style.color = 'var(--muted, #94a3b8)';
|
||||
}
|
||||
} catch (e) {
|
||||
el.textContent = '检查失败: ' + (e.message || e);
|
||||
el.style.color = 'var(--danger, #ef4444)';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAiReplyConfig() {
|
||||
const key = getKey();
|
||||
if (!key) return;
|
||||
@@ -331,6 +395,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let lastLoadedContactList = [];
|
||||
async function loadContactListForWxidSelect() {
|
||||
const key = $('key').value.trim();
|
||||
const sel = $('c-wxid-select');
|
||||
@@ -338,8 +403,9 @@
|
||||
if (!key) { alert('请先登录'); return; }
|
||||
sel.innerHTML = '<option value="">加载中…</option>';
|
||||
try {
|
||||
const data = await callApi('/api/contact-list?key=' + encodeURIComponent(key));
|
||||
const data = await callApi('/api/contact-list?key=' + encodeURIComponent(key), { cache: 'no-store' });
|
||||
const list = data.items || [];
|
||||
lastLoadedContactList = list;
|
||||
if (data.error) {
|
||||
sel.innerHTML = '<option value="">获取失败</option>';
|
||||
alert('获取联系人失败:' + (data.error || '请检查网络或 key'));
|
||||
@@ -347,8 +413,9 @@
|
||||
}
|
||||
sel.innerHTML = list.length
|
||||
? list.map(c => {
|
||||
const w = (c.wxid || c.Wxid || '').toString();
|
||||
const r = (c.remark_name || c.RemarkName || c.NickName || w).toString();
|
||||
const w = (c.wxid || '').toString();
|
||||
const n = (c.nick_name || '').toString();
|
||||
const r = (n || c.remark_name || w).toString(); // 优先显示昵称
|
||||
return '<option value="' + escapeHtml(w) + '">' + escapeHtml(r) + ' (' + escapeHtml(w.slice(0, 20)) + (w.length > 20 ? '…' : '') + ')</option>';
|
||||
}).join('')
|
||||
: '<option value="">无联系人数据</option>';
|
||||
@@ -359,6 +426,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function batchImportContactsAsCustomers() {
|
||||
const key = getKey();
|
||||
if (!key) { alert('请先登录'); return; }
|
||||
if (!lastLoadedContactList.length) { alert('请先点击「加载联系人」'); return; }
|
||||
if (!confirm('将 ' + lastLoadedContactList.length + ' 个联系人导入为客户(备注名使用 wxid),是否继续?')) return;
|
||||
try {
|
||||
for (const c of lastLoadedContactList) {
|
||||
const wxid = (c.wxid || '').trim();
|
||||
if (!wxid) continue;
|
||||
await callApi('/api/customers', { method: 'POST', body: JSON.stringify({ key, wxid, remark_name: (c.remark_name || wxid).trim() }) });
|
||||
}
|
||||
loadCustomers();
|
||||
alert('已导入 ' + lastLoadedContactList.length + ' 个客户');
|
||||
} catch (e) { alert('批量导入失败: ' + (e.message || e)); }
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
const key = $('key').value.trim();
|
||||
if (!key) { $('customer-list').innerHTML = '<p class="small-label">请先登录。</p>'; return; }
|
||||
@@ -558,7 +641,7 @@
|
||||
const el = $('mass-friend-list');
|
||||
el.innerHTML = '<span class="small-label">加载中…</span>';
|
||||
try {
|
||||
const data = await callApi('/api/friends?key=' + encodeURIComponent(key));
|
||||
const data = await callApi('/api/friends?key=' + encodeURIComponent(key), { cache: 'no-store' });
|
||||
const list = data.items || [];
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<span class="small-label">暂无联系人,请先在「客户档案」添加客户。</span>';
|
||||
@@ -602,12 +685,56 @@
|
||||
} catch (e) { alert('群发失败: ' + e.message); }
|
||||
}
|
||||
|
||||
function isImageUrl(s) {
|
||||
const t = (s || '').trim();
|
||||
return t.startsWith('http://') || t.startsWith('https://');
|
||||
}
|
||||
function stripDataUrlPrefix(s) {
|
||||
const m = (s || '').match(/^data:image\/[^;]+;base64,(.+)$/);
|
||||
return m ? m[1] : s;
|
||||
}
|
||||
function readFileAsBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => { resolve(stripDataUrlPrefix(r.result)); };
|
||||
r.onerror = () => reject(new Error('读取文件失败'));
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
async function urlToBase64(url) {
|
||||
const res = await fetch(url, { mode: 'cors' });
|
||||
if (!res.ok) throw new Error('获取图片失败: ' + res.status);
|
||||
const blob = await res.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(stripDataUrlPrefix(r.result));
|
||||
r.onerror = () => reject(new Error('URL 转 base64 失败'));
|
||||
r.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
async function resolveImageContentToBase64() {
|
||||
const fileInput = $('img-file');
|
||||
const textInput = $('img-content').value.trim();
|
||||
if (fileInput && fileInput.files && fileInput.files[0]) {
|
||||
return await readFileAsBase64(fileInput.files[0]);
|
||||
}
|
||||
if (!textInput) return null;
|
||||
if (isImageUrl(textInput)) return await urlToBase64(textInput);
|
||||
return stripDataUrlPrefix(textInput);
|
||||
}
|
||||
async function doSendImage() {
|
||||
const key = getKey();
|
||||
if (!key) return;
|
||||
const toUser = $('img-to-user').value.trim();
|
||||
const imageContent = $('img-content').value.trim();
|
||||
if (!toUser || !imageContent) { alert('请填写接收人 wxid 和图片内容'); return; }
|
||||
if (!toUser) { alert('请填写接收人 wxid'); return; }
|
||||
let imageContent;
|
||||
try {
|
||||
imageContent = await resolveImageContentToBase64();
|
||||
} catch (e) {
|
||||
alert('解析图片失败: ' + (e.message || e));
|
||||
return;
|
||||
}
|
||||
if (!imageContent) { alert('请填写或选择图片内容(base64、URL 或本地图片)'); return; }
|
||||
try {
|
||||
await callApi('/api/send-image', {
|
||||
method: 'POST',
|
||||
@@ -620,8 +747,10 @@
|
||||
});
|
||||
alert('图片已发送');
|
||||
$('img-content').value = '';
|
||||
if ($('img-file')) $('img-file').value = '';
|
||||
if ($('img-file-name')) $('img-file-name').textContent = '';
|
||||
$('img-text').value = '';
|
||||
} catch (e) { alert('发送图片失败: ' + e.message); }
|
||||
} catch (e) { alert('发送图片失败: ' + (e.message || e)); }
|
||||
}
|
||||
|
||||
async function doPushSend() {
|
||||
@@ -663,6 +792,7 @@
|
||||
document.querySelectorAll('.mgmt-tab').forEach(tab => tab.addEventListener('click', () => switchPanel(tab.dataset.panel)));
|
||||
$('btn-customer-save').addEventListener('click', saveCustomer);
|
||||
$('btn-load-contact-list').addEventListener('click', loadContactListForWxidSelect);
|
||||
$('btn-batch-import-remark').addEventListener('click', batchImportContactsAsCustomers);
|
||||
$('c-wxid-select').addEventListener('change', function() {
|
||||
const sel = $('c-wxid-select');
|
||||
const opts = sel ? sel.querySelectorAll('option:checked') : [];
|
||||
@@ -708,7 +838,14 @@
|
||||
$('btn-load-friends').addEventListener('click', loadFriendsForMass);
|
||||
$('btn-mass-send').addEventListener('click', doMassSend);
|
||||
$('btn-send-image').addEventListener('click', doSendImage);
|
||||
$('btn-ai-reply-save').addEventListener('click', saveAiReplyConfig);
|
||||
if ($('img-file') && $('img-file-name')) {
|
||||
$('img-file').addEventListener('change', function() {
|
||||
$('img-file-name').textContent = this.files && this.files[0] ? '已选: ' + this.files[0].name : '';
|
||||
});
|
||||
}
|
||||
$('btn-ai-reply-save').addEventListener('click', async () => { await saveAiReplyConfig(); loadAiReplyStatus(); });
|
||||
$('btn-ai-reply-status') && $('btn-ai-reply-status').addEventListener('click', loadAiReplyStatus);
|
||||
$('btn-callback-status') && $('btn-callback-status').addEventListener('click', loadCallbackStatus);
|
||||
$('btn-pt-add').addEventListener('click', addProductTag);
|
||||
$('btn-push-group-add').addEventListener('click', createPushGroup);
|
||||
$('btn-push-send').addEventListener('click', doPushSend);
|
||||
|
||||
30
public/pages.html
Normal file
30
public/pages.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>静态页面入口</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; margin: 2rem; background: #0f172a; color: #e2e8f0; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
||||
ul { list-style: none; padding: 0; }
|
||||
li { margin: 0.5rem 0; }
|
||||
a { color: #38bdf8; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.note { font-size: 0.875rem; color: #94a3b8; margin-top: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>静态页面入口</h1>
|
||||
<p>以下页面均已释放,可直接访问:</p>
|
||||
<ul>
|
||||
<li><a href="index.html">index.html</a> — 微信登录控制台(获取二维码 / 滑块验证)</li>
|
||||
<li><a href="manage.html">manage.html</a> — 客户与消息管理</li>
|
||||
<li><a href="chat.html">chat.html</a> — 对话/聊天</li>
|
||||
<li><a href="models.html">models.html</a> — 模型管理</li>
|
||||
<li><a href="swagger.html">swagger.html</a> — API 文档(Swagger)</li>
|
||||
</ul>
|
||||
<p class="note">通过 Node 前端(如 :3000)或直接访问后端(如 :8000)均可打开上述页面。</p>
|
||||
</body>
|
||||
</html>
|
||||
144
run-ngrok.sh
Executable file
144
run-ngrok.sh
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# 用 ngrok 暴露本机 8000 端口,并把公网 URL 写入 .env 的 CALLBACK_BASE_URL,便于 7006 回调调通
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v ngrok >/dev/null 2>&1; then
|
||||
echo "未检测到 ngrok。请先安装:"
|
||||
echo " brew install ngrok/ngrok/ngrok # macOS"
|
||||
echo " 或从 https://ngrok.com/download 下载"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NGROK_LOG="/tmp/ngrok-wechataiclaw.log"
|
||||
rm -f "$NGROK_LOG"
|
||||
|
||||
# 避免重复启动导致多个 ngrok
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4040/api/tunnels 2>/dev/null | grep -q 200; then
|
||||
echo "检测到 ngrok 已在运行(4040 可访问),直接读取 URL..."
|
||||
else
|
||||
echo "启动 ngrok http 8000(后端需在 8000 端口,可先在本脚本之后另开终端运行 ./run-dev.sh)..."
|
||||
nohup ngrok http 8000 --log=stdout > "$NGROK_LOG" 2>&1 &
|
||||
NGROK_PID=$!
|
||||
echo "等待 ngrok 就绪(最多 30 秒,并从日志解析 URL)..."
|
||||
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4040/api/tunnels 2>/dev/null | grep -q 200; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# 方式一:从 4040 API 获取
|
||||
PAYLOAD=""
|
||||
for _ in 1 2 3 4 5; do
|
||||
PAYLOAD=$(curl -s http://127.0.0.1:4040/api/tunnels 2>/dev/null || true)
|
||||
if [ -n "$PAYLOAD" ] && [ "$PAYLOAD" != "null" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 仅接受隧道公网地址,排除 dashboard/signup/get-started/docs 等说明页
|
||||
_is_tunnel_url() {
|
||||
case "$1" in
|
||||
*dashboard*|*signup*|*get-started*|*ngrok.com/docs*|*your-authtoken*) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 方式二:4040 不可用时从 ngrok 启动日志中解析 https 公网地址(不依赖本地 API)
|
||||
if [ -z "$PAYLOAD" ] || [ "$PAYLOAD" = "null" ]; then
|
||||
echo "4040 API 不可用,尝试从 ngrok 日志解析 URL(约 20 秒)..."
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
sleep 2
|
||||
if [ -f "$NGROK_LOG" ] && [ -s "$NGROK_LOG" ]; then
|
||||
# 只匹配隧道域名:*.ngrok-free.app / *.ngrok.io(排除 dashboard.ngrok.com 等)
|
||||
PUBLIC_URL_FROM_LOG=$(grep -oE 'https://[a-zA-Z0-9][-a-zA-Z0-9.]*\.(ngrok-free\.app|ngrok\.io|ngrok-app\.com)([^"'\''<> /]*|$)' "$NGROK_LOG" 2>/dev/null | head -1) || true
|
||||
[ -z "$PUBLIC_URL_FROM_LOG" ] && PUBLIC_URL_FROM_LOG=$(grep -iE 'forwarding|Forwarding' "$NGROK_LOG" 2>/dev/null | grep -oE 'https://[a-zA-Z0-9][-a-zA-Z0-9.]*\.(ngrok-free\.app|ngrok\.io)[^ ]*' | head -1) || true
|
||||
if [ -n "$PUBLIC_URL_FROM_LOG" ] && _is_tunnel_url "$PUBLIC_URL_FROM_LOG"; then
|
||||
PUBLIC_URL="$PUBLIC_URL_FROM_LOG"
|
||||
echo "已从日志解析到: $PUBLIC_URL"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 若尚未从日志得到 URL,则从 API 的 PAYLOAD 解析
|
||||
if [ -z "$PUBLIC_URL" ] && [ -n "$PAYLOAD" ] && [ "$PAYLOAD" != "null" ]; then
|
||||
PUBLIC_URL=$(echo "$PAYLOAD" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
tunnels = d.get('tunnels') if isinstance(d, dict) else (d if isinstance(d, list) else [])
|
||||
if not isinstance(tunnels, list):
|
||||
tunnels = []
|
||||
for t in tunnels:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
u = (t.get('public_url') or t.get('PublicURL') or '').strip()
|
||||
if u.startswith('https://'):
|
||||
print(u.rstrip('/'))
|
||||
break
|
||||
else:
|
||||
if tunnels:
|
||||
u = (tunnels[0].get('public_url') or tunnels[0].get('PublicURL') or '').strip()
|
||||
if u:
|
||||
print(u.rstrip('/'))
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null)
|
||||
if [ -z "$PUBLIC_URL" ]; then
|
||||
PUBLIC_URL=$(echo "$PAYLOAD" | grep -oE 'https://[a-zA-Z0-9][-a-zA-Z0-9.]*\.(ngrok-free\.app|ngrok\.io|ngrok-app\.com)[^"]*' | head -1 | sed 's|"$||')
|
||||
fi
|
||||
if [ -z "$PUBLIC_URL" ]; then
|
||||
PUBLIC_URL=$(echo "$PAYLOAD" | grep -oE '"public_url"\s*:\s*"https://[^"]+' | sed 's/.*"https:/https:/' | sed 's/"$//' | head -1)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$PUBLIC_URL" ]; then
|
||||
if [ -f "$NGROK_LOG" ] && grep -qE 'authentication failed|ERR_NGROK_4018|authtoken|requires a verified account' "$NGROK_LOG" 2>/dev/null; then
|
||||
echo "ngrok 需要先配置 authtoken(未登录或 token 未配置)。"
|
||||
echo "请到 https://dashboard.ngrok.com/get-started/your-authtoken 获取 token,然后执行:"
|
||||
echo " ngrok config add-authtoken <你的token>"
|
||||
echo "再重新运行: ./run-ngrok.sh"
|
||||
else
|
||||
echo "解析 ngrok URL 失败。请手动运行: ngrok http 8000"
|
||||
echo "把终端里显示的 https 隧道地址写入 .env: CALLBACK_BASE_URL=https://xxxx.ngrok-free.app"
|
||||
echo "或查看日志: cat $NGROK_LOG"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ngrok 公网地址: $PUBLIC_URL"
|
||||
|
||||
# 更新 .env:存在 CALLBACK_BASE_URL 则替换,否则追加
|
||||
ENV_FILE=".env"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "CALLBACK_BASE_URL=$PUBLIC_URL" >> "$ENV_FILE"
|
||||
echo "已写入 $ENV_FILE: CALLBACK_BASE_URL=$PUBLIC_URL"
|
||||
else
|
||||
if grep -q '^CALLBACK_BASE_URL=' "$ENV_FILE" 2>/dev/null; then
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
sed -i '' "s|^CALLBACK_BASE_URL=.*|CALLBACK_BASE_URL=$PUBLIC_URL|" "$ENV_FILE"
|
||||
else
|
||||
sed -i "s|^CALLBACK_BASE_URL=.*|CALLBACK_BASE_URL=$PUBLIC_URL|" "$ENV_FILE"
|
||||
fi
|
||||
echo "已更新 $ENV_FILE: CALLBACK_BASE_URL=$PUBLIC_URL"
|
||||
else
|
||||
echo "" >> "$ENV_FILE"
|
||||
echo "# 消息回调(ngrok 调通用,由 run-ngrok.sh 自动写入)" >> "$ENV_FILE"
|
||||
echo "CALLBACK_BASE_URL=$PUBLIC_URL" >> "$ENV_FILE"
|
||||
echo "已追加 $ENV_FILE: CALLBACK_BASE_URL=$PUBLIC_URL"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 若尚未启动后端,请在新终端执行: ./run-dev.sh"
|
||||
echo " 2. 后端启动时会向 7006 注册 SetCallback,回调地址: $PUBLIC_URL/api/callback/wechat-message"
|
||||
echo " 3. 访问管理页 http://localhost:3000 登录后,新消息会由 7006 POST 到上述地址"
|
||||
echo ""
|
||||
echo "(本终端可保持 ngrok 运行;或先 Ctrl+C 结束 ngrok,再按上述步骤先 run-ngrok.sh 再 run-dev.sh)"
|
||||
Reference in New Issue
Block a user