233 lines
8.5 KiB
Python
233 lines
8.5 KiB
Python
"""
|
||
微信 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 站点作为 <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)
|