@ynhcj/xiaoyi-channel 0.0.1-beta

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 (60) hide show
  1. package/dist/index.d.ts +16 -0
  2. package/dist/index.js +21 -0
  3. package/dist/src/bot.d.ts +17 -0
  4. package/dist/src/bot.js +260 -0
  5. package/dist/src/channel.d.ts +6 -0
  6. package/dist/src/channel.js +87 -0
  7. package/dist/src/client.d.ts +35 -0
  8. package/dist/src/client.js +147 -0
  9. package/dist/src/config-schema.d.ts +54 -0
  10. package/dist/src/config-schema.js +55 -0
  11. package/dist/src/config.d.ts +17 -0
  12. package/dist/src/config.js +45 -0
  13. package/dist/src/file-download.d.ts +17 -0
  14. package/dist/src/file-download.js +53 -0
  15. package/dist/src/file-upload.d.ts +23 -0
  16. package/dist/src/file-upload.js +129 -0
  17. package/dist/src/formatter.d.ts +77 -0
  18. package/dist/src/formatter.js +252 -0
  19. package/dist/src/heartbeat.d.ts +39 -0
  20. package/dist/src/heartbeat.js +102 -0
  21. package/dist/src/monitor.d.ts +17 -0
  22. package/dist/src/monitor.js +191 -0
  23. package/dist/src/onboarding.d.ts +6 -0
  24. package/dist/src/onboarding.js +173 -0
  25. package/dist/src/outbound.d.ts +6 -0
  26. package/dist/src/outbound.js +208 -0
  27. package/dist/src/parser.d.ts +49 -0
  28. package/dist/src/parser.js +99 -0
  29. package/dist/src/push.d.ts +23 -0
  30. package/dist/src/push.js +146 -0
  31. package/dist/src/reply-dispatcher.d.ts +15 -0
  32. package/dist/src/reply-dispatcher.js +160 -0
  33. package/dist/src/runtime.d.ts +11 -0
  34. package/dist/src/runtime.js +18 -0
  35. package/dist/src/tools/calendar-tool.d.ts +6 -0
  36. package/dist/src/tools/calendar-tool.js +167 -0
  37. package/dist/src/tools/location-tool.d.ts +5 -0
  38. package/dist/src/tools/location-tool.js +136 -0
  39. package/dist/src/tools/note-tool.d.ts +5 -0
  40. package/dist/src/tools/note-tool.js +130 -0
  41. package/dist/src/tools/search-note-tool.d.ts +5 -0
  42. package/dist/src/tools/search-note-tool.js +130 -0
  43. package/dist/src/tools/session-manager.d.ts +29 -0
  44. package/dist/src/tools/session-manager.js +74 -0
  45. package/dist/src/tools/tool-context.d.ts +16 -0
  46. package/dist/src/tools/tool-context.js +7 -0
  47. package/dist/src/types.d.ts +163 -0
  48. package/dist/src/types.js +2 -0
  49. package/dist/src/utils/config-manager.d.ts +26 -0
  50. package/dist/src/utils/config-manager.js +56 -0
  51. package/dist/src/utils/crypto.d.ts +8 -0
  52. package/dist/src/utils/crypto.js +14 -0
  53. package/dist/src/utils/logger.d.ts +6 -0
  54. package/dist/src/utils/logger.js +34 -0
  55. package/dist/src/utils/session.d.ts +34 -0
  56. package/dist/src/utils/session.js +50 -0
  57. package/dist/src/websocket.d.ts +123 -0
  58. package/dist/src/websocket.js +547 -0
  59. package/openclaw.plugin.json +10 -0
  60. package/package.json +71 -0
