feat: export core Hermes skills
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
---
|
||||
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 已加截断逻辑
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: wecom-cross-session-recall
|
||||
description: 跨会话召回——从企微私信session中检索内容并发到群聊。用于Doro在群里要求"把私信里的XX发到这里"的场景。
|
||||
tags: [wecom, session, cross-chat, recall]
|
||||
---
|
||||
|
||||
# 企微跨会话召回
|
||||
|
||||
## 触发条件
|
||||
- Doro在群里说"把私信里的XX发到这里"、"你看看我们私信的聊天记录"等
|
||||
- 需要衔接群聊和私信的上下文
|
||||
|
||||
## 步骤
|
||||
|
||||
### 1. 找到私信session ID
|
||||
```bash
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$HOME/.hermes/sessions/sessions.json') as f:
|
||||
data = json.load(f)
|
||||
for sid, meta in data.items():
|
||||
if 'dm:doro' in sid:
|
||||
print(f'{sid}: session_id={meta[\"session_id\"]}')
|
||||
"
|
||||
```
|
||||
|
||||
### 2. 读取session内容
|
||||
```bash
|
||||
# 搜索关键词
|
||||
grep "关键词" ~/.hermes/sessions/<session_id>.jsonl | python3 -c "
|
||||
import sys, json
|
||||
for line in sys.stdin:
|
||||
d = json.loads(line)
|
||||
role = d.get('role','?')
|
||||
content = str(d.get('content',''))
|
||||
if '关键词' in content:
|
||||
print(f'{role}: {content[:500]}')
|
||||
"
|
||||
```
|
||||
|
||||
### 3. 也可以用 session_search 工具
|
||||
- session_search 会搜索所有session,但可能匹配不精确
|
||||
- 直接grep jsonl文件更可靠
|
||||
|
||||
## 注意事项
|
||||
- 群聊session key: `agent:main:wecom:group:<chatid>:<userid>`
|
||||
- 私信session key: `agent:main:wecom:dm:<userid>`
|
||||
- 两个session完全独立,互相看不到上下文
|
||||
- 私信中无法跨群发消息(send_message工具未启用),需要在群里收到消息时才能发
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
name: wecom-file-receive
|
||||
description: 通过企业微信接收文件并对接现有工作流(合同审查、文件处理等)。企微群里文件无法同时@人,需要用户引用文件消息后@小Maggie触发。
|
||||
version: 1.0.0
|
||||
tags: [企业微信, 文件接收, workflow]
|
||||
---
|
||||
|
||||
# 企业微信文件接收与处理
|
||||
|
||||
## 触发条件
|
||||
- 用户在企微群里发送文件后,引用该文件消息并@小Maggie
|
||||
- 收到的消息中包含文档附件(saved at路径)
|
||||
|
||||
## 文件接收机制
|
||||
|
||||
### 用户操作方式
|
||||
1. 用户在企微群里直接发送文件(此时无法同时@人,小Maggie收不到)
|
||||
2. 用户**回复那条文件消息**并@小Maggie(如"@小Maggie 审查这个合同")
|
||||
3. 网关从引用消息(quote)中提取文件的url和aeskey,下载并AES解密后保存到本地
|
||||
|
||||
### 文件保存位置
|
||||
- 自动保存到 `~/.hermes/cache/documents/` 目录
|
||||
- 文件名格式:`doc_<hash>_<原始文件名>`
|
||||
- 消息中会显示 `The file is saved at: <完整路径>`
|
||||
|
||||
### 支持的文件类型
|
||||
- 文档:.docx, .doc, .pdf, .xlsx, .xls
|
||||
- 图片:.jpg, .png, .gif, .webp
|
||||
- 其他:任意文件类型均可接收
|
||||
|
||||
## 收到文件后的通知规则
|
||||
|
||||
### 群里收到的文件
|
||||
在**同一个群里**回复确认收到,列出文件名和发送人。
|
||||
|
||||
### 私信收到的文件
|
||||
**不要主动往群里推送通知。** 在私信里回复确认收到即可。如需通知群里的人,等群里有新的@消息时顺便汇报,或私信相关人员。
|
||||
|
||||
### ⚠️ 为什么不主动推群消息(铁律)
|
||||
企微 AI Bot 不能用 `APP_CMD_SEND` 主动发群消息,Hermes 底层会复用旧的 inbound `req_id` 通过 `APP_CMD_RESPONSE` 发送。这种"拿过期会话凭证推送非用户发起的消息"的行为有风险:
|
||||
- 可能触发企微服务端风控/限流
|
||||
- 短时间频繁重启后叠加此行为,曾导致部分用户的群@消息不再被推送(2026-06-08事件)
|
||||
- sender 解析可能变成 "unknown",发出不专业的消息
|
||||
- 2026-06-09 Doro明确指令:"不要往群里发消息,已经触发风险机制了"
|
||||
|
||||
**`auto_notify_new_file.sh` 守护进程**也遵守此规则——只走 `notify_doro`(私信),不调 `notify_group`。详见 uwf skill。
|
||||
|
||||
### ⚠️ `get_sender()` 发件人检测不可靠
|
||||
`auto_notify_new_file.sh` 中的 `get_sender()` 函数查 `sessions` 表最近5分钟的最后一个 session 的 `user_id`,经常误判(如邱律师的文件被识别为WeiWei)。准确确认发件人的方法是查 `messages` 表:
|
||||
```sql
|
||||
SELECT s.user_id, m.content FROM messages m
|
||||
JOIN sessions s ON m.session_id = s.id
|
||||
WHERE m.content LIKE '%sent a document%文件名关键词%'
|
||||
ORDER BY m.timestamp DESC LIMIT 3
|
||||
```
|
||||
|
||||
### 通知方式选择
|
||||
- **群里收到的文件** → 在同一个群里回复通知(这是正常的回复,有 reply_req_id)
|
||||
- **私信收到的文件** → **私信 Doro 通知**,不要主动往群里推。原因:AI Bot 不能 APP_CMD_SEND 主动发群消息,Hermes 靠复用旧 req_id 的 APP_CMD_RESPONSE 发送——旧 req_id 可能已过期,且频繁对过期 req_id 发消息可能被企微风控
|
||||
|
||||
### 邱律师(QiuTing)的合同:收到就开干
|
||||
邱律师私信发的合同文件(.docx/.doc/.pdf),收到后**不问处理方式,直接执行全流程**:
|
||||
1. **私信回复邱律师**确认收到文件
|
||||
2. 上传Nextcloud待审查目录
|
||||
3. 启动review-contract workflow(`setsid`后台运行)
|
||||
4. workflow全链路自动跑完(含final_review)
|
||||
5. **小Maggie作为总负责人做终审质检**——发现问题自己修,确认无误才通知Doro
|
||||
6. 终审通过→更新合同审查清单.xlsx→上传Nextcloud→私信Doro(简洁格式:`任务交付/【修】xxx.docx`)
|
||||
|
||||
⚠️ **总负责人原则**(Doro 2026-06-08确立):Doro看到的必须是终审确认无误的。workflow的final_review只是机器检查,真正的终审是小Maggie的判断。
|
||||
|
||||
⚠️ 全程走Doro私信通知,不发群消息。
|
||||
|
||||
### 其他人的文件:通知后等指示
|
||||
Doro、洪总等其他人发的文件,私信Doro汇报后**等Doro指示再处理**。
|
||||
|
||||
通知方法(私信Doro):
|
||||
使用 `send_message(target='wecom:doro', message='...')` 即可。
|
||||
|
||||
## 处理流程
|
||||
|
||||
### 场景一:合同审查
|
||||
用户发文件并说"审查这个合同"时:
|
||||
|
||||
1. 从消息中获取文件保存路径
|
||||
2. 将文件复制到 Nextcloud 待审查目录:
|
||||
```bash
|
||||
docker cp <本地文件> nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/待审查/<原始文件名>
|
||||
docker exec nextcloud-nextcloud-1 chown www-data:www-data <目标路径>
|
||||
docker exec -u www-data nextcloud-nextcloud-1 php occ files:scan doro --path='/doro/files/Doro合同审查任务/待审查/'
|
||||
```
|
||||
3. 按现有合同审查workflow执行(加载contract-reviewer + contract-editor skill)
|
||||
4. 完成后交付到任务交付目录
|
||||
|
||||
### 场景二:文件提取/处理
|
||||
用户发文件并要求提取内容、翻译、格式转换等:
|
||||
|
||||
1. 从消息中获取文件保存路径
|
||||
2. 直接在本地处理(读取docx/pdf内容、提取表格等)
|
||||
3. 按用户指示输出结果(生成新文件、上传到指定目录等)
|
||||
|
||||
### 场景三:文件转存
|
||||
用户发文件并要求存到某个目录:
|
||||
|
||||
1. 从消息中获取文件保存路径
|
||||
2. docker cp 到 Nextcloud 指定目录
|
||||
3. chown + occ files:scan 同步
|
||||
|
||||
## 文件不在cache——检查Nextcloud直传
|
||||
|
||||
当用户说"我上传了XX文件"但 `~/.hermes/cache/documents/` 里没有时,文件可能是通过Nextcloud网页/客户端直接上传的(不经过企微)。
|
||||
|
||||
### 排查步骤
|
||||
1. **先扫描Nextcloud**刷新文件索引:
|
||||
```bash
|
||||
sudo docker exec nextcloud-nextcloud-1 php occ files:scan doro --path="/doro/files/<预期目录>"
|
||||
```
|
||||
2. **按文件名关键词搜索**整个Doro目录树:
|
||||
```bash
|
||||
sudo docker exec nextcloud-nextcloud-1 bash -c 'find /var/www/html/data/doro/files/ -name "*关键词*"'
|
||||
```
|
||||
3. **检查 `.part` 文件**——Nextcloud分块上传的临时文件:
|
||||
```bash
|
||||
sudo docker exec nextcloud-nextcloud-1 bash -c 'find /var/www/html/data/doro/files/ -name "*.part"'
|
||||
```
|
||||
- 文件名格式:`<hash>.ocTransferId<number>.part`
|
||||
- `.part` 文件存在 = 上传正在进行中
|
||||
- 大小在增长 = 还在传,等它完成
|
||||
- `.part` 消失且没有新文件出现 = **上传失败/取消**,通知用户重新上传
|
||||
|
||||
### ⚠️ 注意事项
|
||||
- `.part` 文件不会被 `occ files:scan` 发现(Nextcloud不索引临时文件),必须用 `find` 直接查文件系统
|
||||
- 大文件上传可能很慢(通过Nextcloud网页上传取决于用户网速),不要过早判断失败
|
||||
- 上传失败时`.part`文件会被Nextcloud自动清理,不留痕迹
|
||||
- 确认上传失败后,告知用户"文件上传似乎中断了,请重新上传"
|
||||
|
||||
## 文件名处理
|
||||
- cache中的文件名经过URL编码,需要解码还原原始文件名
|
||||
- 上传到Nextcloud时使用原始文件名(从消息中提取)
|
||||
- 遵循文件命名规则skill(file-naming-convention)
|
||||
|
||||
## 注意事项
|
||||
1. **企微群文件限制**:文件消息无法同时@人,必须通过引用回复触发
|
||||
2. **URL有效期**:企微文件下载URL有效期约5分钟,网关收到后立即下载,不存在过期问题
|
||||
3. **AES加密**:企微文件传输经过AES加密,网关自动解密(已修复base64 padding问题)
|
||||
4. **图片也支持**:图片同样通过引用回复方式接收,保存到cache/images/目录
|
||||
|
||||
## 审计:统计某天某人发了多少文件
|
||||
|
||||
当Doro问"邱律师昨天发了多少合同"时,**不能靠Nextcloud文件时间戳或cache/documents/时间戳**。原因:
|
||||
- Nextcloud待审查目录的文件是小Maggie上传的,时间是上传时间不是接收时间
|
||||
- 有些文件是更早期的积压件(如班组慰问品、夏阳代建在6月5日就在Nextcloud了)
|
||||
- cache/documents/文件可能已被清理
|
||||
|
||||
### 方法1:查Nextcloud数据库(最可靠)
|
||||
|
||||
直接查MariaDB获取精确的存入时间和操作人。详见 `references/nextcloud-db-queries.md`。
|
||||
|
||||
```bash
|
||||
# 查文件存入时间
|
||||
docker exec nextcloud-db-1 mariadb -u nextcloud -p'Nc2026Db!Szw' nextcloud \
|
||||
-e "SELECT fileid, path, FROM_UNIXTIME(storage_mtime) as stored_utc FROM oc_filecache WHERE path LIKE '%关键词%';"
|
||||
|
||||
# 查是谁上传的(仅限通过Nextcloud UI上传的文件)
|
||||
docker exec nextcloud-db-1 mariadb -u nextcloud -p'Nc2026Db!Szw' nextcloud \
|
||||
-e "SELECT FROM_UNIXTIME(timestamp) as time_utc, user, subject, file FROM oc_activity WHERE file LIKE '%关键词%';"
|
||||
```
|
||||
|
||||
⚠️ docker cp + occ files:scan 上传的文件不会产生 oc_activity 记录。无记录=自动流程上传。
|
||||
|
||||
### 方法2:查gateway日志中的接收确认记录
|
||||
|
||||
```bash
|
||||
# 北京时间6月8日 = UTC Jun 7 16:00 ~ Jun 8 16:00
|
||||
journalctl --user -u hermes-gateway \
|
||||
--since "2026-06-07 16:00:00" --until "2026-06-08 16:00:00" \
|
||||
| grep "收到邱律师私信发来\|邱律师好.*文件已收到"
|
||||
```
|
||||
|
||||
每条匹配 = 一次文件接收。注意:
|
||||
- 同一份合同可能发了多次(如复达合同发了2次),要区分"文件发送次数"和"不同合同数"
|
||||
- 时区转换:服务器UTC,Doro问的是北京时间,必须先转换再查
|
||||
- 不要把上传/审查/交付时间当成接收时间
|
||||
|
||||
### 教训(2026-06-09)
|
||||
Doro问"邱律师昨天发了多少合同",小Maggie第一次回答5份(基于Nextcloud文件时间戳),被Doro纠正。查gateway日志后确认是6次文件发送、5份不同合同。
|
||||
|
||||
## Gateway断开期间的文件恢复
|
||||
|
||||
**问题**:企微WebSocket断开期间,私信文件消息收不到。
|
||||
**症状**:`journalctl --user -u hermes-gateway | grep "WeCom.*websocket.*closed"`
|
||||
|
||||
**恢复方法**:
|
||||
1. 重启gateway:`systemctl --user restart hermes-gateway`
|
||||
2. WebSocket重连后,企微服务端通常会重新推送断开期间的消息(但不100%可靠)
|
||||
3. 重启后检查cache/documents/是否有新文件到达
|
||||
4. 如果文件没补回来,检查Nextcloud待审查目录是否已有同名文件(可能之前session已上传)
|
||||
5. 都没有→私信邱律师:"合同X的文件未收到,请重新发送"
|
||||
|
||||
**预防**:cron每15分钟检查WebSocket健康,发现断开及时重启,缩短丢消息窗口。
|
||||
|
||||
## needs_clarification处理(问邱律师,不问Doro)
|
||||
|
||||
当classifier无法判断顾问单位(如甲方信息空白)返回needs_clarification时:
|
||||
1. **私信邱律师**询问:"合同X甲方信息空白,请确认甲方单位"
|
||||
2. 不问Doro——Doro是最终确认人,不应被拉进执行环节
|
||||
3. 邱律师回复后,带补充信息创建新thread重新跑
|
||||
4. 同时跳过该合同,继续处理其他排队的合同
|
||||
@@ -0,0 +1,68 @@
|
||||
# Nextcloud MariaDB Direct Queries
|
||||
|
||||
## Connection
|
||||
|
||||
Container: `nextcloud-db-1`
|
||||
Database: `nextcloud`
|
||||
CLI: `mariadb` (NOT `mysql`)
|
||||
Credentials from `~/nextcloud/docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
docker exec nextcloud-db-1 mariadb -u nextcloud -p'Nc2026Db!Szw' nextcloud -e "<SQL>"
|
||||
```
|
||||
|
||||
## File Metadata (oc_filecache)
|
||||
|
||||
When was a file stored? Who owns it? What's its real size?
|
||||
|
||||
```sql
|
||||
SELECT fileid, path,
|
||||
FROM_UNIXTIME(storage_mtime) as stored_utc,
|
||||
FROM_UNIXTIME(mtime) as modified_utc,
|
||||
size
|
||||
FROM oc_filecache
|
||||
WHERE path LIKE '%关键词%';
|
||||
```
|
||||
|
||||
Key fields:
|
||||
- `storage_mtime`: when the file was written to disk (UNIX timestamp)
|
||||
- `mtime`: last modification time
|
||||
- These are UTC — add 8 hours for Beijing time
|
||||
|
||||
## Activity Log (oc_activity)
|
||||
|
||||
Who created/uploaded/modified a file?
|
||||
|
||||
```sql
|
||||
SELECT activity_id,
|
||||
FROM_UNIXTIME(timestamp) as time_utc,
|
||||
user, subject, file
|
||||
FROM oc_activity
|
||||
WHERE file LIKE '%关键词%'
|
||||
ORDER BY timestamp ASC;
|
||||
```
|
||||
|
||||
Key `subject` values:
|
||||
- `created_self` — user uploaded/created the file
|
||||
- `changed_self` — user modified the file
|
||||
- `deleted_self` — user deleted the file
|
||||
|
||||
## Important Distinction
|
||||
|
||||
Files uploaded via **docker cp + occ files:scan** (our automated workflow) do NOT create an `oc_activity` record. Only files uploaded through the Nextcloud web UI or sync client do. So:
|
||||
|
||||
- Activity record exists → uploaded by that user through Nextcloud UI
|
||||
- No activity record but file exists in `oc_filecache` → uploaded via docker cp (automated pipeline)
|
||||
|
||||
## Provenance Investigation Checklist
|
||||
|
||||
When Doro asks "这份文件哪来的" or "存入时间是什么时候":
|
||||
|
||||
1. **cache/documents/** — check `stat` for when WeChat received it (Birth time)
|
||||
2. **oc_filecache** — `storage_mtime` for when it landed on Nextcloud disk
|
||||
3. **oc_activity** — check if there's a `created_self` record (means web upload)
|
||||
4. **No activity record** → file was put there by automated workflow (docker cp)
|
||||
5. **gateway journal** — `journalctl --user -u hermes-gateway | grep "文件关键词"` for WeChat reception time
|
||||
6. **session_search** — search for the filename to find which session processed it
|
||||
|
||||
All timestamps are UTC on server. Always convert to Beijing time (UTC+8) before reporting to Doro.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Nextcloud Sync Diagnosis via Cloudflare Tunnel
|
||||
|
||||
## 2026-06-12 Incident
|
||||
|
||||
### Symptoms
|
||||
- Doro reported "同步不流畅,更新很难顺利同步"
|
||||
- Multiple PDF files (~10MB each) failed to upload repeatedly over several hours
|
||||
- Desktop client (mirall/33.0.5, Windows 10) kept retrying
|
||||
|
||||
### Log Evidence
|
||||
|
||||
**Nextcloud log** (`/var/www/html/data/nextcloud.log`):
|
||||
```
|
||||
level:3 | user:doro | method:PUT | BadRequest
|
||||
"预期文件大小为10821497字节,实际从Nextcloud客户端读入并写入Nextcloud存储空间的大小为5758976字节。
|
||||
可能是发送端发生了网络问题,或者是服务器写入存储设备时发生错误。"
|
||||
```
|
||||
- Expected: ~10.8MB, actual written: 3-7MB (varies per attempt)
|
||||
- Same file attempted 10+ times between 02:12-08:03 UTC (10:12-16:03 BJT)
|
||||
|
||||
**Cloudflared log** (`journalctl -u cloudflared-nextcloud.service`):
|
||||
```
|
||||
ERR error="Incoming request ended abruptly: context canceled" connIndex=2
|
||||
ERR Request failed error="Incoming request ended abruptly: context canceled"
|
||||
dest=https://maggie-share.shazhou.work/remote.php/dav/files/doro/...万禹案-再审申请书附件材料.pdf
|
||||
```
|
||||
- Also: `failed to run the datagram handler error="timeout: no recent network activity"`
|
||||
- QUIC stream acceptance failures
|
||||
|
||||
### Root Cause Analysis
|
||||
|
||||
1. **Cloudflare free-tier 100-second timeout**: Hard limit on edge nodes for single HTTP requests. Not configurable via tunnel YAML.
|
||||
2. **No chunked upload**: Desktop client uploaded 10MB files as single PUT requests. At Doro's upload bandwidth, 100s wasn't enough.
|
||||
3. **`connectTimeout: 120s`** in tunnel config only governs connection establishment, not transfer duration.
|
||||
4. **Server-side chunking was already enabled** (`chunking: 1.0`, `bigfilechunking: true`) — the problem was purely client-side threshold.
|
||||
|
||||
### Failed Files
|
||||
- 万禹案-再审申请书附件材料-完整版.pdf (~10.8MB, multiple renamed versions)
|
||||
- 万禹案-再审申请书附件材料.pdf (~9.9MB)
|
||||
- 万禹行政诉讼及撤销材料-书签版.pdf (~33MB, different error path)
|
||||
- 0612_万禹案-再审申请书附件材料.pdf (~11.2MB)
|
||||
|
||||
### Fix Applied
|
||||
Client-side: `%APPDATA%\Nextcloud\nextcloud.cfg` → `[General]` → `chunkSize=5242880` (5MB per chunk)
|
||||
|
||||
### Diagnostic Commands
|
||||
```bash
|
||||
# 1. Check server capabilities
|
||||
curl -s -u USER:PASS "http://localhost:5000/ocs/v1.php/cloud/capabilities?format=json" \
|
||||
-H "OCS-APIRequest: true" | python3 -c "
|
||||
import sys,json; d=json.load(sys.stdin)
|
||||
caps=d['ocs']['data']['capabilities']
|
||||
print('DAV:', json.dumps(caps.get('dav',{}), indent=2))
|
||||
print('Files:', json.dumps(caps.get('files',{}), indent=2))"
|
||||
|
||||
# 2. Test chunked upload endpoint
|
||||
curl -s -o /dev/null -w "%{http_code}" -X MKCOL \
|
||||
-u USER:PASS http://localhost:5000/remote.php/dav/uploads/USER/test-chunk
|
||||
# Should return 201
|
||||
|
||||
# 3. Nextcloud error log (today's level 3+ errors)
|
||||
docker exec nextcloud-nextcloud-1 bash -c \
|
||||
"cat /var/www/html/data/nextcloud.log" | grep "$(date -u +%Y-%m-%d)" | grep '"level":3'
|
||||
|
||||
# 4. Cloudflared errors
|
||||
journalctl -u cloudflared-nextcloud.service --since today | grep -i "ERR\|error\|timeout\|cancel"
|
||||
|
||||
# 5. Tunnel config
|
||||
cat /home/maggie/.cloudflared/maggie-share.yml
|
||||
```
|
||||
|
||||
### Infrastructure Context
|
||||
- Tunnel config: `/home/maggie/.cloudflared/maggie-share.yml`
|
||||
- Service: `cloudflared-nextcloud.service` (systemd)
|
||||
- Tunnel ID: 1af48f57-9e20-41d6-a09c-0ef9547eeb88
|
||||
- Hostnames: maggie-share.shazhou.work (Nextcloud), office.shazhou.work (OnlyOffice)
|
||||
- PHP limits: upload_max=512M, post_max=512M, memory=512M — not the bottleneck
|
||||
- Disk: 8% used (412GB free) — not the bottleneck
|
||||
@@ -0,0 +1,102 @@
|
||||
# 企微主动私信的"干净直发"原语 + 错发根因 + 账号白名单
|
||||
|
||||
> 背景:贾茜(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 验证。
|
||||
Reference in New Issue
Block a user