# Cron Job: 热点雷达

**Job ID:** 472ca7495b44
**Run Time:** 2026-07-06 08:31:05
**Schedule:** */30 8-22 * * *

## Prompt

[IMPORTANT: The user has invoked the "wecom-bot-integration" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]

---
name: wecom-bot-integration
description: "Connect custom agents and tools to 企业微信 bots — MCP servers, WebSocket smart bots, and group bot webhooks."
version: 1.0.0
author: agent
platforms: [linux]
---

# 企业微信 Bot 接入指南

企业微信有三种 bot 类型，能力不同：

| 类型 | 连接方式 | 收消息 | 发消息 | 对话 |
|------|---------|--------|--------|------|
| 群机器人 | Webhook URL | ❌ | ✅ 仅推送 | ❌ |
| 智能机器人(旧) | WebSocket + Bot ID/Secret | ✅ @消息 | ✅ 回复 | ✅ |
| AI 机器人(新) | MCP 插件 / API 插件 | ✅ @消息 | ✅ 回复 | ✅ 内置 LLM |

## AI 机器人 + API 插件（推荐 ✓ 稳定）

企微 AI 机器人支持两种插件类型。**强烈推荐 API 插件**，无会话管理，不走 JSON-RPC 握手，比 MCP 插件稳定一个数量级。

| 插件类型 | 协议 | 会话 | 稳定性 | 参数定义 |
|----------|------|------|--------|---------|
| MCP 插件 | JSON-RPC + Session | 有状态 | ❌ 频繁掉线 | 有 inputSchema |
| **API 插件** | HTTP POST | **无状态** | ✅ | 无（靠描述区分） |

### API 插件配置

企微后台 → AI机器人 → 插件 → 添加 API 插件：

| 字段 | 说明 |
|------|------|
| 插件名称 | 一句话功能名，AI 靠这个区分插件 |
| 插件描述 | **用户说什么时调用**（比参数描述重要） |
| 插件 URL | `https://yourdomain.com/api/plugin` |
| 授权方式 | Service token → Parameter name 填 token 名 |
| 请求头 | 可选（仅配置企业域名 URL 时才传 userid） |

**关键**：API 插件**没有参数定义字段**（不像 MCP 的 inputSchema）。AI 从描述 + 用户消息中自行提取参数。因此：

- 每个插件**只能做一件事**，多功能拆多个插件
- 多个插件可共用一个 URL，服务端根据请求中的标识路由
- 描述写「听到什么就调用」比写「接受什么参数」更有效

### API 插件服务端

```python
# 单端点处理所有 API 插件请求
@app.post("/api/plugin")
async def handle_plugin(request: Request):
    body = await request.json()
    # body 包含用户消息、插件名称等
    user_query = body.get("query", "")
    plugin_name = body.get("plugin_name", "")
    
    if "查今日文章" in plugin_name:
        return {"result": get_today_articles()}
    elif "看文章详情" in plugin_name:
        return {"result": get_article_detail(user_query)}
    elif "生成文案" in plugin_name or "发布" in plugin_name:
        return {"result": generate_distribution(user_query)}
```

## AI 机器人 + MCP 插件（不推荐）

⚠️ MCP 插件在企微上极其不稳定。即使 Streamable HTTP + Session-Id 正确配置，工具列表正常返回，`tools/call` 仍可能静默失败。优先用 API 插件。

企微新版 AI 机器人内置 DeepSeek 等模型，天然支持对话。只需提供 MCP 插件让它能查数据。

完整实现参考：`references/mcp-server-implementation.md`

### MCP Server 模板

```python
# mcp_server.py — 标准 MCP SSE 服务
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.types import Tool, TextContent

server = Server("my-bot-tools")

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(name="my_tool", description="工具描述",
             inputSchema={"type": "object", "properties": {...}, "required": [...]}),
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    result = json.dumps(actual_function_call(arguments), ensure_ascii=False)
    return [TextContent(type="text", text=result)]

if __name__ == "__main__":
    import uvicorn
    from starlette.applications import Starlette
    from starlette.routing import Route

    sse = SseServerTransport("/messages/")

    async def handle_sse(request):
        async with sse.connect_sse(request.scope, request.receive, request._send) as streams:
            await server.run(streams[0], streams[1], server.create_initialization_options())

    app = Starlette(routes=[
        Route("/sse", endpoint=handle_sse),
        Route("/messages/", endpoint=lambda r: sse.handle_post_message(r.scope, r.receive, r._send), methods=["POST"]),
    ])
    uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("MCP_PORT", 8100)))
```

### 公网暴露

**方案 A：Cloudflare Tunnel（国内服务器推荐）**

1. Cloudflare DNS 添加 CNAME：`mcp` → `tunnel-id.cfargotunnel.com`
2. Cloudflare Zero Trust → Tunnel → Public Hostname：`mcp.yourdomain.com` → `http://localhost:8100`

