@ynhcj/xiaoyi 2.5.6 → 2.5.7

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.
package/dist/websocket.js CHANGED
@@ -7,8 +7,9 @@ exports.XiaoYiWebSocketManager = void 0;
7
7
  const ws_1 = __importDefault(require("ws"));
8
8
  const events_1 = require("events");
9
9
  const url_1 = require("url");
10
- const auth_1 = require("./auth");
11
- const types_1 = require("./types");
10
+ const auth_js_1 = require("./auth.js");
11
+ const heartbeat_js_1 = require("./heartbeat.js");
12
+ const types_js_1 = require("./types.js");
12
13
  class XiaoYiWebSocketManager extends events_1.EventEmitter {
13
14
  constructor(config) {
14
15
  super();
@@ -37,11 +38,19 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
37
38
  this.activeTasks = new Map();
38
39
  // Resolve configuration with defaults and backward compatibility
39
40
  this.config = this.resolveConfig(config);
40
- this.auth = new auth_1.XiaoYiAuth(this.config.ak, this.config.sk, this.config.agentId);
41
+ this.auth = new auth_js_1.XiaoYiAuth(this.config.ak, this.config.sk, this.config.agentId);
41
42
  console.log(`[WS Manager] Initialized with dual server:`);
42
43
  console.log(` Server 1: ${this.config.wsUrl1}`);
43
44
  console.log(` Server 2: ${this.config.wsUrl2}`);
44
45
  }
46
+ /**
47
+ * Set health event callback to report activity to OpenClaw framework.
48
+ * This callback is invoked on heartbeat success to update lastEventAt.
49
+ */
50
+ setHealthEventCallback(callback) {
51
+ this.onHealthEvent = callback;
52
+ console.log("[WS Manager] Health event callback registered");
53
+ }
45
54
  /**
46
55
  * Check if URL is wss + IP format (skip certificate verification)
47
56
  */
@@ -96,12 +105,12 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
96
105
  }
97
106
  // Apply defaults if not provided
98
107
  if (!wsUrl1) {
99
- console.warn(`[WS Manager] wsUrl1 not provided, using default: ${types_1.DEFAULT_WS_URL_1}`);
100
- wsUrl1 = types_1.DEFAULT_WS_URL_1;
108
+ console.warn(`[WS Manager] wsUrl1 not provided, using default: ${types_js_1.DEFAULT_WS_URL_1}`);
109
+ wsUrl1 = types_js_1.DEFAULT_WS_URL_1;
101
110
  }
102
111
  if (!wsUrl2) {
103
- console.warn(`[WS Manager] wsUrl2 not provided, using default: ${types_1.DEFAULT_WS_URL_2}`);
104
- wsUrl2 = types_1.DEFAULT_WS_URL_2;
112
+ console.warn(`[WS Manager] wsUrl2 not provided, using default: ${types_js_1.DEFAULT_WS_URL_2}`);
113
+ wsUrl2 = types_js_1.DEFAULT_WS_URL_2;
105
114
  }
