---
name: wecom-file-send-receive
description: 企微群/私聊中发送和接收文件的完整方法+故障排查（846609、群消息丢失、发送vs接收问题区分）。发送用MEDIA标签，接收从cache/documents/读取。跨会话召回和文件接收工作流见references/。
version: 1.0.0
tags: [企业微信, 文件, MEDIA]
---

# 企微文件发送与接收

> 📌 **主动私信（proactive DM）找谁发、怎么发不串号**：见
> `references/proactive-dm-clean-send.md` —— 含 `wecom_dm.py` 干净直发原语、
> 错发根因（回复兜底串号）、以及三方交叉验证过的真实 userid 白名单。
>
> 📌 **主动群消息（group notify）**：`~/.hermes/scripts/wecom_group_notify.py`
> —— 自开 WebSocket 直连，不经 gateway adapter，用于 workflow/脚本场景向群里发通知。
> 默认群 = 批量合同审查群（`wrbAFkXAAAiWC3styKqNj0bZyH6BbJ_Q`）。
> ```bash
> python3 ~/.hermes/scripts/wecom_group_notify.py --text "消息内容"             # 默认群
> python3 ~/.hermes/scripts/wecom_group_notify.py --group <群ID> --text "内容"  # 指定群
> ```
> ⚠️ 注意与私信脚本的区别：群通知**不传 chat_type**（群消息默认），私信脚本传 `chat_type=1`。

## 已知问题：send_message无法向WeCom用户发送proactive私信

**根因（2026-06-12定位）**：`send_message_tool.py`的`_parse_target_ref()`函数只认纯数字chat_id为explicit target。WeCom的userid（如"QiuTing"、"doro"）是字母混合的，不被识别→返回`(None, None, False)`→chat_id=None→fallback到home channel。

**表现**：`send_message(target="wecom:QiuTing")`实际发送到了JiaQian（home channel），不是QiuTing的私信。返回的response显示`"note": "Sent to wecom home channel"`。

**为什么给doro发有时能成功**：当前session是doro发起的，gateway有doro的reply_req_id缓存，走的是回复路径而非proactive发送。对没有活跃session的用户（如QiuTing）则必定失败。

**修复方案**：在`tools/send_message_tool.py`的`_parse_target_ref`中为wecom添加explicit识别规则（约第408行`return None, None, False`之前）：
```python
if platform_name == "wecom" and target_ref and not target_ref.startswith("#"):
    return target_ref, None, True
```

**临时绕过**：通过直接WebSocket连接发送aibot_send_msg（chatid=userid, chat_type=1），但受proxy/网络环境限制也不稳定。

## 私信 vs 群消息 脚本选择（2026-07-01 铁律——发错目标=信息泄露）

| 目标 | 用什么脚本 | 绝对不能用 |
|------|-----------|-----------|
| **私信某人** | `wecom_dm.py --to <别名>` | `wecom_group_notify.py`（会发到群里） |
| **群消息** | `wecom_group_notify.py --group <群ID> --text "..."` | `wecom_dm.py`（发不到群里） |

**2026-07-01 教训**：Doro让私信邱律师，用了 `wecom_group_notify.py`（默认发到批量合同审查群），消息发到了群里所有人都看到了。workflow YAML（review-contract.yaml 第16-17行）里 classifier 的 not_a_contract 通知也写的是群通知脚本——应该改为 `wecom_dm.py --to qiuting`。

**铁律**：看到"私信""私聊""单独告诉"等字眼，一律用 `wecom_dm.py --to`。只有明确说"在群里说""发到群里"时才用 `wecom_group_notify.py`。

## 发送文件

### ⚠️ 铁律：不要用 send_message 工具发企微文件
`send_message` 的 MEDIA 功能**不支持企微**（仅支持 telegram/discord/matrix/weixin/signal/yuanbao/feishu）。调用 `send_message(target="wecom:...", message="MEDIA:...")` 会直接报错 `send_message MEDIA delivery is currently only supported for...`。

企微发文件的**唯一正确方式**是在回复正文中写 `MEDIA:` 标签——由 gateway 拦截并调用 adapter.send_document()。不要绕道 send_message，也不要绕道邮件——直接在回复里写标签就行。

在回复文本中包含 `MEDIA:/absolute/path/to/file.ext`，gateway自动：
1. 扫描回复中的 `MEDIA:` 标签
2. 调用 `adapter.send_document()` 发送文件到当前对话
3. 剥离标签，用户只看到文件附件

