--- title: 企微 Webhook Relay 本地 Skill 接入指南 updated: 2026-07-16 project: claude-code-qiwe-assistant scope: local-skill-integration --- # 企微 Webhook Relay 本地 Skill 接入指南 > 本文档说明如何在 `claude-code-qiwe-assistant`(MCP 技能包)中接入**中央 Relay 模式**,通过 Fmode 部署的 Relay 服务端 `http://8.138.37.248:4000` 接收企微实时事件回调。 > > 适用场景:Skill 运行在本地电脑、内网或无固定公网 IP,不想/不能暴露本地 webhook server。 ## 一、前置条件 1. 已获取 Fmode 鉴权 token(`QIWEI_AUTH_TOKEN` / `FMODE_API_KEY`)。 2. 已完成企微设备登录并拿到 `guid`(通过 `qiwei_login_start` 等工具)。 3. Relay 服务端已部署并可访问:`http://8.138.37.248:4000`。 ## 二、注册 Relay 租户 调用 Relay 的自助注册接口,用你的 Fmode token 换取租户凭证。 ```bash curl -X POST http://8.138.37.248:4000/api/tenant/register \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <你的 Fmode token>" \ -d '{ "description": "本地 Skill", "deviceGuid": "你的 guid(可选,建议填写)" }' ``` 返回示例: ```json { "success": true, "relayBaseUrl": "http://8.138.37.248:4000", "tenantId": "cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d", "apiKey": "qk_xxx", "apiSecret": "xxx", "privateKey": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", "publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", "createdAt": "2026-07-16T08:00:00.000Z" } ``` **务必保存**:`tenantId`、`apiKey`、`apiSecret`、`privateKey`。`privateKey` 只返回一次,Relay 服务端不保存。 > 如果注册时没传 `deviceGuid`,后面可以调用 `POST /api/tenant/device`(见第七节)。 ## 三、配置本地 Skill ### 3.1 写入 `.env.local` 在项目根目录 `claude-code/claude-code-qiwe-assistant` 创建或编辑 `.env.local`: ```bash # Fmode 鉴权 token(已有) QIWEI_AUTH_TOKEN=sk-xxxxx # Relay 中央服务器地址 RELAY_BASE_URL=http://8.138.37.248:4000 # Relay 租户凭证 TENANT_API_KEY=qk_xxx TENANT_API_SECRET=xxx # RSA 私钥,必须写成单行,用 \n 替换真实换行符 RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD...\n-----END PRIVATE KEY----- ``` > 私钥格式:`.env` 中不能包含真实换行符,必须把 PEM 每一行换行替换为 `\n` 字面量。 ### 3.2 写入 `outputs/webhook/relay-config.json` ```json { "relayBaseUrl": "http://8.138.37.248:4000", "tenantId": "cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d", "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----", "updatedAt": "2026-07-16T08:00:00.000Z" } ``` 也可以调用现有工具 `qiwei_relay_save_config`: ```json { "name": "qiwei_relay_save_config", "arguments": { "relayBaseUrl": "http://8.138.37.248:4000", "tenantId": "cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d", "publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" } } ``` ## 四、启动 Relay 长轮询客户端 `claude-code-qiwe-assistant` 是 stdio MCP server,**不要在主进程内启动长轮询**。推荐新增独立脚本: ### 4.1 创建 `scripts/start-relay-client.js` ```js /** * Relay 长轮询客户端 * * 独立进程运行,从中央 Relay 拉取属于本租户的加密事件, * 用本地私钥解密后落盘到 outputs/webhook/events/。 */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { outputsRoot, createRunDir } = require('../mcp/src/core/output-paths'); const POLL_WAIT_MS = 30000; const INITIAL_BACKOFF_MS = 1000; const MAX_BACKOFF_MS = 60000; function loadEnvLocal() { const envPath = path.resolve(__dirname, '..', '.env.local'); if (!fs.existsSync(envPath)) return {}; const env = {}; for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); if (!match) continue; let value = match[2].trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } env[match[1]] = value; } return env; } function readRelayConfig() { const filePath = path.join(outputsRoot(), 'webhook', 'relay-config.json'); if (!fs.existsSync(filePath)) return {}; try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return {}; } } function getRelayPrivateKey() { return (process.env.RELAY_PRIVATE_KEY || '').replace(/\\n/g, '\n'); } function decryptPayload(encryptedPayload, privateKeyPem) { const key = crypto.createPrivateKey(privateKeyPem); const buffer = Buffer.from(encryptedPayload, 'base64'); const decrypted = crypto.privateDecrypt({ key, oaepHash: 'sha256' }, buffer); return decrypted.toString('utf8'); } function saveEvent(eventId, payload) { const runDir = createRunDir('webhook', 'relay-event'); const fileName = `event-${eventId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`; const filePath = path.join(runDir, fileName); fs.writeFileSync( filePath, JSON.stringify({ receivedAt: new Date().toISOString(), eventId, payload }, null, 2), 'utf8' ); return filePath; } async function ackEvents(baseUrl, apiSecret, guid, eventIds) { if (!eventIds.length) return; const res = await fetch(`${baseUrl}/api/relay/ack`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiSecret}`, }, body: JSON.stringify({ guid, eventIds }), }); if (!res.ok) { console.warn('[RelayClient] ACK 失败:', res.status, await res.text()); } else { const data = await res.json(); console.log(`[RelayClient] ACK ${data.ackedCount} 条事件`); } } async function runPollOnce(baseUrl, tenantId, apiSecret, guid, privateKey) { const res = await fetch(`${baseUrl}/api/relay/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiSecret}`, }, body: JSON.stringify({ guid, batchSize: 100, waitMs: POLL_WAIT_MS }), }); if (!res.ok) { throw new Error(`poll failed: ${res.status} ${await res.text()}`); } const data = await res.json(); if (!data.events || !data.events.length) return; console.log(`[RelayClient] 取回 ${data.events.length} 条事件`); const eventIds = []; for (const event of data.events) { try { const decrypted = decryptPayload(event.encryptedPayload, privateKey); const payload = JSON.parse(decrypted); const filePath = saveEvent(event.eventId, payload); console.log(`[RelayClient] 已解密落盘: ${filePath}`); eventIds.push(event.eventId); } catch (err) { console.error(`[RelayClient] 解密/落盘失败 eventId=${event.eventId}:`, err.message); // 解密失败也要 ACK,避免 Relay 重复投递 eventIds.push(event.eventId); } } await ackEvents(baseUrl, apiSecret, guid, eventIds); } async function main() { const env = { ...loadEnvLocal(), ...process.env }; const baseUrl = (env.RELAY_BASE_URL || 'http://8.138.37.248:4000').replace(/\/$/, ''); const apiSecret = env.TENANT_API_SECRET || ''; const privateKey = getRelayPrivateKey(); const relayConfig = readRelayConfig(); const tenantId = env.TENANT_ID || relayConfig.tenantId; // guid 优先级:环境变量 > relay-config.json > 命令行参数 const guid = env.RELAY_DEVICE_GUID || relayConfig.deviceGuid || process.argv[2]; if (!apiSecret || !privateKey || !tenantId) { console.error('[RelayClient] 缺少配置:请检查 .env.local 中的 TENANT_API_SECRET、RELAY_PRIVATE_KEY、TENANT_ID'); process.exit(1); } if (!guid) { console.error('[RelayClient] 缺少 deviceGuid:请通过命令行传入,或配置 RELAY_DEVICE_GUID / relay-config.json'); process.exit(1); } console.log(`[RelayClient] 启动 Relay 轮询: ${baseUrl}`); console.log(`[RelayClient] tenantId=${tenantId}, guid=${guid}`); let backoff = INITIAL_BACKOFF_MS; while (true) { try { await runPollOnce(baseUrl, tenantId, apiSecret, guid, privateKey); backoff = INITIAL_BACKOFF_MS; } catch (err) { console.error('[RelayClient] 轮询异常:', err.message); console.log(`[RelayClient] ${backoff}ms 后重试...`); await new Promise((resolve) => setTimeout(resolve, backoff)); backoff = Math.min(backoff * 2, MAX_BACKOFF_MS); } } } main().catch((err) => { console.error('[RelayClient] 致命错误:', err); process.exit(1); }); ``` ### 4.2 运行 ```bash node scripts/start-relay-client.js [device-guid] ``` 或写入 `package.json`: ```json { "scripts": { "relay": "node scripts/start-relay-client.js" } } ``` 然后: ```bash npm run relay ``` 日志示例: ```text [RelayClient] 启动 Relay 轮询: http://8.138.37.248:4000 [RelayClient] tenantId=cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d, guid=xxx [RelayClient] 取回 3 条事件 [RelayClient] ACK 3 条事件 ``` ## 五、配置企微回调地址 ### 5.1 拼接回调 URL ```text http://8.138.37.248:4000/api/webhook/ingest/{tenantId}/{guid} ``` 示例: ```text http://8.138.37.248:4000/api/webhook/ingest/cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d/test-device-guid-001 ``` ### 5.2 设置回调 **方式 A:调用现有 MCP 工具 `qiwei_webhook_auto_setup`** ```json { "name": "qiwei_webhook_auto_setup", "arguments": { "guid": "test-device-guid-001", "callbackUrl": "http://8.138.37.248:4000/api/webhook/ingest/cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d/test-device-guid-001" } } ``` > 不需要本地 webhook server 运行,回调直接指向 Relay。 **方式 B:直接调 Fmode 网关** ```bash curl -X POST https://server.fmode.cn/api/qiwei/doApi \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <你的 Fmode token>" \ -d '{ "uid": "你的 uid", "method": "/client/setCallback", "params": { "guid": "test-device-guid-001", "callbackUrl": "http://8.138.37.248:4000/api/webhook/ingest/cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d/test-device-guid-001", "authSecret": "任意密钥", "authType": "Authorization" } }' ``` ## 六、验证 1. 确保 `scripts/start-relay-client.js` 正在运行。 2. 让好友或客户在企微里发送一条消息。 3. 观察 `outputs/webhook/` 目录: - `outputs/webhook/events/2026-07-16/...` 下应出现解密后的事件文件。 4. 调用状态接口查看当日事件数: ```bash curl http://8.138.37.248:4000/api/tenant/status \ -H "Authorization: Bearer " ``` ## 七、未在注册时传入 deviceGuid 的补救 如果注册时没传 `deviceGuid`,先调用: ```bash curl -X POST http://8.138.37.248:4000/api/tenant/device \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{"guid":"你的 guid","deviceName":"本地 Skill"}' ``` 返回的 `relaySecret` 就是 `/api/webhook/ingest/:tenantId/:guid` 的签名密钥。 ## 八、可选:封装 MCP 工具 为了让用户在对话里一键注册,可在 `mcp/src/tools/qiwei-webhook-relay-run.js` 中新增工具: - `qiwei_relay_register`:调用 `/api/tenant/register`,并把返回的 `apiKey`、`apiSecret`、`privateKey`、`tenantId` 保存到 `.env.local` 和 `outputs/webhook/relay-config.json`。 - `qiwei_relay_connect`:检查配置后,后台 `fork` 启动 `scripts/start-relay-client.js`(注意会话结束可能随 MCP server 一起退出,生产环境建议长期独立运行 `npm run relay`)。 ## 九、常见问题 | 现象 | 可能原因 | 排查 | |------|---------|------| | 注册返回 `INVALID_TOKEN` | Fmode token 无效/过期 | 检查 `.env.local` 的 `QIWEI_AUTH_TOKEN` | | 注册返回 `ALREADY_REGISTERED` | 该 Fmode 账号已注册 | 用返回的 `tenantId` 查询状态或重置 | | 轮询无事件 | 企微回调未配置到 Relay | 检查 `qiwei_webhook_auto_setup` 的 callbackUrl | | 解密失败 | 私钥格式错误 | 确认 `.env.local` 中私钥使用 `\n` 单行存储 | | 401/403 | `TENANT_API_SECRET` 错误 | 与 Relay 端比对,注意服务端存的是哈希 | | 注册返回 `RATE_LIMITED` | IP 或用户触发限流 | 60 秒后重试,或调整 `.env` 的限流配置 | ## 十、相关文档 - [qiwei-relay-mode-guide.md](qiwei-relay-mode-guide.md) — Relay 模式概念与架构 - [qiwei-relay-add-tenant-register-api-plan.md](qiwei-relay-add-tenant-register-api-plan.md) — Relay 服务端注册接口实施计划 --- 按以上步骤操作后,本地 Skill 即可通过 `8.138.37.248:4000` 接收企微实时事件。