**方案 B：直接暴露端口**

腾讯云轻量服务器 → 防火墙 → 添加 TCP 规则（端口 8100）

### 企微后台配置

**传输协议必须选「Streamable HTTP」，URL 尾缀 `/rpc`。SSE 在企微上不稳定，会频繁掉线。**

AI 机器人 → API/MCP 插件 → 添加：
- 插件 URL：`https://mcp.yourdomain.com/rpc`
- 传输协议：**Streamable HTTP**
- 授权方式：不需要授权

机器人指令（系统 prompt）**不支持代码块/表格/emoji**，用纯文本。模板见 `references/bot-prompt-template.md`。

完整 MCP 服务实现见 `references/mcp-server-implementation.md`，关键要点：
- `initialize` 响应**必须**返回 `Mcp-Session-Id` 头（企微多节点轮询，无此头则后续 tools/call 被拒绝）
- 插件频繁消失 = 改用 `/rpc` + 加 `Mcp-Session-Id`

> ⚠️ **禁止使用 `/sse`**。SSE 是长连接，一旦 MCP 服务重启或 tunnel 闪断，session 丢失，企微会认为插件不可用并自动移除。Streamable HTTP 每次请求独立，不会消失。

## 智能机器人(旧) WebSocket 接入

需要 Bot ID + Secret，WebSocket 连接 `wss://openws.work.weixin.qq.com`。

详见 `references/smart-bot-websocket.md`。

**WebSocket 特有陷阱**（完整列表见 `references/websocket-pitfalls.md`）：
- **ping/pong 断连**：必须 `ping_interval=0` 禁用客户端 ping，企微不支持标准 WebSocket ping 帧
- **服务端超时断连**：~5 分钟无消息交换后服务端主动断开，需实现应用层心跳
- **日志不输出**：`StreamHandler` 缓冲问题，用 `sys.stderr` + `FileHandler`

## we-mp-rss 消息任务配置

消息任务通过 API 创建，常见坑和完整 API 参考见 `references/we-mp-rss-api.md`。

### 快速要点

### API 路径
实际前缀是 `/api/v1/wx/`（不是 `/api/v1/`），查看 `core/base.py` 中的 `API_BASE`。

### mps_id 格式
必须是 JSON 数组的**对象**格式：
```json
[{"id": "MP_WXS_3886864470"}, {"id": "MP_WXS_3596295911"}]
```
不能是字符串数组 `["MP_WXS_xxx"]`，否则报错 `string indices must be integers`。

### 认证方式
使用 Access Key 认证：`Authorization: AK-SK <ak>:<sk>`（见项目 `.env` 中的 `WE_MP_RSS_AK` / `WE_MP_RSS_SK`）。

### 消息模板语法
使用 `{{ }}` 双括号模板语法（类似 Jinja2），不是 `{ }` 单括号：
```
📰 {{ feed.mp_name }} 更新
{% for article in articles %}
> **{{ article.title }}**
> 🔗 {{ article.url }}
{% endfor %}
```

### 创建消息任务
```bash
curl -X POST "http://localhost:8001/api/v1/wx/message_tasks" \
  -H "Authorization: AK-SK <ak>:<sk>" \
  -H "Content-Type: application/json" \
  -d '{"name":"推送","message_type":0,"message_template":"...","web_hook_url":"...","mps_id":"[{\"id\":\"MP_WXS_xxx\"}]","cron_exp":"*/30 * * * *","status":1}'
```

## webhook_receiver 部署（自建应用回调 + 消息处理）

`webhook_receiver.py` 是一个 Flask 应用，统一处理所有企微 HTTP 回调，是 API 插件 / 自建应用模式的**生产部署 backbone**。如果它没跑，企微消息进来**全丢**。

### 端点一览

| 路径 | 用途 |
|------|------|
| `POST /wx-callback` | 自建应用消息回调（接收用户消息 → bot_handler 处理 → 群 Webhook 回复） |
| `POST /bot` | 群机器人消息接收（兼容旧版） |
| `POST /rss-webhook` | we-mp-rss 推送触发器（收到文章通知 → 自动抓取 → 推群） |
| `GET /health` | 健康检查 |
| `POST /test-bot` | 手动测试消息处理 |

### 依赖安装

`webhook_receiver.py` 需要 `flask`、`requests`、`cryptography`。Ubuntu 22.04+ PEP 668 禁止直接系统 pip，需加 `--break-system-packages`：

```bash
/usr/bin/python3 -m pip install --break-system-packages flask requests cryptography
```

### Systemd 服务

见 `references/webhook-receiver-service.md`。核心要点：
- `WorkingDirectory` 必须设在 `project/scripts/`（代码中用相对路径 `from utils import ...`）
- `After=we-mp-rss.service` 保证 DB 已就绪
- `Restart=always` + `RestartSec=5`

### 验证

