start-relay-client.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. #!/usr/bin/env node
  2. /**
  3. * Relay 长轮询客户端
  4. *
  5. * 独立进程运行,从中央 Relay 拉取属于本租户的加密事件,
  6. * 用本地 RSA 私钥解密后构造 v1 envelope 并交给 processWebhookEvents 处理。
  7. *
  8. * 启动方式:
  9. * node scripts/start-relay-client.js [device-guid]
  10. * npm run relay
  11. */
  12. const crypto = require('crypto');
  13. const fs = require('fs');
  14. const { processWebhookEvents } = require('../mcp/src/core/webhook-server');
  15. const { readQiweiGuid } = require('../mcp/src/core/credentials');
  16. const { getProductMode } = require('../mcp/src/core/product-mode');
  17. const { buildContext } = require('../mcp/src/core/shared-gateway');
  18. const { callFmodeWecomGateway } = require('../mcp/src/providers/fmode-wecom-gateway');
  19. const {
  20. getRelayBaseUrl,
  21. getTenantApiSecret,
  22. getRelayPrivateKey,
  23. getTenantId,
  24. getRelayDeviceGuid
  25. } = require('../mcp/src/core/relay-config');
  26. const {
  27. relayLockPath,
  28. relayStatePath,
  29. readJson,
  30. isProcessAlive
  31. } = require('../mcp/src/core/relay-daemon');
  32. const POLL_WAIT_MS = 30000;
  33. const INITIAL_BACKOFF_MS = 1000;
  34. const MAX_BACKOFF_MS = 60000;
  35. const UPSTREAM_RECONNECT_INTERVAL_MS = 5 * 60 * 1000;
  36. const UPSTREAM_RECONNECT_RETRY_MS = 60 * 1000;
  37. const DEVICE_REFRESH_INTERVAL_MS = 60 * 1000;
  38. let lockOwned = false;
  39. let runtimeState = {
  40. pid: process.pid,
  41. startedAt: new Date().toISOString(),
  42. heartbeatAt: new Date().toISOString(),
  43. lastPollAt: null,
  44. lastReceivedAt: null,
  45. lastAckAt: null,
  46. lastError: '',
  47. received: 0,
  48. acked: 0,
  49. failed: 0
  50. };
  51. let nextUpstreamReconnectAt = 0;
  52. let startupCatchupPending = true;
  53. let nextDeviceRefreshAt = 0;
  54. let activeDeviceGuids = [];
  55. function writeJsonAtomic(filePath, value) {
  56. const tempPath = `${filePath}.${process.pid}.tmp`;
  57. fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8');
  58. fs.renameSync(tempPath, filePath);
  59. }
  60. function writeRuntimeState(patch = {}) {
  61. runtimeState = { ...runtimeState, ...patch, pid: process.pid, heartbeatAt: new Date().toISOString() };
  62. writeJsonAtomic(relayStatePath(), runtimeState);
  63. }
  64. function acquireLock() {
  65. const lockPath = relayLockPath();
  66. for (let attempt = 0; attempt < 2; attempt += 1) {
  67. try {
  68. const fd = fs.openSync(lockPath, 'wx');
  69. fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: runtimeState.startedAt }, null, 2));
  70. fs.closeSync(fd);
  71. lockOwned = true;
  72. return true;
  73. } catch (error) {
  74. if (error.code !== 'EEXIST') throw error;
  75. const existing = readJson(lockPath, {});
  76. if (isProcessAlive(existing.pid)) return false;
  77. try { fs.rmSync(lockPath, { force: true }); } catch {}
  78. }
  79. }
  80. return false;
  81. }
  82. function releaseLock() {
  83. if (!lockOwned) return;
  84. const existing = readJson(relayLockPath(), {});
  85. if (Number(existing.pid) === process.pid) fs.rmSync(relayLockPath(), { force: true });
  86. lockOwned = false;
  87. writeRuntimeState({ running: false, stoppedAt: new Date().toISOString() });
  88. }
  89. function decryptPayload(encryptedPayload, privateKeyPem) {
  90. const key = crypto.createPrivateKey(privateKeyPem);
  91. if (String(encryptedPayload).startsWith('v2:')) {
  92. const envelope = JSON.parse(Buffer.from(String(encryptedPayload).slice(3), 'base64').toString('utf8'));
  93. const aesKey = crypto.privateDecrypt(
  94. { key, oaepHash: 'sha256' },
  95. Buffer.from(envelope.key, 'base64')
  96. );
  97. const decipher = crypto.createDecipheriv(
  98. 'aes-256-gcm',
  99. aesKey,
  100. Buffer.from(envelope.iv, 'base64')
  101. );
  102. decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
  103. return Buffer.concat([
  104. decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
  105. decipher.final()
  106. ]).toString('utf8');
  107. }
  108. const buffer = Buffer.from(encryptedPayload, 'base64');
  109. const decrypted = crypto.privateDecrypt({ key, oaepHash: 'sha256' }, buffer);
  110. return decrypted.toString('utf8');
  111. }
  112. async function ackEvents(baseUrl, apiSecret, guid, eventIds) {
  113. if (!eventIds.length) return 0;
  114. const res = await fetch(`${baseUrl}/api/relay/ack`, {
  115. method: 'POST',
  116. headers: {
  117. 'Content-Type': 'application/json',
  118. Authorization: `Bearer ${apiSecret}`
  119. },
  120. body: JSON.stringify({ guid, eventIds })
  121. });
  122. if (!res.ok) {
  123. throw new Error(`ACK failed: ${res.status} ${await res.text()}`);
  124. }
  125. const data = await res.json();
  126. const ackedCount = Number(data.ackedCount) || eventIds.length;
  127. console.log(`[RelayClient] ACK ${ackedCount} 条事件`);
  128. return ackedCount;
  129. }
  130. async function runPollOnce(baseUrl, apiSecret, guid, privateKey, options = {}) {
  131. const trackState = options.trackState !== false;
  132. const res = await fetch(`${baseUrl}/api/relay/poll`, {
  133. method: 'POST',
  134. headers: {
  135. 'Content-Type': 'application/json',
  136. Authorization: `Bearer ${apiSecret}`
  137. },
  138. body: JSON.stringify({ guid, batchSize: 100, waitMs: POLL_WAIT_MS })
  139. });
  140. if (!res.ok) {
  141. throw new Error(`poll failed: ${res.status} ${await res.text()}`);
  142. }
  143. const data = await res.json();
  144. if (trackState) writeRuntimeState({ lastPollAt: new Date().toISOString(), lastError: '' });
  145. if (!data.events || !data.events.length) return { received: 0, acked: 0, failed: 0 };
  146. console.log(`[RelayClient] 取回 ${data.events.length} 条事件`);
  147. const eventIds = [];
  148. let failed = 0;
  149. for (const event of data.events) {
  150. try {
  151. const decrypted = decryptPayload(event.encryptedPayload, privateKey);
  152. const payload = JSON.parse(decrypted);
  153. const envelope = payload && payload.version === '2.0'
  154. ? { ...payload, source: 'relay', __rawBody: decrypted }
  155. : payload && typeof payload.code === 'number' && Array.isArray(payload.data)
  156. ? { ...payload, source: 'relay', __rawBody: decrypted }
  157. : { code: 0, msg: 'from-relay', data: Array.isArray(payload) ? payload : [payload], source: 'relay', __rawBody: decrypted };
  158. const processed = await processWebhookEvents(envelope);
  159. if (processed.errors) throw new Error(`webhook processing failed for ${processed.errors} event(s)`);
  160. eventIds.push(event.eventId);
  161. } catch (err) {
  162. console.error(`[RelayClient] 解密/处理失败 eventId=${event.eventId}:`, err.message);
  163. failed += 1;
  164. }
  165. }
  166. const acked = await ackEvents(baseUrl, apiSecret, guid, eventIds);
  167. if (trackState) {
  168. writeRuntimeState({
  169. lastReceivedAt: new Date().toISOString(),
  170. lastAckAt: acked ? new Date().toISOString() : runtimeState.lastAckAt,
  171. received: runtimeState.received + data.events.length,
  172. acked: runtimeState.acked + acked,
  173. failed: runtimeState.failed + failed,
  174. lastError: failed ? `${failed} event(s) failed and remain pending` : ''
  175. });
  176. }
  177. return { received: data.events.length, acked, failed };
  178. }
  179. function resolveGuid() {
  180. // 命令行参数 > 环境变量 > relay-config.json > credentials
  181. return process.argv[2] || process.env.RELAY_DEVICE_GUID || getRelayDeviceGuid() || readQiweiGuid() || '';
  182. }
  183. function resolveRuntimeConfig() {
  184. return {
  185. baseUrl: getRelayBaseUrl(),
  186. apiSecret: getTenantApiSecret(),
  187. privateKey: getRelayPrivateKey(),
  188. tenantId: getTenantId(),
  189. guid: resolveGuid()
  190. };
  191. }
  192. async function refreshTenantDevices(baseUrl, apiSecret, primaryGuid) {
  193. if (Date.now() < nextDeviceRefreshAt && activeDeviceGuids.length) return activeDeviceGuids;
  194. const response = await fetch(`${baseUrl}/api/tenant/status`, {
  195. headers: { Authorization: `Bearer ${apiSecret}` },
  196. signal: AbortSignal.timeout(15000)
  197. });
  198. if (!response.ok) throw new Error(`device discovery failed: ${response.status} ${await response.text()}`);
  199. const data = await response.json();
  200. const discovered = Array.isArray(data.devices)
  201. ? data.devices.map(device => String(device.guid || '').trim()).filter(Boolean)
  202. : [];
  203. activeDeviceGuids = [...new Set([primaryGuid, ...discovered].filter(Boolean))];
  204. nextDeviceRefreshAt = Date.now() + DEVICE_REFRESH_INTERVAL_MS;
  205. writeRuntimeState({ activeDeviceGuids });
  206. return activeDeviceGuids;
  207. }
  208. async function reconnectUpstreamCallback() {
  209. const ctx = buildContext({});
  210. const result = await callFmodeWecomGateway({
  211. gatewayPath: '/relay/connect',
  212. body: { uid: ctx.uid },
  213. token: ctx.token,
  214. apiBase: ctx.apiBase,
  215. timeoutMs: 60000
  216. });
  217. const connected = result.data && result.data.data !== undefined ? result.data.data : result.data;
  218. if (connected && connected.connected === false) throw new Error('Fmode Relay connection was not accepted');
  219. nextUpstreamReconnectAt = Date.now() + UPSTREAM_RECONNECT_INTERVAL_MS;
  220. writeRuntimeState({ lastConnectAt: new Date().toISOString(), lastConnectError: '' });
  221. console.log('[RelayClient] Fmode Relay 回调已重新连接');
  222. if (startupCatchupPending) {
  223. try {
  224. const { syncConversations } = require('../mcp/src/dashboard/agent-service');
  225. const catchup = await syncConversations();
  226. startupCatchupPending = false;
  227. writeRuntimeState({
  228. lastCatchupAt: new Date().toISOString(),
  229. lastCatchupError: '',
  230. lastCatchupMessage: catchup.assistantMessage || ''
  231. });
  232. console.log('[RelayClient] 启动补采完成:', catchup.assistantMessage || 'ok');
  233. } catch (error) {
  234. nextUpstreamReconnectAt = Date.now() + UPSTREAM_RECONNECT_RETRY_MS;
  235. writeRuntimeState({ lastCatchupError: error.message });
  236. console.warn('[RelayClient] 启动补采失败:', error.message);
  237. }
  238. }
  239. return connected;
  240. }
  241. async function maintainUpstreamCallback() {
  242. if (Date.now() < nextUpstreamReconnectAt) return;
  243. try {
  244. await reconnectUpstreamCallback();
  245. } catch (error) {
  246. nextUpstreamReconnectAt = Date.now() + UPSTREAM_RECONNECT_RETRY_MS;
  247. writeRuntimeState({ lastConnectError: error.message });
  248. console.warn('[RelayClient] Fmode Relay 回调重连失败:', error.message);
  249. }
  250. }
  251. async function main() {
  252. if (!acquireLock()) {
  253. console.log('[RelayClient] 已有 Relay 消费进程运行,本进程退出');
  254. return;
  255. }
  256. writeRuntimeState({ running: true });
  257. const product = getProductMode();
  258. if (product.mode !== 'enterprise') {
  259. console.error('[RelayClient] 当前为个人版,请使用工作台本地监听;企业 Relay Client 未启动');
  260. process.exit(1);
  261. }
  262. let config = resolveRuntimeConfig();
  263. if (!config.apiSecret || !config.privateKey || !config.tenantId) {
  264. console.error('[RelayClient] 缺少配置:请检查 .env.local 中的 TENANT_API_SECRET、RELAY_PRIVATE_KEY、TENANT_ID');
  265. process.exit(1);
  266. }
  267. if (!config.guid) {
  268. console.error('[RelayClient] 缺少 deviceGuid:请通过命令行传入,或配置 RELAY_DEVICE_GUID / relay-config.json / 完成企微登录');
  269. process.exit(1);
  270. }
  271. console.log(`[RelayClient] 启动 Relay 轮询: ${config.baseUrl}`);
  272. console.log(`[RelayClient] tenantId=${config.tenantId}, guid=${config.guid}`);
  273. let backoff = INITIAL_BACKOFF_MS;
  274. let configIdentity = `${config.baseUrl}|${config.tenantId}|${config.guid}`;
  275. while (true) {
  276. try {
  277. const latestConfig = resolveRuntimeConfig();
  278. if (!latestConfig.apiSecret || !latestConfig.privateKey || !latestConfig.tenantId || !latestConfig.guid) {
  279. throw new Error('Relay runtime configuration became incomplete');
  280. }
  281. const latestIdentity = `${latestConfig.baseUrl}|${latestConfig.tenantId}|${latestConfig.guid}`;
  282. if (latestIdentity !== configIdentity) {
  283. config = latestConfig;
  284. configIdentity = latestIdentity;
  285. nextUpstreamReconnectAt = 0;
  286. nextDeviceRefreshAt = 0;
  287. activeDeviceGuids = [];
  288. startupCatchupPending = true;
  289. writeRuntimeState({
  290. tenantId: config.tenantId,
  291. deviceGuid: config.guid,
  292. lastConfigReloadAt: new Date().toISOString()
  293. });
  294. console.log(`[RelayClient] 已切换 Relay 配置: tenantId=${config.tenantId}, guid=${config.guid}`);
  295. } else {
  296. config = latestConfig;
  297. }
  298. await maintainUpstreamCallback();
  299. let deviceGuids;
  300. try {
  301. deviceGuids = await refreshTenantDevices(config.baseUrl, config.apiSecret, config.guid);
  302. } catch (error) {
  303. deviceGuids = activeDeviceGuids.length ? activeDeviceGuids : [config.guid];
  304. nextDeviceRefreshAt = Date.now() + Math.min(UPSTREAM_RECONNECT_RETRY_MS, DEVICE_REFRESH_INTERVAL_MS);
  305. console.warn('[RelayClient] 租户设备发现失败,继续轮询已知设备:', error.message);
  306. }
  307. const settled = await Promise.allSettled(
  308. deviceGuids.map(guid => runPollOnce(config.baseUrl, config.apiSecret, guid, config.privateKey, { trackState: false }))
  309. );
  310. const results = settled.filter(item => item.status === 'fulfilled').map(item => item.value);
  311. const errors = settled.filter(item => item.status === 'rejected').map(item => item.reason?.message || String(item.reason));
  312. if (!results.length && errors.length) throw new Error(errors.join('; '));
  313. const received = results.reduce((sum, item) => sum + item.received, 0);
  314. const acked = results.reduce((sum, item) => sum + item.acked, 0);
  315. const failed = results.reduce((sum, item) => sum + item.failed, 0);
  316. writeRuntimeState({
  317. lastPollAt: new Date().toISOString(),
  318. lastReceivedAt: received ? new Date().toISOString() : runtimeState.lastReceivedAt,
  319. lastAckAt: acked ? new Date().toISOString() : runtimeState.lastAckAt,
  320. received: runtimeState.received + received,
  321. acked: runtimeState.acked + acked,
  322. failed: runtimeState.failed + failed,
  323. lastError: [...errors, ...(failed ? [`${failed} event(s) failed and remain pending`] : [])].join('; ')
  324. });
  325. backoff = INITIAL_BACKOFF_MS;
  326. } catch (err) {
  327. console.error('[RelayClient] 轮询异常:', err.message);
  328. writeRuntimeState({ lastError: err.message });
  329. console.log(`[RelayClient] ${backoff}ms 后重试...`);
  330. await new Promise((resolve) => setTimeout(resolve, backoff));
  331. backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
  332. }
  333. }
  334. }
  335. if (require.main === module) {
  336. process.once('SIGINT', () => { releaseLock(); process.exit(0); });
  337. process.once('SIGTERM', () => { releaseLock(); process.exit(0); });
  338. process.once('exit', releaseLock);
  339. main().catch((err) => {
  340. console.error('[RelayClient] 致命错误:', err);
  341. writeRuntimeState({ running: false, lastError: err.message });
  342. releaseLock();
  343. process.exit(1);
  344. });
  345. }
  346. module.exports = { decryptPayload, ackEvents, runPollOnce, resolveGuid, resolveRuntimeConfig, refreshTenantDevices, reconnectUpstreamCallback, maintainUpstreamCallback, acquireLock, releaseLock };