# 企微主动通知错投到错误的人 — 根因与诊断

**症状**：一条本应私信给 A（如 Doro）的**主动通知**，落到了 B（如 JiaQian/Maggie）的企微私信里。
实证（2026-06-17，Maggie 飞书原话）：私信收到"收到 JiaQian 发来的文件…请问如何处理？"——这条内容本应发给 Doro，却投到了 JiaQian 本人。

**关键澄清：不是 userid 传错。** 会话 DB 里 `doro / JiaQian / WeiWei / ShaSha / QiuTing` 都是**各自独立的真实企微 userid**，`chat_id="doro"` 本身指向的就是 Doro。错投来自下面两个机制叠加，而非地址写错。

## 根因 1：发送者归属靠"猜"（旧 `auto_notify_new_file.sh`）
旧版 `get_sender()` 查 session DB 取"最近 5 分钟最后一个会话"：
```sql
SELECT user_id FROM sessions
WHERE source='wecom' AND started_at > (now-300)
ORDER BY started_at DESC LIMIT 1
```
多人**并发**时，这个"最近活跃会话"经常不是真正发文件的人 → 发件人张冠李戴。

**已修复**：企微 adapter 落盘缓存文件时写 `.meta` 边车文件（`~/.hermes/cache/documents/<file>.meta`，字段 `sender_id / chat_id / chat_type`）。脚本改读 `${filepath}.meta`，不再靠"最近会话"猜。验证：`.meta` 内容形如 `sender_id=doro chat_id=doro type=dm`。

## 根因 2：主动私信会"退化成回复"导致串号（adapter 层）
`gateway/platforms/wecom.py` `WeComAdapter.send(chat_id, content)` 在做主动 `aibot_send_msg` 前有一段**回复优先兜底**（约 1430–1445 行）：
```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)  # 回复某条历史消息，而非主动私信
else:
    payload = {"chatid": chat_id, "msgtype": "markdown", ...}
    if not chat_id.startswith("wr"):      # 群 ID 以 "wr" 开头；非 "wr" 当私聊
        payload["chat_type"] = 1          # 主动单聊
    response = await self._send_request(APP_CMD_SEND, payload)
```
`_last_chat_req_ids[chat_id]` 由**入站流量**填充（`_remember_chat_req_id`，约 533 行）。在长跑的 gateway 常驻进程里、多人并发时，某个 `chat_id` key 上记住的 `req_id` 可能绑定到**归属于另一个人的会话上下文**——于是"发给 doro"退化成"回复那条 req_id"，落到错误的人头上。

> 注意两个独立的 dict：`_reply_req_ids`（按 **message_id** 存，给显式 `reply_to` 用）和 `_last_chat_req_ids`（按 **chat_id** 存，作群聊无 `reply_to` 时的兜底）。错投走的是后者这条 chat_id 兜底路径。

## 谁还在踩这条路径（主动私信脚本）
任何脚本调用 `_send_wecom(extra, 'doro', msg)` → `adapter.send()` 都会经过上面的回复兜底，存在同样的串号风险。已知：
- `auto_notify_new_file.sh`：**主循环已不再调用** `notify_doro()`（非 QiuTing 文件只记日志、不通知任何人，符合信息隔离铁律；QiuTing 走静默 workflow 不发中间通知）。`notify_doro()` 函数仍**定义着**但无调用点 → 主路径安全。
- `workflow-watchdog.sh`：其 `notify()` **仍在用** `_send_wecom(extra, 'doro', msg)`，workflow 崩溃重启时会触发，**未拆除的隐患**（低频但路径有风险）。

## 修复方向（动手前须经用户确认，勿擅改代码/脚本）
- **A（最稳）**：给 adapter 加"强制主动私信"参数，让脚本类通知**绕过 `_last_chat_req_ids` 回复兜底**，永远走 `chat_type=1` proactive 直发。一次修复，所有脚本受益。涉及代码库，宜拉熟代码的人（WeiWei）评审。
- **B（最快）**：把 `workflow-watchdog.sh` 的通知目标改到本机日志/Scott，彻底不碰串号路径。改动小。
- **C（最保守）**：先出一页纸根因+修复评审文档，定了再动。

### ✅ 方向 A 已有现成实现：`~/.hermes/scripts/wecom_dm.py`（agent 可直接用）
不必等改 adapter——这个独立脚本已经实现了"强制主动私信、绕过回复兜底"：自开 WebSocket，`aibot_subscribe` 认证后直接 `aibot_send_msg + chat_type=1`，**永不退化成回复**，一条消息=一次干净 proactive send、目标唯一。带 `--list` 白名单（别名↔userid 三方交叉验证）、`--dry-run`、回执 message_id。
```bash
python3 ~/.hermes/scripts/wecom_dm.py --list
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "…" --dry-run
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "…"
```
凡是 agent 会话里要私信某个企微人（绕开 home channel）的场景，**首选这个脚本**，而不是 `send_message(target='wecom:…')`（会静默落 home channel）或 `_send_wecom(extra,…)`（需 gateway 内部 `extra`，会话里拿不到）。`workflow-watchdog.sh` 等仍硬编码 `_send_wecom(extra,'doro',…)` 的脚本，理想改法就是切到这个干净发送器。

## 诊断配方（只读，安全）
```bash
# 1. 脚本实际发了什么、发给谁（看 chat_id 与回执）
tail -n 80 /tmp/auto_notify_new_file.log
grep -aiE "Sending response .* to|aibot_send|chat_type|私信" ~/.hermes/logs/agent.log | tail -30

# 2. 真实 userid ↔ 人 映射（确认不是地址写错）
cd ~/.hermes/hermes-agent && source venv/bin/activate
python3 -c "import sqlite3;d=sqlite3.connect('$HOME/.hermes/state.db');[print(r) for r in d.execute(\"SELECT user_id,COUNT(*),MAX(started_at) FROM sessions WHERE source='wecom' GROUP BY user_id ORDER BY 2 DESC\")]"

# 3. .meta 边车归属是否正确
find ~/.hermes/cache/documents -name '*.meta' -exec cat {} \;

# 4. 谁还在用 doro 硬编码主动发送
# search_files pattern: _send_wecom|DORO_ID="doro"  在 ~/.hermes/scripts
```

## 一句话结论
"给 X 的消息错投给 Y"在企微里**首查 adapter 的 `_last_chat_req_ids` 回复兜底**与**脚本的发件人归属逻辑**，不要一上来就怀疑 userid 写错——userid 往往是对的，错在"主动私信退化成回复历史消息"。
