"""微信会话内容存档 SDK 封装(WeWorkFinanceSdk C 库的 ctypes 绑定 + 消息解密)。 企业微信「会话内容存档」是拿"成员↔外部用户私聊消息正文"(含美团小程序卡片)的官方途径: 主动轮询 GetChatData(seq) 拉取。每条消息两段密文: - encrypt_random_key:用【企业自持 RSA 私钥】解出 AES 密钥 - encrypt_chat_msg :SDK DecryptData 用该密钥解出明文消息 JSON 本模块封装:加载 .so → Init → GetChatData → RSA 解密随机密钥 → DecryptData。纯外部依赖, 不含业务逻辑(识别 weapp/image → 打信号在 core/wx_finance_worker.py)。 .so 需放服务器(Linux `libWeWorkFinanceSdk_C.so`),路径 settings.WX_FINANCE_SDK_PATH。 ⚠️ CDLL 在实例化时才加载(非 import 期),故本模块在无 .so 的机器上也能安全 import。 """ from __future__ import annotations import base64 import ctypes import json import logging from typing import Any from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.serialization import load_pem_private_key logger = logging.getLogger("shagua.wx_finance") class WxFinanceError(RuntimeError): """SDK 调用返回非 0 / 业务 errcode 时抛。""" class WxFinanceSdk: """WeWorkFinanceSdk 薄封装。实例化即加载 .so + Init(需 corpid/secret 正确 + 服务器 IP 在可信IP)。""" def __init__(self, sdk_path: str, corpid: str, secret: str, private_key_pem: bytes) -> None: self._priv = load_pem_private_key(private_key_pem, password=None) self._lib = ctypes.CDLL(sdk_path) # 缺 .so / 缺 libssl 依赖会在此抛 OSError self._bind() self._sdk = self._lib.NewSdk() ret = self._lib.Init(self._sdk, corpid.encode(), secret.encode()) if ret != 0: raise WxFinanceError(f"Init 失败 ret={ret}(检查 corpid / 会话存档 Secret / 可信IP)") def _bind(self) -> None: lib = self._lib lib.NewSdk.restype = ctypes.c_void_p lib.Init.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p] lib.Init.restype = ctypes.c_int lib.GetChatData.argtypes = [ ctypes.c_void_p, ctypes.c_ulonglong, ctypes.c_uint, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_void_p, ] lib.GetChatData.restype = ctypes.c_int # ⚠️ DecryptData 不吃 sdk 句柄(与 GetChatData 不同, 它是纯解密函数)——只有 3 个参数, # 多传 sdk 会让参数错位、encrypt_key 收到 sdk 指针 → DecryptData 返 10008(解析 encrypt_key 出错)。 lib.DecryptData.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p] lib.DecryptData.restype = ctypes.c_int lib.NewSlice.restype = ctypes.c_void_p lib.FreeSlice.argtypes = [ctypes.c_void_p] lib.GetContentFromSlice.argtypes = [ctypes.c_void_p] lib.GetContentFromSlice.restype = ctypes.c_void_p lib.GetSliceLen.argtypes = [ctypes.c_void_p] lib.GetSliceLen.restype = ctypes.c_int lib.DestroySdk.argtypes = [ctypes.c_void_p] def _slice_bytes(self, slc: int) -> bytes: ptr = self._lib.GetContentFromSlice(slc) length = self._lib.GetSliceLen(slc) return ctypes.string_at(ptr, length) def get_chat_data(self, seq: int, limit: int = 1000, timeout: int = 10) -> list[dict[str, Any]]: """拉 seq 之后的消息(返回从 seq+1 起)。返回 chatdata 列表(每项含 seq/encrypt_random_key/encrypt_chat_msg)。""" slc = self._lib.NewSlice() try: ret = self._lib.GetChatData(self._sdk, int(seq), int(limit), None, None, int(timeout), slc) if ret != 0: raise WxFinanceError(f"GetChatData 失败 ret={ret}") data = json.loads(self._slice_bytes(slc).decode("utf-8")) finally: self._lib.FreeSlice(slc) if data.get("errcode"): raise WxFinanceError(f"GetChatData errcode={data.get('errcode')} {data.get('errmsg')}") return data.get("chatdata", []) def decrypt(self, encrypt_random_key_b64: str, encrypt_chat_msg: str) -> dict[str, Any]: """RSA 私钥解 encrypt_random_key → 得 AES 密钥 → DecryptData 解 encrypt_chat_msg → 明文 dict。""" # 企业微信用你上传的 RSA 公钥(PKCS1)加密随机密钥, 这里用对应私钥解出, 原样交给 DecryptData。 aes_key = self._priv.decrypt(base64.b64decode(encrypt_random_key_b64), padding.PKCS1v15()) slc = self._lib.NewSlice() try: ret = self._lib.DecryptData(aes_key, encrypt_chat_msg.encode(), slc) if ret != 0: raise WxFinanceError(f"DecryptData 失败 ret={ret}") return json.loads(self._slice_bytes(slc).decode("utf-8")) finally: self._lib.FreeSlice(slc) def close(self) -> None: if getattr(self, "_sdk", None): self._lib.DestroySdk(self._sdk) self._sdk = None