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

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)