openclaw-vchat-plugin 0.0.7 → 0.0.9

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 (53) hide show
  1. package/dist/commands.d.ts +1 -0
  2. package/dist/commands.d.ts.map +1 -1
  3. package/dist/commands.js +37 -62
  4. package/dist/commands.js.map +1 -1
  5. package/dist/constants.d.ts +1 -1
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/constants.js +3 -1
  8. package/dist/constants.js.map +1 -1
  9. package/dist/group-event-store.d.ts +47 -0
  10. package/dist/group-event-store.d.ts.map +1 -0
  11. package/dist/group-event-store.js +145 -0
  12. package/dist/group-event-store.js.map +1 -0
  13. package/dist/index.js +74 -8
  14. package/dist/index.js.map +1 -1
  15. package/dist/message-handler.d.ts.map +1 -1
  16. package/dist/message-handler.js +11 -4
  17. package/dist/message-handler.js.map +1 -1
  18. package/dist/plugin-update-runner.d.ts +2 -0
  19. package/dist/plugin-update-runner.d.ts.map +1 -0
  20. package/dist/plugin-update-runner.js +179 -0
  21. package/dist/plugin-update-runner.js.map +1 -0
  22. package/dist/relay-server.d.ts +2 -0
  23. package/dist/relay-server.d.ts.map +1 -1
  24. package/dist/relay-server.js +268 -8
  25. package/dist/relay-server.js.bak-self-send-filter-20260310 +1028 -0
  26. package/dist/relay-server.js.map +1 -1
  27. package/dist/routes/config.routes.d.ts.map +1 -1
  28. package/dist/routes/config.routes.js +53 -0
  29. package/dist/routes/config.routes.js.map +1 -1
  30. package/dist/services/config.service.d.ts +8 -0
  31. package/dist/services/config.service.d.ts.map +1 -1
  32. package/dist/services/config.service.js +200 -2
  33. package/dist/services/config.service.js.map +1 -1
  34. package/dist/services/plugin-update.service.d.ts +24 -0
  35. package/dist/services/plugin-update.service.d.ts.map +1 -0
  36. package/dist/services/plugin-update.service.js +308 -0
  37. package/dist/services/plugin-update.service.js.map +1 -0
  38. package/dist/services/skills.service.d.ts +33 -0
  39. package/dist/services/skills.service.d.ts.map +1 -0
  40. package/dist/services/skills.service.js +177 -0
  41. package/dist/services/skills.service.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/commands.ts +38 -61
  44. package/src/constants.ts +4 -1
  45. package/src/group-event-store.ts +204 -0
  46. package/src/index.ts +72 -8
  47. package/src/message-handler.ts +19 -5
  48. package/src/plugin-update-runner.ts +217 -0
  49. package/src/relay-server.ts +292 -8
  50. package/src/routes/config.routes.ts +56 -0
  51. package/src/services/config.service.ts +223 -3
  52. package/src/services/plugin-update.service.ts +335 -0
  53. package/src/services/skills.service.ts +175 -0