@@ -0,0 +1,123 @@
1
+ import { EventEmitter } from "events";
2
+ import type { RuntimeEnv } from "openclaw/plugin-sdk";
3
+ import type { XYChannelConfig, OutboundWebSocketMessage } from "./types.js";
4
+ /**
5
+ * Diagnostics for a single WebSocket connection
6
+ */
7
+ export interface ConnectionDiagnostic {
8
+ exists: boolean;
9
+ readyState: string;
10
+ stateConnected: boolean;
11
+ stateReady: boolean;
12
+ reconnectAttempts: number;
13
+ lastHeartbeat: number;
14
+ heartbeatActive: boolean;
15
+ hasReconnectTimer: boolean;
16
+ listenerCount: number;
17
+ isOrphan: boolean;
18
+ }
19
+ /**
20
+ * Full diagnostics for WebSocket manager
21
+ */
22
+ export interface ManagerDiagnostics {
23
+ cacheKey: string;
24
+ server1: ConnectionDiagnostic;
25
+ server2: ConnectionDiagnostic;
26
+ isShuttingDown: boolean;
27
+ totalEventListeners: number;
28
+ }
29
+ /**
30
+ * Manages dual WebSocket connections to XY servers.
31
+ * Implements session-to-server binding for message routing.
32
+ *
33
+ * Events:
34
+ * - 'message': (message: A2AJsonRpcRequest, sessionId: string, serverId: ServerIdentifier) => void
35
+ * - 'data-event': (event: A2ADataEvent) => void
36
+ * - 'connected': (serverId: ServerIdentifier) => void
37
+ * - 'disconnected': (serverId: ServerIdentifier) => void
38
+ * - 'error': (error: Error, serverId: ServerIdentifier) => void
39
+ * - 'ready': (serverId: ServerIdentifier) => void
40
+ */
41
+ export declare class XYWebSocketManager extends EventEmitter {
42
+ private config;
43
+ private runtime?;
44
+ private ws1;
45
+ private ws2;
46
+ private state1;
47
+ private state2;
48
+ private heartbeat1;
49
+ private heartbeat2;
50
+ private reconnectTimer1;
51
+ private reconnectTimer2;
52
+ private isShuttingDown;
53
+ private log;
54
+ private error;
55
+ private onHealthEvent?;
56
+ constructor(config: XYChannelConfig, runtime?: RuntimeEnv);
57
+ /**
58
+ * Set health event callback to report activity to OpenClaw framework.
59
+ */
60
+ setHealthEventCallback(callback: () => void): void;
61
+ /**
62
+ * Check if config matches the current instance.
63
+ */
64
+ isConfigMatch(config: XYChannelConfig): boolean;
65
+ /**
66
+ * Connect to both WebSocket servers.
67
+ * Does not throw error if connection fails - logs warning instead.
68
+ */
69
+ connect(): Promise<void>;
70
+ /**
71
+ * Disconnect from both WebSocket servers.
72
+ */
73
+ disconnect(): void;
74
+ /**
75
+ * Send a message to the appropriate server based on session binding.
76
+ */
77
+ sendMessage(sessionId: string, message: OutboundWebSocketMessage): Promise<void>;
78
+ /**
79
+ * Check if at least one server is ready.
80
+ */
81
+ isReady(): boolean;
82
+ /**
83
+ * Get detailed connection diagnostics for monitoring and debugging.
84
+ * Helps identify orphan connections and connection leaks.
85
+ */
86
+ getConnectionDiagnostics(): ManagerDiagnostics;
87
+ /**
88
+ * Get diagnostic info for a single server connection.
89
+ */
90
+ private getServerDiagnostic;
91
+ /**
92
+ * Connect to a specific server.
93
+ */
94
+ private connectServer;
95
+ /**
96
+ * Disconnect from a specific server.
97
+ */
98
+ private disconnectServer;
99
+ /**
100
+ * Send init message to server.
101
+ */
102
+ private sendInitMessage;
103
+ /**
104
+ * Start heartbeat for a server.
105
+ */
106
+ private startHeartbeat;
107
+ /**
108
+ * Handle incoming message from server.
109
+ */
110
+ private handleMessage;
111
+ /**
112
+ * Handle connection close.
113
+ */
114
+ private handleClose;
115
+ /**
116
+ * Handle connection error.
117
+ */
118
+ private handleError;
119
+ /**
120
+ * Reconnect to a server with exponential backoff.
121
+ */
122
+ private reconnectServer;
123
+ }
@@ -0,0 +1,547 @@
1
+ // Dual WebSocket connection manager
2
+ // References xiaoyi_v2/websocket.ts for dual connection pattern
3
+ import WebSocket from "ws";
4
+ import { EventEmitter } from "events";
5
+ import { HeartbeatManager } from "./heartbeat.js";
6
+ import { sessionManager } from "./utils/session.js";
7
+ /**
8
+ * Manages dual WebSocket connections to XY servers.
9
+ * Implements session-to-server binding for message routing.
10
+ *
11
+ * Events:
12
+ * - 'message': (message: A2AJsonRpcRequest, sessionId: string, serverId: ServerIdentifier) => void
13
+ * - 'data-event': (event: A2ADataEvent) => void
14
+ * - 'connected': (serverId: ServerIdentifier) => void
15
+ * - 'disconnected': (serverId: ServerIdentifier) => void
16
+ * - 'error': (error: Error, serverId: ServerIdentifier) => void
17
+ * - 'ready': (serverId: ServerIdentifier) => void
18
+ */
19
+ export class XYWebSocketManager extends EventEmitter {
20
+ config;
21
+ runtime;
22
+ ws1 = null;
23
+ ws2 = null;
24
+ state1 = {
25
+ connected: false,
26
+ ready: false,
27
+ lastHeartbeat: 0,
28
+ reconnectAttempts: 0,
29
+ };
30
+ state2 = {
31
+ connected: false,
32
+ ready: false,
33
+ lastHeartbeat: 0,
34
+ reconnectAttempts: 0,
35
+ };
36
+ heartbeat1 = null;
37
+ heartbeat2 = null;
38
+ reconnectTimer1 = null;
39
+ reconnectTimer2 = null;
40
+ isShuttingDown = false;
41
+ // Logging functions following feishu pattern
42
+ log;
43
+ error;
44
+ // Health event callback
45
+ onHealthEvent;
46
+ constructor(config, runtime) {
47
+ super();
48
+ this.config = config;
49
+ this.runtime = runtime;
50
+ this.log = runtime?.log ?? console.log;
51
+ this.error = runtime?.error ?? console.error;
52
+ }
53
+ /**
54
+ * Set health event callback to report activity to OpenClaw framework.
55
+ */
56
+ setHealthEventCallback(callback) {
57
+ this.onHealthEvent = callback;
58
+ }
59
+ /**
60
+ * Check if config matches the current instance.
61
+ */
62
+ isConfigMatch(config) {
63
+ return (this.config.apiKey === config.apiKey &&
64
+ this.config.agentId === config.agentId &&
65
+ this.config.wsUrl1 === config.wsUrl1 &&
66
+ this.config.wsUrl2 === config.wsUrl2);
67
+ }
68
+ /**
69
+ * Connect to both WebSocket servers.
70
+ * Does not throw error if connection fails - logs warning instead.
71
+ */
72
+ async connect() {
73
+ this.log("Connecting to XY WebSocket servers...");
74
+ this.isShuttingDown = false;
75
+ // Try to connect to both servers, but don't fail if both fail
76
+ const results = await Promise.allSettled([
77
+ this.connectServer("server1", this.config.wsUrl1),
78
+ this.connectServer("server2", this.config.wsUrl2),
79
+ ]);
80
+ const successCount = results.filter((r) => r.status === "fulfilled").length;
81
+ const failCount = results.filter((r) => r.status === "rejected").length;
82
+ if (successCount > 0) {
83
+ this.log(`Connected to ${successCount}/2 XY WebSocket servers`);
84
+ }
85
+ else {
86
+ this.error(`Failed to connect to any WebSocket server (${failCount} failures). Plugin will continue but cannot receive messages.`);
87
+ // Log individual failures
88
+ results.forEach((result, index) => {
89
+ if (result.status === "rejected") {
90
+ this.error(` - Server ${index + 1} failed: ${result.reason.message}`);
91
+ }
92
+ });
93
+ }
94
+ }
95
+ /**
96
+ * Disconnect from both WebSocket servers.
97
+ */
98
+ disconnect() {
99
+ this.log("Disconnecting from XY WebSocket servers...");
100
+ this.isShuttingDown = true;
101
+ if (this.reconnectTimer1) {
102
+ clearTimeout(this.reconnectTimer1);
103
+ this.reconnectTimer1 = null;
104
+ }
105
+ if (this.reconnectTimer2) {
106
+ clearTimeout(this.reconnectTimer2);
107
+ this.reconnectTimer2 = null;
108
+ }
109
+ this.disconnectServer("server1");
110
+ this.disconnectServer("server2");
111
+ // Clear session bindings
112
+ sessionManager.clear();
113
+ this.log("Disconnected from XY WebSocket servers");
114
+ }
115
+ /**
116
+ * Send a message to the appropriate server based on session binding.
117
+ */
118
+ async sendMessage(sessionId, message) {
119
+ console.log(`[WEBSOCKET-SEND] <<<<<<< Preparing to send message for session: ${sessionId} <<<<<<<`);
120
+ // Determine which server to use
121
+ let server = sessionManager.getBinding(sessionId);
122
+ // If no binding, choose the first ready server
123
+ if (!server) {
124
+ if (this.state1.ready) {
125
+ server = "server1";
126
+ }
127
+ else if (this.state2.ready) {
128
+ server = "server2";
129
+ }
130
+ else {
131
+ throw new Error("No ready WebSocket servers available");
132
+ }
133
+ console.log(`[WEBSOCKET-SEND] No binding found, selected: ${server}`);
134
+ }
135
+ else {
136
+ console.log(`[WEBSOCKET-SEND] Using bound server: ${server}`);
137
+ }
138
+ // Send to the selected server
139
+ const ws = server === "server1" ? this.ws1 : this.ws2;
140
+ const state = server === "server1" ? this.state1 : this.state2;
141
+ if (!ws || !state.ready || ws.readyState !== WebSocket.OPEN) {
142
+ throw new Error(`WebSocket ${server} not ready`);
143
+ }
144
+ const messageStr = JSON.stringify(message);
145
+ console.log(`[WS-${server}-SEND] Sending message frame:`, JSON.stringify(message, null, 2));
146
+ ws.send(messageStr);
147
+ console.log(`[WS-${server}-SEND] Message sent successfully, size: ${messageStr.length} bytes`);
148
+ }
149
+ /**
150
+ * Check if at least one server is ready.
151
+ */
152
+ isReady() {
153
+ return this.state1.ready || this.state2.ready;
154
+ }
155
+ /**
156
+ * Get detailed connection diagnostics for monitoring and debugging.
157
+ * Helps identify orphan connections and connection leaks.
158
+ */
159
+ getConnectionDiagnostics() {
160
+ const cacheKey = `${this.config.apiKey}-${this.config.agentId}`;
161
+ const server1Diag = this.getServerDiagnostic("server1", this.ws1, this.state1, this.heartbeat1, this.reconnectTimer1);
162
+ const server2Diag = this.getServerDiagnostic("server2", this.ws2, this.state2, this.heartbeat2, this.reconnectTimer2);
163
+ // Count total event listeners on the manager
164
+ const totalEventListeners = this.listenerCount('message') +
165
+ this.listenerCount('connected') +
166
+ this.listenerCount('disconnected') +
167
+ this.listenerCount('error') +
168
+ this.listenerCount('ready') +
169
+ this.listenerCount('data-event');
170
+ return {
171
+ cacheKey,
172
+ server1: server1Diag,
173
+ server2: server2Diag,
174
+ isShuttingDown: this.isShuttingDown,
175
+ totalEventListeners,
176
+ };
177
+ }
178
+ /**
179
+ * Get diagnostic info for a single server connection.
180
+ */
181
+ getServerDiagnostic(serverId, ws, state, heartbeat, reconnectTimer) {
182
+ const exists = ws !== null;
183
+ let readyState = 'NULL';
184
+ let listenerCount = 0;
185
+ if (ws) {
186
+ switch (ws.readyState) {
187
+ case WebSocket.CONNECTING:
188
+ readyState = 'CONNECTING';
189
+ break;
190
+ case WebSocket.OPEN:
191
+ readyState = 'OPEN';
192
+ break;
193
+ case WebSocket.CLOSING:
194
+ readyState = 'CLOSING';
195
+ break;
196
+ case WebSocket.CLOSED:
197
+ readyState = 'CLOSED';
198
+ break;
199
+ }
200
+ // Count event listeners on the WebSocket
201
+ listenerCount = ws.listenerCount('message') +
202
+ ws.listenerCount('close') +
203
+ ws.listenerCount('error') +
204
+ ws.listenerCount('open') +
205
+ ws.listenerCount('pong');
206
+ }
207
+ // Orphan detection: connection is OPEN but has no message listeners
208
+ const isOrphan = exists &&
209
+ ws.readyState === WebSocket.OPEN &&
210
+ ws.listenerCount('message') === 0;
211
+ return {
212
+ exists,
213
+ readyState,
214
+ stateConnected: state.connected,
215
+ stateReady: state.ready,
216
+ reconnectAttempts: state.reconnectAttempts,
217
+ lastHeartbeat: state.lastHeartbeat,
218
+ heartbeatActive: heartbeat !== null,
219
+ hasReconnectTimer: reconnectTimer !== null,
220
+ listenerCount,
221
+ isOrphan,
222
+ };
223
+ }
224
+ /**
225
+ * Connect to a specific server.
226
+ */
227
+ async connectServer(serverId, url) {
228
+ return new Promise((resolve, reject) => {
229
+ // Check if URL is wss with IP address to bypass certificate validation
230
+ const urlObj = new URL(url);
231
+ const isWssWithIP = urlObj.protocol === 'wss:' && /^(\d{1,3}\.){3}\d{1,3}$/.test(urlObj.hostname);
232
+ const wsOptions = {
233
+ headers: {
234
+ "x-uid": this.config.uid,
235
+ "x-api-key": this.config.apiKey,
236
+ "x-agent-id": this.config.agentId,
237
+ "x-request-from": "openclaw",
238
+ },
239
+ };
240
+ // Bypass certificate validation for wss with IP address
241
+ if (isWssWithIP) {
242
+ this.log(`${serverId}: Bypassing certificate validation for IP address: ${urlObj.hostname}`);
243
+ wsOptions.rejectUnauthorized = false;
244
+ }
245
+ const ws = new WebSocket(url, wsOptions);
246
+ const state = serverId === "server1" ? this.state1 : this.state2;
247
+ // Set the WebSocket instance
248
+ if (serverId === "server1") {
249
+ this.ws1 = ws;
250
+ }
251
+ else {
252
+ this.ws2 = ws;
253
+ }
254
+ // Connection timeout
255
+ const connectTimeout = setTimeout(() => {
256
+ if (!state.connected) {
257
+ reject(new Error(`Connection timeout for ${serverId}`));
258
+ ws.close();
259
+ }
260
+ }, 30000); // 30 seconds
261
+ ws.on("open", () => {
262
+ clearTimeout(connectTimeout);
263
+ state.connected = true;
264
+ state.reconnectAttempts = 0;
265
+ this.log(`${serverId} connected`);
266
+ this.emit("connected", serverId);
267
+ // Send init message
268
+ this.sendInitMessage(serverId);
269
+ resolve();
270
+ });
271
+ ws.on("message", (data) => {
272
+ this.handleMessage(serverId, data);
273
+ });
274
+ ws.on("close", (code, reason) => {
275
+ this.handleClose(serverId, code, reason.toString());
276
+ });
277
+ ws.on("error", (error) => {
278
+ this.handleError(serverId, error);
279
+ if (!state.connected) {
280
+ clearTimeout(connectTimeout);
281
+ reject(error);
282
+ }
283
+ });
284
+ });
285
+ }
286
+ /**
287
+ * Disconnect from a specific server.
288
+ */
289
+ disconnectServer(serverId) {
290
+ const ws = serverId === "server1" ? this.ws1 : this.ws2;
291
+ const heartbeat = serverId === "server1" ? this.heartbeat1 : this.heartbeat2;
292
+ const state = serverId === "server1" ? this.state1 : this.state2;
293
+ if (heartbeat) {
294
+ heartbeat.stop();
295
+ if (serverId === "server1") {
296
+ this.heartbeat1 = null;
297
+ }
298
+ else {
299
+ this.heartbeat2 = null;
300
+ }
301
+ }
302
+ if (ws) {
303
+ ws.removeAllListeners();
304
+ if (ws.readyState === WebSocket.OPEN) {
305
+ ws.close();
306
+ }
307
+ if (serverId === "server1") {
308
+ this.ws1 = null;
309
+ }
310
+ else {
311
+ this.ws2 = null;
312
+ }
313
+ }
314
+ state.connected = false;
315
+ state.ready = false;
316
+ }
317
+ /**
318
+ * Send init message to server.
319
+ */
320
+ sendInitMessage(serverId) {
321
+ const ws = serverId === "server1" ? this.ws1 : this.ws2;
322
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
323
+ this.error(`Cannot send init message: ${serverId} not open`);
324
+ return;
325
+ }
326
+ const initMessage = {
327
+ msgType: "clawd_bot_init",
328
+ agentId: this.config.agentId,
329
+ msgDetail: JSON.stringify({ agentId: this.config.agentId }),
330
+ };
331
+ const initMessageStr = JSON.stringify(initMessage);
332
+ console.log(`[WS-${serverId}-SEND] Sending init message frame:`, JSON.stringify(initMessage, null, 2));
333
+ ws.send(initMessageStr);
334
+ console.log(`[WS-${serverId}-SEND] Init message sent successfully, size: ${initMessageStr.length} bytes`);
335
+ // Mark as ready after init
336
+ const state = serverId === "server1" ? this.state1 : this.state2;
337
+ state.ready = true;
338
+ this.emit("ready", serverId);
339
+ // Start heartbeat
340
+ this.startHeartbeat(serverId);
341
+ }
342
+ /**
343
+ * Start heartbeat for a server.
344
+ */
345
+ startHeartbeat(serverId) {
346
+ const ws = serverId === "server1" ? this.ws1 : this.ws2;
347
+ if (!ws)
348
+ return;
349
+ const heartbeat = new HeartbeatManager(ws, {
350
+ interval: 30000, // 30 seconds
351
+ timeout: 10000, // 10 seconds
352
+ message: JSON.stringify({
353
+ msgType: "heartbeat",
354
+ agentId: this.config.agentId,
355
+ msgDetail: JSON.stringify({ timestamp: Date.now() }),
356
+ }),
357
+ }, () => {
358
+ this.error(`Heartbeat timeout for ${serverId}, reconnecting...`);
359
+ this.reconnectServer(serverId);
360
+ }, serverId, this.log, this.error, this.onHealthEvent // ✅ Pass health event callback
361
+ );
362
+ heartbeat.start();
363
+ if (serverId === "server1") {
364
+ this.heartbeat1 = heartbeat;
365
+ }
366
+ else {
367
+ this.heartbeat2 = heartbeat;
368
+ }
369
+ }
370
+ /**
371
+ * Handle incoming message from server.
372
+ */
373
+ handleMessage(serverId, data) {
374
+ console.log(`[WEBSOCKET-HANDLE] >>>>>>> serverId: ${serverId}, receiving message... <<<<<<<`);
375
+ try {
376
+ const messageStr = data.toString();
377
+ console.log(`[WS-${serverId}-RECV] Raw message frame, size: ${messageStr.length} bytes`);
378
+ const parsed = JSON.parse(messageStr);
379
+ // Log raw message
380
+ console.log(`[WS-${serverId}-RECV] Parsed message:`, JSON.stringify(parsed, null, 2));
381
+ // Check if message is in direct A2A JSON-RPC format (server push)
382
+ if (parsed.jsonrpc === "2.0") {
383
+ // Direct A2A format
384
+ const a2aRequest = parsed;
385
+ console.log(`[XY-${serverId}] Message type: Direct A2A JSON-RPC, method: ${a2aRequest.method}`);
386
+ // Extract sessionId from params
387
+ const sessionId = a2aRequest.params?.sessionId;
388
+ if (!sessionId) {
389
+ console.error(`[XY-${serverId}] Message missing sessionId`);
390
+ return;
391
+ }
392
+ console.log(`[XY-${serverId}] Session ID: ${sessionId}`);
393
+ // Bind session to this server if not already bound
394
+ if (!sessionManager.isBound(sessionId)) {
395
+ sessionManager.bind(sessionId, serverId);
396
+ console.log(`[XY-${serverId}] Bound session ${sessionId} to ${serverId}`);
397
+ }
398
+ // Check if message contains only data parts (tool results)
399
+ const dataParts = a2aRequest.params?.message?.parts?.filter((p) => p.kind === "data");
400
+ const hasOnlyDataParts = dataParts && dataParts.length > 0 &&
401
+ dataParts.length === a2aRequest.params?.message?.parts?.length;
402
+ if (hasOnlyDataParts) {
403
+ // This is a data-only message (e.g., intent execution result)
404
+ // Only emit data-event, don't send to openclaw
405
+ console.log(`[XY-${serverId}] Message contains only data parts, processing as tool result`);
406
+ for (const dataPart of dataParts) {
407
+ // Data format: {events: [{header, payload}, ...]}
408
+ const events = dataPart.data?.events;
409
+ if (!Array.isArray(events)) {
410
+ console.warn(`[XY-${serverId}] dataPart.data.events is not an array, skipping`);
411
+ continue;
412
+ }
413
+ console.log(`[XY-${serverId}] Processing ${events.length} events from data.events`);
414
+ for (const item of events) {
415
+ // Check if it's an UploadExeResult (intent execution result)
416
+ if (item.header?.name === "UploadExeResult" && item.payload?.intentName) {
417
+ const dataEvent = {
418
+ intentName: item.payload.intentName,
419
+ outputs: item.payload.outputs || {},
420
+ status: "success",
421
+ };
422
+ console.log(`[XY-${serverId}] Emitting data-event:`, dataEvent);
423
+ this.emit("data-event", dataEvent);
424
+ }
425
+ }
426
+ }
427
+ return; // Don't emit message event
428
+ }
429
+ // Emit message event for non-data-only messages
430
+ console.log(`[XY-${serverId}] *** EMITTING message event (Direct A2A path) ***`);
431
+ this.emit("message", a2aRequest, sessionId, serverId);
432
+ return;
433
+ }
434
+ // Wrapped format (InboundWebSocketMessage)
435
+ const inboundMsg = parsed;
436
+ console.log(`[XY-${serverId}] Message type: Wrapped, msgType: ${inboundMsg.msgType}`);
437
+ // Handle heartbeat responses
438
+ if (inboundMsg.msgType === "heartbeat") {
439
+ console.log(`[XY-${serverId}] Received heartbeat response`);
440
+ // ✅ Report health: application-level heartbeat received
441
+ // This prevents openclaw health-monitor from marking connection as stale
442
+ this.onHealthEvent?.();
443
+ return;
444
+ }
445
+ // Handle data messages (e.g., intent execution results)
446
+ if (inboundMsg.msgType === "data") {
447
+ console.log(`[XY-${serverId}] Processing data message`);
448
+ try {
449
+ const a2aRequest = JSON.parse(inboundMsg.msgDetail);
450
+ const dataParts = a2aRequest.params?.message?.parts?.filter((p) => p.kind === "data");
451
+ if (dataParts && dataParts.length > 0) {
452
+ for (const dataPart of dataParts) {
453
+ // Data format: {events: [{header, payload}, ...]}
454
+ const events = dataPart.data?.events;
455
+ if (!Array.isArray(events)) {
456
+ console.warn(`[XY-${serverId}] dataPart.data.events is not an array, skipping`);
457
+ continue;
458
+ }
459
+ console.log(`[XY-${serverId}] Processing ${events.length} events from data.events`);
460
+ for (const item of events) {
461
+ // Check if it's an UploadExeResult (intent execution result)
462
+ if (item.header?.name === "UploadExeResult" && item.payload?.intentName) {
463
+ const dataEvent = {
464
+ intentName: item.payload.intentName,
465
+ outputs: item.payload.outputs || {},
466
+ status: "success",
467
+ };
468
+ console.log(`[XY-${serverId}] Emitting data-event:`, dataEvent);
469
+ this.emit("data-event", dataEvent);
470
+ }
471
+ }
472
+ }
473
+ }
474
+ }
475
+ catch (error) {
476
+ console.error(`[XY-${serverId}] Failed to process data message:`, error);
477
+ }
478
+ return;
479
+ }
480
+ // Parse msgDetail as A2AJsonRpcRequest
481
+ const a2aRequest = JSON.parse(inboundMsg.msgDetail);
482
+ console.log(`[XY-${serverId}] Parsed A2A request, method: ${a2aRequest.method}`);
483
+ // Bind session to this server if not already bound
484
+ const sessionId = inboundMsg.sessionId;
485
+ if (!sessionManager.isBound(sessionId)) {
486
+ sessionManager.bind(sessionId, serverId);
487
+ console.log(`[XY-${serverId}] Bound session ${sessionId} to ${serverId}`);
488
+ }
489
+ console.log(`[XY-${serverId}] Session ID: ${sessionId}`);
490
+ // Emit message event
491
+ console.log(`[XY-${serverId}] *** EMITTING message event (Wrapped path) ***`);
492
+ this.emit("message", a2aRequest, sessionId, serverId);
493
+ }
494
+ catch (error) {
495
+ console.error(`[XY-${serverId}] Failed to parse message:`, error);
496
+ }
497
+ }
498
+ /**
499
+ * Handle connection close.
500
+ */
501
+ handleClose(serverId, code, reason) {
502
+ console.warn(`${serverId} disconnected: code=${code}, reason=${reason}`);
503
+ const state = serverId === "server1" ? this.state1 : this.state2;
504
+ state.connected = false;
505
+ state.ready = false;
506
+ this.emit("disconnected", serverId);
507
+ // Stop heartbeat
508
+ const heartbeat = serverId === "server1" ? this.heartbeat1 : this.heartbeat2;
509
+ if (heartbeat) {
510
+ heartbeat.stop();
511
+ }
512
+ // Attempt reconnection if not shutting down
513
+ if (!this.isShuttingDown) {
514
+ this.reconnectServer(serverId);
515
+ }
516
+ }
517
+ /**
518
+ * Handle connection error.
519
+ */
520
+ handleError(serverId, error) {
521
+ this.error(`${serverId} error:`, error);
522
+ this.emit("error", error, serverId);
523
+ }
524
+ /**
525
+ * Reconnect to a server with exponential backoff.
526
+ */
527
+ reconnectServer(serverId) {
528
+ if (this.isShuttingDown)
529
+ return;
530
+ const state = serverId === "server1" ? this.state1 : this.state2;
531
+ state.reconnectAttempts++;
532
+ const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts - 1), 30000);
533
+ this.log(`Reconnecting to ${serverId} in ${delay}ms (attempt ${state.reconnectAttempts})...`);
534
+ const timer = setTimeout(() => {
535
+ const url = serverId === "server1" ? this.config.wsUrl1 : this.config.wsUrl2;
536
+ this.connectServer(serverId, url).catch((error) => {
537
+ this.error(`Reconnection failed for ${serverId}:`, error);
538
+ });
539
+ }, delay);
540
+ if (serverId === "server1") {
541
+ this.reconnectTimer1 = timer;
542
+ }
543
+ else {
544
+ this.reconnectTimer2 = timer;
545
+ }
546
+ }
547
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "xiaoyi-channel",
3
+ "channels": ["xiaoyi-channel"],
4
+ "skills": [],
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {}
9
+ }
10
+ }