```bash
# 本地健康检查
curl -s http://localhost:5000/health

# 外网（通过 Cloudflare Tunnel）
curl -s https://icoach.chat/health

# 消息处理测试
curl -s -X POST http://localhost:5000/test-bot \
  -H "Content-Type: application/json" \
  -d '{"text": "帮助", "userid": "test_user"}'
```

### 常见故障

| 现象 | 检查 |
|------|------|
| 企微发消息没回复 | `systemctl status webhook-receiver` — 大概率服务挂了 |
| 服务起不来 | `journalctl -u webhook-receiver -n 30` — 看是不是缺 python 包 |
| 外网 502 | Tunnel 配置中的 hostname → localhost:5000 映射还在不在 |

## 权限与可见范围

### 🔴 成员创建的机器人：非创建者无法私聊（最常见根因）

**官方文档明确**：成员创建的机器人，「可使用的成员」范围内的其他普通成员，**无法在通讯录/工作台中看到该机器人**。用户搜不到、找不到、点不了 → 发私聊提示「仅创建者可使用」。

**解决方法**：创建者「推荐给联系人」

> 工作台 → 智能机器人 → 点机器人 → 详情 → 「去使用」旁边的 **「…」** → **「推荐给联系人」** → 选择目标用户

其他等效方法：分享机器人二维码、复制固定链接、把机器人加到群聊中。

| 创建者类型 | 非创建者能否看到机器人 | 如何让其他人私聊 |
|-----------|:---:|---|
| 管理员创建 | ✅ 可见范围内的成员可看到 | 调整「可见范围」即可 |
| **成员创建** | ❌ 需推荐/分享 | **必须「推荐给联系人」** |

### 🔴 "仅创建者可使用" 排查流程

此错误 **99% 来自企微平台层**（消息从未到达 Hermes）。快速确认：

```bash
# Hermes 侧有无拦截记录
grep -i "Unauthorized\|blocked by policy" ~/.hermes/logs/gateway.log | tail -5
```

- **无记录** → 企微平台层拦的，检查上面「成员创建 vs 管理员创建」+ 可见范围
- **有记录** → Hermes 拦的，检查 `WECOM_ALLOW_ALL_USERS`

### DM 权限双层体系

当用户反馈"仅创建者可使用"时排查两层：

1. **企微平台层**（消息到达 Hermes 之前）：
   - **成员创建的机器人**：必须「推荐给联系人」、分享二维码/链接，或添加到群聊（见上方）
   - **管理员创建的机器人**：协作 → 智能机器人 → 点机器人 → 右上角「…」→ 可见范围 → 改为「所有成员」
   - **自建应用**：应用管理 → 机器人 → 可见范围 → 改为「所有人」
2. **Hermes 服务端**（`gateway/run.py` `_is_user_authorized()`）：
   - 第 5563-5565 行：`WECOM_ALLOW_ALL_USERS=true` → **直接放行所有人**（最先检查）
   - 第 5584-5587 行：配对审批列表 → 已配对用户放行
   - 第 5598-5600 行：无白名单 → 检查 `GATEWAY_ALLOW_ALL_USERS`
   - 结论：如果 `WECOM_ALLOW_ALL_USERS=true` 已设，Hermes 侧不会拦任何人。

### 🔍 OpenClaw MCP 限制（与 Hermes 无关，仅作澄清）

OpenClaw 文档提到：授权了「个人/小团队接口能力」的机器人会被企微强制限制为「仅创建者可对话」。这是 **OpenClaw MCP 集成的平台安全策略**，因为 MCP 授权后机器人可以操作用户的文档、日程、会议等敏感数据。

Hermes 直接通过 WebSocket 长连接接入，不经过 OpenClaw/MCP 层，因此**不受此限制**。如果你的机器人也没用过 OpenClaw，排查「仅创建者可使用」时跳过这个方向。

### ⚠️ 智能机器人模式选择

智能机器人支持两种连接模式。**长连接模式完全支持私聊**（官方文档明确列出 `chattype: single`、`enter_chat` 事件、单聊主动推送）。

| 模式 | 收消息 | 私聊支持 | Hermes 回复 | 适用 |
|------|--------|:---:|-------------|------|
| **WebSocket 长连接** | ✅ | ✅ | ✅（通过 API） | **Hermes 默认，推荐** |
| **URL 回调** | ✅（HTTP POST） | ✅ | ⚠️ 可能受限 | 同步回复场景 |

> ⚠️ URL 回调模式切换需谨慎：之前有 session 报告切到 URL 回调后 API 返回 `errcode 846601`，但如果在企微后台正确配置 Token/AESKey 与 Hermes `config.yaml` 一致，可能正常工作。未充分验证前不推荐切换。如果只为了解私聊，长连接已支持，无需切换。

### 用户如何私聊机器人（重要）

用户不能直接搜索机器人名字发私聊。正确路径：

**工作台 → 智能机器人 → 找到机器人 → 点「去使用」→ 发消息**

如果走错入口，会看到「仅创建者可使用」。