@@ -0,0 +1,1028 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createRelayServer = createRelayServer;
7
+ const express_1 = __importDefault(require("express"));
8
+ const cors_1 = __importDefault(require("cors"));
9
+ const multer_1 = __importDefault(require("multer"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const http_1 = __importDefault(require("http"));
13
+ const child_process_1 = require("child_process");
14
+ const ws_1 = require("ws");
15
+ const crypto_1 = require("crypto");
16
+ const session_manager_1 = require("./session-manager");
17
+ const message_handler_1 = require("./message-handler");
18
+ const media_handler_1 = require("./media-handler");
19
+ const group_manager_1 = require("./group-manager");
20
+ const commands_1 = require("./commands");
21
+ const constants_1 = require("./constants");
22
+ const session_key_1 = require("./session-key");
23
+ const config_routes_1 = __importDefault(require("./routes/config.routes"));
24
+ function createRequestMemo(ttlMs) {
25
+ const inflight = new Map();
26
+ const cache = new Map();
27
+ return async function memoized(key, loader) {
28
+ const now = Date.now();
29
+ const cached = cache.get(key);
30
+ if (cached && cached.expiresAt > now) {
31
+ return cached.value;
32
+ }
33
+ const pending = inflight.get(key);
34
+ if (pending)
35
+ return pending;
36
+ const task = (async () => {
37
+ try {
38
+ const value = await loader();
39
+ cache.set(key, { value, expiresAt: Date.now() + ttlMs });
40
+ return value;
41
+ }
42
+ finally {
43
+ inflight.delete(key);
44
+ }
45
+ })();
46
+ inflight.set(key, task);
47
+ return task;
48
+ };
49
+ }
50
+ function toBridgeSessionPayload(item) {
51
+ const createdAt = Number(item.createdAt) || Date.now();
52
+ return {
53
+ id: String(item.id || '').trim(),
54
+ title: String(item.title || '').trim() || '新对话',
55
+ lastMessage: String(item.lastMessage || '').trim(),
56
+ lastMessageTime: Number(item.lastMessageTime) || createdAt,
57
+ createdAt,
58
+ agentId: String(item.agentId || '').trim() || 'main',
59
+ model: String(item.model || '').trim(),
60
+ };
61
+ }
62
+ function parseTimestamp(value) {
63
+ if (typeof value === 'number' && Number.isFinite(value)) {
64
+ if (value > 1000000000000)
65
+ return value;
66
+ if (value > 1000000000)
67
+ return value * 1000;
68
+ }
69
+ if (typeof value === 'string' && value.trim()) {
70
+ const n = Number(value);
71
+ if (Number.isFinite(n))
72
+ return parseTimestamp(n);
73
+ const d = Date.parse(value);
74
+ if (!Number.isNaN(d))
75
+ return d;
76
+ }
77
+ return 0;
78
+ }
79
+ function pickTimestamp(entry, fallback) {
80
+ return (parseTimestamp(entry?.lastMessageTime) ||
81
+ parseTimestamp(entry?.updatedAt) ||
82
+ parseTimestamp(entry?.lastActiveAt) ||
83
+ parseTimestamp(entry?.lastActivityAt) ||
84
+ parseTimestamp(entry?.timestamp) ||
85
+ parseTimestamp(entry?.createdAt) ||
86
+ fallback);
87
+ }
88
+ function normalizeMessageText(raw) {
89
+ if (typeof raw === 'string')
90
+ return raw;
91
+ if (!raw || typeof raw !== 'object')
92
+ return '';
93
+ if (typeof raw.text === 'string')
94
+ return raw.text;
95
+ if (typeof raw.content === 'string')
96
+ return raw.content;
97
+ if (Array.isArray(raw.content)) {
98
+ return raw.content
99
+ .map((part) => {
100
+ if (typeof part === 'string')
101
+ return part;
102
+ if (part?.type === 'text' && typeof part?.text === 'string')
103
+ return part.text;
104
+ if (typeof part?.text === 'string')
105
+ return part.text;
106
+ return '';
107
+ })
108
+ .filter(Boolean)
109
+ .join('');
110
+ }
111
+ if (raw.message)
112
+ return normalizeMessageText(raw.message);
113
+ return '';
114
+ }
115
+ function decodeInlineUpload(body, fallbackName) {
116
+ const fileBase64 = String(body?.fileBase64 || '').trim();
117
+ if (!fileBase64)
118
+ return null;
119
+ const originalName = String(body?.fileName || fallbackName || 'upload.dat').trim() || fallbackName;
120
+ return {
121
+ buffer: Buffer.from(fileBase64, 'base64'),
122
+ originalName,
123
+ };
124
+ }
125
+ function getHistoryRoleRaw(row) {
126
+ return String(row?.role || row?.author || row?.sender || row?.message?.role || row?.message?.author || '').trim().toLowerCase();
127
+ }
128
+ function isRenderableHistoryRole(roleRaw) {
129
+ if (!roleRaw)
130
+ return true;
131
+ return roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system';
132
+ }
133
+ function getHistoryProvenance(row) {
134
+ if (!row || typeof row !== 'object')
135
+ return null;
136
+ return row?.provenance || row?.message?.provenance || null;
137
+ }
138
+ function shouldHideHistoryRow(row, currentSessionKey) {
139
+ const provenance = getHistoryProvenance(row);
140
+ if (!provenance || typeof provenance !== 'object')
141
+ return false;
142
+ const kind = String(provenance?.kind || '').trim().toLowerCase();
143
+ if (kind !== 'inter_session')
144
+ return false;
145
+ const sourceTool = String(provenance?.sourceTool || '').trim().toLowerCase();
146
+ const sourceSessionKey = String(provenance?.sourceSessionKey || '').trim().toLowerCase();
147
+ const targetSessionKey = String(currentSessionKey || '').trim().toLowerCase();
148
+ // Hide self-routed relay messages. They are persisted by OpenClaw with user role,
149
+ // but should not be rendered as human-authored bubbles in the WeChat UI.
150
+ return sourceTool === 'sessions_send' && Boolean(targetSessionKey) && sourceSessionKey === targetSessionKey;
151
+ }
152
+ function normalizePreviewMessageText(raw, currentSessionKey) {
153
+ if (!raw || typeof raw !== 'object')
154
+ return normalizeMessageText(raw);
155
+ const roleRaw = getHistoryRoleRaw(raw);
156
+ if (!isRenderableHistoryRole(roleRaw))
157
+ return '';
158
+ if (shouldHideHistoryRow(raw, currentSessionKey))
159
+ return '';
160
+ return normalizeMessageText(raw);
161
+ }
162
+ function isInternalSessionTitle(raw) {
163
+ const title = String(raw || '').trim();
164
+ if (!title)
165
+ return true;
166
+ const lower = title.toLowerCase();
167
+ if (lower === 'openclaw-wechat-plugin'
168
+ || lower === 'openclaw-vchat-plugin'
169
+ || lower === 'openclaw-vchat'
170
+ || lower === 'openclaw-tui') {
171
+ return true;
172
+ }
173
+ return (lower.startsWith('webchat:')
174
+ || lower.startsWith('agent:')
175
+ || lower.includes('wechat:direct:')
176
+ || lower.includes('g-agent-')
177
+ || /^[a-f0-9-]{24,}$/i.test(title));
178
+ }
179
+ function pickVisibleSessionTitle(row, lastMessage) {
180
+ const candidates = [row?.title, row?.displayName, row?.name];
181
+ for (const candidate of candidates) {
182
+ const title = String(candidate || '').trim();
183
+ if (!title || isInternalSessionTitle(title))
184
+ continue;
185
+ return title;
186
+ }
187
+ return String(lastMessage || '').trim() || '新对话';
188
+ }
189
+ function normalizeHistoryMessages(rawPayload, sessionId, limit, before, currentSessionKey) {
190
+ const payload = rawPayload || {};
191
+ const rows = Array.isArray(payload.messages) ? payload.messages
192
+ : Array.isArray(payload.items) ? payload.items
193
+ : Array.isArray(payload.history) ? payload.history
194
+ : Array.isArray(payload.entries) ? payload.entries
195
+ : [];
196
+ const mapped = rows.map((row) => {
197
+ if (shouldHideHistoryRow(row, currentSessionKey))
198
+ return null;
199
+ const roleRaw = getHistoryRoleRaw(row) || 'assistant';
200
+ if (!isRenderableHistoryRole(roleRaw))
201
+ return null;
202
+ const role = roleRaw === 'user' ? 'user' : roleRaw === 'system' ? 'system' : 'assistant';
203
+ const typeRaw = String(row?.type || row?.message?.type || 'text').toLowerCase();
204
+ const type = typeRaw === 'voice' || typeRaw === 'image' || typeRaw === 'system' || typeRaw === 'command'
205
+ ? typeRaw
206
+ : 'text';
207
+ const timestamp = pickTimestamp(row, Date.now());
208
+ return {
209
+ id: String(row?.id || row?.messageId || (0, crypto_1.randomUUID)()),
210
+ sessionId,
211
+ role,
212
+ type,
213
+ content: normalizeMessageText(row),
214
+ mediaUrl: row?.mediaUrl || row?.media_url || row?.message?.mediaUrl || '',
215
+ duration: Number(row?.duration || row?.message?.duration) || undefined,
216
+ timestamp,
217
+ };
218
+ }).filter((m) => Boolean(m && (m.content || m.type !== 'text')));
219
+ const sorted = mapped.sort((a, b) => a.timestamp - b.timestamp);
220
+ const filtered = before ? sorted.filter((m) => m.timestamp < before) : sorted;
221
+ if (filtered.length <= limit)
222
+ return filtered;
223
+ return filtered.slice(filtered.length - limit);
224
+ }
225
+ function readConfiguredPrimaryModel(gateway) {
226
+ try {
227
+ const raw = gateway.readConfig();
228
+ const agents = Array.isArray(raw?.agents?.list) ? raw.agents.list : [];
229
+ const mainAgent = agents.find((item) => String(item?.id || '').trim() === 'main');
230
+ const candidates = [
231
+ mainAgent?.model,
232
+ raw?.agents?.defaults?.model?.primary,
233
+ raw?.agents?.defaults?.primary,
234
+ raw?.defaults?.primary,
235
+ raw?.model,
236
+ ];
237
+ for (const candidate of candidates) {
238
+ const value = String(candidate || '').trim();
239
+ if (value)
240
+ return value;
241
+ }
242
+ }
243
+ catch {
244
+ // ignore
245
+ }
246
+ return '';
247
+ }
248
+ function readOpenClawVersion() {
249
+ try {
250
+ const value = (0, child_process_1.execFileSync)('openclaw', ['--version'], {
251
+ encoding: 'utf8',
252
+ timeout: 3000,
253
+ stdio: ['ignore', 'pipe', 'ignore'],
254
+ });
255
+ return String(value || '').trim();
256
+ }
257
+ catch {
258
+ return '';
259
+ }
260
+ }
261
+ async function getOpenClawSummary(gateway) {
262
+ const version = readOpenClawVersion();
263
+ try {
264
+ const status = await gateway.call('status', {});
265
+ const recent = status?.sessions?.recent?.[0];
266
+ const model = String(status?.sessions?.defaults?.model
267
+ || recent?.model
268
+ || readConfiguredPrimaryModel(gateway)
269
+ || '').trim();
270
+ return {
271
+ online: true,
272
+ version,
273
+ model,
274
+ };
275
+ }
276
+ catch (err) {
277
+ return {
278
+ online: false,
279
+ version,
280
+ model: readConfiguredPrimaryModel(gateway),
281
+ error: err?.message || 'gateway-unavailable',
282
+ };
283
+ }
284
+ }
285
+ async function listGatewayWechatSessions(gateway, userId, agentId) {
286
+ const targetUserId = (0, session_key_1.sanitizeWeChatId)(userId, 'unknown');
287
+ const targetAgentId = (0, session_key_1.sanitizeWeChatId)(agentId, '');
288
+ const response = await gateway.call('sessions.list', {});
289
+ const rawSessions = Array.isArray(response?.sessions) ? response.sessions : [];
290
+ const now = Date.now();
291
+ const rows = [];
292
+ for (const row of rawSessions) {
293
+ const key = String(row?.key || row?.sessionKey || row?.id || '').trim().toLowerCase();
294
+ if (!key)
295
+ continue;
296
+ const parsed = (0, session_key_1.parseWeChatDirectThreadSessionKey)(key);
297
+ if (!parsed)
298
+ continue;
299
+ if (parsed.userId !== targetUserId)
300
+ continue;
301
+ if (targetAgentId && parsed.agentId !== targetAgentId)
302
+ continue;
303
+ const lastMessage = normalizePreviewMessageText(row?.lastMessage || row?.preview || row?.summary || '', key);
304
+ const title = pickVisibleSessionTitle(row, lastMessage);
305
+ const createdAt = parseTimestamp(row?.createdAt) || pickTimestamp(row, now);
306
+ const lastMessageTime = pickTimestamp(row, createdAt || now);
307
+ const modelProvider = String(row?.modelProvider || '').trim();
308
+ const modelId = String(row?.model || '').trim();
309
+ const model = modelProvider && modelId ? `${modelProvider}/${modelId}` : modelId;
310
+ rows.push({
311
+ id: parsed.sessionId,
312
+ userId: targetUserId,
313
+ agentId: parsed.agentId,
314
+ gatewaySessionKey: key,
315
+ title,
316
+ lastMessage,
317
+ lastMessageTime,
318
+ createdAt: createdAt || lastMessageTime || now,
319
+ model,
320
+ });
321
+ }
322
+ return rows.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
323
+ }
324
+ async function resolveGatewaySessionKey(gateway, userId, sessionId, agentId) {
325
+ const all = await listGatewayWechatSessions(gateway, userId);
326
+ const targetSessionId = (0, session_key_1.sanitizeWeChatId)(sessionId, '');
327
+ const targetAgent = (0, session_key_1.sanitizeWeChatId)(agentId, '');
328
+ const candidates = all.filter(item => item.id === targetSessionId);
329
+ if (candidates.length > 0) {
330
+ const exact = targetAgent ? candidates.find(c => c.agentId === targetAgent) : null;
331
+ const selected = exact || candidates[0];
332
+ return { key: selected.gatewaySessionKey, agentId: selected.agentId, found: true };
333
+ }
334
+ const fallbackAgent = targetAgent || 'main';
335
+ return {
336
+ key: (0, session_key_1.buildWeChatGatewaySessionKey)(userId, sessionId, fallbackAgent),
337
+ agentId: fallbackAgent,
338
+ found: false,
339
+ };
340
+ }
341
+ async function callGatewayHistory(gateway, key, sessionId, limit, before) {
342
+ const attempts = [
343
+ { sessionKey: key, limit, before },
344
+ { key, limit, before },
345
+ { sessionKey: key, limit },
346
+ { key, limit },
347
+ ];
348
+ let lastError = null;
349
+ for (const params of attempts) {
350
+ try {
351
+ const result = await gateway.call('chat.history', params);
352
+ const messages = normalizeHistoryMessages(result, sessionId, limit, before, key);
353
+ const hasMore = Boolean(result?.hasMore) || messages.length === limit;
354
+ return { messages, hasMore };
355
+ }
356
+ catch (err) {
357
+ lastError = err;
358
+ }
359
+ }
360
+ throw lastError || new Error('Gateway 历史查询失败');
361
+ }
362
+ async function callGatewayDeleteSession(gateway, key) {
363
+ try {
364
+ await gateway.call('sessions.delete', { key, deleteTranscript: true });
365
+ return;
366
+ }
367
+ catch {
368
+ await gateway.call('sessions.delete', { key });
369
+ }
370
+ }
371
+ function ensureBridgeSession(sessionManager, userId, sessionId, agentId, groupId) {
372
+ const normalizedAgentId = (0, session_key_1.sanitizeWeChatId)(agentId, 'main');
373
+ const existingSessionId = String(sessionId || '').trim();
374
+ if (existingSessionId) {
375
+ return sessionManager.ensureSessionBinding(userId, existingSessionId, normalizedAgentId, (0, session_key_1.buildWeChatGatewaySessionKey)(userId, existingSessionId, normalizedAgentId, groupId));
376
+ }
377
+ const created = sessionManager.createSession(userId, undefined, normalizedAgentId);
378
+ return sessionManager.ensureSessionBinding(userId, created.id, normalizedAgentId, (0, session_key_1.buildWeChatGatewaySessionKey)(userId, created.id, normalizedAgentId, groupId));
379
+ }
380
+ /**
381
+ * 内部中继服务器
382
+ * 仅接受来自 wechat-backend 的请求(通过 X-Internal-Secret 验证)
383
+ * 路由前缀: /internal/*
384
+ */
385
+ function createRelayServer(config, gateway) {
386
+ const app = (0, express_1.default)();
387
+ const dataDir = path_1.default.join(config.uploadDir, '..');
388
+ const sessionManager = new session_manager_1.SessionManager(dataDir);
389
+ const messageHandler = new message_handler_1.MessageHandler(config, sessionManager, gateway);
390
+ const mediaHandler = new media_handler_1.MediaHandler(config);
391
+ const groupManager = new group_manager_1.GroupManager(dataDir);
392
+ const memoSessions = createRequestMemo(1000);
393
+ const memoGroups = createRequestMemo(1000);
394
+ const memoHistory = createRequestMemo(800);
395
+ // ===== 中间件 =====
396
+ app.use((0, cors_1.default)());
397
+ app.use(express_1.default.json({ limit: Math.max(config.maxFileSize * 2, 20 * 1024 * 1024) }));
398
+ app.use(express_1.default.urlencoded({ extended: true, limit: Math.max(config.maxFileSize * 2, 20 * 1024 * 1024) }));
399
+ app.use('/uploads', express_1.default.static(config.uploadDir));
400
+ const upload = (0, multer_1.default)({
401
+ storage: multer_1.default.memoryStorage(),
402
+ limits: { fileSize: config.maxFileSize },
403
+ });
404
+ // ===== 内部鉴权中间件 =====
405
+ function internalAuth(req, res, next) {
406
+ const secret = req.headers['x-internal-secret'];
407
+ if (!config.internalSecret || secret === config.internalSecret) {
408
+ next();
409
+ }
410
+ else {
411
+ res.status(403).json({ error: '内部通信密钥无效' });
412
+ }
413
+ }
414
+ function getRequestId(req) {
415
+ return String(req.headers['x-request-id'] || req.body?.requestId || req.query?.requestId || '').trim();
416
+ }
417
+ function logBridgeRequest(req, action, extra) {
418
+ const requestId = getRequestId(req);
419
+ const userId = String(req.body?.userId || req.query?.userId || req.userId || '').trim();
420
+ const sessionId = String(req.body?.sessionId || req.query?.sessionId || req.params?.id || '').trim();
421
+ const payload = {
422
+ requestId: requestId || '-',
423
+ action,
424
+ method: req.method,
425
+ path: req.path,
426
+ userId: userId || '-',
427
+ sessionId: sessionId || '-',
428
+ ...(extra || {}),
429
+ };
430
+ console.log('[BridgeHTTP]', JSON.stringify(payload));
431
+ }
432
+ // ===== 健康检查(无鉴权) =====
433
+ app.get('/internal/health', (_req, res) => {
434
+ res.json({
435
+ status: 'ok',
436
+ service: 'openclaw-wechat-plugin',
437
+ timestamp: Date.now(),
438
+ gateway: config.openclawGatewayUrl,
439
+ });
440
+ });
441
+ // ===== 所有内部路由需要密钥验证 =====
442
+ const internal = express_1.default.Router();
443
+ internal.use(internalAuth);
444
+ internal.get('/bridge/capabilities', async (_req, res) => {
445
+ const openclaw = await getOpenClawSummary(gateway);
446
+ res.json({
447
+ protocolVersion: constants_1.BRIDGE_PROTOCOL_VERSION,
448
+ pluginVersion: constants_1.BRIDGE_PLUGIN_VERSION,
449
+ capabilities: Array.from(constants_1.BRIDGE_CAPABILITIES),
450
+ bridgeRole: 'gateway-bridge',
451
+ openclaw,
452
+ runtime: {
453
+ persistMessagesEnabled: sessionManager.isPersistMessagesEnabled(),
454
+ localFallbackEnabled: Boolean(config.allowLocalFallback),
455
+ },
456
+ });
457
+ });
458
+ // ===== 会话管理 =====
459
+ internal.get('/sessions', async (req, res) => {
460
+ logBridgeRequest(req, 'sessions.list');
461
+ const userId = String(req.query.userId || req.userId || '').trim();
462
+ const agentId = String(req.query.agentId || '').trim();
463
+ if (!userId) {
464
+ res.status(400).json({ error: '缺少 userId' });
465
+ return;
466
+ }
467
+ try {
468
+ const memoKey = `sessions:${userId}:${agentId || '*'}`;
469
+ const payload = await memoSessions(memoKey, async () => {
470
+ const fromGateway = await listGatewayWechatSessions(gateway, userId, agentId || undefined);
471
+ const normalizedAgentId = agentId ? (0, session_key_1.sanitizeWeChatId)(agentId, 'main') : undefined;
472
+ const sessions = fromGateway.map((item) => {
473
+ const local = sessionManager.getSession(item.id, userId);
474
+ sessionManager.upsertSession(userId, item.id, {
475
+ createdAt: local?.createdAt || item.createdAt,
476
+ agentId: item.agentId,
477
+ gatewaySessionKey: item.gatewaySessionKey,
478
+ });
479
+ return toBridgeSessionPayload({
480
+ id: item.id,
481
+ title: item.title || '新对话',
482
+ lastMessage: item.lastMessage || '',
483
+ lastMessageTime: item.lastMessageTime || item.createdAt || Date.now(),
484
+ createdAt: local?.createdAt || item.createdAt,
485
+ agentId: item.agentId,
486
+ model: item.model,
487
+ });
488
+ }).sort((a, b) => b.lastMessageTime - a.lastMessageTime);
489
+ sessionManager.pruneSessions(userId, sessions.map(item => item.id), normalizedAgentId);
490
+ return {
491
+ sessions,
492
+ source: 'gateway',
493
+ };
494
+ });
495
+ res.json(payload);
496
+ }
497
+ catch (err) {
498
+ const gatewayError = err?.message || 'unknown';
499
+ console.error('[Sessions] Gateway 列表获取失败:', gatewayError);
500
+ if (!config.allowLocalFallback) {
501
+ res.status(502).json({
502
+ error: 'Gateway 会话列表查询失败',
503
+ source: 'gateway-error',
504
+ });
505
+ return;
506
+ }
507
+ console.warn('[Sessions] 已启用本地 fallback,返回本地元数据');
508
+ const local = sessionManager.getSessions(userId)
509
+ .filter(s => !agentId || s.agentId === (0, session_key_1.sanitizeWeChatId)(agentId, 'main'))
510
+ .map(item => toBridgeSessionPayload(item));
511
+ res.json({
512
+ sessions: local,
513
+ source: 'local-fallback',
514
+ });
515
+ }
516
+ });
517
+ internal.post('/sessions', (req, res) => {
518
+ logBridgeRequest(req, 'sessions.create', { agentId: String(req.body.agentId || 'main') });
519
+ const userId = String(req.body.userId || '').trim();
520
+ const agentId = String(req.body.agentId || 'main').trim() || 'main';
521
+ if (!userId) {
522
+ res.status(400).json({ error: '缺少 userId' });
523
+ return;
524
+ }
525
+ const session = ensureBridgeSession(sessionManager, userId, typeof req.body.sessionId === 'string' ? req.body.sessionId : '', agentId);
526
+ res.json(toBridgeSessionPayload({
527
+ ...session,
528
+ title: req.body.title || session.title,
529
+ }));
530
+ });
531
+ internal.delete('/sessions/:id', async (req, res) => {
532
+ logBridgeRequest(req, 'sessions.delete', { targetSessionId: String(req.params.id || '').trim() });
533
+ const userId = String(req.query.userId || req.body.userId || '').trim();
534
+ const agentId = String(req.query.agentId || req.body.agentId || '').trim();
535
+ const sessionId = String(req.params.id || '').trim();
536
+ if (!userId) {
537
+ res.status(400).json({ error: '缺少 userId' });
538
+ return;
539
+ }
540
+ if (!sessionId) {
541
+ res.status(400).json({ error: '缺少 sessionId' });
542
+ return;
543
+ }
544
+ let gatewayDeleted = true;
545
+ let matchedGatewaySessions = 0;
546
+ try {
547
+ const all = await listGatewayWechatSessions(gateway, userId);
548
+ const hit = all.filter(s => s.id === (0, session_key_1.sanitizeWeChatId)(sessionId, ''));
549
+ matchedGatewaySessions = hit.length;
550
+ if (hit.length > 0) {
551
+ for (const item of hit) {
552
+ await callGatewayDeleteSession(gateway, item.gatewaySessionKey);
553
+ }
554
+ }
555
+ else {
556
+ const fallbackKey = (0, session_key_1.buildWeChatGatewaySessionKey)(userId, sessionId, agentId || 'main');
557
+ await callGatewayDeleteSession(gateway, fallbackKey);
558
+ }
559
+ }
560
+ catch (err) {
561
+ console.error('[Sessions] Gateway 删除失败:', err.message);
562
+ gatewayDeleted = false;
563
+ }
564
+ const localDeleted = sessionManager.deleteSession(sessionId, userId);
565
+ res.json({
566
+ ok: localDeleted && (gatewayDeleted || matchedGatewaySessions === 0),
567
+ sessionId,
568
+ localDeleted,
569
+ gatewayDeleted,
570
+ });
571
+ });
572
+ // ===== Agent REST API =====
573
+ const OPENCLAW_AGENTS_DIR = path_1.default.join(process.env.HOME || '/root', '.openclaw', 'agents');
574
+ internal.get('/agents', (_req, res) => {
575
+ try {
576
+ const agents = [];
577
+ if (fs_1.default.existsSync(OPENCLAW_AGENTS_DIR)) {
578
+ const dirs = fs_1.default.readdirSync(OPENCLAW_AGENTS_DIR, { withFileTypes: true });
579
+ for (const d of dirs) {
580
+ if (!d.isDirectory())
581
+ continue;
582
+ const agentId = d.name;
583
+ const identityFile = path_1.default.join(OPENCLAW_AGENTS_DIR, agentId, 'agent', 'identity.json');
584
+ let identity = { name: agentId, emoji: '🤖' };
585
+ try {
586
+ if (fs_1.default.existsSync(identityFile)) {
587
+ identity = { ...identity, ...JSON.parse(fs_1.default.readFileSync(identityFile, 'utf-8')) };
588
+ }
589
+ }
590
+ catch { /* ignore */ }
591
+ agents.push({ id: agentId, name: agentId, identity });
592
+ }
593
+ }
594
+ if (agents.length === 0) {
595
+ agents.push({ id: 'main', name: 'main', identity: { name: 'OpenClaw', emoji: '🤖' } });
596
+ }
597
+ res.json({ agents, defaultId: 'main' });
598
+ }
599
+ catch (err) {
600
+ res.json({ agents: [{ id: 'main', name: 'main', identity: { name: 'OpenClaw', emoji: '🤖' } }], defaultId: 'main' });
601
+ }
602
+ });
603
+ internal.get('/agents/:id/identity', (req, res) => {
604
+ const identityFile = path_1.default.join(OPENCLAW_AGENTS_DIR, req.params.id, 'agent', 'identity.json');
605
+ try {
606
+ if (fs_1.default.existsSync(identityFile)) {
607
+ res.json(JSON.parse(fs_1.default.readFileSync(identityFile, 'utf-8')));
608
+ }
609
+ else {
610
+ res.json({ name: req.params.id, emoji: '🤖' });
611
+ }
612
+ }
613
+ catch {
614
+ res.json({ name: req.params.id, emoji: '🤖' });
615
+ }
616
+ });
617
+ internal.post('/agents', (req, res) => {
618
+ try {
619
+ const { name, emoji } = req.body;
620
+ if (!name) {
621
+ res.status(400).json({ error: '缺少 name' });
622
+ return;
623
+ }
624
+ const agentDir = path_1.default.join(OPENCLAW_AGENTS_DIR, name, 'agent');
625
+ fs_1.default.mkdirSync(agentDir, { recursive: true });
626
+ if (emoji) {
627
+ fs_1.default.writeFileSync(path_1.default.join(agentDir, 'identity.json'), JSON.stringify({ name, emoji }, null, 2), 'utf-8');
628
+ }
629
+ res.json({ id: name, name, identity: { name, emoji: emoji || '🤖' } });
630
+ }
631
+ catch (err) {
632
+ res.status(500).json({ error: err.message });
633
+ }
634
+ });
635
+ internal.put('/agents/:id', (req, res) => {
636
+ try {
637
+ const agentDir = path_1.default.join(OPENCLAW_AGENTS_DIR, req.params.id, 'agent');
638
+ const idFile = path_1.default.join(agentDir, 'identity.json');
639
+ let identity = { name: req.params.id, emoji: '🤖' };
640
+ try {
641
+ if (fs_1.default.existsSync(idFile))
642
+ identity = JSON.parse(fs_1.default.readFileSync(idFile, 'utf-8'));
643
+ }
644
+ catch { }
645
+ Object.assign(identity, req.body);
646
+ fs_1.default.mkdirSync(agentDir, { recursive: true });
647
+ fs_1.default.writeFileSync(idFile, JSON.stringify(identity, null, 2), 'utf-8');
648
+ res.json({ id: req.params.id, identity });
649
+ }
650
+ catch (err) {
651
+ res.status(500).json({ error: err.message });
652
+ }
653
+ });
654
+ internal.delete('/agents/:id', (req, res) => {
655
+ try {
656
+ const agentDir = path_1.default.join(OPENCLAW_AGENTS_DIR, req.params.id);
657
+ if (fs_1.default.existsSync(agentDir)) {
658
+ fs_1.default.rmSync(agentDir, { recursive: true, force: true });
659
+ }
660
+ res.json({ ok: true });
661
+ }
662
+ catch (err) {
663
+ res.status(500).json({ error: err.message });
664
+ }
665
+ });
666
+ // Agent 文件管理
667
+ internal.get('/agents/:id/files', async (req, res) => {
668
+ try {
669
+ const result = await gateway.getAgentFiles(req.params.id);
670
+ res.json(result);
671
+ }
672
+ catch (err) {
673
+ res.status(500).json({ error: err.message });
674
+ }
675
+ });
676
+ internal.get('/agents/:id/files/:filename', async (req, res) => {
677
+ try {
678
+ const result = await gateway.getAgentFile(req.params.id, req.params.filename);
679
+ res.json(result);
680
+ }
681
+ catch (err) {
682
+ res.status(500).json({ error: err.message });
683
+ }
684
+ });
685
+ internal.put('/agents/:id/files/:filename', async (req, res) => {
686
+ try {
687
+ const { content } = req.body;
688
+ await gateway.setAgentFile(req.params.id, req.params.filename, content);
689
+ res.json({ ok: true });
690
+ }
691
+ catch (err) {
692
+ res.status(500).json({ error: err.message });
693
+ }
694
+ });
695
+ // ===== Group API =====
696
+ internal.get('/groups', (_req, res) => {
697
+ memoGroups('groups:list', async () => ({ groups: groupManager.list() }))
698
+ .then(payload => res.json(payload))
699
+ .catch((err) => res.status(500).json({ error: err?.message || '群列表获取失败' }));
700
+ });
701
+ internal.post('/groups', (req, res) => {
702
+ const { name, emoji, agentIds, mode } = req.body;
703
+ if (!name) {
704
+ res.status(400).json({ error: '缺少群名称' });
705
+ return;
706
+ }
707
+ const group = groupManager.create(name, emoji, agentIds, mode);
708
+ res.json(group);
709
+ });
710
+ internal.put('/groups/:id', (req, res) => {
711
+ const group = groupManager.update(req.params.id, req.body);
712
+ group ? res.json(group) : res.status(404).json({ error: '群不存在' });
713
+ });
714
+ internal.delete('/groups/:id', (req, res) => {
715
+ groupManager.delete(req.params.id)
716
+ ? res.json({ ok: true })
717
+ : res.status(404).json({ error: '群不存在' });
718
+ });
719
+ internal.post('/groups/:id/agents', (req, res) => {
720
+ const { agentId } = req.body;
721
+ if (!agentId) {
722
+ res.status(400).json({ error: '缺少 agentId' });
723
+ return;
724
+ }
725
+ const group = groupManager.addAgent(req.params.id, agentId);
726
+ group ? res.json(group) : res.status(404).json({ error: '群不存在' });
727
+ });
728
+ internal.delete('/groups/:id/agents/:agentId', (req, res) => {
729
+ const group = groupManager.removeAgent(req.params.id, req.params.agentId);
730
+ group ? res.json(group) : res.status(404).json({ error: '群不存在' });
731
+ });
732
+ // ===== 聊天 =====
733
+ internal.get('/chat/history', async (req, res) => {
734
+ logBridgeRequest(req, 'chat.history');
735
+ const userId = String(req.query.userId || '').trim();
736
+ const sessionId = String(req.query.sessionId || '').trim();
737
+ const agentId = String(req.query.agentId || '').trim();
738
+ const limit = parseInt(req.query.limit) || 50;
739
+ const before = req.query.before ? parseInt(req.query.before) : undefined;
740
+ if (!userId) {
741
+ res.status(400).json({ error: '缺少 userId' });
742
+ return;
743
+ }
744
+ if (!sessionId) {
745
+ res.status(400).json({ error: '缺少 sessionId' });
746
+ return;
747
+ }
748
+ try {
749
+ const memoKey = `history:${userId}:${agentId || '*'}:${sessionId}:${limit}:${before || 0}`;
750
+ const payload = await memoHistory(memoKey, async () => {
751
+ const resolved = await resolveGatewaySessionKey(gateway, userId, sessionId, agentId || undefined);
752
+ if (!resolved.found) {
753
+ sessionManager.deleteSession(sessionId, userId);
754
+ sessionManager.clearMessages(sessionId);
755
+ return {
756
+ messages: [],
757
+ hasMore: false,
758
+ source: 'gateway',
759
+ sessionMissing: true,
760
+ };
761
+ }
762
+ sessionManager.upsertSession(userId, sessionId, {
763
+ agentId: resolved.agentId,
764
+ gatewaySessionKey: resolved.key,
765
+ });
766
+ const { messages, hasMore } = await callGatewayHistory(gateway, resolved.key, sessionId, limit, before);
767
+ if (messages.length === 0) {
768
+ sessionManager.clearMessages(sessionId);
769
+ }
770
+ return { messages, hasMore, source: 'gateway', sessionMissing: false };
771
+ });
772
+ res.json(payload);
773
+ return;
774
+ }
775
+ catch (err) {
776
+ const gatewayError = err?.message || 'unknown';
777
+ console.error('[History] Gateway 查询失败:', gatewayError);
778
+ if (!config.allowLocalFallback) {
779
+ res.status(502).json({
780
+ error: 'Gateway 历史查询失败',
781
+ source: 'gateway-error',
782
+ sessionMissing: false,
783
+ });
784
+ return;
785
+ }
786
+ console.warn('[History] 已启用本地 fallback,返回本地缓存');
787
+ }
788
+ const messages = sessionManager.getMessages(sessionId, limit, before);
789
+ res.json({
790
+ messages,
791
+ hasMore: messages.length === limit,
792
+ source: 'local-fallback',
793
+ sessionMissing: false,
794
+ });
795
+ });
796
+ internal.post('/chat/send', async (req, res) => {
797
+ logBridgeRequest(req, 'chat.send', {
798
+ route: typeof req.body.commandRoute === 'string' ? req.body.commandRoute : '-',
799
+ routeSource: typeof req.body.routeSource === 'string' ? req.body.routeSource : '-',
800
+ });
801
+ const userId = req.body.userId;
802
+ const { sessionId, content } = req.body;
803
+ const agentId = String(req.body.agentId || 'main');
804
+ if (!userId) {
805
+ res.status(400).json({ error: '缺少 userId' });
806
+ return;
807
+ }
808
+ if (!content) {
809
+ res.status(400).json({ error: '消息内容不能为空' });
810
+ return;
811
+ }
812
+ try {
813
+ const session = ensureBridgeSession(sessionManager, userId, sessionId, agentId);
814
+ const sid = session.id;
815
+ const result = await messageHandler.handleMessage(userId, sid, content, 'text', undefined, undefined, agentId, undefined, {
816
+ commandRoute: typeof req.body.commandRoute === 'string' ? req.body.commandRoute : undefined,
817
+ routeSource: typeof req.body.routeSource === 'string' ? req.body.routeSource : undefined,
818
+ requestId: typeof req.body.requestId === 'string' ? req.body.requestId : undefined,
819
+ });
820
+ res.json(result);
821
+ }
822
+ catch (err) {
823
+ console.error('[Chat] 发送消息失败:', err);
824
+ res.status(500).json({ error: err.message });
825
+ }
826
+ });
827
+ internal.post('/chat/send-voice', upload.single('voice'), async (req, res) => {
828
+ logBridgeRequest(req, 'chat.send-voice');
829
+ const userId = req.body.userId;
830
+ const { sessionId } = req.body;
831
+ const agentId = String(req.body.agentId || 'main');
832
+ const duration = parseFloat(req.body.duration) || 0;
833
+ if (!userId) {
834
+ res.status(400).json({ error: '缺少 userId' });
835
+ return;
836
+ }
837
+ const inline = decodeInlineUpload(req.body, 'voice.dat');
838
+ const voiceBuffer = req.file?.buffer || inline?.buffer;
839
+ const voiceOriginalName = req.file?.originalname || inline?.originalName || 'voice.dat';
840
+ if (!voiceBuffer) {
841
+ res.status(400).json({ error: '没有上传语音文件' });
842
+ return;
843
+ }
844
+ try {
845
+ const { url } = mediaHandler.saveVoice(voiceBuffer, voiceOriginalName);
846
+ const session = ensureBridgeSession(sessionManager, userId, sessionId, agentId);
847
+ const sid = session.id;
848
+ const textContent = req.body.text || '[语音消息]';
849
+ const result = await messageHandler.handleMessage(userId, sid, textContent, 'voice', url, duration, agentId);
850
+ res.json(result);
851
+ }
852
+ catch (err) {
853
+ console.error('[Chat] 语音消息失败:', err);
854
+ res.status(500).json({ error: err.message });
855
+ }
856
+ });
857
+ internal.post('/chat/send-image', upload.single('image'), async (req, res) => {
858
+ logBridgeRequest(req, 'chat.send-image');
859
+ const userId = req.body.userId;
860
+ const { sessionId } = req.body;
861
+ const agentId = String(req.body.agentId || 'main');
862
+ if (!userId) {
863
+ res.status(400).json({ error: '缺少 userId' });
864
+ return;
865
+ }
866
+ const inline = decodeInlineUpload(req.body, 'image.dat');
867
+ const imageBuffer = req.file?.buffer || inline?.buffer;
868
+ const imageOriginalName = req.file?.originalname || inline?.originalName || 'image.dat';
869
+ if (!imageBuffer) {
870
+ res.status(400).json({ error: '没有上传图片文件' });
871
+ return;
872
+ }
873
+ try {
874
+ const { url } = mediaHandler.saveImage(imageBuffer, imageOriginalName);
875
+ const session = ensureBridgeSession(sessionManager, userId, sessionId, agentId);
876
+ const sid = session.id;
877
+ const userMessage = sessionManager.addMessage(sid, 'user', 'image', req.body.description || '请查看这张图片', url);
878
+ res.json({ userMessage, mediaUrl: url, sessionId: sid });
879
+ }
880
+ catch (err) {
881
+ console.error('[Chat] 图片上传失败:', err);
882
+ res.status(500).json({ error: err.message });
883
+ }
884
+ });
885
+ internal.get('/media/fetch', (req, res) => {
886
+ const mediaPath = String(req.query.path || '').trim();
887
+ if (!mediaPath) {
888
+ res.status(400).json({ error: '缺少 path' });
889
+ return;
890
+ }
891
+ const media = mediaHandler.readMediaFromUrl(mediaPath);
892
+ if (!media) {
893
+ res.status(404).json({ error: '媒体文件不存在' });
894
+ return;
895
+ }
896
+ res.json({
897
+ contentType: media.contentType,
898
+ dataBase64: media.buffer.toString('base64'),
899
+ });
900
+ });
901
+ internal.get('/commands', (_req, res) => {
902
+ res.json({ commands: commands_1.BOT_COMMANDS });
903
+ });
904
+ // 配置路由
905
+ internal.use('/config', config_routes_1.default);
906
+ app.use('/internal', internal);
907
+ // ===== WebSocket 服务 =====
908
+ const server = http_1.default.createServer(app);
909
+ const wss = new ws_1.WebSocketServer({ server, path: '/internal/ws' });
910
+ const heartbeatInterval = setInterval(() => {
911
+ wss.clients.forEach((ws) => {
912
+ if (ws.isAlive === false)
913
+ return ws.terminate();
914
+ ws.isAlive = false;
915
+ ws.ping();
916
+ });
917
+ }, 30000);
918
+ wss.on('close', () => clearInterval(heartbeatInterval));
919
+ wss.on('connection', (ws, req) => {
920
+ const wsAny = ws;
921
+ wsAny.isAlive = true;
922
+ wsAny.abortCurrent = null;
923
+ // 从后端代理传来的 header 获取 userId 和密钥验证
924
+ const secret = req.headers['x-internal-secret'];
925
+ const userId = req.headers['x-user-id'];
926
+ if (config.internalSecret && secret !== config.internalSecret) {
927
+ ws.close(4003, '内部通信密钥无效');
928
+ return;
929
+ }
930
+ wsAny.authenticated = true;
931
+ wsAny.userId = userId;
932
+ console.log(`[WS] 内部连接: userId=${userId}`);
933
+ ws.on('pong', () => { wsAny.isAlive = true; });
934
+ ws.on('message', (raw) => {
935
+ wsAny.isAlive = true;
936
+ let msg;
937
+ try {
938
+ msg = JSON.parse(raw.toString());
939
+ }
940
+ catch {
941
+ wsSend(ws, { type: 'error', message: '无效的 JSON' });
942
+ return;
943
+ }
944
+ // userId 来自消息或 header
945
+ if (msg.userId)
946
+ wsAny.userId = msg.userId;
947
+ switch (msg.type) {
948
+ case 'chat':
949
+ handleWsChat(ws, msg, messageHandler, sessionManager);
950
+ break;
951
+ case 'stop':
952
+ if (wsAny.abortCurrent) {
953
+ wsAny.abortCurrent();
954
+ wsAny.abortCurrent = null;
955
+ wsSend(ws, { type: 'stopped' });
956
+ }
957
+ break;
958
+ case 'ping':
959
+ wsSend(ws, { type: 'pong' });
960
+ break;
961
+ default:
962
+ wsSend(ws, { type: 'error', message: `未知消息类型: ${msg.type}` });
963
+ }
964
+ });
965
+ ws.on('close', () => {
966
+ if (wsAny.abortCurrent) {
967
+ wsAny.abortCurrent();
968
+ wsAny.abortCurrent = null;
969
+ }
970
+ });
971
+ ws.on('error', (err) => {
972
+ console.error('[WS] 错误:', err.message);
973
+ });
974
+ });
975
+ return { app, server, sessionManager, wss };
976
+ }
977
+ // ===== WebSocket 处理函数 =====
978
+ function wsSend(ws, data) {
979
+ if (ws.readyState === ws_1.WebSocket.OPEN) {
980
+ ws.send(JSON.stringify(data));
981
+ }
982
+ }
983
+ async function handleWsChat(ws, msg, messageHandler, sessionManager) {
984
+ const wsAny = ws;
985
+ if (!wsAny.authenticated || !wsAny.userId) {
986
+ wsSend(ws, { type: 'error', message: '未认证' });
987
+ return;
988
+ }
989
+ const { sessionId, content, messageType, agentId, groupId, mediaUrl } = msg;
990
+ if (!content) {
991
+ wsSend(ws, { type: 'error', message: '消息内容不能为空' });
992
+ return;
993
+ }
994
+ const userId = wsAny.userId;
995
+ const selectedAgentId = String(agentId || 'main');
996
+ const session = ensureBridgeSession(sessionManager, userId, sessionId, selectedAgentId, groupId);
997
+ const sid = session.id;
998
+ const type = messageType || 'text';
999
+ if (wsAny.abortCurrent) {
1000
+ wsAny.abortCurrent();
1001
+ }
1002
+ wsSend(ws, { type: 'start', sessionId: sid, agentId: selectedAgentId });
1003
+ const abort = messageHandler.handleMessageStream(userId, sid, content, type, {
1004
+ onThinking: (text) => {
1005
+ wsSend(ws, { type: 'thinking', text, agentId: selectedAgentId });
1006
+ },
1007
+ onChunk: (text) => {
1008
+ wsSend(ws, { type: 'chunk', text, agentId: selectedAgentId });
1009
+ },
1010
+ onDone: (userMessage, assistantMessage) => {
1011
+ wsAny.abortCurrent = null;
1012
+ wsSend(ws, {
1013
+ type: 'done', userMessage, assistantMessage,
1014
+ sessionId: sid, agentId: selectedAgentId
1015
+ });
1016
+ },
1017
+ onError: (error) => {
1018
+ wsAny.abortCurrent = null;
1019
+ wsSend(ws, { type: 'error', message: error, agentId: selectedAgentId });
1020
+ },
1021
+ }, selectedAgentId, mediaUrl, undefined, groupId, {
1022
+ commandRoute: typeof msg.commandRoute === 'string' ? msg.commandRoute : undefined,
1023
+ routeSource: typeof msg.routeSource === 'string' ? msg.routeSource : undefined,
1024
+ requestId: typeof msg.requestId === 'string' ? msg.requestId : undefined,
1025
+ });
1026
+ wsAny.abortCurrent = abort;
1027
+ }
1028
+ //# sourceMappingURL=relay-server.js.map