import base64 import json import logging import os import struct import time import xml.etree.ElementTree as ET from datetime import datetime, timezone from typing import Optional import httpx from Crypto.Cipher import AES from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from fastapi.responses import HTMLResponse, PlainTextResponse from ..config import PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL router = APIRouter(prefix="/chatbot", tags=["chatbot"]) logger = logging.getLogger(__name__) _processed_callback_cache: dict[str, int] = {} OPEN_API_PROFILES = { "eric": { "name": "Eric在旅行", "APP_ID": "fXYWvGfq04uMScP", "TOKEN": "EWRotyMHfTLAlYmCHpdH8AZcuZPBHn", "ENCODING_AES_KEY": "1wJZiJm8URfRjkHu1QfIVDeNFc2jbzzeCZqvhQecsCx", }, "xidu": { "name": "异度世界", "APP_ID": "LTKfSFeK0h8TRLM", "TOKEN": "64DbvAd5m7YOJ1XslyMgJ9V42jDIUH", "ENCODING_AES_KEY": "KCZGtPDjqFZnBuApvG3xqhvHUMnqFHbC5cnD23HKACX", }, } ACTIVE_PROFILE = (os.getenv("CHATBOT_ACTIVE_PROFILE") or "eric").strip().lower() if ACTIVE_PROFILE not in OPEN_API_PROFILES: ACTIVE_PROFILE = "eric" APP_ID = OPEN_API_PROFILES[ACTIVE_PROFILE]["APP_ID"] TOKEN = OPEN_API_PROFILES[ACTIVE_PROFILE]["TOKEN"] ENCODING_AES_KEY = OPEN_API_PROFILES[ACTIVE_PROFILE]["ENCODING_AES_KEY"] PM_SYNC_AUTHTOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4" PM_SYNC_DEFAULT_WXBOT_BID = "75021053883a9090c67d5becde7f4b0a" PB_COLLECTION = "wechat_private_users" def pkcs5_unpadding(data: bytes) -> bytes: pad = data[-1] if pad < 1 or pad > 32: return data return data[:-pad] def pkcs5_padding(data: bytes) -> bytes: amount_to_pad = 32 - (len(data) % 32) return data + bytes([amount_to_pad]) * amount_to_pad def get_aes_key_and_iv(encoding_aes_key: str) -> tuple[bytes, bytes]: aes_key = base64.b64decode(encoding_aes_key + "=") if len(aes_key) != 32: raise ValueError("encodingAESKey invalid") return aes_key, aes_key[:16] def aes_decrypt_base64(cipher_b64: str, encoding_aes_key: str) -> str: aes_key, iv = get_aes_key_and_iv(encoding_aes_key) compact = "".join(str(cipher_b64 or "").split()) compact += "=" * (-len(compact) % 4) cipher_bytes = base64.b64decode(compact) cipher = AES.new(aes_key, AES.MODE_CBC, iv) decrypted = pkcs5_unpadding(cipher.decrypt(cipher_bytes)) try: content = decrypted[16:] msg_len = struct.unpack("!I", content[:4])[0] msg = content[4 : 4 + msg_len] text = msg.decode("utf-8") if text: return text except Exception: pass return decrypted.decode("utf-8") def aes_encrypt_base64(plaintext: str, encoding_aes_key: str, app_id: str | None = None) -> str: aes_key, iv = get_aes_key_and_iv(encoding_aes_key) raw = plaintext.encode("utf-8") if app_id: data = os.urandom(16) + struct.pack("!I", len(raw)) + raw + app_id.encode("utf-8") else: data = raw padded = pkcs5_padding(data) cipher = AES.new(aes_key, AES.MODE_CBC, iv) return base64.b64encode(cipher.encrypt(padded)).decode("utf-8") def _build_keyword_h5_obj(query: str, user_id: str) -> Optional[dict]: q = (query or "").strip() if "电子书" in q: return { "news": { "articles": [ { "title": "数字游民", "description": "地理套利与自动化杠杆", "url": f"https://vip.hackrobot.cn/ebook?userid={user_id}", "picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png", "type": "h5", } ] } } if "群" in q: return { "news": { "articles": [ { "title": "异度星球", "description": "电子书、视频教程、私密社群", "url": f"https://vip.hackrobot.cn?userid={user_id}", "picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png", "type": "h5", } ] } } return None def _parse_decrypted_payload(decrypted_text: str) -> dict: text = (decrypted_text or "").strip() if text.startswith("{"): obj = json.loads(text) obj["_payload_format"] = "json" return obj if text.startswith("<"): root = ET.fromstring(text) def get_text(tag: str, default: str = "") -> str: node = root.find(tag) return (node.text or "").strip() if node is not None and node.text is not None else default content_node = root.find("content") query = "" if content_node is not None: msg_node = content_node.find("msg") if msg_node is not None and msg_node.text: query = msg_node.text.strip() return { "UserId": get_text("userid", ""), "Query": query, "Timestamp": get_text("createtime", ""), "MsgId": get_text("msgid", ""), "ChannelId": get_text("appid", ""), "appid": get_text("appid", ""), "channel": get_text("channel", "0"), "from": get_text("from", "0"), "_payload_format": "xml", } raise ValueError("unsupported payload format") def _build_xml_callback_reply(data: dict, answer_text: str) -> dict: user_id = str(data.get("UserId", "")).strip() appid = str(data.get("ChannelId", "") or data.get("appid", "")).strip() channel = int(str(data.get("channel", "0") or "0")) msg = _build_keyword_h5_obj(str(data.get("Query", "")), user_id) content = msg if msg else {"msg": answer_text} return {"userid": user_id, "appid": appid, "content": content, "channel": channel, "from": 1} 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_callback(data: dict, 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) key = _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_user_profile(record: dict) -> None: 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) # 业务要求:wechat_private_users.userid 全局唯一(不按 channel_id 分桶) filter_exp = f'userid="{user_expr}"' list_resp = await client.get( f"{pb_url}/api/collections/{PB_COLLECTION}/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/{PB_COLLECTION}/records/{rid}", json=record, headers=headers) # 清理历史重复数据,确保 userid 唯一 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/{PB_COLLECTION}/records/{extra_id}", headers=headers, ) return await client.post(f"{pb_url}/api/collections/{PB_COLLECTION}/records", json=record, headers=headers) async def _sync_user_profile_from_b(data: dict) -> None: authtoken = (PM_SYNC_AUTHTOKEN or "").strip() wxbot_bid = (PM_SYNC_DEFAULT_WXBOT_BID or "").strip() if not authtoken or not wxbot_bid: return target_userid = str(data.get("UserId", "")).strip() if not target_userid or _is_duplicate_callback(data): 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_user_profile( { "userid": target_userid, "channel_id": str(data.get("ChannelId", "")).strip(), "nick": target.get("nick", ""), "avatar": target.get("headImg") or target.get("avatar") or target.get("headimgurl") or target.get("headImgUrl") 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_user_profile_from_b(data: dict) -> None: try: await _sync_user_profile_from_b(data) except Exception: logger.error("chatbot后台同步失败(已忽略)") async def handle_business(data: dict) -> str: user_id = str(data.get("UserId", "")).strip() query = str(data.get("Query", "")).strip() msg = _build_keyword_h5_obj(query, user_id) if msg: return json.dumps(msg, ensure_ascii=False) return "1111111" async def _handle_wechat_request(request: Request, app_id: Optional[str], background_tasks: BackgroundTasks) -> str: if app_id and app_id != APP_ID: logger.warning("chatbot app_id mismatch expected=%s actual=%s", APP_ID, app_id) raw_body = (await request.body()).decode("utf-8").strip() if not raw_body: raise HTTPException(status_code=400, detail="empty body") cipher_b64 = raw_body if "encrypted" in raw_body: try: outer = json.loads(raw_body) if isinstance(outer, dict) and "encrypted" in outer: cipher_b64 = "".join(str(outer["encrypted"]).split()).strip() except Exception: pass try: decrypted = aes_decrypt_base64(cipher_b64, ENCODING_AES_KEY) data = _parse_decrypted_payload(decrypted) except Exception: logger.error("chatbot decrypt failed") raise HTTPException(status_code=400, detail="decrypt error") answer_text = await handle_business(data) background_tasks.add_task(_safe_sync_user_profile_from_b, data) if str(data.get("_payload_format", "")) == "xml": resp_plain = json.dumps(_build_xml_callback_reply(data, answer_text), ensure_ascii=False) response_app_id = str(data.get("ChannelId", "") or data.get("appid", "")).strip() or APP_ID else: resp_plain = json.dumps({"answer_type": "text", "text_info": {"short_answer": answer_text}}, ensure_ascii=False) response_app_id = None if app_id else APP_ID return aes_encrypt_base64(resp_plain, ENCODING_AES_KEY, response_app_id) @router.post("/", response_class=PlainTextResponse) async def chatbot_root(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None): return await _handle_wechat_request(request, app_id, background_tasks) @router.post("/wechat/thirdapi", response_class=PlainTextResponse) async def chatbot_thirdapi(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None): return await _handle_wechat_request(request, app_id, background_tasks) @router.get("/health") def chatbot_health(): return {"status": "ok", "module": "chatbot-clone", "profile": ACTIVE_PROFILE} @router.post("/api/private-message/query") async def chatbot_private_message_query(payload: dict): authtoken = str(payload.get("authtoken", "")).strip() wxbot_bid = str(payload.get("wxbot_bid", "")).strip() if not authtoken or not wxbot_bid: raise HTTPException(status_code=400, detail="authtoken 和 wxbot_bid 不能为空") result = await query_private_messages( authtoken=authtoken, wxbot_bid=wxbot_bid, page=int(payload.get("page", 0)), size=int(payload.get("size", 30)), filter_value=int(payload.get("filter", 0)), request_id=str(payload.get("requestid", "local-debug")).strip() or "local-debug", ) contacts = result.get("contacts") or [] return { "ok": True, "count": result.get("count"), "users": [ { "nick": c.get("nick"), "userId": c.get("userId") or c.get("userid"), "avatar": c.get("avatar") or c.get("headImg") or c.get("headimgurl") or c.get("headImgUrl"), "lastMsg": (c.get("lastMsg") or {}).get("content"), "lastActiveTime": c.get("lastActiveTime"), } for c in contacts ], "raw": result, } @router.post("/api/private-message/analyze") async def chatbot_private_message_analyze(payload: dict): authtoken = str(payload.get("authtoken", "")).strip() if not authtoken: raise HTTPException(status_code=400, detail="authtoken 不能为空") try: parts = authtoken.split(".") payload_part = parts[1] if len(parts) == 3 else "" payload_part += "=" * (-len(payload_part) % 4) claims = json.loads(base64.urlsafe_b64decode(payload_part.encode("utf-8")).decode("utf-8")) if payload_part else {} except Exception: claims = {} now_ts = int(datetime.now(timezone.utc).timestamp()) exp = int(claims.get("exp", 0)) if str(claims.get("exp", "")).isdigit() else 0 expires_in_seconds = exp - now_ts if exp else None status = "无法解析过期时间" if isinstance(expires_in_seconds, int): if expires_in_seconds <= 0: status = "已过期" elif expires_in_seconds < 3600: status = "即将过期(1小时内)" else: status = "有效" return { "status": status, "now_utc": datetime.now(timezone.utc).isoformat(), "claims": claims, "analysis": { "expires_in_seconds": expires_in_seconds, "expires_in_hours": round(expires_in_seconds / 3600, 2) if isinstance(expires_in_seconds, int) else None, "summary": "该分析基于 JWT payload 解码,不包含签名有效性校验。", }, } @router.get("/private-message", response_class=HTMLResponse) def chatbot_private_message_page(): return "
请使用 /chatbot/api/private-message/query 调试接口
"