@quukk/opencode-clawmessenger 0.1.0

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 (105) hide show
  1. package/README.md +526 -0
  2. package/bin/opencode-clawmessenger +2 -0
  3. package/bin/opencode-clawmessenger-setup +5 -0
  4. package/bin/opencode-clawmessenger.cmd +5 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +288 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/core/auto-register.d.ts +24 -0
  10. package/dist/core/auto-register.d.ts.map +1 -0
  11. package/dist/core/auto-register.js +174 -0
  12. package/dist/core/auto-register.js.map +1 -0
  13. package/dist/core/config.d.ts +68 -0
  14. package/dist/core/config.d.ts.map +1 -0
  15. package/dist/core/config.js +80 -0
  16. package/dist/core/config.js.map +1 -0
  17. package/dist/core/daemon.d.ts +19 -0
  18. package/dist/core/daemon.d.ts.map +1 -0
  19. package/dist/core/daemon.js +77 -0
  20. package/dist/core/daemon.js.map +1 -0
  21. package/dist/core/dedup.d.ts +8 -0
  22. package/dist/core/dedup.d.ts.map +1 -0
  23. package/dist/core/dedup.js +25 -0
  24. package/dist/core/dedup.js.map +1 -0
  25. package/dist/core/hook-manager.d.ts +11 -0
  26. package/dist/core/hook-manager.d.ts.map +1 -0
  27. package/dist/core/hook-manager.js +33 -0
  28. package/dist/core/hook-manager.js.map +1 -0
  29. package/dist/core/logger.d.ts +5 -0
  30. package/dist/core/logger.d.ts.map +1 -0
  31. package/dist/core/logger.js +49 -0
  32. package/dist/core/logger.js.map +1 -0
  33. package/dist/core/mac-address.d.ts +2 -0
  34. package/dist/core/mac-address.d.ts.map +1 -0
  35. package/dist/core/mac-address.js +43 -0
  36. package/dist/core/mac-address.js.map +1 -0
  37. package/dist/core/message-handler.d.ts +64 -0
  38. package/dist/core/message-handler.d.ts.map +1 -0
  39. package/dist/core/message-handler.js +879 -0
  40. package/dist/core/message-handler.js.map +1 -0
  41. package/dist/core/ops-assistant.d.ts +26 -0
  42. package/dist/core/ops-assistant.d.ts.map +1 -0
  43. package/dist/core/ops-assistant.js +270 -0
  44. package/dist/core/ops-assistant.js.map +1 -0
  45. package/dist/core/qr-crypto.d.ts +2 -0
  46. package/dist/core/qr-crypto.d.ts.map +1 -0
  47. package/dist/core/qr-crypto.js +66 -0
  48. package/dist/core/qr-crypto.js.map +1 -0
  49. package/dist/core/session-manager.d.ts +22 -0
  50. package/dist/core/session-manager.d.ts.map +1 -0
  51. package/dist/core/session-manager.js +144 -0
  52. package/dist/core/session-manager.js.map +1 -0
  53. package/dist/core/types.d.ts +78 -0
  54. package/dist/core/types.d.ts.map +1 -0
  55. package/dist/core/types.js +26 -0
  56. package/dist/core/types.js.map +1 -0
  57. package/dist/index.d.ts +13 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +12 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/openclaw/client.d.ts +36 -0
  62. package/dist/openclaw/client.d.ts.map +1 -0
  63. package/dist/openclaw/client.js +494 -0
  64. package/dist/openclaw/client.js.map +1 -0
  65. package/dist/opencode/client.d.ts +35 -0
  66. package/dist/opencode/client.d.ts.map +1 -0
  67. package/dist/opencode/client.js +276 -0
  68. package/dist/opencode/client.js.map +1 -0
  69. package/dist/opencode/event-handler.d.ts +38 -0
  70. package/dist/opencode/event-handler.d.ts.map +1 -0
  71. package/dist/opencode/event-handler.js +467 -0
  72. package/dist/opencode/event-handler.js.map +1 -0
  73. package/dist/plugin.d.ts +4 -0
  74. package/dist/plugin.d.ts.map +1 -0
  75. package/dist/plugin.js +84 -0
  76. package/dist/plugin.js.map +1 -0
  77. package/dist/rongcloud/client.d.ts +34 -0
  78. package/dist/rongcloud/client.d.ts.map +1 -0
  79. package/dist/rongcloud/client.js +292 -0
  80. package/dist/rongcloud/client.js.map +1 -0
  81. package/dist/rongcloud/env-polyfill.d.ts +2 -0
  82. package/dist/rongcloud/env-polyfill.d.ts.map +1 -0
  83. package/dist/rongcloud/env-polyfill.js +107 -0
  84. package/dist/rongcloud/env-polyfill.js.map +1 -0
  85. package/dist/rongcloud/server-api.d.ts +38 -0
  86. package/dist/rongcloud/server-api.d.ts.map +1 -0
  87. package/dist/rongcloud/server-api.js +157 -0
  88. package/dist/rongcloud/server-api.js.map +1 -0
  89. package/dist/standalone.d.ts +10 -0
  90. package/dist/standalone.d.ts.map +1 -0
  91. package/dist/standalone.js +229 -0
  92. package/dist/standalone.js.map +1 -0
  93. package/dist/types/plugin.d.ts +20 -0
  94. package/dist/types/plugin.d.ts.map +1 -0
  95. package/dist/types/plugin.js +2 -0
  96. package/dist/types/plugin.js.map +1 -0
  97. package/dist/websocket/client.d.ts +20 -0
  98. package/dist/websocket/client.d.ts.map +1 -0
  99. package/dist/websocket/client.js +88 -0
  100. package/dist/websocket/client.js.map +1 -0
  101. package/dist/websocket/server-client.d.ts +22 -0
  102. package/dist/websocket/server-client.d.ts.map +1 -0
  103. package/dist/websocket/server-client.js +98 -0
  104. package/dist/websocket/server-client.js.map +1 -0
  105. package/package.json +71 -0
@@ -0,0 +1,879 @@
1
+ import { RongyunMessageTypeEnum } from './types.js';
2
+ import { MessageDeduplicator } from './dedup.js';
3
+ import { OpenCodeClient, checkOpencodeStatus } from '../opencode/client.js';
4
+ import { createLogger } from './logger.js';
5
+ const log = createLogger('MessageHandler');
6
+ export class MessageHandler {
7
+ constructor(config, sessionManager, rongClient, opencode) {
8
+ this.config = config;
9
+ this.sessionManager = sessionManager;
10
+ this.rongClient = rongClient;
11
+ this.opencode = opencode;
12
+ // 运维助手使用独立的 OpenCode server(端口19877)
13
+ // 优先级:环境变量 CLAW_OPS_OPENCODE_DIR > process.cwd()
14
+ const opsDir = process.env.CLAW_OPS_OPENCODE_DIR || process.cwd();
15
+ this.opsOpencode = new OpenCodeClient({
16
+ baseUrl: 'http://127.0.0.1:19877',
17
+ directory: opsDir,
18
+ });
19
+ this.dedup = new MessageDeduplicator();
20
+ this.pendingRequests = new Map();
21
+ }
22
+ /**
23
+ * 发送消息给指定用户或群组
24
+ * 供 agent 智能体调用,支持:
25
+ * - 私聊:sendToUser('userId', '消息内容')
26
+ * - 群聊:sendToUser('groupId', '消息内容', { conversationType: 3 })
27
+ */
28
+ async sendToUser(targetId, content, options = {}) {
29
+ const { conversationType = 1, extra } = options;
30
+ if (!targetId || !content) {
31
+ log.warn({ targetId, hasContent: !!content }, 'sendToUser: 缺少 targetId 或 content');
32
+ return { success: false, error: '缺少 targetId 或 content' };
33
+ }
34
+ try {
35
+ let messageContent;
36
+ if (extra && Object.keys(extra).length > 0) {
37
+ messageContent = JSON.stringify({ content, ...extra });
38
+ }
39
+ else {
40
+ messageContent = content;
41
+ }
42
+ await this.rongClient.sendMessage(targetId, messageContent, conversationType);
43
+ log.info({ targetId, conversationType, contentPreview: content.substring(0, 100) }, 'sendToUser: 消息发送成功');
44
+ return { success: true };
45
+ }
46
+ catch (err) {
47
+ log.error({ err, targetId, conversationType }, 'sendToUser: 消息发送失败');
48
+ return { success: false, error: err.message || String(err) };
49
+ }
50
+ }
51
+ async handleMessage(msg) {
52
+ try {
53
+ // 过滤已读回执消息,避免日志噪音
54
+ if (msg.messageType === 'RC:ReadNtf') {
55
+ log.debug({ messageUId: msg.messageUId, senderUserId: msg.senderUserId }, 'Read receipt notification ignored');
56
+ return;
57
+ }
58
+ if (msg.messageUId && this.dedup.isDuplicate(msg.messageUId)) {
59
+ log.debug({ messageUId: msg.messageUId }, 'Duplicate message filtered');
60
+ return;
61
+ }
62
+ // 发送已读回执(fire-and-forget,不阻塞消息处理)
63
+ // 在消息去重之后、业务处理之前发送,确保只给实际处理的消息发送已读回执
64
+ this.sendReadReceipt(msg);
65
+ let msgContent;
66
+ if (typeof msg.content === 'string') {
67
+ try {
68
+ msgContent = JSON.parse(msg.content);
69
+ }
70
+ catch {
71
+ msgContent = { content: msg.content };
72
+ }
73
+ }
74
+ else if (msg.content && typeof msg.content === 'object') {
75
+ msgContent = msg.content;
76
+ }
77
+ else {
78
+ return;
79
+ }
80
+ let innerContent = {};
81
+ if (msgContent.content && typeof msgContent.content === 'string') {
82
+ try {
83
+ innerContent = JSON.parse(msgContent.content);
84
+ }
85
+ catch {
86
+ innerContent = { content: msgContent.content };
87
+ }
88
+ }
89
+ // 兼容驼峰和下划线两种命名方式
90
+ // 同时检查 msgContent 和 innerContent 的 msg_type,因为融云消息可能嵌套两层
91
+ const customMsgType = msgContent.msg_type || innerContent.msg_type;
92
+ const sourceImId = msgContent.source_im_id || msgContent.sourceImId || msg.senderUserId;
93
+ const destinationImId = msgContent.destination_im_id || msgContent.destinationImId || msg.targetId;
94
+ const requestId = msgContent.request_id || msgContent.requestId;
95
+ const merged = {
96
+ ...msgContent,
97
+ ...innerContent,
98
+ request_id: requestId,
99
+ source_im_id: sourceImId,
100
+ destination_im_id: destinationImId,
101
+ };
102
+ // 过滤系统通知消息(前端通过 __sys_notify__ 标记发送),避免AI响应
103
+ if (msgContent.__sys_notify__ === true || innerContent.__sys_notify__ === true) {
104
+ log.debug({ messageType: msg.messageType, text: msgContent.text || innerContent.text }, 'System notification ignored');
105
+ return;
106
+ }
107
+ log.info({
108
+ messageType: msg.messageType,
109
+ customMsgType,
110
+ senderUserId: msg.senderUserId,
111
+ targetId: msg.targetId,
112
+ contentKeys: Object.keys(msgContent),
113
+ hasMsgType: !!msgContent.msg_type,
114
+ msgContentPreview: JSON.stringify(msgContent).substring(0, 200)
115
+ }, 'Message received details');
116
+ switch (customMsgType || msg.messageType) {
117
+ case RongyunMessageTypeEnum.CREATE_OPENCODE_SESSION:
118
+ case 'create_opencode_session':
119
+ await this.handleCreateOpencodeSession(merged, msg);
120
+ return;
121
+ case 'RC:TxtMsg':
122
+ case 'TextMessage':
123
+ // RC:TxtMsg = 普通文本消息 → 点点(主OpenCode)
124
+ await this.handleChatMessage(merged, msg, customMsgType);
125
+ return;
126
+ case RongyunMessageTypeEnum.CHAT_MESSAGE:
127
+ case 'chat_message':
128
+ // chat_message = 普通聊天消息 → 点点(主OpenCode)
129
+ await this.handleChatMessage(merged, msg, customMsgType);
130
+ return;
131
+ case RongyunMessageTypeEnum.DEVICE_STATUS_REQUEST:
132
+ case 'device_status_request':
133
+ await this.handleDeviceStatusRequest(merged, msg);
134
+ return;
135
+ case RongyunMessageTypeEnum.DEVICE_CONTROL:
136
+ case 'device_control':
137
+ await this.handleDeviceControl(merged, msg);
138
+ return;
139
+ case 'command':
140
+ await this.handleCommand(merged, msg);
141
+ return;
142
+ case RongyunMessageTypeEnum.OPS_CHAT_MESSAGE:
143
+ case 'ops_chat_message':
144
+ await this.handleOpsChatMessage(merged, msg);
145
+ return;
146
+ case RongyunMessageTypeEnum.CREATE_SERVICE_SESSION:
147
+ case 'create_service_session':
148
+ await this.handleCreateServiceSession(merged, msg);
149
+ return;
150
+ case RongyunMessageTypeEnum.SERVICE_CHAT_MESSAGE:
151
+ case 'service_chat_message':
152
+ await this.handleServiceChatMessage(merged, msg);
153
+ return;
154
+ case 'RC:HQVCMsg':
155
+ case 'RC:VCMsg':
156
+ // 高质量语音消息:先语音识别,再作为文本消息处理
157
+ await this.handleVoiceMessage(merged, msg);
158
+ return;
159
+ case RongyunMessageTypeEnum.DELETE_OPENCODE_SESSION:
160
+ case 'delete_opencode_session':
161
+ if (merged.session_id) {
162
+ this.sessionManager.deleteSession(merged.session_id);
163
+ await this.opencode.deleteSession(merged.session_id);
164
+ }
165
+ return;
166
+ case RongyunMessageTypeEnum.COMMAND_RESULT:
167
+ case 'command_result':
168
+ this.handleCommandResult(merged, msg);
169
+ return;
170
+ default:
171
+ log.warn({ messageType: msg.messageType, customMsgType }, 'Unknown message type');
172
+ }
173
+ }
174
+ catch (err) {
175
+ log.error({ err }, '处理消息异常');
176
+ try {
177
+ const targetId = msg.conversationType === 3 ? msg.targetId : msg.senderUserId;
178
+ // 不发送错误消息给 system 等虚拟用户(融云 20604 错误)
179
+ if (targetId && targetId !== 'system') {
180
+ const errorPayload = JSON.stringify({
181
+ content: '处理失败,请稍后重试',
182
+ extra: JSON.stringify({
183
+ from_node: this.config.accountId,
184
+ is_ai: true,
185
+ }),
186
+ });
187
+ await this.rongClient.sendMessage(targetId, errorPayload, msg.conversationType);
188
+ }
189
+ }
190
+ catch { }
191
+ }
192
+ }
193
+ /**
194
+ * 发送已读回执(fire-and-forget,不阻塞消息处理)
195
+ * 在 handleMessage 入口处调用,支持单聊和群聊
196
+ */
197
+ sendReadReceipt(msg) {
198
+ // 跳过自己的消息
199
+ if (msg.messageDirection === 1) {
200
+ return;
201
+ }
202
+ // 需要有效的消息 UID 和时间戳
203
+ if (!msg.messageUId || !msg.sentTime) {
204
+ log.debug({ messageUId: msg.messageUId, sentTime: msg.sentTime }, 'Skip read receipt: invalid messageUId or sentTime');
205
+ return;
206
+ }
207
+ // 本地生成的 messageUId 无法发送已读回执(已在 client.ts 过滤,此处二次保险)
208
+ if (String(msg.messageUId).startsWith('local-')) {
209
+ log.debug({ messageUId: msg.messageUId }, 'Skip read receipt: local messageUId');
210
+ return;
211
+ }
212
+ // fire-and-forget:不 await,避免阻塞消息处理
213
+ this.rongClient.sendReadReceipt(msg).catch((err) => {
214
+ log.warn({ err, messageUId: msg.messageUId }, 'Failed to send read receipt');
215
+ });
216
+ }
217
+ async handleChatMessage(data, msg, originalMsgType) {
218
+ const isGroup = msg.conversationType === 3;
219
+ // 群聊时使用 group_<groupId> 作为 chatId,让 event-handler 正确识别为群聊并回复到群里
220
+ const chatId = isGroup ? `group_${msg.targetId}` : `claw-${msg.senderUserId}`;
221
+ const sessionId = data?.session_id || chatId;
222
+ let content = '';
223
+ if (data?.content) {
224
+ content = typeof data.content === 'string' ? data.content : (data.content.content || JSON.stringify(data.content));
225
+ }
226
+ else if (data?._raw_content) {
227
+ content = typeof data._raw_content === 'string' ? data._raw_content : JSON.stringify(data._raw_content);
228
+ }
229
+ if (!content) {
230
+ log.warn('Chat message content is empty');
231
+ return;
232
+ }
233
+ // 判断是否是设备对话:有 room_id 表示来自 device-chat.vue
234
+ const isDeviceChat = !!data?.room_id;
235
+ if (isDeviceChat) {
236
+ log.info({ sessionId, roomId: data.room_id }, 'Device chat detected, routing to ops assistant');
237
+ await this.handleDeviceChat(data, msg, content);
238
+ return;
239
+ }
240
+ // 群聊 @ 判断逻辑
241
+ if (isGroup) {
242
+ // 从多个可能的位置提取 mentionedInfo(msg.content 可能是字符串或对象)
243
+ let msgContentMentioned;
244
+ if (msg.content && typeof msg.content === 'object') {
245
+ msgContentMentioned = msg.content.mentionedInfo || msg.content.mentioned_info;
246
+ }
247
+ else if (typeof msg.content === 'string') {
248
+ try {
249
+ const parsed = JSON.parse(msg.content);
250
+ msgContentMentioned = parsed.mentionedInfo || parsed.mentioned_info;
251
+ }
252
+ catch { }
253
+ }
254
+ const mentionedInfo = data?.mentionedInfo || data?.mentioned_info || msgContentMentioned;
255
+ log.info({
256
+ sessionId,
257
+ chatId,
258
+ content,
259
+ mentionedInfo: JSON.stringify(mentionedInfo),
260
+ dataKeys: Object.keys(data || {}),
261
+ accountId: this.config.accountId
262
+ }, 'Group chat mention check');
263
+ if (mentionedInfo) {
264
+ const userIdList = mentionedInfo.userIdList || mentionedInfo.user_id_list || [];
265
+ // 融云 @所有人 的判断:userIdList 为空数组(无论 type 是 1 还是 2)
266
+ // 实际测试发现 @所有人 时 type=1 且 userIdList=[],@特定用户时 type=2 且有具体 userId
267
+ const isAllMentioned = !userIdList || userIdList.length === 0;
268
+ const isMentioned = isAllMentioned || userIdList.includes(this.config.accountId);
269
+ log.info({
270
+ userIdList,
271
+ isAllMentioned,
272
+ isMentioned,
273
+ accountId: this.config.accountId
274
+ }, 'Mention check result');
275
+ if (!isMentioned) {
276
+ // @了别的用户,当前 AI 不回复
277
+ log.info('Not mentioned, skipping group chat reply');
278
+ return;
279
+ }
280
+ }
281
+ // 没有 @ 任何人,或者 @ 了当前 AI,继续处理
282
+ }
283
+ log.info({ sessionId, chatId, isGroup, contentLength: content.length }, 'Processing chat message');
284
+ this.sessionManager.updateStatus(chatId, 'busy');
285
+ try {
286
+ const session = await this.sessionManager.getOrCreateSession(chatId, `ClawMessenger ${isGroup ? msg.targetId : msg.senderUserId}`);
287
+ const isChatMessage = originalMsgType === 'chat_message' || originalMsgType === RongyunMessageTypeEnum.CHAT_MESSAGE;
288
+ // 使用异步模式,通过 SSE 事件流实时推送回复
289
+ // OpenCode 会自动加载 directory 下的 .opencode/prompt.md 作为 system prompt
290
+ await this.opencode.sendPromptAsync(session.id, content);
291
+ log.info({ sessionId, chatId, opencodeSessionId: session.id }, 'promptAsync sent, streaming via SSE');
292
+ }
293
+ catch (err) {
294
+ log.error({ err, sessionId, chatId }, '处理聊天消息失败');
295
+ this.sessionManager.updateStatus(chatId, 'idle');
296
+ try {
297
+ const errorPayload = JSON.stringify({
298
+ content: '消息处理失败,请稍后重试',
299
+ extra: JSON.stringify({
300
+ from_node: this.config.accountId,
301
+ is_ai: true,
302
+ }),
303
+ });
304
+ await this.rongClient.sendMessage(msg.conversationType === 3 ? msg.targetId : msg.senderUserId, errorPayload, msg.conversationType);
305
+ }
306
+ catch { }
307
+ }
308
+ }
309
+ async handleDeviceChat(data, msg, content) {
310
+ const roomId = data.room_id;
311
+ const requestId = data.request_id || data.requestId;
312
+ const targetId = data.source_im_id || data.sourceImId || msg.senderUserId;
313
+ log.info({ roomId, targetId, contentLength: content.length }, 'Processing device chat via ops assistant');
314
+ try {
315
+ // 使用运维助手 OpenCodeClient(19877)同步获取回复
316
+ const session = await this.opsOpencode.createSession(`Device-${roomId}`);
317
+ log.info({ sessionId: session.id, roomId }, 'Created ops session for device chat');
318
+ const response = await this.opsOpencode.sendPrompt(session.id, content);
319
+ log.info({ roomId, responseLength: response.length }, 'Ops assistant responded for device chat');
320
+ // 以 CHAT_MESSAGE 类型回复(匹配前端 device-rongyun-client 预期)
321
+ const replyPayload = JSON.stringify({
322
+ msg_type: RongyunMessageTypeEnum.CHAT_MESSAGE,
323
+ request_id: requestId,
324
+ content: response,
325
+ status: 'success',
326
+ room_id: roomId,
327
+ });
328
+ await this.rongClient.sendMessage(targetId, replyPayload, msg.conversationType);
329
+ log.info({ targetId, roomId }, 'Device chat reply sent as CHAT_MESSAGE');
330
+ }
331
+ catch (err) {
332
+ log.error({ err, roomId, targetId }, 'Device chat ops assistant failed');
333
+ // 发送错误回复
334
+ const errorPayload = JSON.stringify({
335
+ msg_type: RongyunMessageTypeEnum.CHAT_MESSAGE,
336
+ request_id: requestId,
337
+ content: '运维助手处理失败: ' + (err.message || '未知错误'),
338
+ status: 'error',
339
+ room_id: roomId,
340
+ });
341
+ await this.rongClient.sendMessage(targetId, errorPayload, msg.conversationType);
342
+ }
343
+ }
344
+ async handleCreateOpencodeSession(data, msg) {
345
+ // 群聊(conversationType=3)时 targetId 是群ID,单聊时使用 source_im_id
346
+ const targetId = msg.conversationType === 3
347
+ ? msg.targetId
348
+ : (data.source_im_id || data.sourceImId);
349
+ const title = data.title || '新会话';
350
+ try {
351
+ const sessionId = `claw-${targetId}`;
352
+ const session = await this.sessionManager.getOrCreateSession(sessionId, title);
353
+ const response = {
354
+ msg_type: RongyunMessageTypeEnum.OPENCODE_SESSION_CREATED,
355
+ request_id: data.request_id,
356
+ source_im_id: data.destination_im_id || msg.targetId,
357
+ destination_im_id: targetId,
358
+ content: JSON.stringify({ status: 'success', opencode_session_id: session.id, session_id: sessionId, title }),
359
+ timestamp: Math.floor(Date.now() / 1000),
360
+ };
361
+ await this.rongClient.sendMessage(targetId, JSON.stringify(response), msg.conversationType);
362
+ }
363
+ catch (err) {
364
+ log.error({ err }, '创建 OpenCode 会话失败');
365
+ const errorResponse = {
366
+ msg_type: RongyunMessageTypeEnum.OPENCODE_SESSION_CREATED,
367
+ request_id: data.request_id,
368
+ source_im_id: data.destination_im_id || msg.targetId,
369
+ destination_im_id: targetId,
370
+ content: JSON.stringify({ status: 'error', message: err.message }),
371
+ timestamp: Math.floor(Date.now() / 1000),
372
+ };
373
+ await this.rongClient.sendMessage(targetId, JSON.stringify(errorResponse), msg.conversationType);
374
+ }
375
+ }
376
+ async handleDeviceStatusRequest(data, msg) {
377
+ // 群聊(conversationType=3)时 targetId 是群ID,单聊时是发送者ID
378
+ const targetId = msg.conversationType === 3
379
+ ? msg.targetId
380
+ : (data.source_im_id || data.sourceImId || msg.senderUserId);
381
+ log.info({ targetId, opencodeUrl: this.config.opencodeUrl }, 'Processing device status request');
382
+ try {
383
+ const opencodeOk = await checkOpencodeStatus(this.config.opencodeUrl, this.config.opencodePassword);
384
+ log.info({ opencodeOk }, 'OpenCode status check result');
385
+ const statusData = {
386
+ open_claw_status: opencodeOk ? 1 : 0,
387
+ status_message: opencodeOk ? '运行中' : '未运行',
388
+ version: 'unknown',
389
+ timestamp: Date.now(),
390
+ };
391
+ const report = {
392
+ msg_type: RongyunMessageTypeEnum.DEVICE_STATUS_REPORT,
393
+ request_id: data.request_id,
394
+ source_im_id: this.config.accountId,
395
+ destination_im_id: targetId,
396
+ content: JSON.stringify(statusData),
397
+ timestamp: Math.floor(Date.now() / 1000),
398
+ };
399
+ await this.rongClient.sendMessage(targetId, JSON.stringify(report), msg.conversationType);
400
+ log.info({ targetId, status: statusData.status_message }, 'Device status report sent');
401
+ }
402
+ catch (err) {
403
+ log.error({ err: err.message || err, targetId }, '设备状态查询异常');
404
+ // 发送错误回复
405
+ const errorReport = {
406
+ msg_type: RongyunMessageTypeEnum.DEVICE_STATUS_REPORT,
407
+ request_id: data.request_id,
408
+ source_im_id: this.config.accountId,
409
+ destination_im_id: targetId,
410
+ content: JSON.stringify({
411
+ open_claw_status: -1,
412
+ status_message: '状态查询失败: ' + (err.message || '未知错误'),
413
+ timestamp: Date.now(),
414
+ }),
415
+ timestamp: Math.floor(Date.now() / 1000),
416
+ };
417
+ try {
418
+ await this.rongClient.sendMessage(targetId, JSON.stringify(errorReport), msg.conversationType);
419
+ }
420
+ catch (sendErr) {
421
+ log.error({ sendErr }, 'Failed to send device status error report');
422
+ }
423
+ }
424
+ }
425
+ async handleDeviceControl(data, msg) {
426
+ // 群聊(conversationType=3)时 targetId 是群ID,单聊时是发送者ID
427
+ const targetId = msg.conversationType === 3
428
+ ? msg.targetId
429
+ : (data.source_im_id || data.sourceImId || msg.senderUserId);
430
+ // 解析 content 字段中的 JSON(文档规范:content 包含 {"cmd": 1})
431
+ let commandContent = {};
432
+ try {
433
+ if (data.content && typeof data.content === 'string') {
434
+ commandContent = JSON.parse(data.content);
435
+ }
436
+ else if (data.content && typeof data.content === 'object') {
437
+ commandContent = data.content;
438
+ }
439
+ }
440
+ catch {
441
+ commandContent = {};
442
+ }
443
+ const cmd = commandContent.cmd;
444
+ const cmdNames = {
445
+ 1: 'start',
446
+ 2: 'stop',
447
+ 3: 'restart',
448
+ 4: 'status',
449
+ 5: 'config_fix',
450
+ };
451
+ const cmdName = cmdNames[cmd] || `unknown(${cmd})`;
452
+ log.info({ targetId, cmd, cmdName }, 'Processing device control');
453
+ const result = {
454
+ msg_type: RongyunMessageTypeEnum.DEVICE_CONTROL_RESULT,
455
+ request_id: data.request_id,
456
+ command: cmdName,
457
+ cmd: cmd,
458
+ status: 'success',
459
+ message: `命令 ${cmdName} 已接收`,
460
+ timestamp: Math.floor(Date.now() / 1000),
461
+ };
462
+ await this.rongClient.sendMessage(targetId, JSON.stringify(result), msg.conversationType);
463
+ }
464
+ async handleCommand(data, msg) {
465
+ // 如果 command 消息中嵌套了其他 msg_type,路由到对应的 handler
466
+ const nestedMsgType = data.msg_type;
467
+ if (nestedMsgType && nestedMsgType !== 'command' && nestedMsgType !== RongyunMessageTypeEnum.COMMAND) {
468
+ log.info({ nestedMsgType }, 'Command message contains nested msg_type, routing');
469
+ switch (nestedMsgType) {
470
+ case RongyunMessageTypeEnum.OPS_CHAT_MESSAGE:
471
+ case 'ops_chat_message':
472
+ await this.handleOpsChatMessage(data, msg);
473
+ return;
474
+ case RongyunMessageTypeEnum.DEVICE_CONTROL:
475
+ case 'device_control':
476
+ await this.handleDeviceControl(data, msg);
477
+ return;
478
+ case RongyunMessageTypeEnum.DEVICE_STATUS_REQUEST:
479
+ case 'device_status_request':
480
+ await this.handleDeviceStatusRequest(data, msg);
481
+ return;
482
+ case RongyunMessageTypeEnum.COMMAND_RESULT:
483
+ case 'command_result':
484
+ this.handleCommandResult(data, msg);
485
+ return;
486
+ }
487
+ }
488
+ // 支持两种格式:requestId(驼峰)和 request_id(下划线)
489
+ const requestId = data.requestId || data.request_id;
490
+ const service = data.service;
491
+ const action = data.action;
492
+ const payload = data.payload || {};
493
+ const sourceId = data.source_im_id || data.sourceImId || msg.senderUserId;
494
+ const destinationId = data.destination_im_id || data.destinationImId || msg.targetId;
495
+ // 过滤无效命令:service/action 为空时不处理(避免回复给 system 等虚拟用户)
496
+ if (!service || !action) {
497
+ log.debug({ requestId, senderUserId: msg.senderUserId }, 'Skipping command with empty service/action');
498
+ return;
499
+ }
500
+ log.info({ requestId, service, action }, 'Handling command');
501
+ // 动态路由:_handle_{service}_{action}
502
+ const handlerName = `_handle_${service}_${action}`;
503
+ let result;
504
+ try {
505
+ if (typeof this[handlerName] === 'function') {
506
+ result = await this[handlerName](payload, sourceId);
507
+ }
508
+ else {
509
+ log.warn({ handlerName }, 'Command handler not found');
510
+ result = {
511
+ code: 404,
512
+ message: `Unknown service/action: ${service}/${action}`,
513
+ status: 'error',
514
+ };
515
+ }
516
+ }
517
+ catch (err) {
518
+ log.error({ err, handlerName }, 'Command handler error');
519
+ result = {
520
+ code: 500,
521
+ message: err.message || 'Internal server error',
522
+ status: 'error',
523
+ };
524
+ }
525
+ const response = {
526
+ msg_type: RongyunMessageTypeEnum.COMMAND_RESULT,
527
+ requestId: requestId,
528
+ service: service,
529
+ action: action,
530
+ status: result.status || 'success',
531
+ code: result.code || 200,
532
+ data: result.data || null,
533
+ message: result.message || 'success',
534
+ timestamp: Date.now(),
535
+ };
536
+ await this.rongClient.sendMessage(sourceId, JSON.stringify(response), msg.conversationType);
537
+ }
538
+ /**
539
+ * 处理 command_result 消息(响应回调)
540
+ * 用于语音识别等异步操作的响应
541
+ */
542
+ handleCommandResult(data, msg) {
543
+ const requestId = data.requestId || data.request_id;
544
+ if (!requestId) {
545
+ log.warn('Command result missing requestId');
546
+ return;
547
+ }
548
+ const pending = this.pendingRequests.get(requestId);
549
+ if (!pending) {
550
+ log.warn({ requestId }, 'No pending request found for command result');
551
+ return;
552
+ }
553
+ clearTimeout(pending.timer);
554
+ this.pendingRequests.delete(requestId);
555
+ if (data.status === 'success' && data.code === 200) {
556
+ pending.resolve(data.data);
557
+ }
558
+ else {
559
+ pending.reject(new Error(data.message || '语音识别失败'));
560
+ }
561
+ log.info({ requestId, status: data.status, code: data.code }, 'Command result processed');
562
+ }
563
+ // ========== Command Handlers ==========
564
+ async _handle_user_getInfo(payload, fromUserId) {
565
+ return {
566
+ code: 200,
567
+ message: 'success',
568
+ status: 'success',
569
+ data: {
570
+ userId: fromUserId,
571
+ username: 'user',
572
+ nickname: 'User',
573
+ portraitUri: '',
574
+ phone: '',
575
+ email: '',
576
+ signature: '',
577
+ gender: '',
578
+ birthday: '',
579
+ status: 'active',
580
+ },
581
+ };
582
+ }
583
+ async _handle_user_login(payload, fromUserId) {
584
+ return {
585
+ code: 200,
586
+ message: 'Login successful',
587
+ status: 'success',
588
+ data: {
589
+ userId: fromUserId,
590
+ token: this.config.token,
591
+ },
592
+ };
593
+ }
594
+ async _handle_claw_getStatus(payload, fromUserId) {
595
+ const isRunning = this.opencode !== null;
596
+ return {
597
+ code: 200,
598
+ message: 'success',
599
+ status: 'success',
600
+ data: {
601
+ nodeId: this.config.accountId,
602
+ status: isRunning ? 'online' : 'offline',
603
+ openclawUrl: this.config.opencodeUrl,
604
+ version: '1.0.0',
605
+ },
606
+ };
607
+ }
608
+ async _handle_claw_start(payload, fromUserId) {
609
+ return {
610
+ code: 200,
611
+ message: 'Node started',
612
+ status: 'success',
613
+ data: { nodeId: this.config.accountId, status: 'online' },
614
+ };
615
+ }
616
+ async _handle_claw_stop(payload, fromUserId) {
617
+ return {
618
+ code: 200,
619
+ message: 'Node stopped',
620
+ status: 'success',
621
+ data: { nodeId: this.config.accountId, status: 'offline' },
622
+ };
623
+ }
624
+ async _handle_system_getConfig(payload, fromUserId) {
625
+ return {
626
+ code: 200,
627
+ message: 'success',
628
+ status: 'success',
629
+ data: {
630
+ appKey: this.config.appKey,
631
+ serverUrl: this.config.serverUrl,
632
+ },
633
+ };
634
+ }
635
+ async handleCreateServiceSession(data, msg) {
636
+ const userId = data.userId || data.user_id || msg.senderUserId;
637
+ const targetId = msg.senderUserId;
638
+ const requestId = data.request_id || data.requestId;
639
+ log.info({ userId, targetId, requestId }, 'Processing create service session');
640
+ try {
641
+ const chatId = `service-${userId}`;
642
+ const session = await this.sessionManager.getOrCreateSession(chatId, `客服会话 ${userId}`);
643
+ const response = {
644
+ msg_type: RongyunMessageTypeEnum.SERVICE_SESSION_CREATED,
645
+ request_id: requestId,
646
+ userId: userId,
647
+ sessionId: session.id,
648
+ status: 'success',
649
+ message: '客服会话创建成功',
650
+ timestamp: Math.floor(Date.now() / 1000),
651
+ };
652
+ await this.rongClient.sendMessage(targetId, JSON.stringify(response), msg.conversationType);
653
+ log.info({ userId, sessionId: session.id }, 'Service session created');
654
+ }
655
+ catch (err) {
656
+ log.error({ err, userId }, '创建客服会话失败');
657
+ const errorResponse = {
658
+ msg_type: RongyunMessageTypeEnum.SERVICE_SESSION_CREATED,
659
+ request_id: requestId,
660
+ userId: userId,
661
+ status: 'error',
662
+ message: err.message || '创建会话失败',
663
+ timestamp: Math.floor(Date.now() / 1000),
664
+ };
665
+ await this.rongClient.sendMessage(targetId, JSON.stringify(errorResponse), msg.conversationType);
666
+ }
667
+ }
668
+ /**
669
+ * 语音识别:通过融云 command 消息发送识别请求,等待 command_result 响应
670
+ * 不再使用 HTTP 调用,改为 RongCloud 消息通道
671
+ */
672
+ async _recognizeVoice(voiceUrl) {
673
+ const requestId = `vr_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
674
+ return new Promise((resolve, reject) => {
675
+ // 30秒超时
676
+ const timer = setTimeout(() => {
677
+ this.pendingRequests.delete(requestId);
678
+ reject(new Error('语音识别请求超时'));
679
+ }, 30000);
680
+ this.pendingRequests.set(requestId, { resolve, reject, timer });
681
+ const commandPayload = {
682
+ msg_type: RongyunMessageTypeEnum.COMMAND,
683
+ requestId: requestId,
684
+ service: 'ai',
685
+ action: 'recognizeVoice',
686
+ payload: {
687
+ voiceUrl: voiceUrl,
688
+ format: 'm4a',
689
+ sampleRate: 16000,
690
+ },
691
+ timestamp: Date.now(),
692
+ };
693
+ // 发送给 system 用户(Python server 端处理)
694
+ this.rongClient.sendMessage('system', JSON.stringify(commandPayload), 1)
695
+ .then(() => {
696
+ log.info({ requestId, voiceUrl }, 'Voice recognition command sent via RongCloud');
697
+ })
698
+ .catch((err) => {
699
+ clearTimeout(timer);
700
+ this.pendingRequests.delete(requestId);
701
+ log.error({ err: err.message, requestId }, 'Failed to send voice recognition command');
702
+ reject(new Error('发送语音识别请求失败: ' + (err.message || '未知错误')));
703
+ });
704
+ }).then((data) => {
705
+ const text = data?.text || data?.result || '';
706
+ if (!text) {
707
+ throw new Error('语音识别结果为空');
708
+ }
709
+ log.info({ voiceUrl, recognizedTextPreview: text.substring(0, 100) }, 'Voice recognized via RongCloud');
710
+ return text;
711
+ });
712
+ }
713
+ /**
714
+ * 处理语音消息:先语音识别,再作为文本消息处理
715
+ */
716
+ async handleVoiceMessage(data, msg) {
717
+ const voiceUrl = data.remoteUrl || data.remote_url || data.url;
718
+ const duration = data.duration || 0;
719
+ if (!voiceUrl) {
720
+ log.warn({ messageType: msg.messageType }, 'Voice message missing remoteUrl');
721
+ return;
722
+ }
723
+ log.info({ voiceUrl, duration, senderUserId: msg.senderUserId }, 'Processing voice message');
724
+ try {
725
+ // 语音识别
726
+ const recognizedText = await this._recognizeVoice(voiceUrl);
727
+ log.info({ voiceUrl, recognizedText }, 'Voice recognized successfully');
728
+ // 将识别结果作为文本消息处理
729
+ const textData = {
730
+ ...data,
731
+ content: recognizedText,
732
+ _raw_content: recognizedText,
733
+ voiceUrl: voiceUrl,
734
+ voiceDuration: duration,
735
+ };
736
+ await this.handleChatMessage(textData, msg, msg.messageType);
737
+ }
738
+ catch (err) {
739
+ log.error({ err: err.message, voiceUrl }, 'Voice recognition failed');
740
+ // 发送错误提示给用户
741
+ const errorPayload = JSON.stringify({
742
+ content: '语音消息识别失败,请稍后重试或发送文字消息',
743
+ extra: JSON.stringify({
744
+ from_node: this.config.accountId,
745
+ is_ai: true,
746
+ is_error: true,
747
+ }),
748
+ });
749
+ try {
750
+ await this.rongClient.sendMessage(msg.senderUserId, errorPayload, msg.conversationType);
751
+ }
752
+ catch (sendErr) {
753
+ log.error({ sendErr }, 'Failed to send voice recognition error message');
754
+ }
755
+ }
756
+ }
757
+ async handleServiceChatMessage(data, msg) {
758
+ const userId = data.userId || data.user_id || msg.senderUserId;
759
+ const sessionId = data.sessionId || data.session_id;
760
+ let content = data.content || '';
761
+ const targetId = msg.senderUserId;
762
+ const requestId = data.request_id || data.requestId;
763
+ // 处理语音消息:如果有 voiceUrl,先进行语音识别
764
+ if (data.voiceUrl && !content) {
765
+ try {
766
+ content = await this._recognizeVoice(data.voiceUrl);
767
+ log.info({ userId, voiceUrl: data.voiceUrl, recognizedLength: content.length }, 'Voice message recognized');
768
+ }
769
+ catch (err) {
770
+ log.error({ err, userId, voiceUrl: data.voiceUrl }, 'Voice recognition failed for service chat');
771
+ const errorPayload = JSON.stringify({
772
+ msg_type: RongyunMessageTypeEnum.SERVICE_CHAT_RESPONSE,
773
+ request_id: requestId,
774
+ content: '语音消息识别失败,请稍后重试或发送文字消息',
775
+ sessionId: sessionId || '',
776
+ userId: userId,
777
+ status: 'error',
778
+ timestamp: Math.floor(Date.now() / 1000),
779
+ });
780
+ await this.rongClient.sendMessage(targetId, errorPayload, msg.conversationType);
781
+ return;
782
+ }
783
+ }
784
+ if (!content) {
785
+ log.warn('Service chat message content is empty');
786
+ return;
787
+ }
788
+ log.info({ userId, sessionId, contentLength: content.length }, 'Processing service chat message');
789
+ try {
790
+ const chatId = `service-${userId}`;
791
+ const session = await this.sessionManager.getOrCreateSession(chatId, `客服会话 ${userId}`);
792
+ // 保存客服目标账号ID到 session,供 event-handler 发送回复时使用
793
+ // 这样客服回复的 fromUserId 会是客服账号,而不是当前节点ID
794
+ const serviceTargetId = msg.targetId || this.config.accountId;
795
+ this.sessionManager.updateExtra(chatId, { serviceTargetId });
796
+ log.info({ userId, sessionId: session.id, serviceTargetId }, 'Service session created with targetId');
797
+ // 使用异步模式触发 SSE 流式输出,由 event-handler 处理流式消息发送
798
+ // 最终回复会在 session.idle 时以 service_chat_response 格式发送
799
+ await this.opencode.sendPromptAsync(session.id, content);
800
+ log.info({ userId, sessionId: session.id }, 'Service promptAsync sent, streaming via SSE');
801
+ }
802
+ catch (err) {
803
+ log.error({ err, userId, targetId }, 'Service assistant failed');
804
+ const errorPayload = JSON.stringify({
805
+ msg_type: RongyunMessageTypeEnum.SERVICE_CHAT_RESPONSE,
806
+ request_id: requestId,
807
+ content: '客服处理失败: ' + (err.message || '未知错误'),
808
+ sessionId: sessionId || '',
809
+ userId: userId,
810
+ status: 'error',
811
+ timestamp: Math.floor(Date.now() / 1000),
812
+ });
813
+ await this.rongClient.sendMessage(targetId, errorPayload, msg.conversationType);
814
+ }
815
+ }
816
+ async handleOpsChatMessage(data, msg) {
817
+ // 群聊(conversationType=3)时 targetId 是群ID,单聊时是发送者ID
818
+ const targetId = msg.conversationType === 3
819
+ ? msg.targetId
820
+ : (data.source_im_id || data.sourceImId || msg.senderUserId);
821
+ const content = data.message || data.content || '';
822
+ const nodeId = data.node_id || data.nodeId;
823
+ const requestId = data.request_id || data.requestId;
824
+ if (!content) {
825
+ log.warn('Ops chat message content is empty');
826
+ return;
827
+ }
828
+ log.info({ targetId, nodeId, contentLength: content.length }, 'Processing ops chat message');
829
+ try {
830
+ // 使用独立的运维 OpenCodeClient(19877)发送消息
831
+ // 通过 API 显式传递 system prompt,确保加载运维助手人设
832
+ const session = await this.opsOpencode.createSession(`Ops-${targetId}`);
833
+ log.info({ sessionId: session.id }, 'Created ops session');
834
+ const response = await this.opsOpencode.sendPrompt(session.id, content);
835
+ log.info({ targetId, responseLength: response.length }, 'Ops assistant responded');
836
+ // 发送自定义消息回复: 按照规范包装 AI 回复
837
+ // 同时发送 TextMessage 确保前端兼容显示
838
+ const replyPayload = JSON.stringify({
839
+ msg_type: RongyunMessageTypeEnum.OPS_CHAT_RESPONSE,
840
+ request_id: requestId,
841
+ reply: response,
842
+ node_id: nodeId || this.config.accountId,
843
+ });
844
+ // TextMessage 用于前端显示(RCUIKit 聊天组件兼容)
845
+ const textPayload = JSON.stringify({
846
+ content: response,
847
+ extra: JSON.stringify({
848
+ from_node: this.config.accountId,
849
+ is_ai: true,
850
+ msg_type: RongyunMessageTypeEnum.OPS_CHAT_RESPONSE,
851
+ chat_type: 'ops',
852
+ }),
853
+ });
854
+ // 先发自定义消息,再发 TextMessage
855
+ await this.rongClient.sendMessage(targetId, replyPayload, msg.conversationType);
856
+ await this.rongClient.sendMessage(targetId, textPayload, msg.conversationType);
857
+ }
858
+ catch (err) {
859
+ log.error({ err, targetId }, 'Ops assistant failed');
860
+ const errorReply = JSON.stringify({
861
+ msg_type: RongyunMessageTypeEnum.OPS_CHAT_RESPONSE,
862
+ request_id: requestId,
863
+ reply: '运维助手处理失败: ' + (err.message || '未知错误'),
864
+ node_id: nodeId || this.config.accountId,
865
+ });
866
+ const errorTextPayload = JSON.stringify({
867
+ content: '运维助手处理失败: ' + (err.message || '未知错误'),
868
+ extra: JSON.stringify({
869
+ from_node: this.config.accountId,
870
+ is_ai: true,
871
+ msg_type: RongyunMessageTypeEnum.OPS_CHAT_RESPONSE,
872
+ }),
873
+ });
874
+ await this.rongClient.sendMessage(targetId, errorReply, msg.conversationType);
875
+ await this.rongClient.sendMessage(targetId, errorTextPayload, msg.conversationType);
876
+ }
877
+ }
878
+ }
879
+ //# sourceMappingURL=message-handler.js.map