
最近在帮朋友做一个公众号机器人折腾的恰好是 Clawdbot 汉化版和微信消息加解密对接。聊了一圈发现很多刚把 Clawdbot 部署起来的朋友卡的最深的不是机器人本身怎么配模型而是微信那套 AES 加密和签名验证。这篇文章就把我实际接线过程中的思路、踩的坑、以及最终的代码实现完整记录下来适合刚接触 Clawdbot 汉化版、准备接入微信公众号或企业微信的同学参考。会从“为什么微信要设计这么一套加解密机制”讲起一路讲到网关对接和问题排查尽量做到看完就能自己搭起来。1. 整体链路微信、Clawdbot与适配层怎么配合1.1 谁负责收消息、谁负责回消息我习惯把整个系统拆成三个角色来看这样后续排错时会清晰很多。第一个角色是微信服务器。它只做两件事一是把我们公众号后台配置的接口地址当成回调目标一旦有用户发消息它就把事件推送到这个地址二是把我们返回的内容转发给用户。微信服务器并不关心我们后面的业务逻辑它只认我们填的那个 URL 和配置的 Token。第二个角色是 Clawdbot 网关。Clawdbot 汉化版本身是一个聊天机器人框架核心能力是把用户消息接进来经过对话引擎处理后生成回复。社区汉化版主要改的是界面和文档语言底层协议与原版一致所以对接方式可以通用。Clawdbot 网关会暴露一个本地 API 或 Webhook 入口我们只需要把“用户说了什么”传进去再从响应里拿到“机器人要说什么”。第三个角色就是夹在中间的适配层。这一步很多人容易忽略。微信推送过来的消息是 XML 封装、AES-CBC 加密过的密文而 Clawdbot 网关一般只接收明文 JSON。适配层要做的就是把微信的密文请求解密成明文交给 Clawdbot 处理再把 Clawdbot 的回答加密成微信要求的密文格式返回。理顺这三个角色之后整个对接的核心思路就变成验证接口地址 → 解密微信消息 → 调用 Clawdbot → 加密返回回复。后面所有代码和配置都是在围绕这条链路做文章。1.2 为什么微信的消息要加解密而 Clawdbot 不管这些很多第一次接入的人会问一句直接把消息明文推到我们的 URL 不行吗当然可以微信后台本身提供了“明文模式”只是默认情况下风险较高。微信公众平台和企业微信的消息回调支持两种工作模式。明文模式下POST 请求体就是一个普通的 XML里面直接带着 FromUserName、Content 等字段抓包就能看到完整消息内容。安全模式官方叫“安全模式”也有文档称为加密模式则不同XML 里只有一个Encrypt节点内容是 AES-256-CBC 加密后的密文同时在 URL 参数上多了一个msg_signature用于验证请求来源。为什么要这样做因为回调 URL 必然暴露在公网上任何人都可能往这个地址发送伪造请求。如果只是明文攻击者可以伪装成用户发消息诱导机器人执行一些危险动作同时用户聊天内容也等于裸奔。微信采用“签名验证 对称加密”的做法相当于快递柜的取件码和密码锁双重验证签名验证解决“这个请求是不是微信发来的”AES 加密解决“就算被截获也读不懂内容”。Clawdbot 网关不管这些是因为它的定位是消息处理中枢上游对接的是开发者已经处理好的消息源。它不应该为每种 IM 平台各自适配一套加密协议。所以我们在 Clawdbot 前面加一层适配服务把不同平台的“方言”翻译成 Clawdbot 的“普通话”这正是整个方案里最有价值的一部分。2. 微信消息加解密参数、算法与代码实现2.1 三个参数先搞清楚Token、EncodingAESKey、CorpID进入代码之前先把微信加解密涉及的三个核心参数弄明白。我见过太多人把这三个参数混着填最后签名对不上、解密解不开绕了一整天。第一个是 Token。这是一个普通字符串由我们自己填写长度不限。它的作用是参与消息签名msg_signature的计算。无论是 URL 验证还是消息推送微信都会带上 timestamp、nonce 和加密后的消息体我们用 Token 和这几个值算出一个 SHA1 签名和微信传过来的签名比对就能判断这个消息是不是微信官方推来的。第二个是 EncodingAESKey。这个参数要特别注意微信后台会生成一个长度为 43 位的字符串它在 Base64 解码后的长度必须是 32 字节即 256 位这是 AES-256 的密钥长度要求。很多人以为把这个字符串直接当密钥用那是错误的。我们后面在代码里要做base64.b64decode(EncodingAESKey )之类的补全操作才能得到真正的 AES 密钥。第三个是 CorpID 或 AppID。公众号叫 AppID企业微信自建应用叫 CorpID。它不直接参与 AES 密钥生成但会作为密文尾部的一部分随消息一起被加密。解密后我们要校验尾部这段标识符是否和当前应用匹配如果不一致说明解密过程中可能损坏或者消息根本不是发给我们这个应用的。这三个参数的关系可以用一个生活化的小例子类比Token 像门卫手里的访客名单能证明“你是这个院子里的人”EncodingAESKey 像保险柜密码能打开保险柜拿到真正内容CorpID 像保险柜里物品的标签最后确认这件物品确实属于我们。2.2 解密流程拆解从 Encrypt 到明文的完整计算解密是微信消息接入中最容易出现问题的环节。我先把官方算法的计算过程完整拆一遍然后给出可用的 Python 代码。微信安全模式下的请求体大致长这样xml ToUserName![CDATA[gh_xxx]]/ToUserName Encrypt![CDATA[加密后的消息体]]/Encrypt /xml真正的加密内容全在 Encrypt 节点中。解密时我们对这个 Encrypt 字段做四步操作第一步Base64 解码。微信推送过来的 Encrypt 是一段 Base64 编码的字符串要先还原成二进制密文。第二步AES-256-CBC 解密。密钥是 EncodingAESKey Base64 解码后的 32 字节。IV初始向量不是随机生成的而是取密钥的前 16 字节。这一点很关键很多库默认会用随机 IV 或全零 IV跟微信的标准对不上结果就是解密出来一团乱码。解密后得到的是“原始明文缓冲区”。第三步解析缓冲区。微信在加密前会在明文前面加上 16 字节的随机字符串用来干扰攻击者防止密文模式被恶意构造。接着是 4 字节的网络序整数表示后面真实消息的长度。再往后才是真正的消息明文 XML 文本。最后是 16 字节或更多字节的 CorpID/AppID作为完整性校验尾巴。第四步PKCS7 去填充。AES 是分组加密算法每组固定 16 字节明文长度不一定正好是 16 的倍数所以加密前要按 PKCS7 规则填充。解密出来之后我们要根据最后一个字节的值去掉对应数量的填充字节。下面是一段可以直接用的 Python 解密函数import base64 import socket from cryptography.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import padding def decrypt_message(encrypt_base64, encoding_aes_key, corpid): # 1. 补全长度的 Base64 解码 key base64.b64decode(encoding_aes_key ) iv key[:16] cipher Cipher(algorithms.AES(key), modes.CBC(iv)) decryptor cipher.decryptor() # 2. 解密 decrypted decryptor.update(base64.b64decode(encrypt_base64)) decryptor.finalize() # 3. 去除 PKCS7 填充 pad_len decrypted[-1] decrypted decrypted[:-pad_len] # 4. 去掉随机 16 字节 decrypted decrypted[16:] # 5. 取消息长度4字节网络序 msg_len socket.ntohl(int.from_bytes(decrypted[:4], byteorderbig)) # 或者直接用 struct.unpack(!I, decrypted[:4])[0] # 6. 截取真实消息 msg decrypted[4:4 msg_len] # 7. 校验尾部属主标识 tail decrypted[4 msg_len:] if not tail.decode(utf-8).startswith(corpid): raise ValueError(CorpID/AppID 校验失败) return msg.decode(utf-8)这段代码有几个细节值得强调。一是encoding_aes_key 因为微信生成的 43 位字符本身 Base64 解码后少一个字节的补齐位手动补上等号是常见做法不同语言的处理方式略有差异但思路一致。二是消息长度用的是 4 字节大端序整数直接用socket.ntohl最稳。三是解密后去掉填充之前一定要先验证最后一个字节的取值范围在 1 到 16 之间否则可能是解密失败而不是正常填充。2.3 签名验证为什么用 SHA1 却还频繁踩坑微信消息签名用的是 SHA1算法本身很简单就是把几个参数按照固定顺序拼成一个字符串再做 SHA1 哈希转成十六进制字符串。URL 验证时微信会以 GET 请求访问我们配置的接口地址携带timestamp、nonce、echostr三个参数。我们需要用 Token、timestamp、nonce 拼接后计算 SHA1和紧随其后的signature参数比对。如果一致就把echostr原样返回给微信URL 验证才算通过。POST 消息推送时签名参数改名为msg_signature参与签名的内容除了 Token、timestamp、nonce 之外还要加上 XML 中的 Encrypt 字段。排序规则是先按字典序排列然后直接拼接成一个字符串中间千万不要加任何连接符。很多人在这一步出问题是因为在拼接时自作聪明地加了、|、_之类的分隔符结果签名永远对不上。计算签名的 Python 代码import hashlib def calc_signature(token, timestamp, nonce, encryptNone): params [token, timestamp, nonce] if encrypt: params.append(encrypt) params.sort() raw .join(params) return hashlib.sha1(raw.encode(utf-8)).hexdigest()一个容易被忽略的点是参数排序的顺序。sort()默认按字典序排序需要注意 Python 的字符串排序是按 Unicode 码位来比较的对于纯 ASCII 字符串和微信官方要求的字典序一致。如果你用了中文或者特殊符号的 Token最好在本地多验证几次确保排序结果符合预期。签名验证必须放在解密之前执行。原因很好理解如果签名都验证不过说明这条请求可能不是微信推来的提前解密反而会浪费算力甚至可能因为恶意密文导致解密异常。我见过有人把顺序反过来验签永远失败排查半天才发现是逻辑顺序写错了。3. Clawdbot网关对接从URL验证到消息闭环3.1 准备工作部署Clawdbot、注册测试号、准备回调地址在写适配层代码之前先把环境准备齐。整个方案需要以下几样东西。第一一个运行中的 Clawdbot 汉化版实例。部署方式一般有两种一种是直接用 Docker 镜像启动另一种是在本机 Python 环境中运行。我建议用 Docker因为 Clawdbot 的依赖比较多用容器隔离能省去很多环境问题。启动后要确认 Clawdbot 的网关地址默认情况下它会在某个本地端口提供 Webhook 或 API 入口比如http://127.0.0.1:9000。第二一个公众号或企业微信的测试号。如果只是想联调强烈建议用微信提供的测试号因为申请门槛低、配置项可以随意改非常适合折腾。在测试号后台可以拿到 AppID同时需要自己配置 Token 和 EncodingAESKey。这两个参数就是前一节提到的配置后要妥善保存。第三一个公网可访问的回调地址。微信服务器要求回调 URL 必须是公网可访问的 HTTPS 或 HTTP 地址。如果本地开发阶段没有公网 IP可以用内网穿透工具把本地端口映射出去。我习惯在调试阶段用这类工具临时生成一个公网域名代码跑通后再切换到正式的域名和 HTTPS。第四一个基础的 Web 服务框架。我选择用 FastAPI因为它写起来简洁而且异步支持好后面接 Clawdbot 时不容易被阻塞。准备就绪后先把回调地址在微信后台填好格式一般是https://你的域名/wechat/callback。这时微信会尝试向这个地址发送一个 GET 请求来验证接口我们需要在代码里先把这个 GET 验证请求处理掉。3.2 搭建加密适配服务接收到消息并交给Clawdbot这一步是整个对接的核心我把完整流程和代码一起放上来。先看 GET 请求验证的逻辑。微信发来的 GET 请求携带signature、timestamp、nonce、echostr四个参数。我们按顺序做签名校验如果校验通过直接返回echostr内容否则返回错误状态。这里有一个细节响应体类型要是纯文本不要加 JSON 包装否则微信后台会提示验证失败。FastAPI 里可以用PlainTextResponse来保证输出内容正确。再看 POST 请求处理。微信推送来的 Content-Type 是text/xml所以我们需要从请求体中解析 XML取Encrypt节点先用msg_signature验签再解密获取真实的 XML 消息。解密后的明文消息大概是这样的xml ToUserName![CDATA[gh_xxx]]/ToUserName FromUserName![CDATA[oXXXX]]/FromUserName CreateTime1700000000/CreateTime MsgType![CDATA[text]]/MsgType Content![CDATA[你好]]/Content MsgId1234567890/MsgId /xml拿到明文后要做的就是把用户消息提取出来转成 Clawdbot 网关需要的格式。不同 Clawdbot 版本的接口格式可能略有差异但大体上只需要把对话内容发送到网关的/chat或/v1/chat/completions接口。我就以常见的 Webhook 模式举例请求体大概是{ message: 你好, user_id: oXXXX, session_id: oXXXX }然后调用 Clawdbot 网关的 HTTP 接口获取回答文本。下面的代码把 GET 验证和 POST 接收整合在一个服务里from fastapi import FastAPI, Request, Response from fastapi.responses import PlainTextResponse import xml.etree.ElementTree as ET app FastAPI() TOKEN 你的Token ENCODING_AES_KEY 你的EncodingAESKey APP_ID 你的AppID CLAWDBOT_URL http://127.0.0.1:9000/chat app.get(/wechat/callback) async def verify_url(signature: str, timestamp: str, nonce: str, echostr: str): if calc_signature(TOKEN, timestamp, nonce) signature: return PlainTextResponse(echostr) return PlainTextResponse(invalid signature) app.post(/wechat/callback) async def receive_message(request: Request): body await request.body() params dict(request.query_params) xml_data ET.fromstring(body) encrypt xml_data.find(Encrypt).text # 1. 验签 sig calc_signature(TOKEN, params[timestamp], params[nonce], encrypt) if sig ! params[msg_signature]: return PlainTextResponse(invalid msg_signature) # 2. 解密 plain_xml decrypt_message(encrypt, ENCODING_AES_KEY, APP_ID) msg_xml ET.fromstring(plain_xml) content msg_xml.find(Content).text from_user msg_xml.find(FromUserName).text # 3. 调用 Clawdbot 网关 clawdbot_reply call_clawdbot(CLAWDBOT_URL, from_user, content) # 4. 加密回复下一节实现 reply_xml build_reply_xml(from_user, clawdbot_reply) encrypted_reply, reply_signature, timestamp, nonce encrypt_reply(reply_xml, ENCODING_AES_KEY, APP_ID) response_xml fxml Encrypt![CDATA[{encrypted_reply}]]/Encrypt MsgSignature![CDATA[{reply_signature}]]/MsgSignature TimeStamp{timestamp}/TimeStamp Nonce![CDATA[{nonce}]]/Nonce /xml return Response(contentresponse_xml, media_typetext/xml)call_clawdbot函数其实就是用requests库把消息 POST 到 Clawdbot 网关拿到返回的文本。注意这里最好设置一个超时时间比如 10 秒防止 Clawdbot 处理卡住导致整个回调卡死。我在实际联调时发现Clawdbot 如果加载的模型比较大首次响应可能超过 5 秒而后面的响应会快很多。这个问题在下一章排查部分会展开说。3.3 把Clawdbot的回复加密回传给微信微信被动回复消息需要在 5 秒内完成超时微信会断开连接并重试。我们这里把 Clawdbot 返回的纯文本包装成微信要求的 XML再加密返回。加密过程是解密的逆操作基本步骤是第一步构造原始明文缓冲区。缓冲区结构是16 字节随机字符串 4 字节大端序消息长度 消息 XML AppID。随机字符串可以用os.urandom(16)生成这是安全性更好的做法不要用固定的随机种子。第二步PKCS7 填充。因为 AES-CBC 要求明文长度必须是 16 的倍数我们要对缓冲区做填充。Python 的cryptography库提供了现成的pad函数。第三步AES-256-CBC 加密。密钥和 IV 的取值规则与解密完全一致IV 是密钥前 16 字节。第四步Base64 编码。第五步生成新签名。签名参数是 Token、新生成的 timestamp、nonce以及加密后的密文。加密回复的完整代码import os import struct import time from cryptography.hazmat.primitives import padding def encrypt_reply(reply_xml, encoding_aes_key, app_id): key base64.b64decode(encoding_aes_key ) iv key[:16] # 1. 构造原始缓冲区 random_bytes os.urandom(16) msg_len struct.pack(!I, len(reply_xml.encode(utf-8))) raw random_bytes msg_len reply_xml.encode(utf-8) app_id.encode(utf-8) # 2. PKCS7 填充 padder padding.PKCS7(128).padder() padded padder.update(raw) padder.finalize() # 3. 加密 cipher Cipher(algorithms.AES(key), modes.CBC(iv)) encryptor cipher.encryptor() encrypted encryptor.update(padded) encryptor.finalize() # 4. Base64 encrypted_base64 base64.b64encode(encrypted).decode(utf-8) # 5. 生成新签名 timestamp str(int(time.time())) nonce os.urandom(8).hex()[:8] signature calc_signature(TOKEN, timestamp, nonce, encrypted_base64) return encrypted_base64, signature, timestamp, nonce返回给微信的 XML 格式必须包含Encrypt、MsgSignature、TimeStamp、Nonce四个节点。这里的TimeStamp和Nonce必须是签名计算时用的那组值不能直接复用请求里的值否则微信验签不过。我在实际试用中有一个体会如果 Clawdbot 回复内容本身很短整个加解密耗时几乎可以忽略真正的瓶颈在 Clawdbot 的模型推理时间。所以如果用的是响应较慢的模型建议在适配层增加一个队列先立刻返回一个“正在思考”的文本给微信再异步调用 Clawdbot把最终回复通过微信的客服消息接口推送给用户。这样用户体验会好很多也不会触发 5 秒超时重试。4. 高频问题与排查实录4.1 签名校验失败的几个常见原因签名校验失败是最让新手头疼的问题。我把过去踩过的坑和帮别人排查时发现的典型原因整理成一张表。现象常见原因解决办法GET 验证地址请求返回 invalidToken 填错或拼接顺序错误逐项打印参与签名的四个参数手工比对排序POST 消息推送提示 msg_signature mismatch签名字符串里多加了连接符确保 Token、timestamp、nonce、Encrypt 直接拼接中间不加任何字符不同时段一会通一会不通timestamp 与微信服务器时间偏差过大检查服务器系统时间与时间同步本地调试通过线上不通线上环境 Token 或 EncodingAESKey 配置不一致对比两份配置文件常见于复制粘贴时多了换行或空格微信测试号可以正式号不行AppID 不同导致签名关联的加密信息异常确认解密尾部校验时用的 AppID 与当前账号一致一个非常有用的排查技巧在验签逻辑中加入日志把参与排序的参数和计算出的签名、微信传过来的签名全部打印出来。这样一眼就能看出问题出在拼接、排序还是 Token 本身。4.2 解密报错排查填充错误、AESKey不对、转义问题解密报错的典型错误类型有三种症状各不相同。第一种报binascii.Error: Incorrect padding。这是 Base64 解码失败常见原因是微信推送的 Encrypt 字符串里含有换行符或空格或者被 XML 转义了。比如密文里可能出现\n被替换成文本#10;的情况需要先做反转义。最简单的方式是在解析 XML 后对 Encrypt 文本调用replace(#10;, \n)之类的修复。不过我遇到最多的是直接把带 CDATA 的整段 XML 拿来 Base64 解码这就是纯手误了。第二种解密成功但消息乱码或者解密直接异常。通常是 EncodingAESKey 处理不对。排查方法是打印 Base64 解码后的字节数必须是 32。如果长度不是 32说明 EncodingAESKey 复制时缺了一位或者多敲了一个字符。第三种解密成功但最后一步校验 AppID 失败。这可能是因为消息确实是发往另一个公众号的也可能是因为解密时 IV 取错了。有些人习惯把 IV 设置为全零字节这在微信这里是不对的。正确的 IV 必须是密钥的前 16 字节。在做本地自测时我建议用微信官方提供的加解密示例数据构造一个模拟报文然后跑一遍自己写的解密函数。如果输出能和官方示例对得上说明加解密逻辑基本没问题。这样做的好处是之后真正接微信服务器时可以把问题范围缩小到网络和配置层面而不是在算法上用调试器反复看。4.3 Clawdbot网关无响应或超时怎么办Clawdbot 网关无响应的现象很典型微信后台已经显示消息推送成功但用户那边收不到任何回复。我从两个方向排查第一从微信到适配层这一段。检查适配层有没有收到 POST 请求。可以在进入receive_message函数后第一时间打印日志包括请求参数、XML 原文。如果这里就没有请求那大概率是回调地址配置错误或者免费内网穿透域名失效。如果请求到了但验签失败按上一节的方式排查。第二从适配层到 Clawdbot 这一段。检查网关是否正常监听端口能否连通。我在本地联调时遇到过一种情况Clawdbot 服务在 9000 端口启动但从适配层访问http://127.0.0.1:9000/chat总是 Connection refused。后来发现是 Clawdbot 的 Docker 容器虽然启动了但端口映射写错把宿主机的 9000 映射到了容器的 8000。解决方法是先curl一下这个地址确认能通再继续。超时问题更加微妙。微信对被动回复有 5 秒限制而 Clawdbot 较大模型的推理时间可能在 3 秒左右网络波动下就容易超时。我的处理手法是如果预计 Clawdbot 响应可能超过 3 秒就先把一次“收到我处理一下”的文本加密返回给微信然后再用异步任务调用 Clawdbot。Clawdbot 返回结果后再通过微信的客服消息接口主动推送。这个方案把“同步被动回复”降级为“异步主动推送”虽然实现复杂一点但稳定性提升非常明显。4.4 消息去重、重试与日志优化微信服务器在回调失败或超时时会尝试重试多次。这意味着适配层可能收到同一条消息多遍。如果 Clawdbot 本身是有状态的比如它会记忆上下文重复消息就可能导致对话记录里混入重复内容影响回答质量。处理办法很简单做一个消息去重。微信推送的每一条普通消息都有唯一的 MsgId从明文 XML 里取出来放到一个固定大小的缓存集合里如果遇到重复的 MsgId 就直接忽略。缓存可以设置过期时间比如 60 秒避免内存无限增长。如果是企业微信消息 ID 的字段名可能不同但思路一致。日志优化的价值在联调阶段会被无限放大。我习惯把适配层日志分成三个等级INFO 日志记录收到请求、验签通过、解密成功、Clawdbot 响应耗时WARN 日志记录签名不一致但不主动拦截可以只记不改的情况ERROR 日志记录解密失败、Clawdbot 调用异常。这样一旦线上出问题凭日志就能快速定位是在哪一段断掉的。还要注意日志脱敏。微信消息内容是用户隐私在开发阶段打印明文还可以接受上生产环境前务必去掉对Content字段的完整输出或者用摘要算法脱敏。这既是合规要求也是对用户负责。5. 收尾一点个人经验我实际调下来最省事的路线是先用明文模式把整条链路跑通确认消息能到 Clawdbot 且回复能回来再切换到安全模式把适配层的 Token 和 EncodingAESKey 同步更新。很多同学一上来就直接切安全模式结果验签、解密、加密三个环节同时出问题排错成本非常高。还有一个小技巧值得分享。微信后台的 URL 验证是 GET 请求你完全可以在浏览器地址栏里直接访问这个 URL手动带上timestamp、nonce、echostr和计算好的signature参数提前验证签名算法是否写对了。比如拼一个https://你的域名/wechat/callback?timestamp1700000000nonceabc123echostrhellosignature计算值这样的地址如果页面返回 hello说明 GET 验签逻辑没问题。这个方法帮我省了大量反复在微信后台点“提交”的时间。另外如果要长期维护这套系统建议把适配层单独做成一个服务不要和 Clawdbot 进程混在一起。这样微信回调的抖动不会影响 Clawdbot 的对话引擎Clawdbot 重启也不会导致微信侧出现短暂的 5xx 错误。接收平台扩展时比如以后想接钉钉或飞书只需要新增一个适配器Clawdbot 网关完全不用动。这就是整个架构里最值得保留的设计。