### Hermes 内置 wecom_callback 平台（推荐）

Hermes gateway 自带 `wecom_callback` 平台适配器，监听 **8645 端口、`/wecom/callback` 路径**。配置在 `~/.hermes/config.yaml`：

```yaml
platforms:
  wecom_callback:
    enabled: true
    extra:
      host: "0.0.0.0"
      port: 8645
      path: "/wecom/callback"
      apps:
        - name: "smartbot"
          corp_id: "wwbcfb7cbc0f8912d7"
          token: "<企微后台的Token>"
          encoding_aes_key: "<企微后台的EncodingAESKey>"
          corp_secret: "<自建应用的Secret>"
          agent_id: "1000002"
```

企微后台回调 URL 填 `https://<你的域名>/wecom/callback`（注意是 `/wecom/callback` 不是 `/wx-callback`）。

修改 config.yaml 后重启 Hermes gateway 即可生效，无需额外部署 Flask 服务。

**⚡ 密钥同步流程（关键）**：

1. 企微后台 → 智能机器人 → API 配置 → **随机生成** Token 和 EncodingAESKey
2. **趁还没保存**，把这两个值抄下来
3. 更新到 `~/.hermes/config.yaml` 的 `wecom_callback.extra.apps[].token` 和 `encoding_aes_key`
4. 重启 Hermes gateway
5. 回到企微后台点**保存**

⚠️ 常见踩坑：先在企微生成 → 抄下来 → 保存前先去服务端配好 → 再到企微保存。顺序反了就会「服务器未能正确响应」。

### 遗留 webhook_receiver.py（不推荐）

之前有一段 Flask 服务 (`project/scripts/webhook_receiver.py`，端口 5000，路径 `/wx-callback`) 用于处理自建应用回调。保留它会导致 Token/AESKey 两套配置不一致、端口冲突等问题。**如果 Hermes `wecom_callback` 已启用，应停止 `webhook-receiver` 服务。**

### Cloudflare Tunnel 配置陷阱

**🔴 关键：config.yml 有 ingress 规则 ≠ 公网可达。** 即使 config.yml 里写了 `hostname: callback.example.com → localhost:8645`，还必须在 Cloudflare Zero Trust 后台的 Public Hostname 中添加同一条记录。缺少后台配置时，Cloudflare 边缘节点会直接拦截请求（返回 Cloudflare 默认 403/404），请求根本到达不了 tunnel。

**验证方法**：curl 请求目标域名，检查响应头 `server`：
- `server: cloudflare` → 请求被 Cloudflare 拦截，未到达 tunnel → **后台 Public Hostname 缺失**
- `server: Python/3.11 aiohttp` → 请求到达了 Hermes

Cloudflare Zero Trust 后台（dashboard.teams.cloudflare.com）手动添加的 **Public Hostname 会覆盖 config.yml 中的 ingress 规则**。如果两边都配了同一个 hostname，以后台为准。

**识别方法**：查看 cloudflared 启动日志中的 `Updated to new configuration`，它展示的是**实际生效的**配置：
```bash
grep "Updated to new configuration" /tmp/cloudflared.log 2>/dev/null || \
  journalctl -u cloudflared 2>/dev/null | grep "Updated to new configuration"
```

如果里面没有你在 config.yml 里写的 hostname，说明被后台覆盖了。

**修改原则**：要么全用后台（dashboard）、要么全用 config.yml，避免混用导致「改了 config.yml 但不生效」。

### 🔴 国内服务器 QUIC/端口 7844 阻断（systemd 重启必踩）

**现象**：cloudflared systemd 服务重启后无法连接，日志循环打印 `Failed to dial a quic connection: timeout`，持续 1-2 分钟后才切换到 HTTP/2 恢复。重启期间所有 tunnel 服务（rss、bot、callback 等）全部中断。

**根因**：国内服务器（腾讯云 Lighthouse 等）对 Cloudflare edge IP 的 **UDP 7844 端口（QUIC）和 TCP 7844 端口均可能被阻断**。cloudflared 默认优先尝试 QUIC，连续失败后才 fallback 到 HTTP/2（TCP 7844），这个过程需要 ~60 秒。如果 TCP 7844 也被阻断，fallback 也无法连接。

**修复**：systemd service 的 ExecStart 加 `--protocol http2`，跳过 QUIC 探测，直连 HTTP/2：

```ini
# /etc/systemd/system/cloudflared-tunnel.service
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --protocol http2 --config /home/agentuser/.cloudflared/config.yml run
```

**效果**：重启后 3-5 秒内恢复连接，不再有 1 分钟中断窗口。

**验证连接状态**：
```bash
journalctl -u cloudflared-tunnel --no-pager | grep "Registered tunnel connection"
# 应看到 protocol=http2 的连接注册日志
```

