openclaw-vchat-plugin 0.0.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 (70) hide show
  1. package/bin/openclaw-vchat.js +110 -0
  2. package/dist/commands.d.ts +18 -0
  3. package/dist/commands.d.ts.map +1 -0
  4. package/dist/commands.js +509 -0
  5. package/dist/commands.js.map +1 -0
  6. package/dist/constants.d.ts +14 -0
  7. package/dist/constants.d.ts.map +1 -0
  8. package/dist/constants.js +51 -0
  9. package/dist/constants.js.map +1 -0
  10. package/dist/gateway-client.d.ts +43 -0
  11. package/dist/gateway-client.d.ts.map +1 -0
  12. package/dist/gateway-client.js +623 -0
  13. package/dist/gateway-client.js.map +1 -0
  14. package/dist/group-manager.d.ts +30 -0
  15. package/dist/group-manager.d.ts.map +1 -0
  16. package/dist/group-manager.js +107 -0
  17. package/dist/group-manager.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +382 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/media-handler.d.ts +31 -0
  23. package/dist/media-handler.d.ts.map +1 -0
  24. package/dist/media-handler.js +67 -0
  25. package/dist/media-handler.js.map +1 -0
  26. package/dist/message-handler.d.ts +52 -0
  27. package/dist/message-handler.d.ts.map +1 -0
  28. package/dist/message-handler.js +291 -0
  29. package/dist/message-handler.js.map +1 -0
  30. package/dist/relay-server.d.ts +16 -0
  31. package/dist/relay-server.d.ts.map +1 -0
  32. package/dist/relay-server.js +877 -0
  33. package/dist/relay-server.js.map +1 -0
  34. package/dist/routes/config.routes.d.ts +12 -0
  35. package/dist/routes/config.routes.d.ts.map +1 -0
  36. package/dist/routes/config.routes.js +175 -0
  37. package/dist/routes/config.routes.js.map +1 -0
  38. package/dist/services/config.service.d.ts +57 -0
  39. package/dist/services/config.service.d.ts.map +1 -0
  40. package/dist/services/config.service.js +361 -0
  41. package/dist/services/config.service.js.map +1 -0
  42. package/dist/session-key.d.ts +8 -0
  43. package/dist/session-key.d.ts.map +1 -0
  44. package/dist/session-key.js +28 -0
  45. package/dist/session-key.js.map +1 -0
  46. package/dist/session-manager.d.ts +32 -0
  47. package/dist/session-manager.d.ts.map +1 -0
  48. package/dist/session-manager.js +303 -0
  49. package/dist/session-manager.js.map +1 -0
  50. package/dist/types.d.ts +81 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +3 -0
  53. package/dist/types.js.map +1 -0
  54. package/nginx-proxy.conf +24 -0
  55. package/package.json +51 -0
  56. package/src/commands.ts +499 -0
  57. package/src/constants.ts +49 -0
  58. package/src/gateway-client.ts +648 -0
  59. package/src/group-manager.ts +119 -0
  60. package/src/index.ts +443 -0
  61. package/src/media-handler.ts +70 -0
  62. package/src/message-handler.ts +419 -0
  63. package/src/relay-server.ts +979 -0
  64. package/src/routes/config.routes.ts +144 -0
  65. package/src/services/config.service.ts +398 -0
  66. package/src/session-key.ts +30 -0
  67. package/src/session-manager.ts +374 -0
  68. package/src/types.ts +96 -0
  69. package/start.sh +5 -0
  70. package/tsconfig.json +26 -0
