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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -89,6 +89,17 @@ def init_schema(conn: sqlite3.Connection) -> None:
)
""")
cur.execute("CREATE INDEX IF NOT EXISTS idx_sync_messages_key ON sync_messages(key)")
# 回调原始 body 落库,便于回溯与统计
cur.execute("""
CREATE TABLE IF NOT EXISTS callback_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
received_at TEXT NOT NULL,
raw_body TEXT
)
""")
cur.execute("CREATE INDEX IF NOT EXISTS idx_callback_log_key ON callback_log(key)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_callback_log_received ON callback_log(received_at)")
# 模型配置
cur.execute("""
CREATE TABLE IF NOT EXISTS models (

View File

@@ -3,6 +3,14 @@ import html
import logging
import os
from contextlib import asynccontextmanager
# 优先加载项目根目录的 .env不依赖当前工作目录使 HTTP_PROXY/HTTPS_PROXY 等生效
try:
from dotenv import load_dotenv
_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
load_dotenv(os.path.join(_root, ".env"))
except ImportError:
pass
from logging.handlers import RotatingFileHandler
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -29,7 +37,8 @@ CHECK_STATUS_BASE_URL = os.getenv("CHECK_STATUS_BASE_URL", "http://113.44.162.18
# 消息实时回调:设置后 7006 将新消息 POST 到该地址,作为主接收入口(与 SetCallback 一致)
CALLBACK_BASE_URL = (os.getenv("CALLBACK_BASE_URL") or "").strip().rstrip("/")
SLIDER_VERIFY_BASE_URL = os.getenv("SLIDER_VERIFY_BASE_URL", "http://113.44.162.180:7765").rstrip("/")
SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", os.getenv("KEY", "408449830"))
# 滑块服务 7765 的 key与账号 key 无关,默认使用提供方 QQ使用其公共服务时必填
SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", "408449830")
# 发送文本消息swagger 中为 POST /message/SendTextMessagebody 为 SendMessageModelMsgItem 数组)
SEND_MSG_PATH = (os.getenv("SEND_MSG_PATH") or "/message/SendTextMessage").strip()
# 发送图片消息:部分上游为独立接口,或与文本同 path 仅 MsgType 不同(如 3=图片)
@@ -228,7 +237,7 @@ async def _register_message_callback(key: str) -> bool:
callback_url = f"{CALLBACK_BASE_URL.rstrip('/')}/api/callback/wechat-message"
body = {"CallbackURL": callback_url, "Enabled": True}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=10.0) as client:
resp = await client.post(url, params={"key": key}, json=body)
if resp.status_code >= 400:
logger.warning("SetCallback %s key=%s: %s %s", url, key[-4:] if len(key) >= 4 else "****", resp.status_code, resp.text[:200])
@@ -340,6 +349,16 @@ class QrCodeRequest(BaseModel):
Proxy: Optional[str] = ""
IpadOrmac: Optional[str] = ""
Check: Optional[bool] = False
"""仅当需滑块且为「无数字」时传 True重新取码用 Mac 设备;其余一律 iPad传 Mac 易封号。"""
force_mac: Optional[bool] = False
class WakeUpRequest(BaseModel):
"""唤醒登录(只限扫码登录),仅调用 7006 WakeUpLogin不拉取二维码。"""
key: str
Check: Optional[bool] = False
IpadOrmac: Optional[str] = "ipad"
Proxy: Optional[str] = ""
@app.middleware("http")
@@ -371,23 +390,74 @@ def _proxy_from_env() -> str:
)
@app.post("/auth/wake")
async def wake_up_login(body: WakeUpRequest):
"""唤醒登录:仅调用上游 /login/WakeUpLogin只限扫码登录不获取二维码。"""
key = (body.key or "").strip()
if not key:
raise HTTPException(status_code=400, detail="key is required")
proxy = (body.Proxy or "").strip()
if not proxy:
proxy = _proxy_from_env()
if proxy:
logger.info("WakeUpLogin: using proxy from env (HTTP_PROXY/HTTPS_PROXY), len=%s", len(proxy))
else:
logger.info("WakeUpLogin: Proxy 为空,请在 .env 中设置 HTTP_PROXY/HTTPS_PROXY或登录页填写代理后重试")
payload = {
"Check": body.Check,
"IpadOrmac": "ipad",
"Proxy": proxy,
}
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}/login/WakeUpLogin"
logger.info("WakeUpLogin: key=%s, payload=%s, url=%s", key, payload, url)
try:
async with httpx.AsyncClient(trust_env=False, timeout=20.0) as client:
resp = await client.post(url, params={"key": key}, json=payload)
except Exception as exc:
logger.exception("Error calling upstream WakeUpLogin: %s", exc)
raise HTTPException(
status_code=502,
detail={"error": "upstream_connect_error", "detail": str(exc)},
) from exc
if resp.status_code >= 400:
body_preview = resp.text[:500]
logger.warning("WakeUpLogin bad response: status=%s, body=%s", resp.status_code, body_preview)
raise HTTPException(
status_code=502,
detail={"error": "upstream_bad_response", "status_code": resp.status_code, "body": body_preview},
)
try:
data = resp.json()
except Exception:
data = {"ok": True, "text": resp.text[:200]}
logger.info("WakeUpLogin success: status=%s", resp.status_code)
return data
@app.post("/auth/qrcode")
async def get_login_qrcode(body: QrCodeRequest):
key = body.key
if not key:
raise HTTPException(status_code=400, detail="key is required")
payload = body.dict(exclude={"key"})
if not (payload.get("Proxy") or "").strip():
env_proxy = _proxy_from_env()
if env_proxy:
payload["Proxy"] = env_proxy
logger.info("GetLoginQrCodeNewDirect: using proxy from env (HTTP_PROXY/HTTPS_PROXY), len=%s", len(env_proxy))
proxy = (body.Proxy or "").strip()
if not proxy:
proxy = _proxy_from_env()
if not proxy:
raise HTTPException(
status_code=400,
detail="必须配置代理Proxy。服务器在香港不上代理必封号请填写 socks5 代理后再取码。",
)
payload = body.dict(exclude={"key", "force_mac"})
payload["Check"] = False
payload["IpadOrmac"] = "mac" if body.force_mac else "ipad"
payload["Proxy"] = proxy
logger.info("GetLoginQrCodeNewDirect: proxy=yes, force_mac=%s, IpadOrmac=%s", body.force_mac, payload["IpadOrmac"])
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/GetLoginQrCodeNewDirect"
logger.info("GetLoginQrCodeNewDirect: key=%s, payload=%s, url=%s", key, payload, url)
try:
async with httpx.AsyncClient(timeout=20.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=20.0) as client:
resp = await client.post(url, params={"key": key}, json=payload)
except Exception as exc:
logger.exception("Error calling upstream GetLoginQrCodeNewDirect: %s", exc)
@@ -452,7 +522,7 @@ async def get_online_status(
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/GetLoginStatus"
logger.info("GetLoginStatus: key=%s, url=%s", key, url)
try:
async with httpx.AsyncClient(timeout=15.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
resp = await client.get(url, params={"key": key})
except Exception as exc:
logger.exception("Error calling upstream GetLoginStatus: %s", exc)
@@ -545,7 +615,7 @@ async def check_scan_status(
url = f"{CHECK_STATUS_BASE_URL}/login/CheckLoginStatus"
logger.info("CheckLoginStatus: key=%s, url=%s", key, url)
try:
async with httpx.AsyncClient(timeout=15.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
resp = await client.get(url, params={"key": key})
except Exception as exc:
logger.exception("Error calling upstream CheckLoginStatus: %s", exc)
@@ -557,7 +627,12 @@ async def check_scan_status(
resp.status_code,
body_full[:2000] if len(body_full) > 2000 else body_full,
)
data = resp.json()
try:
data = resp.json() if body_full.strip() else {}
except Exception:
data = {"Code": resp.status_code, "Text": body_full[:500] or "Non-JSON response"}
if not isinstance(data, dict):
data = {"Code": resp.status_code, "Text": str(data)[:500]}
ticket = _extract_clean_ticket(data)
if ticket:
# data62 使用完整原始数据,来自 GetLoginQrCodeNewDirect 的存储或本次响应的 Data62
@@ -635,7 +710,7 @@ async def slider_asset_proxy(path: str):
"""代理 7765 的 assets如 N_jYM_2V.js避免跨域加载被 CORS 拦截。"""
url = f"{SLIDER_VERIFY_BASE_URL.rstrip('/')}/assets/{path}"
try:
async with httpx.AsyncClient(timeout=15.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
resp = await client.get(url)
if resp.status_code >= 400:
raise HTTPException(status_code=resp.status_code, detail=resp.text[:200])
@@ -678,7 +753,7 @@ async def api_slider_verify_get(
url = SLIDER_VERIFY_BASE_URL.rstrip("/") + "/"
params = {"key": key, "data62": (data62 or "").strip(), "original_ticket": ticket_val}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
resp = await client.get(url, params=params)
# 返回上游的 body若为 JSON 则解析后返回
try:
@@ -706,7 +781,7 @@ async def api_slider_verify_post(body: SliderVerifyBody):
url = SLIDER_VERIFY_BASE_URL.rstrip("/") + "/"
params = {"key": body.key, "data62": (body.data62 or "").strip(), "original_ticket": ticket_val}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
resp = await client.get(url, params=params)
try:
return resp.json()
@@ -966,7 +1041,7 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
body = await request.json()
except Exception:
body = {}
# 打印回调原始内容,便于排查结构与字段(截断避免日志过大)
# 打印回调原始内容,便于排查(截断避免日志过大)
try:
logger.info("callback/wechat-message raw body: %s", str(body)[:1000])
except Exception:
@@ -975,6 +1050,11 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
if not k:
logger.warning("callback/wechat-message: missing key in query and body")
return JSONResponse(content={"ok": False, "error": "missing key"}, status_code=200)
# 原始 body 落库,便于回溯与统计
try:
store.append_callback_log(k, body if isinstance(body, dict) else {"raw": str(body)})
except Exception as le:
logger.warning("callback_log append failed: %s", le)
try:
payload: Any = body
# 7006 回调当前格式示例:{"key": "...", "message": {...}, "type": "message"}
@@ -988,6 +1068,7 @@ async def api_callback_wechat_message(request: Request, key: Optional[str] = Que
if isinstance(inner, (dict, list)):
payload = inner
_on_ws_message(k, payload)
logger.info("callback message saved to sync_messages, key=%s", k[:8] + "..." if len(k) > 8 else k)
except Exception as e:
logger.exception("callback/wechat-message key=%s: %s", k[-4:] if len(k) >= 4 else "****", e)
return {"ok": True}
@@ -997,7 +1078,7 @@ async def _send_message_upstream(key: str, to_user_name: str, content: str) -> d
"""调用上游发送文本消息;成功时写入发出记录并返回响应,失败抛 HTTPException。"""
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_MSG_PATH}"
payload = {"MsgItem": [{"ToUserName": to_user_name, "MsgType": 1, "TextContent": content}]}
async with httpx.AsyncClient(timeout=15.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
resp = await client.post(url, params={"key": key}, json=payload)
if resp.status_code >= 400:
body_preview = resp.text[:400] if resp.text else ""
@@ -1026,7 +1107,7 @@ async def _send_batch_upstream(key: str, items: List[dict]) -> dict:
if not msg_items:
raise HTTPException(status_code=400, detail="items 中至少需要一条有效 to_user_name 与 content")
payload = {"MsgItem": msg_items}
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
resp = await client.post(url, params={"key": key}, json=payload)
if resp.status_code >= 400:
body_preview = resp.text[:400] if resp.text else ""
@@ -1056,7 +1137,7 @@ async def _send_image_upstream(key: str, to_user_name: str, image_content: str,
"AtWxIDList": at_wxid_list or [],
}
payload = {"MsgItem": [item]}
async with httpx.AsyncClient(timeout=15.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
resp = await client.post(url, params={"key": key}, json=payload)
if resp.status_code >= 400:
body_preview = resp.text[:400] if resp.text else ""
@@ -1128,6 +1209,16 @@ def _log_contact_list_response_structure(raw: dict) -> None:
for k, v in list(data.items())[:5]:
preview = str(v)[:80] if v is not None else "null"
logger.info(" Data.%s: %s", k, preview)
# 7006 常见为 Data.ContactList 对象,内挂 contactUsernameList 数组
cl = data.get("ContactList") or data.get("contactList")
if isinstance(cl, dict):
cl_keys = list(cl.keys())
logger.info(" Data.ContactList keys: %s", cl_keys)
for uk in ("contactUsernameList", "ContactUsernameList", "UserNameList", "userNameList", "usernameList"):
arr = cl.get(uk)
if isinstance(arr, list):
logger.info(" Data.ContactList.%s length=%s, sample=%s", uk, len(arr), arr[:3] if arr else [])
break
def _unwrap_wechat_field(v: Any) -> Any:
@@ -1215,12 +1306,14 @@ def _normalize_contact_list(raw: Any) -> List[dict]:
or data.get("wxcontactList")
or data.get("CachedContactList")
)
# 7006 格式ContactList 为对象,联系人 id 在 contactUsernameList 字符串数组里
# 7006 格式ContactList 为对象,联系人 id 在 contactUsernameList 数组里
if isinstance(contact_list, dict):
username_list = (
contact_list.get("contactUsernameList")
or contact_list.get("ContactUsernameList")
or contact_list.get("UserNameList")
or contact_list.get("userNameList")
or contact_list.get("usernameList")
or []
)
if isinstance(username_list, list) and username_list:
@@ -1241,23 +1334,21 @@ def _normalize_contact_list(raw: Any) -> List[dict]:
continue
if not isinstance(x, dict):
continue
wxid = (
wxid = _unwrap_wechat_field(
x.get("wxid")
or x.get("Wxid")
or x.get("UserName")
or x.get("userName")
or x.get("Alias")
or ""
)
remark = (
) or ""
remark = _unwrap_wechat_field(
x.get("remark_name")
or x.get("RemarkName")
or x.get("NickName")
or x.get("nickName")
or x.get("DisplayName")
or wxid
)
result.append({"wxid": wxid, "remark_name": remark})
) or wxid
result.append({"wxid": str(wxid).strip(), "remark_name": str(remark).strip()})
return result
@@ -1272,7 +1363,7 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
body: dict = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
max_rounds = 50
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
for round_num in range(max_rounds):
resp = await client.post(url, params={"key": key}, json=body)
if resp.status_code >= 400:
@@ -1282,9 +1373,26 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
chunk = _normalize_contact_list(raw)
if not chunk and isinstance(raw, dict):
chunk = _normalize_contact_list(raw.get("Data") or raw.get("data") or raw)
if round_num == 0 and not chunk and isinstance(raw, dict):
_log_contact_list_response_structure(raw)
for item in chunk:
# 首轮无归一化结果时,直接从 Data.ContactList 下任意已知数组键取 id 列表7006 格式)
if not chunk and round_num == 0 and isinstance(raw, dict):
data = raw.get("Data") or raw.get("data") or {}
if isinstance(data, dict):
cl = data.get("ContactList") or data.get("contactList")
if isinstance(cl, dict):
ul = (
cl.get("contactUsernameList")
or cl.get("ContactUsernameList")
or cl.get("UserNameList")
or cl.get("userNameList")
or cl.get("usernameList")
or []
)
if isinstance(ul, list) and ul:
chunk = [{"wxid": (x if isinstance(x, str) else str(x)), "remark_name": ""} for x in ul]
logger.info("GetContactList fallback from Data.ContactList.* list, count=%s", len(chunk))
if not chunk:
_log_contact_list_response_structure(raw)
for item in chunk or []:
wxid = (item.get("wxid") or "").strip()
if wxid and wxid not in seen:
seen.add(wxid)
@@ -1306,14 +1414,15 @@ async def _fetch_all_contact_usernames(key: str) -> List[str]:
return usernames
async def _build_contact_index(key: str) -> Dict[str, dict]:
async def _build_contact_index(key: str, force_refresh: bool = False) -> Dict[str, dict]:
"""
通用联系人索引:
- 先通过 GetContactList 拿到全部 UserName 列表;
- 再通过 /friend/GetContactDetailsList 批量拉取详情;
- 构建 name(微信号/昵称/备注) -> 联系人详情 的索引。
force_refresh=True 时跳过内存缓存,重新请求上游。
"""
if key in _contact_index and _contact_index[key]:
if not force_refresh and key in _contact_index and _contact_index[key]:
return _contact_index[key]
usernames = await _fetch_all_contact_usernames(key)
@@ -1323,83 +1432,68 @@ async def _build_contact_index(key: str) -> Dict[str, dict]:
url = f"{CHECK_STATUS_BASE_URL.rstrip('/')}/friend/GetContactDetailsList"
index: Dict[str, dict] = {}
# 小批量遍历,多请求并发调用,直到全部返回(不把 contactUsernameList 整包当 UserNames 一次传)
batch_size = 10
max_concurrent = 6
sem = asyncio.Semaphore(max_concurrent)
async with httpx.AsyncClient(timeout=30.0) as client:
chunk_size = 50
for i in range(0, len(usernames), chunk_size):
batch = usernames[i : i + chunk_size]
body = {
"RoomWxIDList": [],
"UserNames": batch,
}
async def fetch_one_batch(client: httpx.AsyncClient, batch: List[str], batch_idx: int) -> List[dict]:
body = {"RoomWxIDList": [], "UserNames": batch}
async with sem:
try:
resp = await client.post(url, params={"key": key}, json=body)
except Exception as e:
logger.warning("GetContactDetailsList batch error: %s", e)
continue
logger.warning("GetContactDetailsList batch %s error: %s", batch_idx, e)
return []
if resp.status_code >= 400:
logger.warning("GetContactDetailsList %s: %s", resp.status_code, resp.text[:200])
continue
logger.warning("GetContactDetailsList batch %s %s: %s", batch_idx, resp.status_code, resp.text[:200])
return []
raw = resp.json()
data = raw.get("Data") or raw.get("data") or raw
# 打印一次结构,便于排查为何没有解析出联系人详情
try:
if i == 0:
top_keys = list(raw.keys()) if isinstance(raw, dict) else type(raw).__name__
data_keys = list(data.keys()) if isinstance(data, dict) else type(data).__name__
logger.info(
"GetContactDetailsList structure: top_keys=%s, Data_keys=%s, batch_size=%s",
top_keys,
data_keys,
len(batch),
)
except Exception:
pass
items = []
if isinstance(data, dict):
# 7006 GetContactDetailsList 当前结构Data.contactList 为联系人详情数组
items = (
data.get("List")
or data.get("list")
or data.get("ContactDetailsList")
or data.get("contacts")
or data.get("contactList")
or data.get("ContactList") # 7006 可能用大写
or []
)
elif isinstance(data, list):
items = data
if not isinstance(items, list):
# 结构不符时记录一条日志,帮助判断需要从哪里取联系人列表
logger.info(
"GetContactDetailsList no list items parsed, data_type=%s, sample=%s",
type(data).__name__,
str(data)[:200],
)
return []
if not items and batch_idx == 0:
logger.info("GetContactDetailsList batch 0: data keys=%s, no list parsed", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
if batch_idx == 0 and items:
sample = items[0]
if isinstance(sample, dict):
logger.info(
"GetContactDetailsList first batch item keys=%s",
list(sample.keys()),
)
return items
async with httpx.AsyncClient(trust_env=False, timeout=30.0) as client:
batches = [usernames[i : i + batch_size] for i in range(0, len(usernames), batch_size)]
tasks = [fetch_one_batch(client, b, i) for i, b in enumerate(batches)]
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, one in enumerate(results):
if isinstance(one, BaseException):
logger.warning("GetContactDetailsList batch %s exception: %s", i, one)
continue
# 追加一次示例项日志便于确认字段名UserName/NickName/RemarkName 等)
try:
if i == 0 and items:
sample = items[0]
if isinstance(sample, dict):
logger.info(
"GetContactDetailsList first item keys=%s, sample=%s",
list(sample.keys()),
str(sample)[:200],
)
except Exception:
pass
for d in items:
for d in one or []:
if not isinstance(d, dict):
continue
# 只保留 bitVal == 3 的联系人(如上游定义的「有效联系人」),其它忽略
# 仅当上游明确返回 bitVal 且不为 3 时跳过(未返回或为 3 则保留,避免漏掉联系人)
try:
bit_val = int(d.get("bitVal") or 0)
bv = d.get("bitVal")
if bv is not None and int(bv) != 3:
continue
except (TypeError, ValueError):
bit_val = 0
if bit_val != 3:
continue
# 7006 联系人详情字段为 userName/nickName/pyinitial/quanPin 等,内部多为 {'str': 'xxx'} 包装
pass
wxid = _unwrap_wechat_field(
d.get("userName") or d.get("UserName") or d.get("user_name") or d.get("wxid")
)
@@ -1424,14 +1518,18 @@ async def _build_contact_index(key: str) -> Dict[str, dict]:
"quan_pin": quan_pin,
"raw": d,
}
# 主键wxid
index[wxid] = info
# 昵称 / 备注作为别名,便于按自然语言查找
if nick and nick not in index:
index[nick] = info
if remark and remark not in index:
index[remark] = info
if usernames and not index:
logger.warning(
"Contact index empty for key=***%s despite usernames count=%s: GetContactDetailsList may return different structure or all items filtered",
key[-4:] if len(key) >= 4 else "****",
len(usernames),
)
_contact_index[key] = index
logger.info("Contact index built for key=***%s, size=%s", key[-4:] if len(key) >= 4 else "****", len(index))
return index
@@ -1484,10 +1582,14 @@ _NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate", "Pr
@app.get("/api/contact-list")
async def api_contact_list(key: str = Query(..., description="账号 key")):
async def api_contact_list(
key: str = Query(..., description="账号 key"),
refresh: Optional[str] = Query(None, description="传 1/true/yes 时强制重新拉取,不用内存缓存"),
):
"""获取全部联系人详情:基于 GetContactList + GetContactDetailsList 构建的通用索引。禁止缓存。"""
try:
index = await _build_contact_index(key)
force_refresh = (refresh or "").lower() in ("1", "true", "yes")
index = await _build_contact_index(key, force_refresh=force_refresh)
# 只返回去重后的联系人详情(以 wxid 主键)
uniques: Dict[str, dict] = {}
for name, info in index.items():
@@ -1711,7 +1813,7 @@ async def logout(body: LogoutBody):
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/LogOut"
logger.info("LogOut: key=%s, url=%s", key, url)
try:
async with httpx.AsyncClient(timeout=15.0) as client:
async with httpx.AsyncClient(trust_env=False, timeout=15.0) as client:
resp = await client.get(url, params={"key": key})
except Exception as exc:
logger.exception("Error calling upstream LogOut: %s", exc)

View File

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

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
"""数据库存储:客户档案、定时问候、商品标签、推送群组/任务、同步消息、模型、AI 回复配置。使用 SQLite便于增删改查。"""
"""数据库存储:客户档案、定时问候、商品标签、推送群组/任务、同步消息、回调原始日志、模型、AI 回复配置。使用 SQLite便于增删改查。"""
import datetime
import json
import threading
import time
@@ -403,6 +404,32 @@ def append_sent_message(key: str, to_user_name: str, content: str) -> None:
append_sync_messages(key, [{"direction": "out", "ToUserName": to_user_name, "Content": content, "CreateTime": int(time.time())}])
def append_callback_log(key: str, raw_body: dict, max_raw_len: int = 51200) -> None:
"""将 7006 回调的原始 body 落库便于回溯与统计。raw_body 序列化后截断,避免单条过大。"""
received_at = datetime.datetime.utcnow().isoformat() + "Z"
raw_str = json.dumps(raw_body, ensure_ascii=False)
if len(raw_str) > max_raw_len:
raw_str = raw_str[:max_raw_len] + "...[truncated]"
with _LOCK:
conn = _conn()
try:
conn.execute(
"INSERT INTO callback_log (key, received_at, raw_body) VALUES (?,?,?)",
(key, received_at, raw_str),
)
conn.commit()
# 每个 key 仅保留最近 2000 条原始回调
cur = conn.execute("SELECT id FROM callback_log WHERE key = ? ORDER BY id DESC", (key,))
rows = cur.fetchall()
if len(rows) > 2000:
to_del = [r["id"] for r in rows[2000:]]
placeholders = ",".join("?" * len(to_del))
conn.execute(f"DELETE FROM callback_log WHERE id IN ({placeholders})", to_del)
conn.commit()
finally:
conn.close()
# ---------- 模型 ----------
def list_models() -> List[Dict]:
with _LOCK:

View File

@@ -37,7 +37,7 @@ async def _run_ws(key: str) -> None:
while True:
try:
_ws_connected = False
async with websockets.connect(url, ping_interval=20, ping_timeout=10, close_timeout=5) as ws:
async with websockets.connect(url, ping_interval=20, ping_timeout=10, close_timeout=5, proxy=None) as ws:
_ws_connected = True
logger.info("WS connected for key=%s", key)
while True: