@voko/lite 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +32 -0
  2. package/scripts/build-native.js +72 -0
  3. package/src/bankHeadOffices.js +20543 -0
  4. package/src/channels/email.js +35 -0
  5. package/src/channels/feishu.js +31 -0
  6. package/src/channels/qq-email.js +30 -0
  7. package/src/channels/registry.js +279 -0
  8. package/src/channels/telegram.js +28 -0
  9. package/src/channels/voko-email.js +7 -0
  10. package/src/channels/wechat.js +35 -0
  11. package/src/cli.js +120 -0
  12. package/src/context.js +164 -0
  13. package/src/core/access-control-api.js +150 -0
  14. package/src/core/access-control.js +56 -0
  15. package/src/core/agent-registration.js +319 -0
  16. package/src/core/api-signature.js +33 -0
  17. package/src/core/audit.js +133 -0
  18. package/src/core/database.js +1409 -0
  19. package/src/core/did-auth.js +54 -0
  20. package/src/core/hermes-paths.js +57 -0
  21. package/src/core/invitation.js +49 -0
  22. package/src/core/lite-bus.js +16 -0
  23. package/src/core/llm-client.js +1032 -0
  24. package/src/core/messenger.js +456 -0
  25. package/src/core/notifier.js +99 -0
  26. package/src/core/offline-sync.js +150 -0
  27. package/src/core/payment.js +285 -0
  28. package/src/core/publish-agent.js +166 -0
  29. package/src/core/register-capabilities.js +119 -0
  30. package/src/core/search-capabilities.js +136 -0
  31. package/src/core/send-message.js +85 -0
  32. package/src/core/set-agent-status.js +65 -0
  33. package/src/core/update-agent-profile.js +102 -0
  34. package/src/core/worker-manager.js +332 -0
  35. package/src/endpoints.json +21 -0
  36. package/src/index.js +712 -0
  37. package/src/mcp/CLAUDE_TEST.md +82 -0
  38. package/src/mcp/FULL_TEST.md +139 -0
  39. package/src/mcp/TEST.md +124 -0
  40. package/src/mcp/TEST_STEPS.md +75 -0
  41. package/src/mcp/server.js +612 -0
  42. package/src/mcp/tools.js +1367 -0
  43. package/src/mcp/transport/http.js +95 -0
  44. package/src/mcp/transport/stdio.js +20 -0
  45. package/src/preload.js +27 -0
  46. package/src/server/agent-email-api.js +120 -0
  47. package/src/server/agent-manager.js +580 -0
  48. package/src/server/email-handler.js +329 -0
  49. package/src/server/feishu-handler.js +249 -0
  50. package/src/server/hermes-api-client.js +166 -0
  51. package/src/server/hermes-discovery.js +80 -0
  52. package/src/server/hermes-handler.js +287 -0
  53. package/src/server/openclaw-handler-cli.js +131 -0
  54. package/src/server/openclaw-websocket-handler.js +1290 -0
  55. package/src/server/oss.js +186 -0
  56. package/src/server/owner-intervention-notifier.js +320 -0
  57. package/src/server/release-page.html +204 -0
  58. package/src/server/telegram-handler.js +208 -0
  59. package/src/server/voko-email-handler.js +68 -0
  60. package/src/server/wechat-handler.js +439 -0
  61. package/src/workers/agent-worker.js +378 -0
  62. package/src/workers/message-content.js +51 -0
@@ -0,0 +1,1290 @@
1
+ const WebSocket = require('ws');
2
+ const { getPublicKeyAsync, signAsync, utils } = require('@noble/ed25519');
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const bus = require('../core/lite-bus');
8
+
9
+ /**
10
+ * OpenClaw WebSocket 处理器
11
+ * 使用 WebSocket + Ed25519 设备认证与 OpenClaw Gateway 通信
12
+ *
13
+ * 特性:
14
+ * - 自动检测配置文件变化,动态更新 token
15
+ * - 连接断开后自动重连
16
+ * - 消息队列,离线时缓存消息
17
+ * - 连接状态监控
18
+ */
19
+ class OpenClawWebSocketHandler {
20
+ constructor(database, mainWindow) {
21
+ this.db = database;
22
+ this.mainWindow = mainWindow;
23
+
24
+ // Gateway 配置
25
+ this.gatewayUrl = 'ws://127.0.0.1:18789';
26
+ this.authToken = null;
27
+ this.gatewayPort = 18789;
28
+
29
+ // 状态控制
30
+ this.enabled = false;
31
+ this.processingChannels = new Set();
32
+
33
+ // WebSocket 连接
34
+ this.ws = null;
35
+ this.sessionId = null;
36
+ this._protocolVer = 4; // 优先 v4,失败后降级到 v3
37
+ this.device = null;
38
+ this.connected = false;
39
+ this.connecting = false;
40
+
41
+ // 消息队列(离线时缓存)
42
+ this.messageQueue = [];
43
+ this.maxQueueSize = 100;
44
+
45
+ // Gateway 启动互斥锁(防止重复启动)
46
+ this._gatewayStarting = false;
47
+
48
+ // 重连配置
49
+ this.reconnectAttempts = 0;
50
+ this.maxReconnectAttempts = 10;
51
+ this.reconnectDelay = 2000; // 初始重连延迟 2 秒
52
+ this.maxReconnectDelay = 30000; // 最大重连延迟 30 秒
53
+ this.reconnectTimer = null;
54
+
55
+ // 日志缓冲区
56
+ this.logs = [];
57
+ this.maxLogSize = 200;
58
+
59
+ // 配置文件监控
60
+ this.configPath = path.join(
61
+ os.homedir(),
62
+ '.openclaw',
63
+ 'openclaw.json'
64
+ );
65
+ this.configWatcher = null;
66
+ this.lastConfigMtime = 0;
67
+
68
+ // 当前回复处理(按 visitorId 隔离)
69
+ this.pendingReplies = new Map(); // {visitorId: {currentReply, replyResolve, timeout}}
70
+
71
+ // 事件监听器
72
+ this.eventListeners = new Map(); // {eventName: [handler]}
73
+
74
+ // 订阅状态追踪
75
+ this.subscribedSessions = new Set(); // 已成功订阅的 session key
76
+ this.pendingSubscriptions = new Map(); // 待确认的订阅请求 {sessionKey: {timestamp, id}}
77
+ this._caseMap = new Map(); // gateway 转小写后的 key → 原始大小写 key
78
+ this._processedMsgs = new Map(); // 已处理 final 消息去重 key → timestamp
79
+
80
+ // 初始化
81
+ this.loadConfig();
82
+ this.startConfigWatcher();
83
+ }
84
+
85
+ /**
86
+ * 加载 OpenClaw 配置文件
87
+ */
88
+ loadConfig() {
89
+ try {
90
+ if (fs.existsSync(this.configPath)) {
91
+ const stats = fs.statSync(this.configPath);
92
+ this.lastConfigMtime = stats.mtimeMs;
93
+
94
+ const config = JSON.parse(fs.readFileSync(this.configPath, 'utf8'));
95
+
96
+ const newPort = config.gateway?.port || 18789;
97
+ const newToken = config.gateway?.auth?.token || null;
98
+ const newUrl = `ws://127.0.0.1:${newPort}`;
99
+
100
+ // 检测配置变化
101
+ const portChanged = this.gatewayPort !== newPort;
102
+ const tokenChanged = this.authToken !== newToken;
103
+ const urlChanged = this.gatewayUrl !== newUrl;
104
+
105
+ if (portChanged || tokenChanged || urlChanged) {
106
+ console.log('[OpenClaw WS] 配置更新:');
107
+ if (portChanged) console.log(` - Port: ${this.gatewayPort} → ${newPort}`);
108
+ if (tokenChanged) console.log(` - Token: ${this.authToken ? '已设置' : '未设置'} → ${newToken ? '已设置' : '未设置'}`);
109
+ if (urlChanged) console.log(` - URL: ${this.gatewayUrl} → ${newUrl}`);
110
+
111
+ this.gatewayPort = newPort;
112
+ this.authToken = newToken;
113
+ this.gatewayUrl = newUrl;
114
+
115
+ // 如果已连接,需要重新连接以应用新配置
116
+ if (this.connected || this.connecting) {
117
+ console.log('[OpenClaw WS] 配置变化,触发重新连接...');
118
+ this.scheduleReconnect(100); // 100ms 后重连
119
+ }
120
+ } else {
121
+ console.log('[OpenClaw WS] 配置检查完成,无变化');
122
+ }
123
+
124
+ return true;
125
+ } else {
126
+ console.warn('[OpenClaw WS] 配置文件不存在:', this.configPath);
127
+ return false;
128
+ }
129
+ } catch (err) {
130
+ console.error('[OpenClaw WS] 加载配置失败:', err.message);
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 启动配置文件监控(使用轮询方式,更可靠)
137
+ */
138
+ startConfigWatcher() {
139
+ // 每 5 秒检查一次配置文件变化
140
+ this.configWatcher = setInterval(() => {
141
+ try {
142
+ if (fs.existsSync(this.configPath)) {
143
+ const stats = fs.statSync(this.configPath);
144
+ if (stats.mtimeMs > this.lastConfigMtime) {
145
+ console.log('[OpenClaw WS] 检测到配置文件变化,重新加载...');
146
+ this.loadConfig();
147
+ }
148
+ }
149
+ } catch (err) {
150
+ console.error('[OpenClaw WS] 检查配置失败:', err.message);
151
+ }
152
+ }, 5000);
153
+
154
+ console.log('[OpenClaw WS] 配置文件监控已启动');
155
+ }
156
+
157
+ /**
158
+ * 停止配置文件监控
159
+ */
160
+ stopConfigWatcher() {
161
+ if (this.configWatcher) {
162
+ clearInterval(this.configWatcher);
163
+ this.configWatcher = null;
164
+ console.log('[OpenClaw WS] 配置文件监控已停止');
165
+ }
166
+ }
167
+
168
+ /**
169
+ * 设置启用/禁用状态
170
+ */
171
+ setEnabled(enabled) {
172
+ this.enabled = enabled;
173
+ console.log('[OpenClaw WS] 自动回复:', enabled ? '已启用' : '已禁用');
174
+
175
+ if (enabled) {
176
+ if (!this.connected && !this.connecting) {
177
+ this.connect();
178
+ }
179
+ } else {
180
+ this.disconnect();
181
+ }
182
+ }
183
+
184
+ /**
185
+ * 获取连接状态
186
+ */
187
+ getStatus() {
188
+ return {
189
+ connected: this.connected,
190
+ connecting: this.connecting,
191
+ enabled: this.enabled,
192
+ queueSize: this.messageQueue.length,
193
+ gatewayUrl: this.gatewayUrl,
194
+ authToken: this.authToken,
195
+ reconnectAttempts: this.reconnectAttempts,
196
+ deviceId: this.device?.deviceId || null,
197
+ devicePublicKey: this.device?.publicKey || null,
198
+ gatewayPort: this.gatewayPort,
199
+ sessionId: this.sessionId,
200
+ subscribedSessions: Array.from(this.subscribedSessions || []),
201
+ pendingSubscriptions: Array.from(this.pendingSubscriptions?.keys() || []),
202
+ maxReconnectAttempts: this.maxReconnectAttempts,
203
+ reconnectDelay: this.reconnectDelay,
204
+ maxReconnectDelay: this.maxReconnectDelay,
205
+ logs: this.logs.slice(),
206
+ hasToken: !!this.authToken
207
+ };
208
+ }
209
+
210
+ /**
211
+ * 根据平台解析 openclaw 命令,返回 { cmd, args, shell }
212
+ * Windows: 直接找 node.exe + openclaw.mjs 入口文件,绕过 .cmd 避免弹窗
213
+ * macOS/Linux: which 查找 openclaw 可执行文件
214
+ */
215
+ _resolveOpenclawCmd() {
216
+ if (process.platform === 'win32') {
217
+ // Windows: 直接用 node + openclaw.mjs 入口运行,绕过 .cmd 文件的 title %COMSPEC% 弹窗
218
+ const npmDir = path.join(process.env.APPDATA || '', 'npm');
219
+ const entryPoint = path.join(npmDir, 'node_modules', 'openclaw', 'openclaw.mjs');
220
+ if (require('fs').existsSync(entryPoint)) {
221
+ const nodePath = path.join(npmDir, 'node.exe');
222
+ if (require('fs').existsSync(nodePath)) {
223
+ return { cmd: nodePath, args: [entryPoint, 'gateway', 'run', '--force'], shell: false };
224
+ }
225
+ // npm 目录下没有 node.exe,用系统 PATH 中的 node
226
+ return { cmd: 'node', args: [entryPoint, 'gateway', 'run', '--force'], shell: false };
227
+ }
228
+ // 兜底:走 .cmd 文件
229
+ const cmdPath = path.join(npmDir, 'openclaw.cmd');
230
+ if (require('fs').existsSync(cmdPath)) return { cmd: cmdPath, args: ['gateway', 'run', '--force'], shell: true };
231
+ try {
232
+ const result = require('child_process').execSync('where openclaw', { encoding: 'utf8', timeout: 5000, shell: true, windowsHide: true });
233
+ const firstLine = result.trim().split('\n')[0].trim();
234
+ if (firstLine) return { cmd: firstLine, args: ['gateway', 'run', '--force'], shell: firstLine.endsWith('.cmd') || firstLine.endsWith('.bat') };
235
+ } catch {}
236
+ } else {
237
+ // macOS/Linux
238
+ try {
239
+ const result = require('child_process').execSync('which openclaw', { encoding: 'utf8', timeout: 5000 });
240
+ if (result.trim()) return { cmd: result.trim(), args: ['gateway', 'run', '--force'], shell: false };
241
+ } catch {}
242
+ }
243
+ // 终极兜底
244
+ const isWin = process.platform === 'win32';
245
+ return { cmd: isWin ? 'openclaw.cmd' : 'openclaw', args: ['gateway', 'run', '--force'], shell: isWin };
246
+ }
247
+
248
+ /**
249
+ * 检测并确保 OpenClaw Gateway 正在运行
250
+ * 如果 gateway 未运行,自动尝试启动
251
+ * @returns {Promise<boolean>} gateway 是否已就绪
252
+ */
253
+ async _ensureGatewayRunning() {
254
+ const { spawn } = require('child_process');
255
+
256
+ // 已连上就不需要操作
257
+ if (this.connected) return true;
258
+ // 已经在启动中,防止重复启动
259
+ if (this._gatewayStarting) return false;
260
+ this._gatewayStarting = true;
261
+
262
+ try {
263
+ // 先检查 gateway 是否已在运行
264
+ try {
265
+ const http = require('http');
266
+ await new Promise((resolve, reject) => {
267
+ const req = http.get(`http://127.0.0.1:${this.gatewayPort}/health`, (res) => {
268
+ resolve(res.statusCode === 200);
269
+ });
270
+ req.on('error', reject);
271
+ req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); });
272
+ });
273
+ console.log(`[OpenClaw WS] Gateway 已在运行 (port=${this.gatewayPort})`);
274
+ return true;
275
+ } catch {
276
+ console.log(`[OpenClaw WS] Gateway 未运行,尝试启动 (port=${this.gatewayPort})...`);
277
+ }
278
+
279
+ // 启动 gateway 进程
280
+ const started = await new Promise((resolve) => {
281
+ const { cmd, args, shell } = this._resolveOpenclawCmd();
282
+ let child;
283
+ try {
284
+ child = spawn(cmd, args, {
285
+ stdio: 'ignore',
286
+ detached: true,
287
+ windowsHide: true,
288
+ shell,
289
+ // Windows: 直接 spawn node 时不创建控制台窗口
290
+ ...(process.platform === 'win32' && !shell ? { creationFlags: 0x08000000 } : {}),
291
+ });
292
+ child.unref();
293
+ child.on('error', (err) => {
294
+ console.error('[OpenClaw WS] 无法启动 openclaw 进程:', err.message);
295
+ resolve(false);
296
+ });
297
+ } catch (err) {
298
+ console.error('[OpenClaw WS] 无法启动 openclaw 进程:', err.message);
299
+ resolve(false);
300
+ return;
301
+ }
302
+
303
+ // 等待最多 15 秒,每秒检测 gateway 是否就绪
304
+ let waited = 0;
305
+ const interval = setInterval(async () => {
306
+ waited++;
307
+ try {
308
+ const http = require('http');
309
+ await new Promise((resolve, reject) => {
310
+ const req = http.get(`http://127.0.0.1:${this.gatewayPort}/health`, (res) => {
311
+ resolve(res.statusCode === 200);
312
+ });
313
+ req.on('error', reject);
314
+ req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
315
+ });
316
+ clearInterval(interval);
317
+ clearTimeout(timeout);
318
+ console.log(`[OpenClaw WS] Gateway 启动成功 (${waited}s)`);
319
+ resolve(true);
320
+ } catch {
321
+ if (waited >= 15) {
322
+ clearInterval(interval);
323
+ clearTimeout(timeout);
324
+ console.error(`[OpenClaw WS] Gateway 启动超时 (15s)`);
325
+ resolve(false);
326
+ }
327
+ }
328
+ }, 1000);
329
+
330
+ const timeout = setTimeout(() => {
331
+ clearInterval(interval);
332
+ resolve(false);
333
+ }, 16000);
334
+ });
335
+
336
+ return started;
337
+ } finally {
338
+ this._gatewayStarting = false;
339
+ }
340
+ }
341
+
342
+ addLog(msg) {
343
+ const entry = `[${new Date().toLocaleTimeString()}] ${msg}`;
344
+ this.logs.push(entry);
345
+ if (this.logs.length > this.maxLogSize) {
346
+ this.logs.shift();
347
+ }
348
+ console.log(`[OpenClaw WS] ${msg}`);
349
+ }
350
+
351
+ on(event, handler) {
352
+ if (!this.eventListeners.has(event)) {
353
+ this.eventListeners.set(event, []);
354
+ }
355
+ this.eventListeners.get(event).push(handler);
356
+ }
357
+
358
+ off(event, handler) {
359
+ if (!this.eventListeners.has(event)) return;
360
+ const handlers = this.eventListeners.get(event);
361
+ const idx = handlers.indexOf(handler);
362
+ if (idx >= 0) handlers.splice(idx, 1);
363
+ }
364
+
365
+ emit(event, msg) {
366
+ if (!this.eventListeners.has(event)) return;
367
+ this.eventListeners.get(event).forEach(h => h(msg));
368
+ }
369
+
370
+ // ============ 设备身份 ============
371
+
372
+ /**
373
+ * 生成设备身份
374
+ */
375
+ async createDeviceIdentity() {
376
+ // 生成 32 字节私钥种子 (@noble/ed25519 格式)
377
+ const privateKey = utils.randomSecretKey();
378
+ const publicKey = await getPublicKeyAsync(privateKey);
379
+
380
+ // deviceId = SHA256(publicKey) in hex
381
+ const hash = crypto.createHash('sha256').update(Buffer.from(publicKey)).digest();
382
+ const deviceId = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
383
+
384
+ return {
385
+ deviceId,
386
+ publicKey: this.base64UrlEncode(publicKey),
387
+ privateKey: this.base64UrlEncode(privateKey) // base64 encode 存储
388
+ };
389
+ }
390
+
391
+ base64UrlEncode(bytes) {
392
+ return Buffer.from(bytes).toString('base64')
393
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
394
+ }
395
+
396
+ base64UrlDecode(input) {
397
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
398
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
399
+ return new Uint8Array(Buffer.from(padded, 'base64'));
400
+ }
401
+
402
+ generateId() {
403
+ return `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
404
+ }
405
+
406
+ // ============ 认证 ============
407
+
408
+ /**
409
+ * 构建认证 payload
410
+ */
411
+ buildAuthPayload(params) {
412
+ return [
413
+ 'v2',
414
+ params.deviceId,
415
+ params.clientId,
416
+ params.clientMode,
417
+ params.role,
418
+ params.scopes.join(','),
419
+ String(params.signedAtMs),
420
+ params.token ?? '',
421
+ params.nonce,
422
+ ].join('|');
423
+ }
424
+
425
+ /**
426
+ * 签名 payload
427
+ */
428
+ async signPayload(privateKeyBase64Url, payload) {
429
+ const key = this.base64UrlDecode(privateKeyBase64Url);
430
+ const data = new TextEncoder().encode(payload);
431
+ const sig = await signAsync(data, key);
432
+ return this.base64UrlEncode(sig);
433
+ }
434
+
435
+ // ============ WebSocket 连接管理 ============
436
+
437
+ /**
438
+ * 连接 WebSocket
439
+ */
440
+ async connect() {
441
+ if (this.connecting || this.connected) {
442
+ console.log('[OpenClaw WS] 已经在连接中或已连接');
443
+ return;
444
+ }
445
+
446
+ if (!this.authToken) {
447
+ console.error('[OpenClaw WS] 无法连接: 没有有效的 auth token,请先配置 OpenClaw Gateway');
448
+ return;
449
+ }
450
+
451
+ this.connecting = true;
452
+ console.log('[OpenClaw WS] 正在连接:', this.gatewayUrl);
453
+
454
+ return new Promise((resolve, reject) => {
455
+ // 连接超时处理
456
+ const connectionTimeout = setTimeout(() => {
457
+ if (!this.connected) {
458
+ this.addLog('❌ 连接超时');
459
+ this.connecting = false;
460
+ this.ws?.close();
461
+ this.scheduleReconnect();
462
+ reject(new Error('连接超时'));
463
+ }
464
+ }, 30000);
465
+
466
+ try {
467
+ this.ws = new WebSocket(this.gatewayUrl);
468
+ } catch (err) {
469
+ console.error('[OpenClaw WS] 创建 WebSocket 失败:', err.message);
470
+ this.connecting = false;
471
+ clearTimeout(connectionTimeout);
472
+ this.scheduleReconnect();
473
+ reject(err);
474
+ return;
475
+ }
476
+
477
+ this.ws.on('open', () => {
478
+ console.log('[OpenClaw WS] ✅ WebSocket 已连接,等待认证...');
479
+ this.addLog('🔌 WebSocket 已连接,等待认证...');
480
+ });
481
+
482
+ this.ws.on('message', async (data) => {
483
+ try {
484
+ const msg = JSON.parse(data.toString());
485
+ // 噪音事件只打印到控制台,不写入日志缓冲区
486
+ const hideEvents = ['event health', 'event tick', 'event agent', 'event chat', 'res'];
487
+ const eventStr = msg.type + ' ' + (msg.event || msg.method || '');
488
+ const isNoise = hideEvents.some(e => eventStr.includes(e));
489
+ if (!isNoise) {
490
+ console.log('[OpenClaw WS] 📩 收到:', msg.type, msg.event || msg.method || msg.payload?.type);
491
+ this.addLog(`📩 收到: ${msg.type} ${msg.event || msg.method || msg.payload?.type || ''}`);
492
+ }
493
+
494
+ await this.handleMessage(msg, resolve, connectionTimeout);
495
+ } catch (e) {
496
+ console.error('[OpenClaw WS] 解析消息失败:', e.message, data.toString().substring(0, 200));
497
+ }
498
+ });
499
+
500
+ this.ws.on('error', (err) => {
501
+ console.error('[OpenClaw WS] ❌ 连接错误:', err.message);
502
+ this.connecting = false;
503
+ clearTimeout(connectionTimeout);
504
+ this.scheduleReconnect();
505
+ reject(err);
506
+ });
507
+
508
+ this.ws.on('close', (code, reason) => {
509
+ console.log(`[OpenClaw WS] 🔌 连接关闭 (code: ${code}, reason: ${reason || '无'})`);
510
+ this.addLog(`🔌 连接关闭 (code: ${code}, reason: ${reason || '无'})`);
511
+ this.connected = false;
512
+ this.connecting = false;
513
+ this.ws = null;
514
+
515
+ // 如果启用了自动回复,尝试重连
516
+ if (this.enabled) {
517
+ this.scheduleReconnect();
518
+ }
519
+ });
520
+ }).catch(err => {
521
+ // Promise 被拒绝时的处理已在上面完成
522
+ console.log('[OpenClaw WS] 连接 Promise 被拒绝:', err.message);
523
+ });
524
+ }
525
+
526
+ /**
527
+ * 处理收到的消息
528
+ */
529
+ async handleMessage(msg, resolve, connectionTimeout) {
530
+ // 处理连接挑战
531
+ if (msg.type === 'event' && msg.event === 'connect.challenge') {
532
+ console.log('[OpenClaw WS] 🔐 收到认证挑战');
533
+
534
+ try {
535
+ this.device = await this.createDeviceIdentity();
536
+
537
+ const signedAtMs = Date.now();
538
+ const payload = this.buildAuthPayload({
539
+ deviceId: this.device.deviceId,
540
+ clientId: 'cli',
541
+ clientMode: 'cli',
542
+ role: 'operator',
543
+ scopes: ['operator.read', 'operator.write', 'operator.admin'],
544
+ signedAtMs,
545
+ token: this.authToken,
546
+ nonce: msg.payload.nonce
547
+ });
548
+
549
+ const signature = await this.signPayload(this.device.privateKey, payload);
550
+
551
+ this.send({
552
+ type: 'req',
553
+ id: this.generateId(),
554
+ method: 'connect',
555
+ params: {
556
+ minProtocol: this._protocolVer,
557
+ maxProtocol: this._protocolVer,
558
+ client: {
559
+ id: 'cli',
560
+ version: '1.0.0',
561
+ platform: 'windows',
562
+ mode: 'cli'
563
+ },
564
+ role: 'operator',
565
+ scopes: ['operator.read', 'operator.write', 'operator.admin'],
566
+ auth: { token: this.authToken },
567
+ device: {
568
+ id: this.device.deviceId,
569
+ publicKey: this.device.publicKey,
570
+ signature: signature,
571
+ signedAt: signedAtMs,
572
+ nonce: msg.payload.nonce
573
+ },
574
+ locale: 'zh-CN',
575
+ userAgent: 'voko-im-websocket/1.0'
576
+ }
577
+ });
578
+ } catch (err) {
579
+ console.error('[OpenClaw WS] 认证挑战处理失败:', err.message);
580
+ this.ws?.close();
581
+ }
582
+ }
583
+
584
+ // 处理认证响应
585
+ if (msg.type === 'res' && msg.ok) {
586
+ const wasConnected = this.connected; // 记录重连前状态
587
+ const hadSubscribed = this.subscribedSessions.size;
588
+ const wasReconnecting = this.reconnectAttempts > 0; // 是否在重连中
589
+ console.log('[OpenClaw WS] ✅ 认证成功 wasConnected=' + wasConnected + ' hadSubscribed=' + hadSubscribed + ' wasReconnecting=' + wasReconnecting);
590
+ this.addLog('✅ 认证成功');
591
+ this.connected = true;
592
+ this.connecting = false;
593
+ this.emit('connected');
594
+ // 使用默认 session key
595
+ this.sessionId = msg.payload?.sessionDefaults?.mainSessionKey || 'agent:main:main';
596
+ this.reconnectAttempts = 0; // 重置重连计数
597
+
598
+ console.log(`[OpenClaw WS] 认证完成 pending=${this.pendingSubscriptions.size}`);
599
+
600
+ clearTimeout(connectionTimeout);
601
+
602
+ // Gateway 重连后,清除所有订阅状态
603
+ // 只有在真正重连场景下(有重连计数且认证前已订阅)才需要清除
604
+ // 防止首次连接时错误清除订阅状态
605
+ if (wasReconnecting && wasConnected && hadSubscribed > 0) {
606
+ console.log(`[OpenClaw WS] 🔄 重连后清除 ${hadSubscribed} 个旧订阅状态`);
607
+ this.subscribedSessions.clear();
608
+ }
609
+
610
+ // 发送队列中的消息
611
+ this.processMessageQueue();
612
+
613
+ if (resolve) resolve();
614
+ }
615
+
616
+ // 处理认证失败
617
+ if (msg.type === 'res' && !msg.ok && msg.error) {
618
+ console.error('[OpenClaw WS] ❌ 认证失败:', msg.error.message);
619
+ this.addLog(`❌ 认证失败: ${msg.error.message}`);
620
+
621
+ // 如果是 token 无效,尝试重新加载配置
622
+ if (msg.error.message?.includes('token') || msg.error.code === 'UNAUTHORIZED') {
623
+ console.log('[OpenClaw WS] Token 可能已过期,尝试重新加载配置...');
624
+ this.loadConfig();
625
+ }
626
+
627
+ // 协议版本不匹配 → 降级到 v3 重试
628
+ if (msg.error.message?.includes('protocol')) {
629
+ if (this._protocolVer === 4) {
630
+ this._protocolVer = 3;
631
+ console.log('[OpenClaw WS] 协议版本不匹配,降级到 v3 重试...');
632
+ this.addLog('⬇️ 协议版本不匹配,降级到 v3');
633
+ this.ws?.close();
634
+ this.connecting = false;
635
+ this.scheduleReconnect(100);
636
+ } else {
637
+ console.error('[OpenClaw WS] v3 也不匹配,停止重试');
638
+ this.addLog('❌ 协议版本不兼容');
639
+ }
640
+ }
641
+ }
642
+
643
+ // 处理订阅响应
644
+ // 注意: msg.id 可能与发送时不一致,改用 payload.key 匹配
645
+ if (msg.type === 'res') {
646
+ const hasSubscribed = msg.payload?.subscribed !== undefined;
647
+ const hasKey = !!msg.payload?.key;
648
+ console.log(`[OpenClaw WS] 📩 收到 res id=${msg.id} subscribed=${msg.payload?.subscribed} key=${msg.payload?.key} full=${JSON.stringify(msg.payload)}`);
649
+
650
+ if (hasSubscribed && hasKey) {
651
+ const key = msg.payload.key;
652
+ const subscribed = msg.payload.subscribed;
653
+
654
+ if (this.pendingSubscriptions.has(key)) {
655
+ const pending = this.pendingSubscriptions.get(key);
656
+ const elapsed = Date.now() - pending.timestamp;
657
+ this.pendingSubscriptions.delete(key);
658
+
659
+ if (subscribed) {
660
+ this.subscribedSessions.add(key);
661
+ console.log(`[OpenClaw WS] ✅ 订阅成功 sessionKey=${key} 耗时=${elapsed}ms`);
662
+ } else {
663
+ console.log(`[OpenClaw WS] ❌ 订阅失败 sessionKey=${key} 耗时=${elapsed}ms`);
664
+ }
665
+ }
666
+ }
667
+ }
668
+
669
+ // 处理助手回复(流式)
670
+ if (msg.type === 'event' && msg.event === 'session.message') {
671
+ // 去重:final 消息 30 秒内跳过重复
672
+ const _finalMsg = msg.payload?.message;
673
+ if (_finalMsg && _finalMsg.stopReason === 'stop') {
674
+ let _fullText = '';
675
+ const _contentArr = _finalMsg.content || [];
676
+ if (Array.isArray(_contentArr)) {
677
+ for (const _item of _contentArr) { if (_item.type === 'text' && _item.text) _fullText += _item.text; }
678
+ } else if (typeof _contentArr === 'string') _fullText = _contentArr;
679
+ const _dedupKey = (msg.payload.sessionKey || '') + ':' + _fullText.substring(0, 100);
680
+ const _lastTime = this._processedMsgs.get(_dedupKey);
681
+ if (_lastTime && Date.now() - _lastTime < 30000) {
682
+ console.log('[OpenClaw WS] ⏭️ 跳过重复的 final 消息');
683
+ return;
684
+ }
685
+ this._processedMsgs.set(_dedupKey, Date.now());
686
+ }
687
+ // 触发 session.message 事件
688
+ this.emit('session.message', msg);
689
+ // payload 结构: {sessionKey, message: {role, content, done}, ...}
690
+ const innerMsg = msg.payload?.message;
691
+ if (!innerMsg) return;
692
+
693
+ let sessionKey = msg.payload.sessionKey || '';
694
+ // 解码:gateway 返回的 key 是全小写的,还原为原始大小写
695
+ if (sessionKey && this._caseMap.has(sessionKey)) {
696
+ sessionKey = this._caseMap.get(sessionKey);
697
+ }
698
+ const role = innerMsg.role;
699
+ const rawContent = innerMsg.content || [];
700
+ const isFinal = innerMsg.stopReason === 'stop';
701
+
702
+ // 从 sessionKey 提取 visitorId 和 agentId
703
+ // 格式: agent:{agentId}:{visitorId}
704
+ let visitorId = null;
705
+ let agentId = null;
706
+ const agentMatch = sessionKey.match(/^agent:([^:]+):(.+)$/);
707
+ if (agentMatch) {
708
+ agentId = agentMatch[1];
709
+ visitorId = agentMatch[2];
710
+ }
711
+
712
+ if (role === 'assistant') {
713
+ // 只提取 type="text" 的内容,过滤掉 thinking
714
+ let text = '';
715
+ if (Array.isArray(rawContent)) {
716
+ for (const item of rawContent) {
717
+ if (item.type === 'text' && item.text) {
718
+ text += item.text;
719
+ }
720
+ }
721
+ } else if (typeof rawContent === 'string') {
722
+ text = rawContent;
723
+ }
724
+
725
+ // 获取该 visitor 的 pending reply 状态
726
+ const pending = visitorId ? this.pendingReplies.get(visitorId) : null;
727
+
728
+ if (text) {
729
+ if (pending) {
730
+ pending.currentReply += text;
731
+ }
732
+ }
733
+
734
+ if (isFinal) {
735
+ const finalReply = pending ? pending.currentReply : text;
736
+ console.log(`[OpenClaw WS] ✅ 收到完整回复 sessionKey=${sessionKey} visitorId=${visitorId} 内容="${finalReply}"`);
737
+
738
+ // 不再直接发送回复到访客,只存储到 pending 状态等待后续处理
739
+ // 清理 pending reply 状态
740
+ if (pending) {
741
+ clearTimeout(pending.timeout);
742
+ if (pending.replyResolve) {
743
+ pending.replyResolve(finalReply);
744
+ }
745
+ this.pendingReplies.delete(visitorId);
746
+ }
747
+
748
+ // 触发事件让其他模块处理(存储和发送)
749
+ this.emit('agent.reply', {
750
+ agentId,
751
+ visitorId,
752
+ content: finalReply,
753
+ sessionKey
754
+ });
755
+ }
756
+ }
757
+ }
758
+
759
+ // 处理错误
760
+ if (msg.type === 'res' && !msg.ok && msg.error) {
761
+ console.error('[OpenClaw WS] ❌ 错误:', msg.error.code, msg.error.message);
762
+ }
763
+ }
764
+
765
+ /**
766
+ * 发送消息到 WebSocket
767
+ */
768
+ send(msg) {
769
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
770
+ try {
771
+ const data = JSON.stringify(msg);
772
+ this.ws.send(data);
773
+ // chat.send 的详细日志在 sendChatSend 中,这里只记录其他方法
774
+ if (msg.method !== 'chat.send') {
775
+ console.log('[OpenClaw WS] 📤 发送:', msg.type, msg.method || msg.event);
776
+ }
777
+ } catch (err) {
778
+ console.error('[OpenClaw WS] 发送失败:', err.message);
779
+ }
780
+ } else {
781
+ try { console.error("[OpenClaw WS] ❌ WebSocket 未连接,无法发送消息"); } catch (_) {}
782
+ }
783
+ }
784
+
785
+ /**
786
+ * 断开连接
787
+ */
788
+ disconnect() {
789
+ // 取消重连定时器
790
+ if (this.reconnectTimer) {
791
+ clearTimeout(this.reconnectTimer);
792
+ this.reconnectTimer = null;
793
+ }
794
+
795
+ if (this.ws) {
796
+ // 正常关闭,不触发重连
797
+ this.ws.removeAllListeners('close');
798
+ this.ws.close(1000, 'Client disconnect');
799
+ this.ws = null;
800
+ }
801
+
802
+ this.connected = false;
803
+ this.connecting = false;
804
+ this.reconnectAttempts = 0;
805
+
806
+ // 清理订阅状态
807
+ this.subscribedSessions.clear();
808
+ this.pendingSubscriptions.clear();
809
+ this._caseMap.clear();
810
+
811
+ // 清理 pending replies
812
+ for (const [visitorId, pending] of this.pendingReplies) {
813
+ clearTimeout(pending.timeout);
814
+ }
815
+ this.pendingReplies.clear();
816
+
817
+ console.log('[OpenClaw WS] 已断开连接');
818
+ }
819
+
820
+ /**
821
+ * 计划重连
822
+ */
823
+ scheduleReconnect(delay = null) {
824
+ if (this.reconnectTimer) {
825
+ return; // 已经在计划重连
826
+ }
827
+
828
+ if (!this.enabled) {
829
+ console.log('[OpenClaw WS] 已禁用,取消重连');
830
+ return;
831
+ }
832
+
833
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
834
+ console.error(`[OpenClaw WS] 重连次数超过限制 (${this.maxReconnectAttempts}),停止重连`);
835
+ this.addLog(`❌ 重连次数超过限制,停止重连`);
836
+ return;
837
+ }
838
+
839
+ this.reconnectAttempts++;
840
+
841
+ // 计算重连延迟(指数退避)
842
+ const actualDelay = delay !== null ? delay : Math.min(
843
+ this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1),
844
+ this.maxReconnectDelay
845
+ );
846
+
847
+ console.log(`[OpenClaw WS] 计划 ${actualDelay}ms 后重连 (尝试 ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
848
+ this.addLog(`⏳ 计划 ${actualDelay}ms 后重连 (第${this.reconnectAttempts}次)`);
849
+
850
+ this.reconnectTimer = setTimeout(() => {
851
+ this.reconnectTimer = null;
852
+ if (this.enabled && !this.connected && !this.connecting) {
853
+ console.log('[OpenClaw WS] 执行重连...');
854
+ this.addLog('🔄 执行重连...');
855
+ this.connect().catch(err => {
856
+ console.error('[OpenClaw WS] 重连失败:', err.message);
857
+ });
858
+ }
859
+ }, actualDelay);
860
+ }
861
+
862
+ // ============ 消息处理 ============
863
+
864
+ /**
865
+ * 发送聊天消息到 OpenClaw(按 visitorId 隔离 pending reply)
866
+ */
867
+ async sendMessage(message, visitorId = 'default', timeoutMs = 60000) {
868
+ if (!this.connected) {
869
+ console.log('[OpenClaw WS] 未连接,尝试连接...');
870
+ try {
871
+ await this.connect();
872
+ } catch (err) {
873
+ console.error('[OpenClaw WS] 连接失败,消息进入队列:', err.message);
874
+ this.queueMessage(message);
875
+ throw new Error('WebSocket 未连接,消息已缓存');
876
+ }
877
+ }
878
+
879
+ return new Promise((resolve, reject) => {
880
+ // 按 visitorId 存储 pending reply 状态
881
+ this.pendingReplies.set(visitorId, {
882
+ replyResolve: resolve,
883
+ currentReply: '',
884
+ timeout: setTimeout(() => {
885
+ const pending = this.pendingReplies.get(visitorId);
886
+ if (pending?.replyResolve) {
887
+ console.log('[OpenClaw WS] 等待回复超时 visitor=' + visitorId);
888
+ pending.replyResolve(pending.currentReply || '收到你的消息');
889
+ this.pendingReplies.delete(visitorId);
890
+ }
891
+ }, timeoutMs)
892
+ });
893
+
894
+ this.send({
895
+ type: 'req',
896
+ id: this.generateId(),
897
+ method: 'chat.send',
898
+ params: {
899
+ sessionKey: this.sessionId || 'agent:main:main',
900
+ message: message,
901
+ deliver: false,
902
+ idempotencyKey: this.generateId()
903
+ }
904
+ });
905
+ });
906
+ }
907
+
908
+ /**
909
+ * 将消息加入队列(离线缓存)
910
+ */
911
+ queueMessage(item) {
912
+ // 支持简单消息或带类型的对象
913
+ const message = typeof item === 'string' ? item : item.message;
914
+ const type = typeof item === 'string' ? 'simple' : (item.type || 'simple');
915
+
916
+ if (this.messageQueue.length >= this.maxQueueSize) {
917
+ console.warn('[OpenClaw WS] 消息队列已满,丢弃最旧的消息');
918
+ this.messageQueue.shift();
919
+ }
920
+ this.messageQueue.push({
921
+ type,
922
+ message,
923
+ sessionKey: item.sessionKey,
924
+ extraData: item.extraData || null,
925
+ timestamp: Date.now()
926
+ });
927
+ console.log('[OpenClaw WS] 消息已加入队列 type=' + type + ' 当前队列大小:', this.messageQueue.length);
928
+ }
929
+
930
+ /**
931
+ * 处理队列中的消息
932
+ */
933
+ async processMessageQueue() {
934
+ if (this.messageQueue.length === 0) return;
935
+
936
+ console.log('[OpenClaw WS] 处理队列中的', this.messageQueue.length, '条消息');
937
+
938
+ const queue = [...this.messageQueue];
939
+ this.messageQueue = [];
940
+
941
+ for (const item of queue) {
942
+ try {
943
+ // 跳过超过 5 分钟的消息
944
+ if (Date.now() - item.timestamp > 5 * 60 * 1000) {
945
+ console.log('[OpenClaw WS] 跳过过期消息');
946
+ continue;
947
+ }
948
+
949
+ if (item.type === 'sendToSession') {
950
+ // 处理 sendToSession 类型的消息
951
+ console.log(`[OpenClaw WS] 处理队列中的sendToSession sessionKey=${item.sessionKey}`);
952
+ this.sendToSession(item.sessionKey, item.message, item.extraData).then(() => {
953
+ if (item.onSent) item.onSent();
954
+ });
955
+ } else {
956
+ // 处理简单消息
957
+ const reply = await this.sendMessage(item.message);
958
+ console.log('[OpenClaw WS] 队列消息处理完成:', reply?.substring(0, 50));
959
+ }
960
+ } catch (err) {
961
+ console.error('[OpenClaw WS] 队列消息处理失败:', err.message);
962
+ }
963
+ }
964
+ }
965
+
966
+ // ============ 工具方法 ============
967
+
968
+ // 从 _caseMap 还原原始大小写的 visitorId
969
+ getOriginalVisitorId(lowercaseId, agentId) {
970
+ if (!lowercaseId || !agentId) return null;
971
+ const lowerKey = `agent:${agentId}:${lowercaseId.toLowerCase()}`;
972
+ const original = this._caseMap.get(lowerKey);
973
+ if (original) {
974
+ const parts = original.split(':');
975
+ return parts[parts.length - 1] || null;
976
+ }
977
+ return null;
978
+ }
979
+
980
+ // 向 _caseMap 添加大小写映射(供 main.js 在消息到达时预填充)
981
+ setCaseMapEntry(agentId, originalVisitorId) {
982
+ if (!agentId || !originalVisitorId) return;
983
+ const originalKey = `agent:${agentId}:${originalVisitorId}`;
984
+ const lowerKey = originalKey.toLowerCase();
985
+ if (originalKey !== lowerKey) {
986
+ this._caseMap.set(lowerKey, originalKey);
987
+ }
988
+ }
989
+
990
+ // 从 sessionKey 提取 visitorId
991
+ extractVisitorId(sessionKey) {
992
+ // agent:gym:http:visitor:sess_visitor123 → sess_visitor123
993
+ const parts = sessionKey.split(':');
994
+ return parts[parts.length - 1];
995
+ }
996
+
997
+ /**
998
+ * 发送消息到指定 session(不等待回复,用于转发到 gym agent)
999
+ * 发送前先订阅该 session,订阅成功后再发送
1000
+ */
1001
+ sendToSession(sessionKey, message, extraData = null) {
1002
+ return new Promise((resolve, reject) => {
1003
+ // Gateway 内部统一用小写处理,voko 侧对齐
1004
+ const originalKey = sessionKey;
1005
+ sessionKey = sessionKey.toLowerCase();
1006
+ if (originalKey !== sessionKey) {
1007
+ this._caseMap.set(sessionKey, originalKey);
1008
+ }
1009
+ const now = Date.now();
1010
+ console.log(`[OpenClaw WS] 🚀 sendToSession sessionKey=${sessionKey} t=${now}`);
1011
+
1012
+ // 如果已经订阅过该 session,直接发送消息
1013
+ if (this.subscribedSessions.has(sessionKey)) {
1014
+ console.log(`[OpenClaw WS] 📤 已订阅,直接发送chat.send sessionKey=${sessionKey} t=${now}`);
1015
+ this.sendChatSend(sessionKey, message, extraData, now);
1016
+ resolve();
1017
+ return;
1018
+ }
1019
+
1020
+ // 如果正在等待订阅确认,不重复订阅,排队等待
1021
+ if (this.pendingSubscriptions.has(sessionKey)) {
1022
+ console.log(`[OpenClaw WS] 订阅中,排队等待 sessionKey=${sessionKey}`);
1023
+ this.queueMessage({ type: 'sendToSession', sessionKey, message, extraData, timestamp: now, onSent: resolve });
1024
+ return;
1025
+ }
1026
+
1027
+ // 如果未连接或正在连接中,等待连接完成
1028
+ if (!this.connected || this.connecting) {
1029
+ console.log(`[OpenClaw WS] ⚠️ 未连接或连接中,等待... sessionKey=${sessionKey} connected=${this.connected} connecting=${this.connecting}`);
1030
+ // 放入队列,连接成功后会自动处理
1031
+ this.queueMessage({ type: 'sendToSession', sessionKey, message, extraData, timestamp: now, onSent: resolve });
1032
+ return;
1033
+ }
1034
+
1035
+ // 发起订阅请求
1036
+ console.log(`[OpenClaw WS] 📤 发起订阅请求 sessionKey=${sessionKey} t=${now}`);
1037
+ const reqId = this.generateId();
1038
+ this.pendingSubscriptions.set(sessionKey, { timestamp: now, id: reqId, onSent: resolve, extraData });
1039
+
1040
+ this.send({
1041
+ type: 'req',
1042
+ id: reqId,
1043
+ method: 'sessions.messages.subscribe',
1044
+ params: { key: sessionKey }
1045
+ });
1046
+
1047
+ // 等待订阅确认后发送消息
1048
+ const waitForSubscribe = () => {
1049
+ const pending = this.pendingSubscriptions.get(sessionKey);
1050
+ if (!pending) {
1051
+ // 订阅已完成(成功或失败)
1052
+ if (this.subscribedSessions.has(sessionKey)) {
1053
+ console.log(`[OpenClaw WS] 📤 订阅确认后发送chat.send sessionKey=${sessionKey} t=${Date.now()}`);
1054
+ this.sendChatSend(sessionKey, message, extraData, Date.now());
1055
+ resolve();
1056
+ } else {
1057
+ console.log(`[OpenClaw WS] ❌ 订阅失败,跳过消息发送 sessionKey=${sessionKey}`);
1058
+ resolve(); // 也 resolve,避免永久等待
1059
+ }
1060
+ } else {
1061
+ // 还在等待,继续检查
1062
+ setTimeout(waitForSubscribe, 50);
1063
+ }
1064
+ };
1065
+
1066
+ // 延迟检查订阅状态(订阅响应应该很快)
1067
+ setTimeout(waitForSubscribe, 100);
1068
+ });
1069
+ }
1070
+
1071
+ /**
1072
+ * 发送 chat.send 消息(结构化 JSON 格式)
1073
+ */
1074
+ sendChatSend(sessionKey, message, extraData = null, sendTimestamp) {
1075
+ // 格式: agent:{agentId}:{visitorId}
1076
+ let visitorId = null;
1077
+ const agentMatch = sessionKey.match(/^agent:([^:]+):(.+)$/);
1078
+ if (agentMatch) {
1079
+ visitorId = agentMatch[2];
1080
+ }
1081
+ // 构造结构化 JSON(去掉 untrusted 标记)
1082
+ const structuredMsg = JSON.stringify({
1083
+ type: 'message',
1084
+ content: message,
1085
+ fromUid: visitorId || '',
1086
+ channelId: extraData?.channelId || visitorId || '',
1087
+ channelType: extraData?.channelType ?? 1,
1088
+ contentType: extraData?.contentType ?? 1,
1089
+ messageId: extraData?.messageId || '',
1090
+ timestamp: extraData?.timestamp || Math.floor((sendTimestamp || Date.now()) / 1000)
1091
+ });
1092
+ console.log(`[OpenClaw WS] 📤 发送chat.send sessionKey=${sessionKey} visitorId=${visitorId} t=${sendTimestamp}`);
1093
+
1094
+ this.send({
1095
+ type: 'req',
1096
+ id: this.generateId(),
1097
+ method: 'chat.send',
1098
+ params: {
1099
+ sessionKey: sessionKey,
1100
+ message: structuredMsg,
1101
+ deliver: false,
1102
+ idempotencyKey: this.generateId()
1103
+ }
1104
+ });
1105
+ }
1106
+
1107
+ /**
1108
+ * 处理 IM 新消息(入口方法)
1109
+ */
1110
+ async onNewMessage(channelId, messageContent, agentId) {
1111
+ console.log(`[OpenClaw WS] onNewMessage 被调用 - Channel: ${channelId}`);
1112
+ console.log(`[OpenClaw WS] 当前状态: enabled=${this.enabled}, connected=${this.connected}, connecting=${this.connecting}`);
1113
+ console.log(`[OpenClaw WS] 当前处理中的频道:`, Array.from(this.processingChannels));
1114
+
1115
+ if (!this.enabled) {
1116
+ console.log('[OpenClaw WS] ❌ 未启用,跳过处理');
1117
+ return;
1118
+ }
1119
+
1120
+ if (this.processingChannels.has(channelId)) {
1121
+ console.log('[OpenClaw WS] ❌ 该频道正在处理中,跳过');
1122
+ return;
1123
+ }
1124
+
1125
+ try {
1126
+ this.processingChannels.add(channelId);
1127
+ console.log(`[OpenClaw WS] ✅ 开始处理 - Channel: ${channelId}`);
1128
+ console.log(`[OpenClaw WS] 消息内容: ${messageContent?.substring(0, 100)}`);
1129
+
1130
+ // 构建提示词 - 简洁版本,避免双重身份定义
1131
+ const prompt = `用户消息:${messageContent}
1132
+
1133
+ 请直接回复用户,保持友好专业的语气(100字以内)。只输出回复内容。`;
1134
+
1135
+ console.log('[OpenClaw WS] 发送消息到 OpenClaw Gateway...');
1136
+ const reply = await this.sendMessage(prompt, channelId);
1137
+ console.log(`[OpenClaw WS] 收到回复: ${reply?.substring(0, 100)}`);
1138
+
1139
+ if (reply && reply.trim()) {
1140
+ // 发送回复到 VOKO IM
1141
+ await this.sendReply(channelId, reply, agentId);
1142
+ console.log('[OpenClaw WS] ✅ 回复发送成功');
1143
+ } else {
1144
+ console.warn('[OpenClaw WS] ⚠️ 收到空回复,不发送');
1145
+ }
1146
+
1147
+ } catch (err) {
1148
+ console.error('[OpenClaw WS] ❌ 处理失败:', err.message);
1149
+ console.error('[OpenClaw WS] 错误堆栈:', err.stack);
1150
+ } finally {
1151
+ this.processingChannels.delete(channelId);
1152
+ console.log(`[OpenClaw WS] 清理完成 - Channel: ${channelId}`);
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * 发送回复到 VOKO IM HTTP API
1158
+ */
1159
+ sendReply(channelId, content, agentId) {
1160
+ return new Promise((resolve, reject) => {
1161
+ const http = require('http');
1162
+
1163
+ // 剥离 gym agent 消息中的 visitorId 前缀(格式: "{visitorId}\n\n{content}")
1164
+ const prefix = channelId + '\n\n';
1165
+ const cleanContent = content?.startsWith(prefix) ? content.substring(prefix.length) : content;
1166
+
1167
+ const postData = JSON.stringify({
1168
+ toUid: channelId,
1169
+ content: cleanContent,
1170
+ channelType: 1,
1171
+ agentId: agentId
1172
+ });
1173
+
1174
+ console.log(`[OpenClaw WS] 发送回复到 IM API: POST localhost:3002/api/send-message agent=${agentId}`);
1175
+ console.log(`[OpenClaw WS] Channel: ${channelId}, Content: ${cleanContent?.substring(0, 80)}...`);
1176
+
1177
+ const req = http.request({
1178
+ hostname: 'localhost',
1179
+ port: 3002,
1180
+ path: '/api/send-message',
1181
+ method: 'POST',
1182
+ headers: {
1183
+ 'Content-Type': 'application/json',
1184
+ 'Content-Length': Buffer.byteLength(postData)
1185
+ }
1186
+ }, (res) => {
1187
+ let data = '';
1188
+ res.on('data', chunk => data += chunk);
1189
+ res.on('end', () => {
1190
+ console.log(`[OpenClaw WS] IM API 响应: ${res.statusCode}, ${data}`);
1191
+ if (res.statusCode >= 200 && res.statusCode < 300) {
1192
+ // 发送成功后,通知渲染进程显示这条消息
1193
+ const timestamp = Date.now() / 1000;
1194
+ bus.emit('gym:reply-sent', {
1195
+ channelId,
1196
+ content: cleanContent,
1197
+ timestamp,
1198
+ agentId
1199
+ });
1200
+ resolve(data);
1201
+ } else {
1202
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
1203
+ }
1204
+ });
1205
+ });
1206
+
1207
+ req.on('error', (err) => {
1208
+ console.error('[OpenClaw WS] 发送回复失败:', err.message);
1209
+ reject(err);
1210
+ });
1211
+
1212
+ req.write(postData);
1213
+ req.end();
1214
+ });
1215
+ }
1216
+
1217
+ /**
1218
+ * 销毁处理器(清理资源)
1219
+ */
1220
+ destroy() {
1221
+ console.log('[OpenClaw WS] 销毁处理器...');
1222
+ this.stopConfigWatcher();
1223
+ this.disconnect();
1224
+ this.messageQueue = [];
1225
+ }
1226
+
1227
+ /**
1228
+ * 测试到 main agent 的连接
1229
+ */
1230
+ testMainAgent() {
1231
+ return new Promise((resolve) => {
1232
+ const testSessionKey = 'agent:main:main';
1233
+ const testMessage = '[VOKO连接测试] 这是一条来自voko-desktop的WS连接测试消息,如果正常收到,请回复:已正常接收VOKO消息';
1234
+ this.addLog(`🧪 发送测试消息到 ${testSessionKey}...`);
1235
+ this.addLog(`📤 发送内容: ${testMessage}`);
1236
+
1237
+ if (!this.connected) {
1238
+ this.addLog('❌ 未连接,无法发送测试消息');
1239
+ resolve({ success: false, error: '未连接' });
1240
+ return;
1241
+ }
1242
+
1243
+ let gotEcho = false;
1244
+ const sendTime = Date.now();
1245
+ const handler = (msg) => {
1246
+ if (msg.type === 'event' && msg.event === 'session.message') {
1247
+ const innerMsg = msg.payload?.message;
1248
+ if (!innerMsg) return;
1249
+ const rawContent = innerMsg.content || [];
1250
+ let text = '';
1251
+ if (Array.isArray(rawContent)) {
1252
+ for (const item of rawContent) {
1253
+ if (item.type === 'text' && item.text) text += item.text;
1254
+ }
1255
+ } else if (typeof rawContent === 'string') {
1256
+ text = rawContent;
1257
+ }
1258
+ if (!text) return;
1259
+ // 跳过第一次回显,只处理真正的回复
1260
+ if (text.includes('[VOKO连接测试]')) {
1261
+ gotEcho = true;
1262
+ return;
1263
+ }
1264
+ const cleanText = text.replace(/\n/g, ' ').trim();
1265
+ const elapsed = Math.round((Date.now() - sendTime) / 1000);
1266
+ this.addLog(`🤖 Agent回复: ${cleanText}(耗时${elapsed}秒)`);
1267
+ this.off('session.message', handler);
1268
+ clearTimeout(timer);
1269
+ resolve({ success: true, reply: text, elapsed });
1270
+ }
1271
+ };
1272
+ this.on('session.message', handler);
1273
+
1274
+ const timer = setTimeout(() => {
1275
+ this.off('session.message', handler);
1276
+ this.addLog('⏰ 测试超时(60秒内无回复)');
1277
+ resolve({ success: false, error: '超时' });
1278
+ }, 60000);
1279
+
1280
+ this.sendToSession(testSessionKey, testMessage).catch(err => {
1281
+ clearTimeout(timer);
1282
+ this.off('session.message', handler);
1283
+ this.addLog(`❌ 测试失败: ${err.message}`);
1284
+ resolve({ success: false, error: err.message });
1285
+ });
1286
+ });
1287
+ }
1288
+ }
1289
+
1290
+ module.exports = OpenClawWebSocketHandler;