**添加新 hostname**：在 config.yml 的 ingress 中加入新 hostname → service 映射后，还需在 Cloudflare DNS 添加 CNAME 记录指向 `<tunnel-id>.cfargotunnel.com`（`cloudflared tunnel route dns` 需要 origin cert，通常不可用）。

### 🔴 Lighthouse 文件管理器不可用

腾讯云 Lighthouse 控制台自带的「文件管理」功能有以下已知限制，**不能作为团队文件共享方案**：

| 限制 | 表现 |
|------|------|
| 无法访问 `/root/` | 文件管理器看不到该路径 |
| 不支持隐藏目录 | `.hermes` 等点开头目录不显示 |
| 不支持中文目录名 | 中文文件夹不显示或乱码 |
| 权限受限 | 即使用 root 登录，部分目录仍不可见 |

**替代方案**：Python http.server + Basic Auth + Tunnel（见 `references/web-file-browser.md`）。

### URL 回调验证排障

**排障核心技巧**：把 Token 验证和 AES Key 验证分开。计算 `SHA1(sorted([token, timestamp, nonce, echostr]))` 与日志中的 `msg_signature` 比对：
- 签名匹配 → Token 正确，问题在 AES Key
- 签名不匹配 → Token 不对，两边没对齐

**验证脚本**（测试 AES key 能否解密企微发来的 echostr）：

```python
import base64, hashlib, struct
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding

key_str = "<你的EncodingAESKey>"
echostr_b64 = "<从日志复制的echostr>"

# 1. 验证 Token（签名）
sort_str = ''.join(sorted([token, timestamp, nonce, echostr]))
computed_sig = hashlib.sha1(sort_str.encode()).hexdigest()
print(f"Signature match: {computed_sig == msg_signature}")

# 2. 验证 AES Key（解密）
aes_key = base64.b64decode(key_str + '=')
encrypted = base64.b64decode(echostr_b64)
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(aes_key[:16]), backend=default_backend())
decryptor = cipher.decryptor()
padded = decryptor.update(encrypted) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
decrypted = unpadder.update(padded) + unpadder.finalize()
content = decrypted[16:]
msg_len = struct.unpack('>I', content[:4])[0]
print(f"Decrypted: {content[4:4+msg_len].decode('utf-8')}")
```

**常见失败原因**：
| 现象 | 根因 |
|------|------|
| `Invalid padding bytes` | AES Key 不匹配（两边不一样） |
| `Incorrect padding` | AES Key 长度不对（不是标准 43 字符） |
| 签名不匹配 | Token 不匹配 |
| 隧道 403/404（签名却对） | Cloudflare Tunnel 指向了错误的端口（比如还在指向旧 webhook_receiver 的 :5000 而非 Hermes 的 :8645） |

> ⚠️ **排障顺序**：先确认隧道端口（`curl -s https://域名/wecom/callback?...` 看返回是不是 `signature verification failed`），再排查签名和密钥。隧道不对的情况下所有加解密调试都是白费力气。

完整排障参考 `references/url-callback-verification-troubleshooting.md`。

### 可见范围 vs 可使用权限

| 设置 | 位置 | 控制什么 |
|------|------|---------|
| 可见范围（智能机器人） | 协作 → 智能机器人 → 「…」→ 可见范围 | 谁能看到/使用机器人 |
| 可见范围（自建应用） | 应用管理 → 机器人 → 编辑 → 可见范围 | 谁能看到/使用应用 |
| 可使用权限（用户信息/消息/文档等） | 机器人 API 配置页 | 机器人能访问什么数据 |

两者是独立的。即使可见范围包含了用户，「可使用权限」中的「消息」也必须「已授权」才能收发消息。

详见 `references/dm-permission-config.md` 和 `references/dm-troubleshooting.md`。

Hermes 内部授权流程（gateway/run.py `_is_user_authorized()`）的逐行分析见 `references/hermes-wecom-auth-internals.md`。

---

## 现网部署参考

广东春季高考信息网 (gdcjgk.net / chunkaonewshub) 的完整企微部署 playbook 见 `references/chunkaonewshub-topic-assistant.md`，包含：
- 服务器拓扑与域名路由
- we-mp-rss 部署与 cron job 配置
- 选题日报/热点雷达工作流
- 公众号订阅列表与状态文件路径
- DeepSeek API 高峰时段超时修复实录

配套支持文件在 `references/chunkaonewshub/` 和 `scripts/chunkaonewshub-*.py`。

### 企微 DM 文件附件不支持

**现象**：用户通过企微 DM 发送文件（PDF/Word/图片等），Hermes 完全收不到。对话中只有文字消息，没有文件引用的痕迹。

**原因**：企微 wecom_callback 回调只转发消息体中的文本内容，文件附件不在消息 payload 中。这与群聊中的图片/文件处理机制不同。

**应对**：告诉用户以下替代方案（按推荐顺序）：
1. 直接复制文件内容，粘贴为文字消息
2. 将文件上传到服务器（scp/FTP/云盘），把路径发过来
3. 如果是网页内容，直接发链接