### 示例
```
审查完成，请查收修订版合同。

MEDIA:/tmp/contract-review/【修】合同名称.docx
```

### 注意事项
- 路径必须是绝对路径，文件必须存在
- 可以在一条回复中包含多个 `MEDIA:` 标签发送多个文件
- 适用于所有平台（企微、Telegram等），不限于企微
- `MEDIA:` 标签放在回复末尾即可，不影响正文显示

## 接收文件

### 企微AI Bot消息类型限制
**AI Bot只接收msgtype=text的消息**。纯图片、纯文件消息不触发回调（群聊和私信均如此）。
- 私信中直接发图片/文件 → 不触发回调 → 小Maggie收不到
- 合并转发 → API层面替换为`[该消息类型暂不能展示]` → 图片数据丢失
- 唯一能传图/文件的方式：群里**引用**图片/文件消息并@小Maggie发文字

### 群Session隔离
配置 `group_sessions_per_user: true`（默认）时，同一群里不同用户@小Maggie走**独立session**。session key 格式：`agent:main:wecom:group:<群id>:<用户id>`。session 状态可查 `~/.hermes/sessions/sessions.json`。

### 触发方式（群里引用）
用户操作：
1. 先发送文件/图片（此时小Maggie收不到）
2. **引用/回复那条文件/图片消息**并@小Maggie发文字说明需求

### 文件保存位置
`~/.hermes/cache/documents/doc_<hash>_<原始文件名>`

文件名经过URL编码，用 `urllib.parse.unquote()` 还原。

### Gateway处理流程
1. 收到引用消息 → 从quote中提取文件url和aes_key
2. 下载文件 → AES解密（已修复base64 padding问题）
3. 保存到 `~/.hermes/cache/documents/`
4. 消息中包含 `The file is saved at: <路径>`

### 读取文件
```python
# docx
import zipfile, io
from lxml import etree
with open(path, 'rb') as f:
    # 正常docx操作

# 或用python-docx只读（不要用它save）
from docx import Document
doc = Document(path)
```

## 跨聊天发送消息/文件（发到非当前对话）

### ⚠️ 铁律：不要用 send_message 工具发企微私信
`send_message(target='wecom:QiuTing')` 等写法**不可靠**——实测会静默路由到 home channel（JiaQian）而不是目标用户，无报错。2026-06-12验证：连续两次 `send_message(target='wecom:QiuTing')` 均返回 `chat_id: JiaQian`，消息发给了Maggie而不是邱律师。

> ⚠️ **2026-06-21 重要修正**：`_send_wecom` / `adapter.send()` 内部有"回复兜底"
> 机制（`_last_chat_req_ids` → 退化成 `APP_CMD_RESPONSE` 回复历史消息），在**长期
> 运行的 gateway 进程**里多人并发时会**串号错发**（贾茜反映"给 Doro 的消息错发给
> 她"即源于此叠加旧脚本的 get_sender 误判）。单次 `_send_wecom` 命令行调用每次 new
> 一个空 adapter，通常不串号；但**后台脚本/daemon 的主动通知**不要依赖这个兜底
> 语义。最干净可靠的主动私信原语是 **`~/.hermes/scripts/wecom_dm.py`**（自开
> WebSocket，固定 `chat_type=1`，永不退化成回复，带账号白名单防呆 + errcode 真实
> 判定）。详见 `references/proactive-dm-clean-send.md`，含三方交叉验证的 userid 白名单。
>
> ```bash
> python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "内容"   # 推荐
> python3 ~/.hermes/scripts/wecom_dm.py --list                    # 看核准账号
> ```

发私信/群消息到非当前对话的另一种方式是直接调用 `_send_wecom`（单次命令行调用可靠，后台 daemon 优先用上面的 wecom_dm.py）：
```python
cd /home/maggie/.hermes/hermes-agent && source venv/bin/activate && python -c "
import asyncio, yaml
from tools.send_message_tool import _send_wecom
with open('/home/maggie/.hermes/config.yaml') as f:
    cfg = yaml.safe_load(f)
extra = cfg.get('gateway', {}).get('platforms', {}).get('wecom', {}).get('extra', {})
result = asyncio.run(_send_wecom(extra, 'QiuTing', '消息内容'))
print(result)
"
```
- `_send_wecom(extra, '<用户ID>', msg)` = 私信 ✅ 可靠
- `send_message(target='wecom:<用户ID>')` = ❌ 会误发到home channel

