# 企微主动私信的"干净直发"原语 + 错发根因 + 账号白名单

> 背景：贾茜(Maggie)反映"给 Doro 的消息错发给她"。排查发现根因在企微
> adapter 的"回复兜底"机制。本文记录干净直发的可靠原语、错发机制、以及
> 三方交叉验证过的真实 userid 白名单。

## 一、错发根因：adapter.send() 的"回复兜底"会串号

`gateway/platforms/wecom.py` 的 `WeComAdapter.send()`（约 1430–1445 行）有一条
fallback：

```python
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`**，
永不退化成回复。一条消息 = 一次目标唯一确定的主动私信。已实测真发成功。

```bash
# 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` → 单聊提醒部分（历史脚本，群发点原样保留）

### 改脚本前的标准流程
1. `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 验证。
