上周一个做教培的朋友找我,说他的 AI 客服群上线三天就炸了------家长发的欢迎语被机器人重复触发了三遍,AI 自己发的回复又被当作新消息处理,最后同一个家长收到了七八条重复回复。他以为是 AI 的问题,后来我一看代码,是消息回调的三个细节没处理:自己发的消息没过滤、回调没去重、群消息里真实发送者没提取到,导致机器人把 AI 发的消息又当成新消息处理了一遍。
这个问题不是 AI 的问题,是回调处理的问题。平台的回调机制有三个容易被忽略的特性:回调会乱序、自己发的消息(手机端发的)也会触发、群消息真实发送者藏在 Content.string 里。这篇把这三个坑的处理方式一次讲透,附完整可运行代码,看完你能知道自己该怎么接回调,并且代码可以直接跑。
全景对比:三种容易出问题的回调场景
| 场景 | 表现 | 根因 | 正确处理 |
|---|---|---|---|
| 回调乱序 | 撤回消息先到,被撤回的正文后到,正文重新被处理 | 平台重推、历史同步、服务重启补推 | Redis ZSet 按 NewMsgId 排序后再处理 |
| 自己发的消息 | AI 发的欢迎语触发回调 → 又被处理 → 又发欢迎语 → 无限循环 | 手机端发的消息也会触发回调(多端同步机制) | 过滤 FromUserName.string == Wxid 的消息 |
| 群消息真实发送者 | 从群回调里拿不到谁发的,只能看到 @chatroom |
群消息 Content.string 格式是 wxid:\n内容,真实发送者藏在里面 |
切 :\n 前的 wxid |
回调判定路径(官方原文):先判外层 TypeName → 再判 MsgType → 最后解析 Content.string。按这个路径分流,不会乱。
Step 1:回调入口------3 秒内只做去重 + 入队
平台明确要求:回调请求到达后 3 秒内必须返回 HTTP 200 + 空响应体。超过 3 秒会重推。所以回调入口不能做任何业务逻辑,只做两件事:去重、入队。
python
import queue
import threading
import redis
# ---------------------------------------------------------------------------
# 接口路径、鉴权头以 weiti.apifox.cn 实时文档为准
# Base URL: https://wx.chuapi.com
# X-finder-TOKEN(必需)/ Authorization(部分环境需要,官方标为可选)
# 控制台「微信实例」页扫码后生成实例标识 appId
# ---------------------------------------------------------------------------
r = redis.Redis(decode_responses=True)
task_queue = queue.Queue()
def callback_entry(payload: dict) -> str:
"""
回调入口:3 秒内只做去重 + 入队,立即返回空字符串
业务逻辑全部放在 worker 线程里异步处理
"""
app_id = payload["Appid"]
new_msg_id = str(payload["Data"]["NewMsgId"])
# 去重键:官方指定 Appid + NewMsgId
dedup_key = f"wx:dedup:{app_id}:{new_msg_id}"
# SETNX 原子操作,TTL 24 小时覆盖所有重推场景
if not r.set(dedup_key, "1", nx=True, ex=86400):
return "" # 重复消息,直接 ack
# 入异步队列,不阻塞主线程
task_queue.put(payload)
return "" # 3 秒内返回空响应体
Step 2:过滤自己发的消息
"通过 API 接口主动发送的消息不会触发回调,但手机端主动发送的消息会有回调,属于多端消息同步的 IM 机制"。
判断方式:对比 FromUserName.string 与 Wxid ,一致就是自己发的。群消息同理------自己发的群消息 FromUserName.string 和 ToUserName.string 都以 @chatroom 结尾。
python
def is_self_sent(payload: dict) -> bool:
"""判断这条消息是不是当前微信实例自己发的"""
my_wxid = payload["Wxid"]
from_user = payload["Data"]["FromUserName"]["string"]
to_user = payload["Data"]["ToUserName"]["string"]
# 私聊:自己发的,FromUserName 就是自己的 wxid
if from_user == my_wxid:
return True
# 群消息:自己发的,FromUserName 和 ToUserName 都是 @chatroom
if from_user.endswith("@chatroom") and to_user.endswith("@chatroom"):
return True
return False
如果不做这层过滤,AI 自己发的欢迎语会触发回调 → 被处理 → 又发欢迎语 → 无限循环。
Step 3:提取群消息真实发送者
群消息的 FromUserName.string 给的不是群里发消息的那个人,而是群本身 ------值是 xxx@chatroom。真实发送者的 wxid 藏在 Data.Content.string 里,格式是 wxid_xxxx:\n消息内容。
python
def extract_real_sender(data: dict) -> tuple[str, str]:
"""从群消息里提取真实发送者的 wxid 和消息内容"""
content_raw = data["Content"]["string"]
# 文本群消息格式: "wxid_abc123:\n真正的消息内容"
if ":\n" in content_raw:
wxid, _, real_content = content_raw.partition(":\n")
return wxid, real_content
# 非文本消息(图片/语音/链接)Content.string 是 XML
# 从 XML 里解析 <fromusr> 节点提取发送者
import re
match = re.search(r"<fromusr><!\[CDATA\[(.*?)\]\]></fromusr>", content_raw)
if match:
return match.group(1), content_raw
# 兜底:拿不到真实发送者返回空字符串,业务层跳过
return "", content_raw
Step 4:MsgType 分流------先判类型,再决定要不要解析
回调处理按文档给的判定路径:先判外层 TypeName → 再判 MsgType → 最后解析 Content.string。
python
def handle(payload: dict) -> None:
"""异步处理主逻辑"""
type_name = payload["TypeName"]
data = payload["Data"]
app_id = payload["Appid"]
# 过滤自己发的
if is_self_sent(payload):
return
# 账号变动:ModContacts(资料变更/加粉成功)/ DelContacts(删好友/退群)
if type_name in ("ModContacts", "DelContacts"):
handle_contact_change(payload)
return
# 节点掉线:必须立即告警并重登
if type_name == "Offline":
alert_and_relogin(app_id)
return
# 普通消息
if type_name != "AddMsg":
return
msg_type = data["MsgType"]
from_user = data["FromUserName"]["string"]
is_group = from_user.endswith("@chatroom")
# 一级分流:按 MsgType
if msg_type == 1:
# 文本消息:直接拿 Content.string
content = data["Content"]["string"]
if is_group:
sender_wxid, content = extract_real_sender(data)
if not sender_wxid:
return # 拿不到发送者就跳过
handle_group_text(sender_wxid, content)
else:
handle_private_text(from_user, content)
elif msg_type == 49:
# 复合/应用类消息:必须解析 Content.string XML 里的 appmsg.type
# 官方文档明确标注:MsgType=49 不是"链接消息"一个类型,是容器
# appmsg.type=5 公众号文章 / 33 小程序 / 74 文件发送中(不可下载)
content_xml = data["Content"]["string"]
import re
type_match = re.search(r"<type>(\d+)</type>", content_xml)
appmsg_type = int(type_match.group(1)) if type_match else 0
handle_appmsg(appmsg_type, content_xml)
elif msg_type in (10000, 10002):
# 系统消息:按 sysmsg.type 区分
# MsgType=10002 + revokemsg → 撤回
# MsgType=10002 + sysmsgtemplate → 踢人/解散群
# MsgType=10000 → 被移出/改群名/换群主(靠 Content 关键字区分)
handle_system(data)
Step 5:异步处理主循环
python
def worker() -> None:
while True:
payload = task_queue.get()
try:
handle(payload)
except Exception as e:
# 业务层异常记日志,不影响其他消息
print(f"handle failed: {e}, appId={payload['Appid']}, msgId={payload['Data']['NewMsgId']}")
finally:
task_queue.task_done()
# 启动 worker 线程
threading.Thread(target=worker, daemon=True).start()
三个最容易犯的错
1. 不做自己发的消息过滤
AI 发的欢迎语(用 API 发的不会触发回调,但如果手机端有其他操作会触发)会被当作新消息处理,导致无限循环。必须对比 FromUserName.string 和 Wxid。
2. MsgType=49 不解析 appmsg.type
MsgType=49 是容器,里面装公众号文章、小程序卡片、文件就绪、文件发送中(不可下载)等好几种。不解析 appmsg.type 会把所有 49 当成同一种消息处理。
3. 回调入口做业务逻辑
回调入口必须 3 秒内返回。如果在入口里同步处理业务逻辑(发欢迎语、写 CRM、调 AI),很容易超时导致平台重推,重推又触发业务逻辑执行两次。
上线前 checklist
- 回调入口 3 秒内返回空字符串,业务逻辑全在 worker 线程
- 去重键 =
Appid + NewMsgId(官方指定),TTL ≥ 24 小时 - 自己发的消息已过滤(私聊
FromUserName == Wxid,群聊 from/to 都以@chatroom结尾) - 群消息真实发送者从
Content.string里切:\n前的 wxid - MsgType=49 解析了
appmsg.type - 系统消息(10000/10002)按
sysmsg.type区分 -
Offline事件有立即告警和重登逻辑