This commit is contained in:
丹尼尔
2026-03-11 18:19:30 +08:00
parent 152877cef2
commit b7ef2569c4
13 changed files with 6907 additions and 94 deletions

3
.env
View File

@@ -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

View File

@@ -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
# 或使用阿里云 DashScopeDASHSCOPE_API_KEY=sk-xxx

80
DEBUG.md Normal file
View 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.

View File

@@ -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/SendTextMessagebody 为 SendMessageModelMsgItem 数组)
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/GetContactListbody 传 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,16 +102,72 @@ 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 接管对话。"""
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)
@@ -121,7 +184,10 @@ 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
elif isinstance(data, list):
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,7 +202,9 @@ 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:
return
# 3兜底单条 dict / 其它类型
store.append_sync_messages(key, [data])
m = data if isinstance(data, dict) else {}
if not _is_self_sent(m):
@@ -145,14 +213,33 @@ def _on_ws_message(key: str, data: dict) -> None:
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:
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:
"""定时检查到期问候任务,通过发送消息接口向匹配客户发送,并标记已执行。"""
check_interval = 30
@@ -219,6 +306,14 @@ async def _run_greeting_scheduler() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
set_message_callback(_on_ws_message)
_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
@@ -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="账号 key7006 回调时可能带在 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")
)
# 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 []
)
items = items or raw.get("items") or raw.get("list") or raw.get("List") 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=0key 走 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")

View File

@@ -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

View File

@@ -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;

View File

@@ -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
View 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
View 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"

2
run.sh Normal file
View File

@@ -0,0 +1,2 @@
sh ./run-ngrok.sh
sh ./run-dev.sh