6.6 KiB
企微主动私信的"干净直发"原语 + 错发根因 + 账号白名单
背景:贾茜(Maggie)反映"给 Doro 的消息错发给她"。排查发现根因在企微 adapter 的"回复兜底"机制。本文记录干净直发的可靠原语、错发机制、以及 三方交叉验证过的真实 userid 白名单。
一、错发根因:adapter.send() 的"回复兜底"会串号
gateway/platforms/wecom.py 的 WeComAdapter.send()(约 1430–1445 行)有一条
fallback:
reply_req_id = self._reply_req_id_for_message(reply_to)
if not reply_req_id and chat_id in self._last_chat_req_ids:
reply_req_id = self._last_chat_req_ids[chat_id] # ← 退化点
if reply_req_id:
response = await self._send_reply_markdown(reply_req_id, content) # 用历史 req_id "回复"
else:
# 才是真正的主动私信 aibot_send_msg + chat_type=1
含义:在长期运行的 gateway 进程里,_last_chat_req_ids 会按 chat_id 缓存
最近一条 inbound 的 req_id。多人并发时这个缓存可能串号——"发给 A 的主动消息"
退化成"回复一条 req_id 绑定的历史消息",而那条历史消息的会话上下文可能属于 B,
于是消息落到 B 头上。这是"给 Doro 的通知发到贾茜"的核心机制。
注意:
tools/send_message_tool.py里的_send_wecom()每次会 new 一个 全新 adapter(_last_chat_req_ids为空),所以单次_send_wecom调用本身 通常走 proactive 分支、不串号。真正高危的是常驻 gateway 进程内复用同一个 adapter 实例的发送,以及旧版auto_notify_new_file.sh的 "get_sender() 猜发件人 + 无差别 notify_doro" 叠加。结论:不要依赖_send_wecom/adapter.send()的兜底语义来保证"主动私信永远直达"——它不保证。
二、干净直发原语:~/.hermes/scripts/wecom_dm.py
这个脚本完全绕开 adapter.send() 的回复兜底:自己开 WebSocket、
aibot_subscribe 认证、直接 aibot_send_msg + 固定 chat_type=1,
永不退化成回复。一条消息 = 一次目标唯一确定的主动私信。已实测真发成功。
# CLI
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "内容"
python3 ~/.hermes/scripts/wecom_dm.py --list # 白名单
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "x" --dry-run
# 作为模块(注意:脚本在 ~/.hermes/scripts,需要时 sys.path.append)
from wecom_dm import send_dm
res = send_dm("doro", "内容") # res["success"], res["message_id"]
特性:
- 白名单防呆:发给未核准账号会被拦截(除非
--allow-raw)。 - 真实成功判定:按企微
errcode in {0, None}判断,不盲报成功;返回message_id。 - 无第三方依赖:仅需
aiohttp+pyyaml(hermes venv 已有)。 - 凭据从
config.yaml的gateway.platforms.wecom.extra(bot_id/secret)读取。
后续可把后台脚本(workflow-watchdog.sh 的 notify() 等)的通知改成调用此脚本,
彻底脱离会串号的旧 _send_wecom 路径。
三、协议要点(自建发送时照此,已逐行核对源码)
- WS URL:
wss://openws.work.weixin.qq.com(可被extra.websocket_url覆盖) - 认证:
cmd=aibot_subscribe,body={bot_id, secret, device_id},等同 req_id 的 ack - 发送:
cmd=aibot_send_msg,body={chatid, msgtype:"markdown", markdown:{content}, chat_type:1} - 帧格式:
{"cmd":..., "headers":{"req_id":...}, "body":...},响应按 req_id 关联 - 成功判定:响应顶层
errcode in {0, None}即成功,否则读errmsg - aiohttp 新版兼容:
ws_connect(timeout=...)用 float 会告警,优先from aiohttp import ClientWSTimeout; ClientWSTimeout(ws_close=...),回退 float
四、已核准 userid 白名单(2026-06-21,三方交叉验证)
验证方法:state.db 的 sessions.user_id + cache/documents/*.meta 的 sender_id +
logs/*.log 里 platform=wecom user=X chat=Y 三方对照。日志里 user==chat 的记录
即"私聊 DM"样本,证明该 userid 是企微可直达的真实私聊 chatid(chat_type=1)。
| 别名 | 真实 userid | 身份 | 可信度 |
|---|---|---|---|
| doro | doro |
Doro(律师·合同审查指导) | ★高 DB+meta+log,私聊×156 |
| jiaqian | JiaQian |
贾茜 / Maggie(主人) | ★高 DB+log,私聊×108 |
| qiuting | QiuTing |
邱律师(Doro 团队) | ★高 DB+log,私聊×71 |
| weiwei | WeiWei |
WeiWei(技术支持) | ★高 DB+log,私聊×49 |
| shasha | ShaSha |
苌莎莎(律师·同团队) | △ DB+群 log,私聊无样本 |
| yangayi | YanGaYi |
颜伽艺(架构师) | △ 单源 DB,低频 |
| xiaonan | XiaoNan |
XiaoNan | △ 单源 DB,低频 |
userid 大小写敏感(
JiaQian不是jiaqian)。脚本白名单同时接受小写别名和 精确 userid。给真人发测试私信前先确认对象——优先发 WeiWei(技术支持,懂测试)。
五、★群聊不可替换(边界铁律)
wecom_dm.py 的 chat_type=1 是主动私信专用——企微 AI Bot 在群聊里不能主动 aibot_send_msg,只能走 adapter 的 RESPONSE 兜底(回复某条历史 inbound)。所以:
- 本脚本只用于单聊私信,群聊发送绝不能改用它(chat_type=1 在群里无效)。
- 排查/替换发送点时,先用 chatid 前缀区分:
wr开头 = 群聊(不动),其余 = 单聊(可迁移到 wecom_dm.py)。典型如notify_new_files.sh的群发adapter.send(chat_id='wrbAF...')必须原样保留。
六、已迁移调用点(单聊发送统一走本脚本)
所有发企微单聊私信的后台脚本已从旧 _send_wecom/adapter.send(chat_id='doro') 改为调用 wecom_dm.py,每处加了"为什么不用 _send_wecom"的注释防回退,备份后缀 .bak_replace_<时间戳>:
auto_notify_new_file.sh→notify_doro()(当前唯一活跃运行)workflow-watchdog.sh→notify()(历史脚本,当前未调度;注意它仍可能硬编码_send_wecom(extra,'doro',msg),是未拆隐患)notify_new_files.sh→ 单聊提醒部分(历史脚本,群发点原样保留)
改脚本前的标准流程
search_files搜_send_wecom|aibot_send_msg|adapter.send找全发送点 → 2. 逐个看 chatid 前缀判单聊/群聊 → 3. 备份cp 脚本 脚本.bak_replace_$(date +%Y%m%d_%H%M%S)→ 4. patch 替换单聊点、群聊点加注释保留 → 5.bash -n语法 + grep 确认无残留单聊直调且群发点完好 → 6. 临时把目标改 weiwei 发一条测试拿 message_id 验证。