106
115
  return {
107
116
  wsUrl1,
@@ -141,6 +150,22 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
141
150
  async connectToServer1() {
142
151
  console.log(`[Server1] Connecting to ${this.config.wsUrl1}...`);
143
152
  try {
153
+ // ✅ Close existing connection and heartbeat before creating new one
154
+ if (this.ws1) {
155
+ console.log(`[Server1] Closing existing connection before reconnect`);
156
+ if (this.heartbeat1) {
157
+ this.heartbeat1.stop();
158
+ this.heartbeat1 = undefined;
159
+ }
160
+ try {
161
+ this.ws1.removeAllListeners();
162
+ this.ws1.close();
163
+ }
164
+ catch (err) {
165
+ console.warn(`[Server1] Error closing old connection:`, err);
166
+ }
167
+ this.ws1 = null;
168
+ }
144
169
  const authHeaders = this.auth.generateAuthHeaders();
145
170
  // Check if URL is wss + IP format, skip certificate verification
146
171
  const skipCertVerify = this.isWssWithIp(this.config.wsUrl1);
@@ -151,6 +176,26 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
151
176
  headers: authHeaders,
152
177
  rejectUnauthorized: !skipCertVerify,
153
178
  });
179
+ // ✅ Initialize HeartbeatManager for server1
180
+ this.heartbeat1 = new heartbeat_js_1.HeartbeatManager(this.ws1, {
181
+ interval: 30000, // 30 seconds
182
+ timeout: 10000, // 10 seconds timeout
183
+ message: JSON.stringify({
184
+ msgType: "heartbeat",
185
+ agentId: this.config.agentId,
186
+ timestamp: Date.now(),
187
+ }),
188
+ }, () => {
189
+ console.log(`[Server1] Heartbeat timeout, reconnecting...`);
190
+ if (this.ws1 && (this.ws1.readyState === ws_1.default.OPEN || this.ws1.readyState === ws_1.default.CONNECTING)) {
191
+ this.ws1.close();
192
+ }
193
+ }, "server1", console.log, console.error, () => {
194
+ // ✅ Heartbeat success callback - report health to OpenClaw
195
+ this.emit("heartbeat", "server1");
196
+ // ✅ Report liveness to OpenClaw framework to prevent stale-socket detection
197
+ this.onHealthEvent?.();
198
+ });
154
199
  this.setupWebSocketHandlers(this.ws1, 'server1');
155
200
  await new Promise((resolve, reject) => {
156
201
  const timeout = setTimeout(() => reject(new Error("Connection timeout")), 30000);
@@ -171,8 +216,9 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
171
216
  this.scheduleStableConnectionCheck('server1');
172
217
  // Send init message
173
218
  this.sendInitMessage(this.ws1, 'server1');
174
- // Start protocol heartbeat
175
- this.startProtocolHeartbeat('server1');
219
+ // Start heartbeat (replaces old startProtocolHeartbeat)
220
+ this.heartbeat1.start();
221
+ console.log(`[Server1] Heartbeat started (30s interval, 10s timeout)`);
176
222
  }
177
223
  catch (error) {
178
224
  console.error(`[Server1] Connection failed:`, error);
@@ -188,6 +234,22 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
188
234
  async connectToServer2() {
189
235
  console.log(`[Server2] Connecting to ${this.config.wsUrl2}...`);
190
236
  try {
237
+ // ✅ Close existing connection and heartbeat before creating new one
238
+ if (this.ws2) {
239
+ console.log(`[Server2] Closing existing connection before reconnect`);
240
+ if (this.heartbeat2) {
241
+ this.heartbeat2.stop();
242
+ this.heartbeat2 = undefined;
243
+ }
244
+ try {
245
+ this.ws2.removeAllListeners();
246
+ this.ws2.close();
247
+ }
248
+ catch (err) {
249
+ console.warn(`[Server2] Error closing old connection:`, err);
250
+ }
251
+ this.ws2 = null;
252
+ }
191
253
  const authHeaders = this.auth.generateAuthHeaders();
192
254
  // Check if URL is wss + IP format, skip certificate verification
193
255
  const skipCertVerify = this.isWssWithIp(this.config.wsUrl2);
@@ -198,6 +260,26 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
198
260
  headers: authHeaders,
199
261
  rejectUnauthorized: !skipCertVerify,
200
262
  });
263
+ // ✅ Initialize HeartbeatManager for server2
264
+ this.heartbeat2 = new heartbeat_js_1.HeartbeatManager(this.ws2, {
265
+ interval: 30000, // 30 seconds
266
+ timeout: 10000, // 10 seconds timeout
267
+ message: JSON.stringify({
268
+ msgType: "heartbeat",
269
+ agentId: this.config.agentId,
270
+ timestamp: Date.now(),
271
+ }),
272
+ }, () => {
273
+ console.log(`[Server2] Heartbeat timeout, reconnecting...`);
274
+ if (this.ws2 && (this.ws2.readyState === ws_1.default.OPEN || this.ws2.readyState === ws_1.default.CONNECTING)) {
275
+ this.ws2.close();
276
+ }
277
+ }, "server2", console.log, console.error, () => {
278
+ // ✅ Heartbeat success callback - report health to OpenClaw
279
+ this.emit("heartbeat", "server2");
280
+ // ✅ Report liveness to OpenClaw framework to prevent stale-socket detection
281
+ this.onHealthEvent?.();
282
+ });
201
283
  this.setupWebSocketHandlers(this.ws2, 'server2');
202
284
  await new Promise((resolve, reject) => {
203
285
  const timeout = setTimeout(() => reject(new Error("Connection timeout")), 30000);
@@ -218,8 +300,9 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
218
300
  this.scheduleStableConnectionCheck('server2');
219
301
  // Send init message
220
302
  this.sendInitMessage(this.ws2, 'server2');
221
- // Start protocol heartbeat
222
- this.startProtocolHeartbeat('server2');
303
+ // Start heartbeat (replaces old startProtocolHeartbeat)
304
+ this.heartbeat2.start();
305
+ console.log(`[Server2] Heartbeat started (30s interval, 10s timeout)`);
223
306
  }
224
307
  catch (error) {
225
308
  console.error(`[Server2] Connection failed:`, error);
@@ -235,12 +318,42 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
235
318
  disconnect() {
236
319
  console.log("[WS Manager] Disconnecting from all servers...");
237
320
  this.clearTimers();
321
+ // ✅ Stop heartbeat managers
322
+ if (this.heartbeat1) {
323
+ console.log("[Server1] Stopping heartbeat manager");
324
+ this.heartbeat1.stop();
325
+ this.heartbeat1 = undefined;
326
+ }
327
+ if (this.heartbeat2) {
328
+ console.log("[Server2] Stopping heartbeat manager");
329
+ this.heartbeat2.stop();
330
+ this.heartbeat2 = undefined;
331
+ }
332
+ // ✅ Properly cleanup WebSocket connections to prevent ghost connections
238
333
  if (this.ws1) {
239
- this.ws1.close();
334
+ try {
335
+ console.log("[Server1] Removing all listeners and closing connection");
336
+ this.ws1.removeAllListeners();
337
+ if (this.ws1.readyState === ws_1.default.OPEN || this.ws1.readyState === ws_1.default.CONNECTING) {
338
+ this.ws1.close();
339
+ }
340
+ }
341
+ catch (err) {
342
+ console.warn("[Server1] Error during disconnect:", err);
343
+ }
240
344
  this.ws1 = null;
241
345
  }
242
346
  if (this.ws2) {
243
- this.ws2.close();
347
+ try {
348
+ console.log("[Server2] Removing all listeners and closing connection");
349
+ this.ws2.removeAllListeners();
350
+ if (this.ws2.readyState === ws_1.default.OPEN || this.ws2.readyState === ws_1.default.CONNECTING) {
351
+ this.ws2.close();
352
+ }
353
+ }
354
+ catch (err) {
355
+ console.warn("[Server2] Error during disconnect:", err);
356
+ }
244
357
  this.ws2 = null;
245
358
  }
246
359
  this.state1.connected = false;
@@ -257,6 +370,7 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
257
370
  }
258
371
  this.sessionCleanupStateMap.clear();
259
372
  this.emit("disconnected");
373
+ console.log("[WS Manager] Disconnect complete");
260
374
  }
261
375
  /**
262
376
  * Send init message to specific server
@@ -560,6 +674,42 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
560
674
  // TODO: Implement actual push message sending via HTTP API
561
675
  // Need to confirm correct push message format with XiaoYi API documentation
562
676
  }
677
+ /**
678
+ * Send an outbound WebSocket message directly.
679
+ * This is a low-level method that sends a pre-formatted OutboundWebSocketMessage.
680
+ *
681
+ * @param sessionId - Session ID for routing
682
+ * @param message - Pre-formatted outbound message
683
+ */
684
+ async sendMessage(sessionId, message) {
685
+ // Check if session is pending cleanup
686
+ const cleanupState = this.sessionCleanupStateMap.get(sessionId);
687
+ if (cleanupState) {
688
+ console.log(`[SEND_MESSAGE] Discarding message for pending cleanup session ${sessionId}`);
689
+ return;
690
+ }
691
+ // Find which server this session belongs to
692
+ const targetServer = this.sessionServerMap.get(sessionId);
693
+ if (!targetServer) {
694
+ console.error(`[SEND_MESSAGE] Unknown server for session ${sessionId}`);
695
+ throw new Error(`Cannot route message: unknown session ${sessionId}`);
696
+ }
697
+ // Get the corresponding WebSocket connection
698
+ const ws = targetServer === 'server1' ? this.ws1 : this.ws2;
699
+ const state = targetServer === 'server1' ? this.state1 : this.state2;
700
+ if (!ws || ws.readyState !== ws_1.default.OPEN) {
701
+ console.error(`[SEND_MESSAGE] ${targetServer} not connected for session ${sessionId}`);
702
+ throw new Error(`${targetServer} is not available`);
703
+ }
704
+ try {
705
+ ws.send(JSON.stringify(message));
706
+ console.log(`[SEND_MESSAGE] Message sent to ${targetServer} for session ${sessionId}, msgType=${message.msgType}`);
707
+ }
708
+ catch (error) {
709
+ console.error(`[SEND_MESSAGE] Failed to send to ${targetServer}:`, error);
710
+ throw error;
711
+ }
712
+ }
563
713
  /**
564
714
  * Send tasks cancel response to specific server
565
715
  */
@@ -0,0 +1,19 @@
1
+ import type { OpenClawConfig, RuntimeEnv } from "openclaw/dist/plugin-sdk/index.js";
2
+ type ClawdbotConfig = OpenClawConfig;
3
+ import type { A2AJsonRpcRequest } from "./types.js";
4
+ /**
5
+ * Parameters for handling an XY message.
6
+ */
7
+ export interface HandleXYMessageParams {
8
+ cfg: ClawdbotConfig;
9
+ runtime: RuntimeEnv;
10
+ message: A2AJsonRpcRequest;
11
+ accountId: string;
12
+ }
13
+ /**
14
+ * Handle an incoming A2A message.
15
+ * This is the main entry point for message processing.
16
+ * Runtime is expected to be validated before calling this function.
17
+ */
18
+ export declare function handleXYMessage(params: HandleXYMessageParams): Promise<void>;
19
+ export {};
package/dist/xy-bot.js ADDED
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleXYMessage = handleXYMessage;
4
+ const runtime_js_1 = require("./runtime.js");
5
+ const xy_reply_dispatcher_js_1 = require("./xy-reply-dispatcher.js");
6
+ const xy_parser_js_1 = require("./xy-parser.js");
7
+ const file_download_js_1 = require("./file-download.js");
8
+ const xy_config_js_1 = require("./xy-config.js");
9
+ const xy_formatter_js_1 = require("./xy-formatter.js");
10
+ const session_manager_js_1 = require("./xy-tools/session-manager.js");
11
+ const config_manager_js_1 = require("./xy-utils/config-manager.js");
12
+ /**
13
+ * Handle an incoming A2A message.
14
+ * This is the main entry point for message processing.
15
+ * Runtime is expected to be validated before calling this function.
16
+ */
17
+ async function handleXYMessage(params) {
18
+ const { cfg, runtime, message, accountId } = params;
19
+ const log = runtime?.log ?? console.log;
20
+ const error = runtime?.error ?? console.error;
21
+ // Get OpenClaw PluginRuntime (not XiaoYiRuntime)
22
+ const xiaoYiRuntime = (0, runtime_js_1.getXiaoYiRuntime)();
23
+ const core = xiaoYiRuntime.getPluginRuntime();
24
+ try {
25
+ // Check for special messages BEFORE parsing (these have different param structures)
26
+ const messageMethod = message.method;
27
+ log(`[BOT-ENTRY] <<<<<<< Received message with method: ${messageMethod}, id: ${message.id} >>>>>>>`);
28
+ log(`[BOT-ENTRY] Stack trace for debugging:`, new Error().stack?.split('\n').slice(1, 4).join('\n'));
29
+ // Handle clearContext messages (params only has sessionId)
30
+ if (messageMethod === "clearContext" || messageMethod === "clear_context") {
31
+ const sessionId = message.params?.sessionId;
32
+ if (!sessionId) {
33
+ throw new Error("clearContext request missing sessionId in params");
34
+ }
35
+ log(`Clear context request for session ${sessionId}`);
36
+ const config = (0, xy_config_js_1.resolveXYConfig)(cfg);
37
+ await (0, xy_formatter_js_1.sendClearContextResponse)({
38
+ config,
39
+ sessionId,
40
+ messageId: message.id,
41
+ });
42
+ return;
43
+ }
44
+ // Handle tasks/cancel messages
45
+ if (messageMethod === "tasks/cancel" || messageMethod === "tasks_cancel") {
46
+ const sessionId = message.params?.sessionId;
47
+ const taskId = message.params?.id || message.id;
48
+ if (!sessionId) {
49
+ throw new Error("tasks/cancel request missing sessionId in params");
50
+ }
51
+ log(`Tasks cancel request for session ${sessionId}, task ${taskId}`);
52
+ const config = (0, xy_config_js_1.resolveXYConfig)(cfg);
53
+ await (0, xy_formatter_js_1.sendTasksCancelResponse)({
54
+ config,
55
+ sessionId,
56
+ taskId,
57
+ messageId: message.id,
58
+ });
59
+ return;
60
+ }
61
+ // Parse the A2A message (for regular messages)
62
+ const parsed = (0, xy_parser_js_1.parseA2AMessage)(message);
63
+ // Extract and update push_id if present
64
+ const pushId = (0, xy_parser_js_1.extractPushId)(parsed.parts);
65
+ if (pushId) {
66
+ log(`[BOT] 📌 Extracted push_id from user message`);
67
+ log(`[BOT] - Session ID: ${parsed.sessionId}`);
68
+ log(`[BOT] - Push ID preview: ${pushId.substring(0, 20)}...`);
69
+ log(`[BOT] - Full push_id: ${pushId}`);
70
+ config_manager_js_1.configManager.updatePushId(parsed.sessionId, pushId);
71
+ }
72
+ else {
73
+ log(`[BOT] ℹ️ No push_id found in message, will use config default`);
74
+ }
75
+ // Resolve configuration (needed for status updates)
76
+ const config = (0, xy_config_js_1.resolveXYConfig)(cfg);
77
+ // ✅ Resolve agent route (following feishu pattern)
78
+ // accountId is "default" for XY (single account mode)
79
+ // Use sessionId as peer.id to ensure all messages in the same session share context
80
+ let route = core.channel.routing.resolveAgentRoute({
81
+ cfg,
82
+ channel: "xiaoyi-channel",
83
+ accountId, // "default"
84
+ peer: {
85
+ kind: "direct",
86
+ id: parsed.sessionId, // ✅ Use sessionId to share context within the same conversation session
87
+ },
88
+ });
89
+ log(`xy: resolved route accountId=${route.accountId}, sessionKey=${route.sessionKey}`);
90
+ // Register session context for tools
91
+ log(`[BOT] 📝 About to register session for tools...`);
92
+ log(`[BOT] - sessionKey: ${route.sessionKey}`);
93
+ log(`[BOT] - sessionId: ${parsed.sessionId}`);
94
+ log(`[BOT] - taskId: ${parsed.taskId}`);
95
+ (0, session_manager_js_1.registerSession)(route.sessionKey, {
96
+ config,
97
+ sessionId: parsed.sessionId,
98
+ taskId: parsed.taskId,
99
+ messageId: parsed.messageId,
100
+ agentId: route.accountId,
101
+ });
102
+ log(`[BOT] ✅ Session registered for tools`);
103
+ // Send initial status update immediately after parsing message
104
+ log(`[STATUS] Sending initial status update for session ${parsed.sessionId}`);
105
+ void (0, xy_formatter_js_1.sendStatusUpdate)({
106
+ config,
107
+ sessionId: parsed.sessionId,
108
+ taskId: parsed.taskId,
109
+ messageId: parsed.messageId,
110
+ text: "任务正在处理中,请稍后~",
111
+ state: "working",
112
+ }).catch((err) => {
113
+ error(`Failed to send initial status update:`, err);
114
+ });
115
+ // Extract text and files from parts
116
+ const text = (0, xy_parser_js_1.extractTextFromParts)(parsed.parts);
117
+ const fileParts = (0, xy_parser_js_1.extractFileParts)(parsed.parts);
118
+ // Download files if present (using core's media download)
119
+ const mediaList = await (0, file_download_js_1.downloadFilesFromParts)(fileParts);
120
+ // Build media payload for inbound context (following feishu pattern)
121
+ const mediaPayload = buildXYMediaPayload(mediaList);
122
+ // Resolve envelope format options (following feishu pattern)
123
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
124
+ // Build message body with speaker prefix (following feishu pattern)
125
+ let messageBody = text || "";
126
+ // Add speaker prefix for clarity
127
+ const speaker = parsed.sessionId;
128
+ messageBody = `${speaker}: ${messageBody}`;
129
+ // Format agent envelope (following feishu pattern)
130
+ const body = core.channel.reply.formatAgentEnvelope({
131
+ channel: "xiaoyi-channel",
132
+ from: speaker,
133
+ timestamp: new Date(),
134
+ envelope: envelopeOptions,
135
+ body: messageBody,
136
+ });
137
+ // ✅ Finalize inbound context (following feishu pattern)
138
+ // Use route.accountId and route.sessionKey instead of parsed fields
139
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
140
+ Body: body,
141
+ RawBody: text || "",
142
+ CommandBody: text || "",
143
+ From: parsed.sessionId,
144
+ To: parsed.sessionId, // ✅ Simplified: use sessionId as target (context is managed by SessionKey)
145
+ SessionKey: route.sessionKey, // ✅ Use route.sessionKey
146
+ AccountId: route.accountId, // ✅ Use route.accountId ("default")
147
+ ChatType: "direct",
148
+ GroupSubject: undefined,
149
+ SenderName: parsed.sessionId,
150
+ SenderId: parsed.sessionId,
151
+ Provider: "xiaoyi-channel",
152
+ Surface: "xiaoyi-channel",
153
+ MessageSid: parsed.messageId,
154
+ Timestamp: Date.now(),
155
+ WasMentioned: false,
156
+ CommandAuthorized: true,
157
+ OriginatingChannel: "xiaoyi-channel",
158
+ OriginatingTo: parsed.sessionId, // Original message target
159
+ ReplyToBody: undefined, // A2A protocol doesn't support reply/quote
160
+ ...mediaPayload,
161
+ });
162
+ // Send initial status update immediately after parsing message
163
+ log(`[STATUS] Sending initial status update for session ${parsed.sessionId}`);
164
+ void (0, xy_formatter_js_1.sendStatusUpdate)({
165
+ config,
166
+ sessionId: parsed.sessionId,
167
+ taskId: parsed.taskId,
168
+ messageId: parsed.messageId,
169
+ text: "任务正在处理中,请稍后~",
170
+ state: "working",
171
+ }).catch((err) => {
172
+ error(`Failed to send initial status update:`, err);
173
+ });
174
+ // Create reply dispatcher (following feishu pattern)
175
+ log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher for session=${parsed.sessionId}, taskId=${parsed.taskId}, messageId=${parsed.messageId}`);
176
+ const { dispatcher, replyOptions, markDispatchIdle, startStatusInterval } = (0, xy_reply_dispatcher_js_1.createXYReplyDispatcher)({
177
+ cfg,
178
+ runtime,
179
+ sessionId: parsed.sessionId,
180
+ taskId: parsed.taskId,
181
+ messageId: parsed.messageId,
182
+ accountId: route.accountId, // ✅ Use route.accountId
183
+ });
184
+ log(`[BOT-DISPATCHER] ✅ Reply dispatcher created successfully`);
185
+ // Start status update interval (will send updates every 60 seconds)
186
+ // Interval will be automatically stopped when onIdle/onCleanup is triggered
187
+ startStatusInterval();
188
+ log(`xy: dispatching to agent (session=${parsed.sessionId})`);
189
+ // Dispatch to OpenClaw core using correct API (following feishu pattern)
190
+ log(`[BOT] 🚀 Starting dispatcher with session: ${route.sessionKey}`);
191
+ await core.channel.reply.withReplyDispatcher({
192
+ dispatcher,
193
+ onSettled: () => {
194
+ log(`[BOT] 🏁 onSettled called for session: ${route.sessionKey}`);
195
+ log(`[BOT] - About to unregister session...`);
196
+ markDispatchIdle();
197
+ // Unregister session context when done
198
+ (0, session_manager_js_1.unregisterSession)(route.sessionKey);
199
+ log(`[BOT] ✅ Session unregistered in onSettled`);
200
+ },
201
+ run: () => core.channel.reply.dispatchReplyFromConfig({
202
+ ctx: ctxPayload,
203
+ cfg,
204
+ dispatcher,
205
+ replyOptions,
206
+ }),
207
+ });
208
+ log(`[BOT] ✅ Dispatcher completed for session: ${parsed.sessionId}`);
209
+ log(`xy: dispatch complete (session=${parsed.sessionId})`);
210
+ }
211
+ catch (err) {
212
+ // ✅ Only log error, don't re-throw to prevent gateway restart
213
+ error("Failed to handle XY message:", err);
214
+ runtime.error?.(`xy: Failed to handle message: ${String(err)}`);
215
+ log(`[BOT] ❌ Error occurred, attempting cleanup...`);
216
+ // Try to unregister session on error (if route was established)
217
+ try {
218
+ const xiaoYiRuntime = (0, runtime_js_1.getXiaoYiRuntime)();
219
+ const core = xiaoYiRuntime.getPluginRuntime();
220
+ const params = message.params;
221
+ const sessionId = params?.sessionId;
222
+ if (sessionId) {
223
+ log(`[BOT] 🧹 Cleaning up session after error: ${sessionId}`);
224
+ const route = core.channel.routing.resolveAgentRoute({
225
+ cfg,
226
+ channel: "xiaoyi-channel",
227
+ accountId,
228
+ peer: {
229
+ kind: "direct",
230
+ id: sessionId, // ✅ Use sessionId for cleanup consistency
231
+ },
232
+ });
233
+ log(`[BOT] - Unregistering session: ${route.sessionKey}`);
234
+ (0, session_manager_js_1.unregisterSession)(route.sessionKey);
235
+ log(`[BOT] ✅ Session unregistered after error`);
236
+ }
237
+ }
238
+ catch (cleanupErr) {
239
+ log(`[BOT] ⚠️ Cleanup failed:`, cleanupErr);
240
+ // Ignore cleanup errors
241
+ }
242
+ // ❌ Don't re-throw: message processing error should not affect gateway stability
243
+ }
244
+ }
245
+ /**
246
+ * Build media payload for inbound context.
247
+ * Following feishu pattern: buildFeishuMediaPayload().
248
+ */
249
+ function buildXYMediaPayload(mediaList) {
250
+ const first = mediaList[0];
251
+ const mediaPaths = mediaList.map((media) => media.path);
252
+ const mediaTypes = mediaList.map((media) => media.mimeType).filter(Boolean);
253
+ return {
254
+ MediaPath: first?.path,
255
+ MediaType: first?.mimeType,
256
+ MediaUrl: first?.path,
257
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
258
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
259
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
260
+ };
261
+ }
262
+ /**
263
+ * Infer OpenClaw media type from file type string.
264
+ */
265
+ function inferMediaType(fileType) {
266
+ const lower = fileType.toLowerCase();
267
+ if (lower.includes("image") || /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(lower)) {
268
+ return "image";
269
+ }
270
+ if (lower.includes("video") || /\.(mp4|avi|mov|mkv|webm)$/i.test(lower)) {
271
+ return "video";
272
+ }
273
+ if (lower.includes("audio") || /\.(mp3|wav|ogg|m4a)$/i.test(lower)) {
274
+ return "audio";
275
+ }
276
+ return "file";
277
+ }
@@ -0,0 +1,26 @@
1
+ import { XiaoYiWebSocketManager } from "./websocket.js";
2
+ import type { XiaoYiChannelConfig } from "./types.js";
3
+ import type { RuntimeEnv } from "openclaw/dist/plugin-sdk/index.js";
4
+ /**
5
+ * Set the runtime for logging in client module.
6
+ */
7
+ export declare function setClientRuntime(rt: RuntimeEnv | undefined): void;
8
+ /**
9
+ * Get or create a WebSocket manager for the given configuration.
10
+ * Reuses existing managers if config matches.
11
+ * Adapted for xiaoyi - uses aksk instead of apiKey/uid
12
+ */
13
+ export declare function getXYWebSocketManager(config: XiaoYiChannelConfig): XiaoYiWebSocketManager;
14
+ /**
15
+ * Remove a specific WebSocket manager from cache.
16
+ * Disconnects the manager and removes it from the cache.
17
+ */
18
+ export declare function removeXYWebSocketManager(config: XiaoYiChannelConfig): void;
19
+ /**
20
+ * Clear all cached WebSocket managers.
21
+ */
22
+ export declare function clearXYWebSocketManagers(): void;
23
+ /**
24
+ * Get the number of cached managers.
25
+ */
26
+ export declare function getCachedManagerCount(): number;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setClientRuntime = setClientRuntime;
4
+ exports.getXYWebSocketManager = getXYWebSocketManager;
5
+ exports.removeXYWebSocketManager = removeXYWebSocketManager;
6
+ exports.clearXYWebSocketManagers = clearXYWebSocketManagers;
7
+ exports.getCachedManagerCount = getCachedManagerCount;
8
+ // WebSocket client cache management
9
+ // Adapted for xiaoyi - uses xiaoyi's WebSocket manager with aksk auth
10
+ const websocket_js_1 = require("./websocket.js");
11
+ // Runtime reference for logging
12
+ let runtime;
13
+ /**
14
+ * Set the runtime for logging in client module.
15
+ */
16
+ function setClientRuntime(rt) {
17
+ runtime = rt;
18
+ }
19
+ /**
20
+ * Global cache for WebSocket managers.
21
+ * Key format: `${ak}-${agentId}` (using xiaoyi's aksk auth)
22
+ */
23
+ const wsManagerCache = new Map();
24
+ /**
25
+ * Get or create a WebSocket manager for the given configuration.
26
+ * Reuses existing managers if config matches.
27
+ * Adapted for xiaoyi - uses aksk instead of apiKey/uid
28
+ */
29
+ function getXYWebSocketManager(config) {
30
+ const cacheKey = `${config.ak}-${config.agentId}`;
31
+ let cached = wsManagerCache.get(cacheKey);
32
+ if (cached) {
33
+ const log = runtime?.log ?? console.log;
34
+ log(`[WS-MANAGER-CACHE] ✅ Reusing cached WebSocket manager: ${cacheKey}, total managers: ${wsManagerCache.size}`);
35
+ return cached;
36
+ }
37
+ // Create new manager with xiaoyi's config (aksk auth)
38
+ const log = runtime?.log ?? console.log;
39
+ log(`[WS-MANAGER-CACHE] 🆕 Creating new WebSocket manager: ${cacheKey}, total managers before: ${wsManagerCache.size}`);
40
+ cached = new websocket_js_1.XiaoYiWebSocketManager(config);
41
+ wsManagerCache.set(cacheKey, cached);
42
+ log(`[WS-MANAGER-CACHE] 📊 Total managers after creation: ${wsManagerCache.size}`);
43
+ return cached;
44
+ }
45
+ /**
46
+ * Remove a specific WebSocket manager from cache.
47
+ * Disconnects the manager and removes it from the cache.
48
+ */
49
+ function removeXYWebSocketManager(config) {
50
+ const cacheKey = `${config.ak}-${config.agentId}`;
51
+ const manager = wsManagerCache.get(cacheKey);
52
+ if (manager) {
53
+ console.log(`🗑️ [WS-MANAGER-CACHE] Removing manager from cache: ${cacheKey}`);
54
+ manager.disconnect();
55
+ wsManagerCache.delete(cacheKey);
56
+ console.log(`🗑️ [WS-MANAGER-CACHE] Manager removed, remaining managers: ${wsManagerCache.size}`);
57
+ }
58
+ else {
59
+ console.log(`⚠️ [WS-MANAGER-CACHE] Manager not found in cache: ${cacheKey}`);
60
+ }
61
+ }
62
+ /**
63
+ * Clear all cached WebSocket managers.
64
+ */
65
+ function clearXYWebSocketManagers() {
66
+ const log = runtime?.log ?? console.log;
67
+ log("Clearing all WebSocket manager caches");
68
+ for (const manager of wsManagerCache.values()) {
69
+ manager.disconnect();
70
+ }
71
+ wsManagerCache.clear();
72
+ }
73
+ /**
74
+ * Get the number of cached managers.
75
+ */
76
+ function getCachedManagerCount() {
77
+ return wsManagerCache.size;
78
+ }