qiwei-relay-local-integration-guide.md 13 KB


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 换取租户凭证。

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"
}

务必保存tenantIdapiKeyapiSecretprivateKeyprivateKey 只返回一次,Relay 服务端不保存。

如果注册时没传 deviceGuid,后面可以调用 POST /api/tenant/device(见第七节)。

三、配置本地 Skill

3.1 写入 .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 字面量。

3.2 写入 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-----"
  }
}

四、启动 Relay 长轮询客户端

claude-code-qiwe-assistant 是 stdio MCP server,不要在主进程内启动长轮询。推荐新增独立脚本:

4.1 创建 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);
});

4.2 运行

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 条事件

五、配置企微回调地址

5.1 拼接回调 URL

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

5.2 设置回调

方式 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"
    }
  }'

六、验证

  1. 确保 scripts/start-relay-client.js 正在运行。
  2. 让好友或客户在企微里发送一条消息。
  3. 观察 outputs/webhook/ 目录:
    • outputs/webhook/events/2026-07-16/... 下应出现解密后的事件文件。
  4. 调用状态接口查看当日事件数:

    curl http://8.138.37.248:4000/api/tenant/status \
    -H "Authorization: Bearer <TENANT_API_SECRET>"
    

七、未在注册时传入 deviceGuid 的补救

如果注册时没传 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 工具

为了让用户在对话里一键注册,可在 mcp/src/tools/qiwei-webhook-relay-run.js 中新增工具:

  • qiwei_relay_register:调用 /api/tenant/register,并把返回的 apiKeyapiSecretprivateKeytenantId 保存到 .env.localoutputs/webhook/relay-config.json
  • qiwei_relay_connect:检查配置后,后台 fork 启动 scripts/start-relay-client.js(注意会话结束可能随 MCP server 一起退出,生产环境建议长期独立运行 npm run relay)。

九、常见问题

现象 可能原因 排查
注册返回 INVALID_TOKEN Fmode token 无效/过期 检查 .env.localQIWEI_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 接收企微实时事件。