014e9a15e5
DecryptData 与 GetChatData 不同, 不接收 sdk 句柄(纯解密函数、3 个参数)。之前 binding 多传了 self._sdk 作首参 → 参数整体错位、encrypt_key 收到 sdk 指针 → DecryptData 返 10008(解析 encrypt_key 出错)。云上实测:去掉 sdk 句柄后 ret=0、成功解出明文(external 用户发给成员的 msgtype=weapp 美团小程序卡片)。 - argtypes 从 4 参(含 c_void_p sdk)改为 3 参(encrypt_key, encrypt_msg, msg_slice) - decrypt() 调用去掉 self._sdk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
101 lines
4.9 KiB
Python
101 lines
4.9 KiB
Python
"""微信会话内容存档 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
|