不要反复追问"收到了吗"——文件发不过来就是收不到，直接让用户换方式。

---

### 🔴 服务器迁移后企微不回消息（IP 白名单）

**现象**：回调 URL 验证成功，用户发消息也能收到（从日志可见），但 Hermes 的回复发不出去。

**诊断**：直接测试企微 API 是否接受本机 IP：
```bash
curl -s "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=<corpid>&corpsecret=<secret>" | python3 -c "import sys,json; print(json.load(sys.stdin).get('errmsg'))"
# 如果报 errcode 60020: "not allow to access from your ip" → IP 不在白名单

curl -s "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"touser":"<userid>","msgtype":"text","agentid":1000004,"text":{"content":"test"}}'
# errcode 60020 确认问题
```

**修复**：企微后台 → 应用管理 → 自建应用 → 企业可信IP → 添加新服务器公网 IP。保存后立即生效。

**为什么回调能收到但回复发不出**：回调是企微主动 POST 到你的服务器（入站，通过 cloudflared tunnel），不走 IP 白名单。回复是 Hermes 调企微 API（出站，直接 HTTP），企微会验证请求来源 IP。

当用户反馈发消息后机器人不回复时，按步骤排查：隧道 → API → 端口 → 日志。完整检查清单和命令见 `references/wecom-callback-debug-flow.md`。

**服务器迁移完整清单**见 `references/server-migration-checklist.md`。

**快速命令**：
```bash
# 隧道
journalctl -u cloudflared-tunnel --no-pager | grep "Registered tunnel" | tail -4
# 企微 API
curl -s "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=<id>&corpsecret=<secret>" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('errmsg'))"
# 端口
ss -tlnp | grep 8645
# 最近消息
grep "wecom_callback" ~/.hermes/logs/gateway.log | tail -5
```

### ⚠️ wecom 平台 disabled 但持续重连（噪声）

如果 `config.yaml` 中 `wecom.enabled: false`，但 gateway 日志持续出现 `[Wecom] Failed to connect: invalid bot_id or secret (errcode=853000)`，这是 `gateway/platforms/wecom.py` 在 disabled 状态下仍尝试 WebSocket 连接的已知行为。**不影响 wecom_callback 的正常收发**，可以忽略。




❌ **不要尝试通过远程浏览器（Browserbase）做 QR 码登录企微管理后台。** QR 登录依赖浏览器端 JavaScript 轮询，远程 session 收不到回调，用户扫码后页面不跳转。

✅ 让用户在自己手机上查：工作台 → 客户联系 / 应用管理 / 协作 → 智能机器人。

详细说明和公网图片分发方案见 `references/admin-backend-access.md`。

### FastAdmin CMS 发布 API

gdcjgk.net 等 FastAdmin 站点自带内容发布 API。完整文档、独立接口模板、栏目树提取方法见 `references/fastadmin-cms-api.md`。模板代码见 `templates/api_publish.php`。

**核心要点**：
- 栏目名不唯一（如「报考相关」在多处出现），**必须用 channel_id**
- apikey 默认值为空时后台不显示配置项，用独立接口绕过
- 从 SQL 备份提取栏目树：`grep -oP "VALUES \(\d+, '\w+', \d+, \d+, '\K[^']+" backup.sql`

### MCP 插件在企微后台消失了

**根因**：使用了 SSE (`/sse`) 端点。SSE 是长连接，MCP 服务重启或 Cloudflare tunnel 闪断 → session 丢失 → 企微检测不到服务端 → 自动移除插件。

**修复**：
1. 重新添加 MCP 插件，URL 用 `https://mcp.yourdomain.com/rpc`
2. 传输协议选 **Streamable HTTP**
3. 加上后不会再消失

### 机器人不调用 MCP 工具

检查清单：
1. MCP 插件状态是否为绿色 ✓（企微后台 → 机器人 → 插件）
2. URL 是否以 `/rpc` 结尾
3. 外网可达：`curl https://mcp.yourdomain.com/health`
4. 工具注册正常：`curl -X POST https://mcp.yourdomain.com/rpc -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'`
5. 机器人指令中是否写明了工具使用规则

### MCP tools/call 静默失败（工具列表正常，调用不到）

**现象**：MCP 服务器日志显示 `tools/list` 成功（返回 3 tools），但从不出现 `tools/call`。机器人 ReAct 日志显示「工具名称无法正确匹配」。

**根因**：企微 MCP 插件基础设施不稳定。即使 Session-Id 正确、Streamable HTTP 配置正确，多节点架构下 tools/call 仍有概率被吞掉。这是企微平台的 bug，不是服务端问题。

**应对策略**：
1. **短期**：重新添加 MCP 插件碰运气（有时新加的能多撑几天）
2. **长期**：不要依赖 MCP 做实时工具调用。改用「Webhook 推送内容 + 机器人纯对话」的分离架构（见下方）

