title: 企微 Webhook Relay 本地 Skill 接入指南 updated: 2026-07-16 project: claude-code-qiwe-assistant
本文档说明如何在
claude-code-qiwe-assistant(MCP 技能包)中接入中央 Relay 模式,通过 Fmode 部署的 Relay 服务端http://8.138.37.248:4000接收企微实时事件回调。适用场景:Skill 运行在本地电脑、内网或无固定公网 IP,不想/不能暴露本地 webhook server。
QIWEI_AUTH_TOKEN / FMODE_API_KEY)。guid(通过 qiwei_login_start 等工具)。http://8.138.37.248:4000。调用 Relay 的自助注册接口,用你的 Fmode token 换取租户凭证。
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(可选,建议填写)"
}'
返回示例:
{
"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(见第七节)。
.env.local在项目根目录 claude-code/claude-code-qiwe-assistant 创建或编辑 .env.local:
# 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字面量。
outputs/webhook/relay-config.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:
{
"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-----"
}
}
claude-code-qiwe-assistant 是 stdio MCP server,不要在主进程内启动长轮询。推荐新增独立脚本:
scripts/start-relay-client.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);
});
node scripts/start-relay-client.js [device-guid]
或写入 package.json:
{
"scripts": {
"relay": "node scripts/start-relay-client.js"
}
}
然后:
npm run relay
日志示例:
[RelayClient] 启动 Relay 轮询: http://8.138.37.248:4000
[RelayClient] tenantId=cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d, guid=xxx
[RelayClient] 取回 3 条事件
[RelayClient] ACK 3 条事件
http://8.138.37.248:4000/api/webhook/ingest/{tenantId}/{guid}
示例:
http://8.138.37.248:4000/api/webhook/ingest/cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d/test-device-guid-001
方式 A:调用现有 MCP 工具 qiwei_webhook_auto_setup
{
"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 网关
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"
}
}'
scripts/start-relay-client.js 正在运行。outputs/webhook/ 目录:
outputs/webhook/events/2026-07-16/... 下应出现解密后的事件文件。调用状态接口查看当日事件数:
curl http://8.138.37.248:4000/api/tenant/status \
-H "Authorization: Bearer <TENANT_API_SECRET>"
如果注册时没传 deviceGuid,先调用:
curl -X POST http://8.138.37.248:4000/api/tenant/device \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TENANT_API_SECRET>" \
-d '{"guid":"你的 guid","deviceName":"本地 Skill"}'
返回的 relaySecret 就是 /api/webhook/ingest/:tenantId/:guid 的签名密钥。
为了让用户在对话里一键注册,可在 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 的限流配置 |
按以上步骤操作后,本地 Skill 即可通过 8.138.37.248:4000 接收企微实时事件。