MEDIA标签只能发到当前对话。要发文件到其他聊天（如从群聊发文件到某人私信），需要直接调用WeComAdapter：

```python
import asyncio

async def send_file_to_chat(chat_id: str, file_path: str, file_name: str = None):
    """发送文件到指定企微聊天（私信或群）"""
    import os, yaml
    from gateway.platforms.wecom import WeComAdapter
    from gateway.config import PlatformConfig

    with open(os.path.expanduser("~/.hermes/config.yaml")) as f:
        cfg = yaml.safe_load(f)
    wecom_cfg = cfg.get("gateway", {}).get("wecom", {})
    pconfig = PlatformConfig(extra=wecom_cfg)
    adapter = WeComAdapter(pconfig)

    connected = await adapter.connect()
    if not connected:
        raise RuntimeError(f"Failed to connect: {adapter.fatal_error_message}")
    try:
        result = await adapter.send_document(
            chat_id=chat_id,
            file_path=file_path,
            file_name=file_name or os.path.basename(file_path),
        )
        return result
    finally:
        await adapter.disconnect()

# 使用示例：从群聊中发文件到邱律师私信
# asyncio.run(send_file_to_chat("QiuTing", "/tmp/file.docx"))
```

### 注意事项
- chat_id：私信用企微用户ID（如"QiuTing"），群聊用群ID（如"wrbAFk..."）
- 必须在hermes-agent的venv中运行（需要gateway模块）
- 运行目录：`cd /home/maggie/.hermes/hermes-agent && source venv/bin/activate`

## 故障排查

### 先分清是发送问题还是接收问题
发送失败（errcode 846609）和接收不到是**两个独立问题**，不要混为一谈：
- **发送失败**：gateway.log 中有 `Sending response` 但紧跟 `Send failed: errcode 846609`（aibot websocket not subscribed）。消息收到了、处理了，只是回复发不出去。
- **接收不到**：gateway.log 中完全没有某个群/私信的 `inbound message` 记录。消息根本没到达 gateway。
- 846609 是企微服务端的订阅态问题，通常重启 gateway 可恢复，但它**不影响接收**——可以收到消息但发不出回复。

### 群消息收不到但私信正常
**⚠️ 第一反应不要重启 gateway。** 企微群消息和私信走同一条 WebSocket 连接。如果私信能收到，WebSocket 没断，重启不解决问题。

**⚠️ 不要先猜测原因再找证据。** 先看日志事实，再得结论。

排查顺序（严格按此顺序，不要跳步）：

1. **查 gateway.log（首选，不是 journalctl）**：gateway.log 包含 INFO 级别的 inbound message 记录（含 chat_id 和 user），journalctl 通常只有 WARNING+，看不到消息是否到达。
   ```bash
   tail -200 ~/.hermes/logs/gateway.log | grep "inbound message"
   # 能看到哪些群/私信的消息被收到了，哪些完全没出现
   # 对比不同群的 chat_id，确认哪些群有消息哪些没有
   ```
   关键判断：如果某个群的消息在日志中**完全没出现过**（连 debug 级别的 policy/block 记录都没有），说明消息在企微服务端就没推过来，跟本地代码无关。

2. **查本地代码改动**（Hermes 源码有本地 patch）——当用户怀疑代码改动导致问题时，**立即看代码**，不要先猜：
   ```bash
   cd ~/.hermes/hermes-agent
   git diff                    # 未提交的改动
   git log --oneline -5        # 本地提交 vs 上游
   git stash list              # 暂存的改动
   git diff <upstream_tag>..HEAD -- gateway/platforms/wecom.py  # 与上游对比
   git show <commit> --stat    # 看影响了哪些文件
   git show <commit> -- gateway/platforms/wecom.py  # 看具体改动
   ```
   分析改动时必须精确分类到三条路径：
   - **接收路径**：`_on_message`, `_dispatch_payload`, `_read_events`, `_extract_text`, `_extract_media`, `_derive_message_type`
   - **发送路径**：`_send_*`, `send_document`, `_send_reply_markdown`, `_send_media_message`
   - **连接路径**：`_listen_loop`, `_open_connection`, `_heartbeat_loop`
   不要笼统说"都不影响"——按路径逐条分析。