@@ -0,0 +1,419 @@
1
+ import { SessionManager } from './session-manager';
2
+ import { handleBuiltinCommand, handleBuiltinCommandAsync, parseCommandInput, ParsedSlashCommand } from './commands';
3
+ import { MessageType, PluginConfig, Message, CommandExecutionContext } from './types';
4
+ import { GatewayClient } from './gateway-client';
5
+ import { buildWeChatGatewaySessionKey } from './session-key';
6
+
7
+ type CommandRouteHint = 'local' | 'gateway-rpc' | 'gateway-passthrough' | 'model';
8
+
9
+ interface RoutingHint {
10
+ commandRoute?: CommandRouteHint;
11
+ routeSource?: string;
12
+ requestId?: string;
13
+ }
14
+
15
+ /**
16
+ * 流式回调
17
+ */
18
+ export interface StreamCallbacks {
19
+ onThinking?: (text: string) => void;
20
+ onChunk?: (text: string) => void;
21
+ onDone?: (userMessage: Message, assistantMessage: Message) => void;
22
+ onError?: (error: string) => void;
23
+ }
24
+
25
+ /**
26
+ * 消息处理器
27
+ * 通过 GatewayClient 实现流式输出,支持多 Agent 路由
28
+ */
29
+ export class MessageHandler {
30
+ private config: PluginConfig;
31
+ private sessionManager: SessionManager;
32
+ private gateway: GatewayClient;
33
+
34
+ constructor(config: PluginConfig, sessionManager: SessionManager, gateway: GatewayClient) {
35
+ this.config = config;
36
+ this.sessionManager = sessionManager;
37
+ this.gateway = gateway;
38
+ }
39
+
40
+ /**
41
+ * 同步处理消息(HTTP fallback)
42
+ */
43
+ async handleMessage(
44
+ userId: string, sessionId: string, content: string,
45
+ type: MessageType = 'text', mediaUrl?: string, duration?: number,
46
+ agentId: string = 'main', groupId?: string,
47
+ routingHint?: RoutingHint
48
+ ): Promise<{ userMessage: Message; assistantMessage: Message }> {
49
+ const { userMessage, sid, builtinResponse, parsedCommand } = this.prepareMessage(
50
+ userId, sessionId, content, type, mediaUrl, duration, agentId, groupId, routingHint
51
+ );
52
+ const commandContext = this.buildCommandContext(userId, sid, agentId, groupId);
53
+ const routeSource = routingHint?.commandRoute ? (routingHint.routeSource || 'backend') : 'plugin-fallback';
54
+
55
+ if (builtinResponse) {
56
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'text', builtinResponse);
57
+ return { userMessage, assistantMessage };
58
+ }
59
+
60
+ // Gateway RPC 命令(秒回路径)
61
+ if (parsedCommand && parsedCommand.route === 'gateway-rpc') {
62
+ console.log(`[MessageHandler] 命令路由 cmd=${parsedCommand.command} route=gateway-rpc source=${routeSource} agent=${commandContext.agentId || agentId} session=${commandContext.gatewaySessionKey} requestId=${routingHint?.requestId || '-'}`);
63
+ const asyncResult = await handleBuiltinCommandAsync(parsedCommand.command, parsedCommand.args, commandContext);
64
+ if (asyncResult) {
65
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'text', asyncResult);
66
+ return { userMessage, assistantMessage };
67
+ }
68
+ }
69
+
70
+ try {
71
+ // 图片消息使用 streamAgent 传递附件
72
+ if (type === 'image' && mediaUrl) {
73
+ const aiResponse = await this.callWithAttachments(
74
+ content || '请查看这张图片',
75
+ mediaUrl,
76
+ commandContext.gatewaySessionKey,
77
+ commandContext.agentId || agentId || 'main',
78
+ groupId,
79
+ );
80
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'text', aiResponse);
81
+ return { userMessage, assistantMessage };
82
+ }
83
+ if (parsedCommand && parsedCommand.route === 'gateway-passthrough') {
84
+ console.log(`[MessageHandler] 命令路由 cmd=${parsedCommand.command} route=gateway-passthrough source=${routeSource} agent=${commandContext.agentId || agentId} session=${commandContext.gatewaySessionKey} requestId=${routingHint?.requestId || '-'}`);
85
+ const commandResponse = await this.callGatewayText(
86
+ content,
87
+ commandContext.gatewaySessionKey,
88
+ );
89
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'text', commandResponse);
90
+ return { userMessage, assistantMessage };
91
+ }
92
+ const aiResponse = await this.callGatewayAgentSync(
93
+ content,
94
+ type,
95
+ commandContext.gatewaySessionKey,
96
+ commandContext.agentId || agentId || 'main',
97
+ groupId,
98
+ );
99
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'text', aiResponse);
100
+ return { userMessage, assistantMessage };
101
+ } catch (err: any) {
102
+ console.error('[MessageHandler] OpenClaw 调用失败:', err);
103
+ const normalized = this.normalizeImageErrorMessage(type, err?.message || '未知错误');
104
+ const errorMsg = normalized === (err?.message || '未知错误')
105
+ ? `⚠️ AI 回复失败: ${normalized}`
106
+ : normalized;
107
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'system', errorMsg);
108
+ return { userMessage, assistantMessage };
109
+ }
110
+ }
111
+
112
+ /**
113
+ * 流式处理消息(WebSocket 模式)
114
+ * @param agentId — 目标 Agent,默认 'main'
115
+ * @param groupId — 虚拟群 ID(可选,用于多代理群聊)
116
+ */
117
+ handleMessageStream(
118
+ userId: string, sessionId: string, content: string,
119
+ type: MessageType = 'text', callbacks: StreamCallbacks,
120
+ agentId: string = 'main',
121
+ mediaUrl?: string, duration?: number,
122
+ groupId?: string,
123
+ routingHint?: RoutingHint
124
+ ): () => void {
125
+ const { userMessage, sid, builtinResponse, parsedCommand } = this.prepareMessage(
126
+ userId, sessionId, content, type, mediaUrl, duration, agentId, groupId, routingHint
127
+ );
128
+ const commandContext = this.buildCommandContext(userId, sid, agentId, groupId);
129
+ const routeSource = routingHint?.commandRoute ? (routingHint.routeSource || 'backend') : 'plugin-fallback';
130
+
131
+ // 内置命令立即返回
132
+ if (builtinResponse) {
133
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'text', builtinResponse);
134
+ callbacks.onChunk?.(builtinResponse);
135
+ callbacks.onDone?.(userMessage, assistantMessage);
136
+ return () => { };
137
+ }
138
+
139
+ // Gateway 透传命令(例如 /restart /context /approve)
140
+ if (parsedCommand && parsedCommand.route === 'gateway-passthrough') {
141
+ console.log(`[MessageHandler] 命令路由 cmd=${parsedCommand.command} route=gateway-passthrough source=${routeSource} agent=${agentId} session=${commandContext.gatewaySessionKey} requestId=${routingHint?.requestId || '-'}`);
142
+ const abort = this.gateway.streamChatSend(
143
+ content,
144
+ {
145
+ onThinking: (text) => callbacks.onThinking?.(text),
146
+ onChunk: (delta) => callbacks.onChunk?.(delta),
147
+ onDone: (fullText) => {
148
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'text', fullText);
149
+ callbacks.onDone?.(userMessage, assistantMessage);
150
+ },
151
+ onError: (error) => callbacks.onError?.(error),
152
+ },
153
+ { sessionKey: commandContext.gatewaySessionKey || 'main' }
154
+ );
155
+ return abort;
156
+ }
157
+
158
+ if (parsedCommand) {
159
+ console.log(`[MessageHandler] 命令路由 cmd=${parsedCommand.command} route=${parsedCommand.route} source=${routeSource} agent=${agentId} session=${commandContext.gatewaySessionKey} requestId=${routingHint?.requestId || '-'}`);
160
+ }
161
+
162
+ // Gateway RPC 命令(秒回路径)
163
+ if (parsedCommand && parsedCommand.route === 'gateway-rpc') {
164
+ handleBuiltinCommandAsync(parsedCommand.command, parsedCommand.args, commandContext).then(result => {
165
+ if (result) {
166
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'text', result);
167
+ callbacks.onChunk?.(result);
168
+ callbacks.onDone?.(userMessage, assistantMessage);
169
+ return;
170
+ }
171
+ callbacks.onError?.(`命令执行失败: ${parsedCommand.command} 未返回结果`);
172
+ }).catch(err => {
173
+ console.error('[MessageHandler] 异步命令错误:', err);
174
+ callbacks.onError?.(`命令执行失败: ${err.message}`);
175
+ });
176
+ return () => { };
177
+ }
178
+
179
+ // 准备输入文本和附件
180
+ let inputText = content;
181
+ const streamOpts: any = {
182
+ sessionKey: commandContext.gatewaySessionKey,
183
+ };
184
+ if (groupId) {
185
+ streamOpts.groupId = groupId;
186
+ streamOpts.groupChannel = 'wechat';
187
+ }
188
+
189
+ if (type === 'voice') {
190
+ inputText = `[语音消息] ${content}`;
191
+ } else if (type === 'image' && mediaUrl) {
192
+ // 将图片作为附件传给 Gateway(和电报一样)
193
+ inputText = content || '请查看这张图片';
194
+ try {
195
+ const fs = require('fs');
196
+ const path = require('path');
197
+ // mediaUrl 格式: /uploads/image/xxx.png
198
+ const uploadDir = this.config.uploadDir;
199
+ const relativePath = mediaUrl.replace(/^\/uploads\//, '');
200
+ const filePath = path.join(uploadDir, relativePath);
201
+ if (fs.existsSync(filePath)) {
202
+ const imageBuffer = fs.readFileSync(filePath);
203
+ const base64Data = imageBuffer.toString('base64');
204
+ const ext = path.extname(filePath).replace('.', '') || 'png';
205
+ const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
206
+ streamOpts.attachments = [{
207
+ type: 'image',
208
+ mimeType,
209
+ content: base64Data,
210
+ fileName: path.basename(filePath),
211
+ }];
212
+ console.log(`[MessageHandler] 图片附件已准备: ${filePath} (${(base64Data.length / 1024).toFixed(1)}KB base64)`);
213
+ } else {
214
+ console.error(`[MessageHandler] 图片文件不存在: ${filePath}`);
215
+ inputText = `[用户发送了一张图片,但文件未找到] ${content}`;
216
+ }
217
+ } catch (imgErr: any) {
218
+ console.error('[MessageHandler] 读取图片失败:', imgErr);
219
+ inputText = `[用户发送了一张图片] ${content}`;
220
+ }
221
+ }
222
+
223
+ console.log(`[MessageHandler] 流式调用 (agent=${agentId}): ${inputText.substring(0, 80)}...`);
224
+
225
+ // 使用 GatewayClient.streamAgent() — 替代原来的内联 WS 代码
226
+ const abort = this.gateway.streamAgent(
227
+ inputText,
228
+ agentId,
229
+ {
230
+ onThinking: (text) => {
231
+ callbacks.onThinking?.(text);
232
+ },
233
+ onChunk: (delta) => {
234
+ callbacks.onChunk?.(delta);
235
+ },
236
+ onDone: (fullText) => {
237
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'text', fullText);
238
+ console.log(`[MessageHandler] 完成 (agent=${agentId}):`, fullText.substring(0, 100));
239
+ callbacks.onDone?.(userMessage, assistantMessage);
240
+ },
241
+ onError: (error) => {
242
+ const normalized = this.normalizeImageErrorMessage(type, error);
243
+ if (normalized !== error) {
244
+ const assistantMessage = this.sessionManager.addMessage(sid, 'assistant', 'system', normalized);
245
+ callbacks.onChunk?.(normalized);
246
+ callbacks.onDone?.(userMessage, assistantMessage);
247
+ return;
248
+ }
249
+ callbacks.onError?.(error);
250
+ },
251
+ },
252
+ Object.keys(streamOpts).length > 0 ? streamOpts : undefined
253
+ );
254
+
255
+ return abort;
256
+ }
257
+
258
+ // --- 公共逻辑 ---
259
+
260
+ /**
261
+ * 通过 streamAgent 发送带附件的消息(Promise 包装)
262
+ */
263
+ private callWithAttachments(
264
+ content: string,
265
+ mediaUrl: string,
266
+ sessionKey?: string,
267
+ agentId: string = 'main',
268
+ groupId?: string,
269
+ ): Promise<string> {
270
+ return new Promise((resolve, reject) => {
271
+ const fs = require('fs');
272
+ const path = require('path');
273
+ const uploadDir = this.config.uploadDir;
274
+ const relativePath = mediaUrl.replace(/^\/uploads\//, '');
275
+ const filePath = path.join(uploadDir, relativePath);
276
+
277
+ const attachments: any[] = [];
278
+ try {
279
+ if (fs.existsSync(filePath)) {
280
+ const imageBuffer = fs.readFileSync(filePath);
281
+ const base64Data = imageBuffer.toString('base64');
282
+ const ext = path.extname(filePath).replace('.', '') || 'png';
283
+ const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
284
+ attachments.push({
285
+ type: 'image',
286
+ mimeType,
287
+ content: base64Data,
288
+ fileName: path.basename(filePath),
289
+ });
290
+ console.log(`[MessageHandler] 图片附件: ${filePath} (${(base64Data.length / 1024).toFixed(1)}KB)`);
291
+ }
292
+ } catch (e: any) {
293
+ console.error('[MessageHandler] 读取图片失败:', e);
294
+ }
295
+
296
+ this.gateway.streamAgent(
297
+ content, agentId,
298
+ {
299
+ onDone: (fullText) => resolve(fullText),
300
+ onError: (error) => reject(new Error(error)),
301
+ },
302
+ {
303
+ sessionKey,
304
+ groupId,
305
+ groupChannel: groupId ? 'wechat' : undefined,
306
+ attachments: attachments.length > 0 ? attachments : undefined,
307
+ }
308
+ );
309
+ });
310
+ }
311
+
312
+ private callGatewayAgentSync(
313
+ content: string,
314
+ type: MessageType,
315
+ sessionKey?: string,
316
+ agentId: string = 'main',
317
+ groupId?: string,
318
+ ): Promise<string> {
319
+ let inputText = content;
320
+ if (type === 'voice') inputText = `[语音消息] ${content}`;
321
+ else if (type === 'image') inputText = `[用户发送了一张图片] ${content}`;
322
+
323
+ return new Promise((resolve, reject) => {
324
+ this.gateway.streamAgent(
325
+ inputText,
326
+ agentId,
327
+ {
328
+ onDone: (fullText) => resolve(fullText),
329
+ onError: (error) => reject(new Error(error)),
330
+ },
331
+ {
332
+ sessionKey,
333
+ groupId,
334
+ groupChannel: groupId ? 'wechat' : undefined,
335
+ }
336
+ );
337
+ });
338
+ }
339
+
340
+ private callGatewayText(
341
+ content: string,
342
+ sessionKey?: string,
343
+ ): Promise<string> {
344
+ return new Promise((resolve, reject) => {
345
+ this.gateway.streamChatSend(
346
+ content,
347
+ {
348
+ onDone: (fullText) => resolve(fullText),
349
+ onError: (error) => reject(new Error(error)),
350
+ },
351
+ { sessionKey: sessionKey || 'main' }
352
+ );
353
+ });
354
+ }
355
+
356
+ private prepareMessage(
357
+ userId: string, sessionId: string, content: string,
358
+ type: MessageType, mediaUrl?: string, duration?: number,
359
+ agentId: string = 'main',
360
+ groupId?: string,
361
+ routingHint?: RoutingHint
362
+ ): { userMessage: Message; sid: string; builtinResponse: string | null; parsedCommand: ParsedSlashCommand | null } {
363
+ const incomingSessionId = String(sessionId || '').trim();
364
+ const session = incomingSessionId
365
+ ? this.sessionManager.ensureSessionBinding(
366
+ userId,
367
+ incomingSessionId,
368
+ agentId,
369
+ buildWeChatGatewaySessionKey(userId, incomingSessionId, agentId, groupId),
370
+ )
371
+ : this.sessionManager.createSession(userId, undefined, agentId);
372
+ if (!incomingSessionId) {
373
+ this.sessionManager.ensureSessionBinding(
374
+ userId,
375
+ session.id,
376
+ agentId,
377
+ buildWeChatGatewaySessionKey(userId, session.id, agentId, groupId),
378
+ );
379
+ }
380
+ sessionId = session.id;
381
+
382
+ const userMessage = this.sessionManager.addMessage(sessionId, 'user', type, content, mediaUrl, duration);
383
+ let builtinResponse: string | null = null;
384
+ let parsedCommand = type === 'text' ? parseCommandInput(content) : null;
385
+ if (parsedCommand && routingHint?.commandRoute) {
386
+ parsedCommand = {
387
+ ...parsedCommand,
388
+ route: routingHint.commandRoute,
389
+ };
390
+ }
391
+ if (parsedCommand && parsedCommand.route === 'local') {
392
+ builtinResponse = handleBuiltinCommand(parsedCommand.command, parsedCommand.args);
393
+ }
394
+ return { userMessage, sid: sessionId, builtinResponse, parsedCommand };
395
+ }
396
+
397
+ private buildCommandContext(
398
+ userId: string,
399
+ sessionId: string,
400
+ agentId: string,
401
+ groupId?: string
402
+ ): CommandExecutionContext {
403
+ return {
404
+ agentId,
405
+ gatewaySessionKey: buildWeChatGatewaySessionKey(userId, sessionId, agentId, groupId),
406
+ };
407
+ }
408
+
409
+ private normalizeImageErrorMessage(type: MessageType, error: string): string {
410
+ if (type !== 'image') return error;
411
+ const source = (error || '').toLowerCase();
412
+ const imageUnsupported =
413
+ /image|vision|multimodal|unsupported|does not support|invalid_image|图片|图像|视觉/.test(source)
414
+ && /support|不支持|unsupported|invalid/.test(source);
415
+ if (!imageUnsupported) return error;
416
+ return '⚠️ 当前模型不支持图片输入,请切换支持视觉的模型后重试。';
417
+ }
418
+
419
+ }