""" 微信 Chatbot 开放对话回调后:从 chatbot.weixin 联系人接口拉取资料,写入 PocketBase wechat_private_users。 /chatbot(eric)与 /hackrobot(xidu)共用,按 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 站点作为 会触发混合内容拦截,同路径可改用 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)