3. **检查消息类型限制**：AI Bot 只接收 `msgtype=text`，纯图片/文件消息不触发回调

4. **检查 group_policy 配置**：`~/.hermes/config.yaml` 中 wecom.extra.group_policy（默认 open）

5. **确认 bot 已被加入目标群** + **@的是正确的 bot 名称**：
   - 同一个企微环境可能有多个 bot
   - 不同用户 @小Maggie 的效果可能不同（A能@到但B@不到——可能B的客户端上显示的bot名不同）
   - 如果某用户@的群消息完全没到达，但另一个用户@同一个群的消息能收到，问题在@的目标身份而非群本身

### WebSocket 断开（群消息和私信都收不到）
- 日志特征：`WARNING [Wecom] WebSocket error: WeCom websocket closed`
- 修复：`systemctl --user restart hermes-gateway`
- 本地 patch 加了 consecutive_failures 上限（MAX=20），超过会停止重连循环
- ⚠️ **超过20次后gateway永不自动恢复**——必须手动restart

#### 网络层故障导致的WebSocket断连
当服务器本身网络短暂故障时（DNS解析失败、SSL证书错误），WeCom和Weixin会**同时**挂掉。这是区分网络问题 vs WeCom-specific问题的关键信号。

日志特征（三种错误交替出现）：
```
WARNING [Wecom] Reconnect failed: Cannot connect to host openws.work.weixin.qq.com:443 ssl:True [SSLCertVerificationError: self-signed certificate in certificate chain]
WARNING [Wecom] Reconnect failed: Cannot connect to host openws.work.weixin.qq.com:443 ssl:default [Temporary failure in name resolution]
ERROR [Wecom] Too many consecutive reconnect failures (20), stopping listen loop
```

诊断和恢复步骤：
1. **确认网络已恢复**（不要盲目重启——网络没好重启也没用）：
   ```bash
   curl -sI https://openws.work.weixin.qq.com 2>&1 | head -3  # 应返回HTTP响应
   dig openws.work.weixin.qq.com +short                        # 应返回IP
   ```
2. **重启gateway**：`systemctl --user restart hermes-gateway`
3. **验证所有平台重连成功**：用 `send_message(action='list')` 确认WeCom targets出现，或查日志确认WebSocket connected

### errcode 846609: aibot websocket not subscribed
- 含义：企微服务端认为此 bot 的 WebSocket 订阅无效，拒绝发送/回复
- 影响：**只影响发送**，不影响接收——可能同时还在收消息但回复全部失败
- **⚠️ 不要因为 846609 就说"WebSocket 断了"**——846609 是发送端的订阅态问题，接收端的 WebSocket 连接可能完全正常。如果私信能收到，WebSocket 连接没断。
- 日志特征：`ERROR [Wecom] Send failed: ... WeCom errcode 846609: aibot websocket not subscribed`
- 修复：`systemctl --user restart hermes-gateway`（重新建立 WebSocket 订阅）
- 注意：重启期间正在处理的消息会丢失回复，但消息本身已被 gateway 处理过

### 主动往群里推消息的风险
企微 AI Bot 不能用 `APP_CMD_SEND` 主动发群消息，Hermes 代码通过复用旧 `req_id`（`_last_chat_req_ids`）以 `APP_CMD_RESPONSE` 方式发送。这个 fallback 是**唯一可行的做法**，不是绕限制。
- 但如果旧 req_id 已过期太久，发送会失败（846609）

### ⚠️ 铁律：后台脚本/daemon 禁止往群里发消息
所有自动化通知必须走**私信**（chat_id=用户ID, chat_type=1），不发群。
- 2026-06-09教训：原脚本用`notify_group`同时往群里和私信发，Doro说"已经触发风险机制了"。已修复为`notify_doro`只走私信。任何修改此脚本的人必须遵守此规则。
- **例外**：`wecom_group_notify.py`用于workflow中classifier识别到非合同文件后**一次性通知邱律师**（低频、事件驱动、非循环推送），不属于daemon定时推送，不触发风控。区分：daemon循环发=禁止；workflow事件触发一次=允许。
- 排查"谁在往群里发消息"的标准流程：
  1. `ps aux | grep -E "auto_notify|monitor|watchdog"` 找后台脚本
  2. 检查其中是否有 `_send_wecom(extra, '<群ID>', ...)` 调用
  3. 检查 `/tmp/auto_notify_new_file.log` 看是否有 `Group:` 开头的发送记录
