| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- #!/usr/bin/env node
- /**
- * Relay 长轮询客户端
- *
- * 独立进程运行,从中央 Relay 拉取属于本租户的加密事件,
- * 用本地 RSA 私钥解密后构造 v1 envelope 并交给 processWebhookEvents 处理。
- *
- * 启动方式:
- * node scripts/start-relay-client.js [device-guid]
- * npm run relay
- */
- const crypto = require('crypto');
- const fs = require('fs');
- const { processWebhookEvents } = require('../mcp/src/core/webhook-server');
- const { readQiweiGuid } = require('../mcp/src/core/credentials');
- const { getProductMode } = require('../mcp/src/core/product-mode');
- const { buildContext } = require('../mcp/src/core/shared-gateway');
- const { callFmodeWecomGateway } = require('../mcp/src/providers/fmode-wecom-gateway');
- const {
- getRelayBaseUrl,
- getTenantApiSecret,
- getRelayPrivateKey,
- getTenantId,
- getRelayDeviceGuid
- } = require('../mcp/src/core/relay-config');
- const {
- relayLockPath,
- relayStatePath,
- readJson,
- isProcessAlive
- } = require('../mcp/src/core/relay-daemon');
- const POLL_WAIT_MS = 30000;
- const INITIAL_BACKOFF_MS = 1000;
- const MAX_BACKOFF_MS = 60000;
- const UPSTREAM_RECONNECT_INTERVAL_MS = 5 * 60 * 1000;
- const UPSTREAM_RECONNECT_RETRY_MS = 60 * 1000;
- const DEVICE_REFRESH_INTERVAL_MS = 60 * 1000;
- let lockOwned = false;
- let runtimeState = {
- pid: process.pid,
- startedAt: new Date().toISOString(),
- heartbeatAt: new Date().toISOString(),
- lastPollAt: null,
- lastReceivedAt: null,
- lastAckAt: null,
- lastError: '',
- received: 0,
- acked: 0,
- failed: 0
- };
- let nextUpstreamReconnectAt = 0;
- let startupCatchupPending = true;
- let nextDeviceRefreshAt = 0;
- let activeDeviceGuids = [];
- function writeJsonAtomic(filePath, value) {
- const tempPath = `${filePath}.${process.pid}.tmp`;
- fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8');
- fs.renameSync(tempPath, filePath);
- }
- function writeRuntimeState(patch = {}) {
- runtimeState = { ...runtimeState, ...patch, pid: process.pid, heartbeatAt: new Date().toISOString() };
- writeJsonAtomic(relayStatePath(), runtimeState);
- }
- function acquireLock() {
- const lockPath = relayLockPath();
- for (let attempt = 0; attempt < 2; attempt += 1) {
- try {
- const fd = fs.openSync(lockPath, 'wx');
- fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: runtimeState.startedAt }, null, 2));
- fs.closeSync(fd);
- lockOwned = true;
- return true;
- } catch (error) {
- if (error.code !== 'EEXIST') throw error;
- const existing = readJson(lockPath, {});
- if (isProcessAlive(existing.pid)) return false;
- try { fs.rmSync(lockPath, { force: true }); } catch {}
- }
- }
- return false;
- }
- function releaseLock() {
- if (!lockOwned) return;
- const existing = readJson(relayLockPath(), {});
- if (Number(existing.pid) === process.pid) fs.rmSync(relayLockPath(), { force: true });
- lockOwned = false;
- writeRuntimeState({ running: false, stoppedAt: new Date().toISOString() });
- }
- function decryptPayload(encryptedPayload, privateKeyPem) {
- const key = crypto.createPrivateKey(privateKeyPem);
- if (String(encryptedPayload).startsWith('v2:')) {
- const envelope = JSON.parse(Buffer.from(String(encryptedPayload).slice(3), 'base64').toString('utf8'));
- const aesKey = crypto.privateDecrypt(
- { key, oaepHash: 'sha256' },
- Buffer.from(envelope.key, 'base64')
- );
- const decipher = crypto.createDecipheriv(
- 'aes-256-gcm',
- aesKey,
- Buffer.from(envelope.iv, 'base64')
- );
- decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
- return Buffer.concat([
- decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
- decipher.final()
- ]).toString('utf8');
- }
- const buffer = Buffer.from(encryptedPayload, 'base64');
- const decrypted = crypto.privateDecrypt({ key, oaepHash: 'sha256' }, buffer);
- return decrypted.toString('utf8');
- }
- async function ackEvents(baseUrl, apiSecret, guid, eventIds) {
- if (!eventIds.length) return 0;
- 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) {
- throw new Error(`ACK failed: ${res.status} ${await res.text()}`);
- }
- const data = await res.json();
- const ackedCount = Number(data.ackedCount) || eventIds.length;
- console.log(`[RelayClient] ACK ${ackedCount} 条事件`);
- return ackedCount;
- }
- async function runPollOnce(baseUrl, apiSecret, guid, privateKey, options = {}) {
- const trackState = options.trackState !== false;
- 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 (trackState) writeRuntimeState({ lastPollAt: new Date().toISOString(), lastError: '' });
- if (!data.events || !data.events.length) return { received: 0, acked: 0, failed: 0 };
- console.log(`[RelayClient] 取回 ${data.events.length} 条事件`);
- const eventIds = [];
- let failed = 0;
- for (const event of data.events) {
- try {
- const decrypted = decryptPayload(event.encryptedPayload, privateKey);
- const payload = JSON.parse(decrypted);
- const envelope = payload && payload.version === '2.0'
- ? { ...payload, source: 'relay', __rawBody: decrypted }
- : payload && typeof payload.code === 'number' && Array.isArray(payload.data)
- ? { ...payload, source: 'relay', __rawBody: decrypted }
- : { code: 0, msg: 'from-relay', data: Array.isArray(payload) ? payload : [payload], source: 'relay', __rawBody: decrypted };
- const processed = await processWebhookEvents(envelope);
- if (processed.errors) throw new Error(`webhook processing failed for ${processed.errors} event(s)`);
- eventIds.push(event.eventId);
- } catch (err) {
- console.error(`[RelayClient] 解密/处理失败 eventId=${event.eventId}:`, err.message);
- failed += 1;
- }
- }
- const acked = await ackEvents(baseUrl, apiSecret, guid, eventIds);
- if (trackState) {
- writeRuntimeState({
- lastReceivedAt: new Date().toISOString(),
- lastAckAt: acked ? new Date().toISOString() : runtimeState.lastAckAt,
- received: runtimeState.received + data.events.length,
- acked: runtimeState.acked + acked,
- failed: runtimeState.failed + failed,
- lastError: failed ? `${failed} event(s) failed and remain pending` : ''
- });
- }
- return { received: data.events.length, acked, failed };
- }
- function resolveGuid() {
- // 命令行参数 > 环境变量 > relay-config.json > credentials
- return process.argv[2] || process.env.RELAY_DEVICE_GUID || getRelayDeviceGuid() || readQiweiGuid() || '';
- }
- function resolveRuntimeConfig() {
- return {
- baseUrl: getRelayBaseUrl(),
- apiSecret: getTenantApiSecret(),
- privateKey: getRelayPrivateKey(),
- tenantId: getTenantId(),
- guid: resolveGuid()
- };
- }
- async function refreshTenantDevices(baseUrl, apiSecret, primaryGuid) {
- if (Date.now() < nextDeviceRefreshAt && activeDeviceGuids.length) return activeDeviceGuids;
- const response = await fetch(`${baseUrl}/api/tenant/status`, {
- headers: { Authorization: `Bearer ${apiSecret}` },
- signal: AbortSignal.timeout(15000)
- });
- if (!response.ok) throw new Error(`device discovery failed: ${response.status} ${await response.text()}`);
- const data = await response.json();
- const discovered = Array.isArray(data.devices)
- ? data.devices.map(device => String(device.guid || '').trim()).filter(Boolean)
- : [];
- activeDeviceGuids = [...new Set([primaryGuid, ...discovered].filter(Boolean))];
- nextDeviceRefreshAt = Date.now() + DEVICE_REFRESH_INTERVAL_MS;
- writeRuntimeState({ activeDeviceGuids });
- return activeDeviceGuids;
- }
- async function reconnectUpstreamCallback() {
- const ctx = buildContext({});
- const result = await callFmodeWecomGateway({
- gatewayPath: '/relay/connect',
- body: { uid: ctx.uid },
- token: ctx.token,
- apiBase: ctx.apiBase,
- timeoutMs: 60000
- });
- const connected = result.data && result.data.data !== undefined ? result.data.data : result.data;
- if (connected && connected.connected === false) throw new Error('Fmode Relay connection was not accepted');
- nextUpstreamReconnectAt = Date.now() + UPSTREAM_RECONNECT_INTERVAL_MS;
- writeRuntimeState({ lastConnectAt: new Date().toISOString(), lastConnectError: '' });
- console.log('[RelayClient] Fmode Relay 回调已重新连接');
- if (startupCatchupPending) {
- try {
- const { syncConversations } = require('../mcp/src/dashboard/agent-service');
- const catchup = await syncConversations();
- startupCatchupPending = false;
- writeRuntimeState({
- lastCatchupAt: new Date().toISOString(),
- lastCatchupError: '',
- lastCatchupMessage: catchup.assistantMessage || ''
- });
- console.log('[RelayClient] 启动补采完成:', catchup.assistantMessage || 'ok');
- } catch (error) {
- nextUpstreamReconnectAt = Date.now() + UPSTREAM_RECONNECT_RETRY_MS;
- writeRuntimeState({ lastCatchupError: error.message });
- console.warn('[RelayClient] 启动补采失败:', error.message);
- }
- }
- return connected;
- }
- async function maintainUpstreamCallback() {
- if (Date.now() < nextUpstreamReconnectAt) return;
- try {
- await reconnectUpstreamCallback();
- } catch (error) {
- nextUpstreamReconnectAt = Date.now() + UPSTREAM_RECONNECT_RETRY_MS;
- writeRuntimeState({ lastConnectError: error.message });
- console.warn('[RelayClient] Fmode Relay 回调重连失败:', error.message);
- }
- }
- async function main() {
- if (!acquireLock()) {
- console.log('[RelayClient] 已有 Relay 消费进程运行,本进程退出');
- return;
- }
- writeRuntimeState({ running: true });
- const product = getProductMode();
- if (product.mode !== 'enterprise') {
- console.error('[RelayClient] 当前为个人版,请使用工作台本地监听;企业 Relay Client 未启动');
- process.exit(1);
- }
- let config = resolveRuntimeConfig();
- if (!config.apiSecret || !config.privateKey || !config.tenantId) {
- console.error('[RelayClient] 缺少配置:请检查 .env.local 中的 TENANT_API_SECRET、RELAY_PRIVATE_KEY、TENANT_ID');
- process.exit(1);
- }
- if (!config.guid) {
- console.error('[RelayClient] 缺少 deviceGuid:请通过命令行传入,或配置 RELAY_DEVICE_GUID / relay-config.json / 完成企微登录');
- process.exit(1);
- }
- console.log(`[RelayClient] 启动 Relay 轮询: ${config.baseUrl}`);
- console.log(`[RelayClient] tenantId=${config.tenantId}, guid=${config.guid}`);
- let backoff = INITIAL_BACKOFF_MS;
- let configIdentity = `${config.baseUrl}|${config.tenantId}|${config.guid}`;
- while (true) {
- try {
- const latestConfig = resolveRuntimeConfig();
- if (!latestConfig.apiSecret || !latestConfig.privateKey || !latestConfig.tenantId || !latestConfig.guid) {
- throw new Error('Relay runtime configuration became incomplete');
- }
- const latestIdentity = `${latestConfig.baseUrl}|${latestConfig.tenantId}|${latestConfig.guid}`;
- if (latestIdentity !== configIdentity) {
- config = latestConfig;
- configIdentity = latestIdentity;
- nextUpstreamReconnectAt = 0;
- nextDeviceRefreshAt = 0;
- activeDeviceGuids = [];
- startupCatchupPending = true;
- writeRuntimeState({
- tenantId: config.tenantId,
- deviceGuid: config.guid,
- lastConfigReloadAt: new Date().toISOString()
- });
- console.log(`[RelayClient] 已切换 Relay 配置: tenantId=${config.tenantId}, guid=${config.guid}`);
- } else {
- config = latestConfig;
- }
- await maintainUpstreamCallback();
- let deviceGuids;
- try {
- deviceGuids = await refreshTenantDevices(config.baseUrl, config.apiSecret, config.guid);
- } catch (error) {
- deviceGuids = activeDeviceGuids.length ? activeDeviceGuids : [config.guid];
- nextDeviceRefreshAt = Date.now() + Math.min(UPSTREAM_RECONNECT_RETRY_MS, DEVICE_REFRESH_INTERVAL_MS);
- console.warn('[RelayClient] 租户设备发现失败,继续轮询已知设备:', error.message);
- }
- const settled = await Promise.allSettled(
- deviceGuids.map(guid => runPollOnce(config.baseUrl, config.apiSecret, guid, config.privateKey, { trackState: false }))
- );
- const results = settled.filter(item => item.status === 'fulfilled').map(item => item.value);
- const errors = settled.filter(item => item.status === 'rejected').map(item => item.reason?.message || String(item.reason));
- if (!results.length && errors.length) throw new Error(errors.join('; '));
- const received = results.reduce((sum, item) => sum + item.received, 0);
- const acked = results.reduce((sum, item) => sum + item.acked, 0);
- const failed = results.reduce((sum, item) => sum + item.failed, 0);
- writeRuntimeState({
- lastPollAt: new Date().toISOString(),
- lastReceivedAt: received ? new Date().toISOString() : runtimeState.lastReceivedAt,
- lastAckAt: acked ? new Date().toISOString() : runtimeState.lastAckAt,
- received: runtimeState.received + received,
- acked: runtimeState.acked + acked,
- failed: runtimeState.failed + failed,
- lastError: [...errors, ...(failed ? [`${failed} event(s) failed and remain pending`] : [])].join('; ')
- });
- backoff = INITIAL_BACKOFF_MS;
- } catch (err) {
- console.error('[RelayClient] 轮询异常:', err.message);
- writeRuntimeState({ lastError: err.message });
- console.log(`[RelayClient] ${backoff}ms 后重试...`);
- await new Promise((resolve) => setTimeout(resolve, backoff));
- backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
- }
- }
- }
- if (require.main === module) {
- process.once('SIGINT', () => { releaseLock(); process.exit(0); });
- process.once('SIGTERM', () => { releaseLock(); process.exit(0); });
- process.once('exit', releaseLock);
- main().catch((err) => {
- console.error('[RelayClient] 致命错误:', err);
- writeRuntimeState({ running: false, lastError: err.message });
- releaseLock();
- process.exit(1);
- });
- }
- module.exports = { decryptPayload, ackEvents, runPollOnce, resolveGuid, resolveRuntimeConfig, refreshTenantDevices, reconnectUpstreamCallback, maintainUpstreamCallback, acquireLock, releaseLock };
|