### 企微群类型与推送能力

| 群类型 | 能加群机器人 | 能 Webhook 推送 | 适用 |
|--------|:---:|:---:|------|
| **内部群** | ✅ | ✅ | 运营工作群 |
| **客户群** | ❌ | ❌ | 用户群 |
| **外部群** | ❌ | ❌ | 混合 |

客户群无法自动推送。如需触达用户群，只能：内部群收到推送 → 人工转发到客户群。

### AI 机器人知识集不可程序化更新

知识集只能在企微后台手动上传文档，没有开放 API。不能用 Hermes 定时任务自动更新。

### 推荐架构：推送 + 对话分离

MCP 不稳定 + 知识集无 API = 把数据流和对话流分开：
- **数据流**：Hermes 定时任务 → 拉文章 → AI 精选 → 群 Webhook 推送日报
- **对话流**：同事看推送后 @机器人追问 → 机器人用内置 LLM 自然对话

各司其职，不依赖 MCP。

## 微盘 / 文档 API 接入

企微微盘和文档 API 可以为自建应用开放接口权限，让 Hermes 直接读写企微文档和微盘文件。

### 开通权限（正确路径）

官方文档：
- 微盘：https://developer.work.weixin.qq.com/document/path/93654
- 文档：https://developer.work.weixin.qq.com/document/path/97392

**自建应用的微盘/文档 API 权限不在「应用管理」里，而是在「协作」里反向授权**——两个 API 分开配置：

> 微盘：管理后台 →「**协作**」→「**微盘**」→「**API**」→ 配置「可调用接口的应用」
> 文档：管理后台 →「**协作**」→「**文档**」→「**API**」→ 配置「可调用接口的应用」

⚠️ **常见踩坑**：第一反应去「应用管理」→ 自建应用 → 接口权限里找「文档/微盘」，找不到。正确入口都在「协作」菜单下，且文档和微盘是**两个独立入口**。

### API 端点

| 类别 | Base Path | 开通入口 |
|------|-----------|---------|
| 微盘（wedrive） | `/cgi-bin/wedrive/...` |「协作」→「微盘」→「API」|
| 文档（wedoc） | `/cgi-bin/wedoc/...` |「协作」→「文档」→「API」|

先获取 access_token（`/cgi-bin/gettoken`），再调具体接口。Token 与普通自建应用共用。

### 错误码

| errcode | 含义 | 场景 |
|---------|------|------|
| 48002 | API forbidden | 自建应用未被加到对应「可调用接口的应用」列表 |
| 301085 | invalid docid | 用了 URL docid 而非 API docid（见 `references/wecom-docid-systems.md`） |
| 640001 | space not exist | spaceid 格式不对或不存在 |
| 640008 | permission deny | 应用未被加为该空间成员 |
| 640020 | Invalid auth type | `space_acl_add` 缺少 `auth` 字段 |
| 60011 | no privilege | 应用没有通讯录/部门访问权限 |
| 2050099 | doc version invalid | `batch_update` 需传正确 version |

48002 出现时不要折腾 token 或 URL，直接去对应入口加白名单。

### 两种 API 的区别

- **微盘 API**（wedrive）：管理空间、文件列表、上传下载、权限管理。适合「整理文件夹」场景。
- **文档 API**（wedoc）：读写文档内容、表格、收集表。适合「读取文档内容」场景。

### ⚠️ 读权限 vs 写权限（关键发现）

两种 API 的「只能访问应用自己创建的」限制**不是全局的**，而是逐接口不同：

**文档 API 权限粒度：**

| 接口 | 端点 | 限制 |
|------|------|------|
| 获取文档数据（读内容） | `POST /cgi-bin/wedoc/document/get` | ❌ **未写限制** |
| 获取文档基础信息 | `POST /cgi-bin/wedoc/get_doc_base_info` | ❌ **未写限制** |
| 分享文档 | `POST /cgi-bin/wedoc/doc_share` | ✅ 只能访问该应用创建的 |
| 重命名文档 | `POST /cgi-bin/wedoc/rename_doc` | ✅ 仅可修改应用自己创建的 |
| 删除文档 | `POST /cgi-bin/wedoc/del_doc` | ✅ 仅可删除应用自己创建的 |

**微盘 API 权限粒度：**

| 接口 | 端点 | 限制 |
|------|------|------|
| 获取文件列表 | `POST /cgi-bin/wedrive/file_list` | ❌ **未写限制** |

> ⚠️ **反直觉结论**：官网概述页说"应用仅可读取和编辑自己创建的文档"，但实际逐接口检查发现，读操作（获取文档数据、获取文档基础信息、获取文件列表）的权限说明中**没有**「只能访问该应用创建的」字样，而写操作（分享、重命名、删除）**明确写了**。这意味着读已存在的文档/文件**理论上可行**。

### ⚠️ 两套 ID 体系（关键坑）