- `_send_wecom(extra, '<用户ID>', msg)` = 私信 ✅
- `_send_wecom(extra, '<群ID>', msg)` = 群消息 ❌ 禁止

### ⚠️ `auto_notify_new_file.sh` 的 `get_sender()` 不可靠
脚本用 `get_sender()` 查 `sessions` 表最近5分钟内的最后一个session来判断发件人。这不准——如果多人同时在聊天或session时间不吻合，就会误判（实测：邱律师的文件被误判为WeiWei）。
- 准确查发件人的方法：查 `messages` 表中 `[The user sent a document:` 开头的用户消息，JOIN `sessions` 表取 `user_id`
- 手动确认时直接查 state.db：
  ```sql
  SELECT s.user_id, m.content FROM messages m
  JOIN sessions s ON m.session_id = s.id
  WHERE m.role='user' AND m.content LIKE '%sent a document%'
  ORDER BY m.timestamp DESC LIMIT 5
  ```
### Nextcloud桌面客户端同步失败诊断（Cloudflare Tunnel）

当用户报告"同步不流畅/更新同步不了"时，查以下两个日志：

1. **Nextcloud日志**：`docker exec nextcloud-nextcloud-1 tail -100 /var/www/html/data/nextcloud.log | grep '"level":3'`
   - 典型错误：`预期文件大小为X字节，实际写入Y字节`（BadRequest） → 上传被中途截断
2. **Cloudflared日志**：`journalctl -u cloudflared-nextcloud.service --since today | grep ERR`
   - 典型错误：`Incoming request ended abruptly: context canceled` → Cloudflare超时掐断

**根因**：Cloudflare免费版单次HTTP请求100秒超时硬限制（不可配置）。桌面客户端默认整体传输>10MB文件，上传带宽不足时被超时掐断。

**解法**：桌面客户端开启分块上传，把大文件拆成小块传输：
- Windows: `%APPDATA%\Nextcloud\nextcloud.cfg` → `[General]` 段加 `chunkSize=5242880`
- macOS: `~/Library/Preferences/Nextcloud/nextcloud.cfg`
- Linux: `~/.config/Nextcloud/nextcloud.cfg`
- 改完需退出并重启客户端

**服务端验证**（确认分块上传端点正常）：
```bash
curl -s -o /dev/null -w "%{http_code}" -u doro:PASS http://localhost:5000/remote.php/dav/uploads/doro/
# 返回200 = 正常
```

**注意**：tunnel配置的`connectTimeout`只管建立连接超时，不管传输过程超时。加`noChunkedEncoding`让cloudflared先缓存再转发理论上有帮助，但治不了CF边缘100秒硬限制——分块上传才是正解。参见 `references/nextcloud-sync-diagnosis.md`。

### 大文件传输失败的fallback路径

当用户需要传大文件（>30MB），常规渠道可能都失败：
- **企微群/私信**：文件消息无法同时@人，且AI Bot只收text类型
- **Nextcloud上传**：大文件上传可能中断（.part文件出现后消失，无最终文件）
- **诊断Nextcloud上传中断**：在容器内查 `.part` 文件和 `uploads/` 目录
  ```bash
  sudo docker exec nextcloud-nextcloud-1 bash -c 'find /var/www/html/data/<user>/ -name "*.part" 2>/dev/null'
  sudo docker exec nextcloud-nextcloud-1 bash -c 'find /var/www/html/data/<user>/uploads/ -type f 2>/dev/null'
  ```

**fallback方案：让用户发邮件**。提供 `maggiejunior@shazhou.work`，用himalaya skill下载附件。注意大附件（>20MB）IMAP下载也可能很慢，参见himalaya skill的"Pitfalls: Large Attachments"部分。

### 常见文件问题
- **找不到文件**：搜 `~/.hermes/cache/documents/`，不要只搜 `/tmp`
- **文件乱码**：检查 `file` 命令输出，正常应显示 `Microsoft Word 2007+`
- **发送失败**：确认文件路径存在且为绝对路径
- **文件名太长**：中文文件名 URL 编码后可能超 255 字节 ext4 限制，本地 patch 已加截断逻辑
