title: 企微 Webhook Relay 模式完整配置指南 updated: 2026-07-27 project: claude-code-qiwe-assistant
本文档说明如何在
claude-code-qiwe-assistant(MCP 技能包)中使用中央 Relay 模式接收企微实时事件回调。该模式适合 Skill 运行在本地电脑、内网或无固定公网 IP 的场景。
中央 Relay 模式是 Fmode 提供的一种 webhook 中转方案:
| 维度 | 中央 Relay | 公网直收 |
|---|---|---|
| 是否需要公网地址 | 不需要 | 需要 |
| Skill 网络要求 | 能访问公网即可 | 能被公网访问 |
| 事件到达方式 | 本地主动长轮询取回 | 企微平台被动推送 |
| 部署位置 | 本地电脑、内网、云服务器均可 | 必须有公网 IP/域名 |
| 安全性 | RSA 加密 + Tenant Secret 认证 | HMAC/Authorization 校验 |
| 实时性 | 秒级延迟(受轮询间隔影响) | 实时 |
| 适用场景 | 开发调试、本地运行、无公网 IP | 生产服务器、追求低延迟 |
┌─────────────┐ HTTP POST ┌──────────────────┐
│ 企微平台 │ ──────────────────▶ │ Fmode 中央 Relay │
└─────────────┘ │ (公网服务器) │
└────────┬─────────┘
│
长轮询 /api/relay/poll
RSA 私钥解密
Tenant Secret 认证
│
▼
┌──────────────────┐
│ 本地 Skill │
│ claude-code- │
│ qiwei-assistant │
└──────────────────┘
claude-code-qiwe-assistant。QIWEI_AUTH_TOKEN 或 FMODE_API_KEY)。guid)。TENANT_API_KEYTENANT_API_SECRETRELAY_PRIVATE_KEY(RSA 私钥)RELAY_BASE_URL(中央 Relay 公网地址)Relay 凭证需要向 Fmode 提供方或你的服务管理员申请。申请时通常需要提供:
申请成功后,你会拿到以下信息:
RELAY_BASE_URL=http://8.138.37.248:4000
TENANT_API_KEY=<tenant-api-key>
TENANT_API_SECRET=<tenant-api-secret>
RELAY_PRIVATE_KEY=<pem-private-key>
安全提醒:
TENANT_API_SECRET和RELAY_PRIVATE_KEY是敏感信息,不要截图传播、不要提交到 Git。
目标项目使用文件存储配置,所有 Relay 配置写入:
outputs/webhook/relay-config.json
你也可以通过 MCP 工具 qiwei_relay_save_config 写入。
在 Claude Code 中调用:
{
"name": "qiwei_relay_save_config",
"arguments": {
"relayBaseUrl": "http://8.138.37.248:4000",
"tenantId": "你的租户ID(可选)",
"publicKey": "对应的 RSA 公钥(可选,用于本地调试)"
}
}
注意:当前
qiwei_relay_save_config工具只保存relayBaseUrl、tenantId、publicKey,不保存tenantApiKey、tenantApiSecret、privateKey。为了安全,后三者建议写入.env.local或环境变量。
在目标项目根目录创建或编辑 .env.local:
# Fmode 鉴权 token(已有)
QIWEI_AUTH_TOKEN=sk-xxxxxxxx
# Relay 中央服务器地址
RELAY_BASE_URL=http://8.138.37.248:4000
# Relay 租户凭证
TENANT_API_KEY=qk_xxxxxxxx
TENANT_API_SECRET=xxxxxxxx
# RSA 私钥,必须写成单行,用 \n 替换真实换行符
RELAY_PRIVATE_KEY=<pem-private-key-with-escaped-newlines>
私钥格式说明:
.env文件中不能包含真实换行符,必须将 PEM 私钥中的每一行换行替换为\n字面量。程序读取时会自动还原为真实换行符(与源 Qiwei 项目lib/relay-config.ts的getRelayPrivateKey()逻辑一致)。
outputs/webhook/relay-config.json:
{
"relayBaseUrl": "http://8.138.37.248:4000",
"tenantId": "tenant_xxx",
"publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----",
"updatedAt": "2026-07-16T08:00:00.000Z"
}
目标项目已有:
mcp/src/core/webhook-server.js:本地 webhook server(仅落盘)mcp/src/tools/qiwei-webhook-relay-run.js:配置读写工具但缺少真正的 Relay 长轮询客户端。需要从源 Qiwei 项目迁移 lib/relay-client.ts 到 mcp/src/core/relay-client.js。
核心职责:
.env.local / relay-config.json 读取 Relay 凭证。guid。POST {RELAY_BASE_URL}/api/relay/poll。encryptedPayload。POST {RELAY_BASE_URL}/api/relay/ack。参考实现要点(来自源项目 lib/relay-client.ts):
const POLL_WAIT_MS = 30000;
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 60000;
async function runPollOnce() {
const response = await fetch(`${RELAY_BASE_URL}/api/relay/poll`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TENANT_API_SECRET}`
},
body: JSON.stringify({ guid: deviceGuid, batchSize: 100, waitMs: POLL_WAIT_MS })
});
const data = await response.json();
for (const event of data.events) {
const decrypted = decryptPayload(event.encryptedPayload, RELAY_PRIVATE_KEY);
const payload = JSON.parse(decrypted);
await processWebhookEvents({ code: 0, msg: 'from-relay', data: [payload] });
}
await ackEvents(deviceGuid, data.events.map(e => e.eventId));
}
function decryptPayload(encryptedPayload, privateKey) {
const key = crypto.createPrivateKey(privateKey);
const buffer = Buffer.from(encryptedPayload, 'base64');
const decrypted = crypto.privateDecrypt({ key, oaepHash: 'sha256' }, buffer);
return decrypted.toString('utf8');
}
读取 .env.local 中的私钥时,必须做换行还原:
function getRelayPrivateKey() {
return (process.env.RELAY_PRIVATE_KEY || '').replace(/\\n/g, '\n');
}
这是源 Qiwei 项目 lib/relay-config.ts 中的关键处理,迁移时必须保留。
目标项目是 stdio MCP server,不建议在主进程内启动长轮询,否则会阻塞 MCP 消息循环。推荐以下方式:
新增 scripts/start-relay-client.js:
node scripts/start-relay-client.js
该脚本单独运行,与 MCP server 解耦。
如果已使用 npm run dashboard 启动 dashboard,可在 dashboard server 启动时附带启动 Relay 客户端。
通过 qiwei_relay_connect 工具 fork 子进程启动。这种方式会话结束后可能随 MCP server 一起退出,不够稳定。
# 1. 确保 .env.local 已配置 Relay 凭证
# 2. 启动 MCP server(正常对话即可)
# 3. 在另一个终端启动 Relay 客户端
node scripts/start-relay-client.js
或封装为 npm script:
{
"scripts": {
"relay": "node scripts/start-relay-client.js"
}
}
调用 MCP 工具:
{ "name": "qiwei_relay_config" }
应返回:
{
"status": "ok",
"data": {
"configured": true,
"relayBaseUrl": "http://8.138.37.248:4000"
}
}
启动 scripts/start-relay-client.js 后,观察日志:
[RelayClient] 启动 Relay 轮询
[RelayClient] 取回 3 条事件
[RelayClient] ACK 3 条事件
让好友通过你的企微账号,或在客户群里发送一条消息。观察:
outputs/webhook/events/ 目录下是否生成新事件文件outputs/messages/<roomId>/ 是否出现新消息文件outputs/portraits/<externalUserId>.json 是否被触发更新| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 轮询无事件 | 企微回调未配置到 Relay | 检查 Fmode 平台设置的回调地址是否为 Relay 的 ingest URL |
| 解密失败 | 私钥格式错误 | 确认 .env.local 中私钥使用 \n 单行存储,程序正确还原 |
| 401/403 | Tenant Secret 错误 | 检查 TENANT_API_SECRET 是否与 Relay 端匹配 |
| 获取不到 guid | 设备未登录 | 先调用 qiwei_login_start 完成扫码登录 |
| 事件处理报错 | webhook 处理逻辑未迁移 | 检查 processWebhookEvents() 是否正常 |
当 Relay 模式启用后,企微平台侧的回调地址应配置为:
{RELAY_BASE_URL}/api/webhook/ingest
企微回调对服务 Token 全局生效。Skill 先把真实设备 guid 注册到 Relay,再调用 Fmode 专用接口 POST /relay/connect;由 Fmode 服务端持有回调密钥并调用 /client/setCallback。客户端不能提交回调 URL 或签名密钥。
旧的 /{tenantId}/{guid} 入口仅保留兼容,不用于新部署。
TENANT_API_SECRET 和 RELAY_PRIVATE_KEY 提交到 Git。目标项目 outputs/ 已在 .gitignore 中,但 .env.local 需要自行确认是否忽略。\n 字面量,不要直接粘贴带真实换行的 PEM。| 源 Qiwei 项目 | 目标 MCP 项目 |
|---|---|
lib/relay-client.ts |
需新增 mcp/src/core/relay-client.js |
lib/relay-config.ts |
复用 mcp/src/core/webhook-server.js 的配置读写,加 .env.local 读取 |
lib/webhook-setup.ts |
合并到 mcp/src/tools/qiwei-webhook-relay-run.js |
SQLite WebhookEvent 表 |
outputs/webhook/events/ 文件 |
processWebhookEvents() 在 api/module/webhook/routes.ts |
迁移到 mcp/src/core/webhook-processor.js |
| 启动时自动启动 Relay 客户端 | 改为独立进程 scripts/start-relay-client.js |
mcp/src/core/relay-client.js(从 Qiwei lib/relay-client.ts 迁移)。scripts/start-relay-client.js 作为独立启动入口。qiwei_relay_connect 工具,支持一键启动 Relay 客户端。processWebhookEvents() 和事件解析逻辑迁移到 mcp/src/core/webhook-processor.js。需要我继续执行实际的代码迁移吗?