Files
gitlab-instance-0a899031_pa…/app/services/wechat_private_user_pb.py
2026-04-02 01:04:08 -05:00

233 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
微信 Chatbot 开放对话回调后:从 chatbot.weixin 联系人接口拉取资料,写入 PocketBase wechat_private_users。
/chatboteric与 /hackrobotxidu共用按 profile_key 选用不同 wxbot_bid。
"""
from __future__ import annotations
import json
import logging
import os
import time
from typing import Optional
import httpx
from ..config import PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL
logger = logging.getLogger(__name__)
WECHAT_PRIVATE_USERS_COLLECTION = "wechat_private_users"
# 与线上一致:默认同一套 JWT 可拉多个 bid异度单独 bid。可用环境变量覆盖。
_DEFAULT_PM_SYNC_AUTHTOKEN = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4"
)
_DEFAULT_WXBOT_BID_ERIC = "75021053883a9090c67d5becde7f4b0a"
_DEFAULT_WXBOT_BID_XIDU = "4888c6ab5cb70dcc3adea1e2d2ff0201"
_PM_PROFILE_KEYS = frozenset({"eric", "xidu"})
_processed_callback_cache: dict[str, int] = {}
def normalize_pm_profile_key(profile_key: str) -> str:
k = (profile_key or "").strip().lower()
return k if k in _PM_PROFILE_KEYS else "eric"
def pm_sync_credentials_for_profile(profile_key: str) -> tuple[str, str]:
"""返回 (authtoken, wxbot_bid),供 getaccessstautuslist 使用。"""
k = normalize_pm_profile_key(profile_key)
base_token = (os.getenv("PM_SYNC_AUTHTOKEN") or _DEFAULT_PM_SYNC_AUTHTOKEN).strip()
if k == "xidu":
token = (os.getenv("PM_SYNC_AUTHTOKEN_XIDU") or base_token).strip()
bid = (
os.getenv("PM_SYNC_WXBOT_BID_XIDU")
or os.getenv("PM_SYNC_DEFAULT_WXBOT_BID_XIDU")
or _DEFAULT_WXBOT_BID_XIDU
).strip()
else:
token = base_token
bid = (
os.getenv("PM_SYNC_WXBOT_BID")
or os.getenv("PM_SYNC_DEFAULT_WXBOT_BID")
or _DEFAULT_WXBOT_BID_ERIC
).strip()
return token, bid
def safe_filter_value(value: str) -> str:
return (value or "").replace("\\", "\\\\").replace('"', '\\"')
def _build_callback_dedupe_key(data: dict) -> str:
return "|".join(
[
str(data.get("UserId", "")).strip(),
str(data.get("MsgId", "")).strip(),
str(data.get("Timestamp", "")).strip(),
str(data.get("Query", "")).strip(),
]
)
def is_duplicate_wechat_callback(data: dict, profile_key: str, ttl_seconds: int = 3600) -> bool:
now_ts = int(time.time())
for k, exp in list(_processed_callback_cache.items()):
if exp <= now_ts:
_processed_callback_cache.pop(k, None)
pk = normalize_pm_profile_key(profile_key)
key = pk + "|" + _build_callback_dedupe_key(data)
if not key.strip("|"):
return False
if key in _processed_callback_cache:
return True
_processed_callback_cache[key] = now_ts + ttl_seconds
return False
async def query_private_messages(
authtoken: str,
wxbot_bid: str,
page: int = 0,
size: int = 30,
filter_value: int = 0,
request_id: str = "local-debug",
) -> dict:
url = "https://chatbot.weixin.qq.com/miniopenai/manualservice/getaccessstautuslist"
body = {
"bid": wxbot_bid,
"filter": filter_value,
"order": {"page": page, "size": size},
"base": {"requestid": request_id},
}
headers = {
"authtoken": authtoken,
"wxbot_bid": wxbot_bid,
"content-type": "application/json",
"charset": "utf-8",
}
async with httpx.AsyncClient(timeout=10, verify=False) as client:
resp = await client.post(url, json=body, headers=headers)
resp.raise_for_status()
return resp.json()
async def pb_upsert_wechat_private_user(record: dict, *, collection: Optional[str] = None) -> None:
coll = (collection or WECHAT_PRIVATE_USERS_COLLECTION).strip() or WECHAT_PRIVATE_USERS_COLLECTION
pb_url = (PB_URL or "").rstrip("/")
if not pb_url or not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
return
userid = str(record.get("userid", "")).strip()
if not userid:
return
async with httpx.AsyncClient(timeout=10, verify=False) as client:
auth_resp = await client.post(
f"{pb_url}/api/admins/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
)
if auth_resp.status_code == 404:
auth_resp = await client.post(
f"{pb_url}/api/collections/_superusers/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
)
if auth_resp.status_code != 200:
return
token = (auth_resp.json() or {}).get("token", "")
if not token:
return
headers = {"Authorization": f"Bearer {token}"}
user_expr = safe_filter_value(userid)
filter_exp = f'userid="{user_expr}"'
list_resp = await client.get(
f"{pb_url}/api/collections/{coll}/records",
params={"page": 1, "perPage": 200, "filter": filter_exp, "sort": "-updated"},
headers=headers,
)
items = ((list_resp.json() or {}).get("items")) or [] if list_resp.status_code == 200 else []
if items:
rid = items[0].get("id")
if rid:
await client.patch(f"{pb_url}/api/collections/{coll}/records/{rid}", json=record, headers=headers)
for extra in items[1:]:
extra_id = str(extra.get("id", "")).strip()
if not extra_id:
continue
await client.delete(
f"{pb_url}/api/collections/{coll}/records/{extra_id}",
headers=headers,
)
return
await client.post(f"{pb_url}/api/collections/{coll}/records", json=record, headers=headers)
def normalize_wechat_avatar_url(url: str) -> str:
"""微信头像常为 http://wx.qlogo.cn在 HTTPS 站点作为 <img src> 会触发混合内容拦截,同路径可改用 https。"""
s = str(url or "").strip()
low = s.lower()
if low.startswith("http://") and "qlogo.cn" in low:
return "https://" + s[7:]
return s
def contact_avatar_url(contact: dict) -> str:
v = (
contact.get("headImg")
or contact.get("avatar")
or contact.get("headimgurl")
or contact.get("headImgUrl")
)
return normalize_wechat_avatar_url(str(v or "").strip())
async def sync_wechat_private_user_to_pb(data: dict, profile_key: str) -> None:
"""根据解密后的用户消息 data拉取 B 端联系人并 upsert 到 PocketBase。"""
authtoken, wxbot_bid = pm_sync_credentials_for_profile(profile_key)
if not authtoken or not wxbot_bid:
return
target_userid = str(data.get("UserId", "")).strip()
if not target_userid or is_duplicate_wechat_callback(data, profile_key):
return
result = await query_private_messages(
authtoken=authtoken,
wxbot_bid=wxbot_bid,
page=0,
size=100,
filter_value=0,
request_id=f"thirdapi-{target_userid}",
)
contacts = result.get("contacts") or []
target = next(
(
c
for c in contacts
if str(c.get("userId", "") or c.get("userid", "")).strip() == target_userid
),
None,
)
if not target:
return
last_msg = target.get("lastMsg") or {}
await pb_upsert_wechat_private_user(
{
"userid": target_userid,
"channel_id": str(data.get("ChannelId", "")).strip(),
"nick": target.get("nick", ""),
"avatar": contact_avatar_url(target) or "",
"source_query": str(data.get("Query", "")).strip(),
"source_message_id": str(data.get("MsgId", "")).strip(),
"source_message_time": str(data.get("Timestamp", "")),
"matched_last_msg": last_msg.get("content", ""),
"matched_last_msg_id": str(last_msg.get("msgId", "") or last_msg.get("id", "")),
"matched_last_msg_time": str(target.get("lastActiveTime", "")),
"raw_contact": json.dumps(target, ensure_ascii=False),
}
)
async def safe_sync_wechat_private_user_to_pb(data: dict, profile_key: str) -> None:
try:
await sync_wechat_private_user_to_pb(data, profile_key)
except Exception:
logger.exception("微信私信用户同步 PocketBase 失败(已忽略) profile=%s", profile_key)