企微文档 URL 中的 docid（如 `w3_XXX`）和 API 的 docid（长 base64 串）是**两套互不互通的 ID 体系**，没有转换 API。详见 `references/wecom-docid-systems.md`。

### 微盘 spaceid 发现

微盘 API 没有「列出所有空间」的接口。获取已有空间 spaceid 的唯一办法：从企微分享的空间链接 URL 中提取。详见 `references/wedrive-api-test-results.md`。

### 研究注意事项

在做出「能不能读/写」的结论前，必须**逐接口查阅官方文档的权限说明**，不能根据概述页的总描述推测。概述声明和单接口权限说明可能不一致。

> 来源：逐接口权限对比，验证于 2026-06-22。文档 API 各接口文档 path 不连续（101161, 97743, 97733, 97735 等），需从开发者中心侧边栏抓取实际 URL。

详见 `references/wedrive-api-setup.md`。

**完整 API 端点速查与测试结果：**
- `references/wecom-api-endpoint-reference.md` — WeDoc/WeDrive 全部端点、Python helper、错误码表
- `references/wecom-doc-test-results.md` — 逐接口实测结果、读/写权限验证

### 机器人回复失败：errcode 846601

**完整错误**：`errcode 846601: websocket mode not enabled for this robot`

**根因**：智能机器人被切换到了「URL 回调」模式。在 URL 回调模式下，WeCom API 的 send 接口拒绝所有请求——回复只能通过回调的 HTTP 响应同步返回。

**修复**：
1. 企微后台 → 应用管理 → 机器人 → 你的智能机器人 → API 配置
2. **关掉「使用 URL 回调」**，恢复 WebSocket 长连接模式
3. 如果之前改了 Token/AES Key 用于回调验证，切回长连接后这些设置不影响
4. 重启 Hermes gateway 后测试

⚠️ URL 回调模式与 Hermes 的异步 LLM 对话不兼容。即使回调验证通过，回复也会全部失败。

**根因**：机器人指令（系统提示词）格式过于复杂，企微 LLM 解析器崩溃。

**触发条件**：
- 指令中包含 Markdown 代码块（```）
- 指令中包含表格（`|` 管道符）
- 指令中包含 emoji
- 指令超过一定长度上限

**修复步骤**：
1. 清空机器人指令，用一句话测试（如「你是内容创作助手」）
2. 确认能正常回复后，逐步加回指令
3. 使用纯文本格式：无代码块、无表格、无 emoji
4. 完整可用的机器人指令模板见 `references/bot-persona-guide.md`

### 机器人指令编写规范

详见 `references/bot-persona-guide.md`。核心约束：
- **禁止** Markdown 代码块（\`\`\`）
- **禁止** 管道符表格（`| --- |`）
- **禁止** emoji
- 用纯文本 + 粗体标题分隔
- 工具使用规则写在最前面

## MCP Server 双传输协议

生产环境应同时支持 SSE（本地调试）和 Streamable HTTP（企微生产）。实际部署代码见 `references/mcp-server-implementation.md`，模板代码仅为最小 SSE 示例。

## 独立 Agent 模式（备选）

当不需要企微内置 AI 时，可自建对话 Agent。完整模式和陷阱见 `references/conversation-agent-pattern.md`。

The user has provided the following instruction alongside the skill invocation: [IMPORTANT: You are running as a scheduled cron job. DELIVERY: Your final response will be automatically delivered to the user — do NOT use send_message or try to deliver the output yourself. Just produce your report/output as your final response and the system handles the rest. SILENT: If there is genuinely nothing new to report, respond with exactly "[SILENT]" (nothing else) to suppress delivery. Never combine [SILENT] with content — either report your findings normally, or say [SILENT] and nothing more.]

检测 we-mp-rss 数据库中最近 3 小时文章，判断不同公众号（广东中职菌、中职生）是否在同追一个热点话题。

步骤：
1. 复制 ~/project/we-mp-rss-data/db.db 到 /tmp/，查询最近 3 小时文章（articles JOIN feeds，取 id/title/url/source/publish_time）
2. 如果文章 < 2 篇 → 静默退出，不输出任何内容
3. 对比不同来源的标题，找共同话题：不同来源标题有 ≥2 个共同有意义关键词 → 候选
4. 去重：同一组文章ID可能形成多对，合并
5. 判断是否真的同追一个事件，去重（查 ~/project/state/hot_radar_sent.json 避免重复推送）

输出格式（有热点才推，没热点什么都不说）：
🚨 热点预警

**话题名**
>判断依据
> [来源1] [文章标题](文章链接)
> [来源2] [文章标题](文章链接)

推送后更新 ~/project/state/hot_radar_sent.json 记录已推送的文章ID集合。

全自动，不问我任何问题。没热点就静默。

## Response

数据库最新文章发布于 7月2日18:00，距今已超过 4 天。最近 3 小时内无任何新文章，文章数 < 2。

[SILENT]
