@ynhcj/xiaoyi-channel 0.0.9 → 0.0.11-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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Manages dynamic configuration updates that can change at runtime.
3
+ * Specifically handles pushId which can be updated per-session.
4
+ */
5
+ declare class ConfigManager {
6
+ private sessionPushIds;
7
+ private globalPushId;
8
+ /**
9
+ * Update push ID for a specific session.
10
+ */
11
+ updatePushId(sessionId: string, pushId: string): void;
12
+ /**
13
+ * Get push ID for a session (falls back to global if not found).
14
+ */
15
+ getPushId(sessionId?: string): string | null;
16
+ /**
17
+ * Clear push ID for a session.
18
+ */
19
+ clearSession(sessionId: string): void;
20
+ /**
21
+ * Clear all cached push IDs.
22
+ */
23
+ clear(): void;
24
+ }
25
+ export declare const configManager: ConfigManager;
26
+ export {};
@@ -0,0 +1,56 @@
1
+ // Dynamic configuration manager for runtime updates
2
+ import { logger } from "./logger.js";
3
+ /**
4
+ * Manages dynamic configuration updates that can change at runtime.
5
+ * Specifically handles pushId which can be updated per-session.
6
+ */
7
+ class ConfigManager {
8
+ sessionPushIds = new Map();
9
+ globalPushId = null;
10
+ /**
11
+ * Update push ID for a specific session.
12
+ */
13
+ updatePushId(sessionId, pushId) {
14
+ if (!pushId) {
15
+ logger.warn(`[ConfigManager] Attempted to set empty pushId for session ${sessionId}`);
16
+ return;
17
+ }
18
+ const previous = this.sessionPushIds.get(sessionId);
19
+ if (previous !== pushId) {
20
+ logger.log(`[ConfigManager] ✨ Updated pushId for session ${sessionId}`);
21
+ logger.log(`[ConfigManager] - Previous: ${previous ? previous.substring(0, 20) + '...' : 'none'}`);
22
+ logger.log(`[ConfigManager] - New: ${pushId.substring(0, 20)}...`);
23
+ logger.log(`[ConfigManager] - Full new pushId: ${pushId}`);
24
+ this.sessionPushIds.set(sessionId, pushId);
25
+ this.globalPushId = pushId; // Also update global for backward compatibility
26
+ }
27
+ }
28
+ /**
29
+ * Get push ID for a session (falls back to global if not found).
30
+ */
31
+ getPushId(sessionId) {
32
+ if (sessionId) {
33
+ const sessionPushId = this.sessionPushIds.get(sessionId);
34
+ if (sessionPushId) {
35
+ return sessionPushId;
36
+ }
37
+ }
38
+ return this.globalPushId;
39
+ }
40
+ /**
41
+ * Clear push ID for a session.
42
+ */
43
+ clearSession(sessionId) {
44
+ this.sessionPushIds.delete(sessionId);
45
+ logger.debug(`[ConfigManager] Cleared pushId for session ${sessionId}`);
46
+ }
47
+ /**
48
+ * Clear all cached push IDs.
49
+ */
50
+ clear() {
51
+ this.sessionPushIds.clear();
52
+ this.globalPushId = null;
53
+ logger.debug(`[ConfigManager] Cleared all cached pushIds`);
54
+ }
55
+ }
56
+ export const configManager = new ConfigManager();
@@ -1,6 +1,31 @@
1
1
  import { EventEmitter } from "events";
2
2
  import type { RuntimeEnv } from "openclaw/plugin-sdk";
3
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
+ }
4
29
  /**
5
30
  * Manages dual WebSocket connections to XY servers.
6
31
  * Implements session-to-server binding for message routing.
@@ -27,7 +52,12 @@ export declare class XYWebSocketManager extends EventEmitter {
27
52
  private isShuttingDown;
28
53
  private log;
29
54
  private error;
55
+ private onHealthEvent?;
30
56
  constructor(config: XYChannelConfig, runtime?: RuntimeEnv);
57
+ /**
58
+ * Set health event callback to report activity to OpenClaw framework.
59
+ */
60
+ setHealthEventCallback(callback: () => void): void;
31
61
  /**
32
62
  * Check if config matches the current instance.
33
63
  */
@@ -49,6 +79,15 @@ export declare class XYWebSocketManager extends EventEmitter {
49
79
  * Check if at least one server is ready.
50
80
  */
51
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;
52
91
  /**
53
92
  * Connect to a specific server.
54
93
  */
@@ -41,6 +41,8 @@ export class XYWebSocketManager extends EventEmitter {
41
41
  // Logging functions following feishu pattern
42
42
  log;
43
43
  error;
44
+ // Health event callback
45
+ onHealthEvent;
44
46
  constructor(config, runtime) {
45
47
  super();
46
48
  this.config = config;
@@ -48,6 +50,12 @@ export class XYWebSocketManager extends EventEmitter {
48
50
  this.log = runtime?.log ?? console.log;
49
51
  this.error = runtime?.error ?? console.error;
50
52
  }
53
+ /**
54
+ * Set health event callback to report activity to OpenClaw framework.
55
+ */
56
+ setHealthEventCallback(callback) {
57
+ this.onHealthEvent = callback;
58
+ }
51
59
  /**
52
60
  * Check if config matches the current instance.
53
61
  */
@@ -144,19 +152,97 @@ export class XYWebSocketManager extends EventEmitter {
144
152
  isReady() {
145
153
  return this.state1.ready || this.state2.ready;
146
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
+ }
147
224
  /**
148
225
  * Connect to a specific server.
149
226
  */
150
227
  async connectServer(serverId, url) {
151
228
  return new Promise((resolve, reject) => {
152
- const ws = new WebSocket(url, {
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 = {
153
233
  headers: {
154
234
  "x-uid": this.config.uid,
155
235
  "x-api-key": this.config.apiKey,
156
236
  "x-agent-id": this.config.agentId,
157
237
  "x-request-from": "openclaw",
158
238
  },
159
- });
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);
160
246
  const state = serverId === "server1" ? this.state1 : this.state2;
161
247
  // Set the WebSocket instance
162
248
  if (serverId === "server1") {
@@ -271,7 +357,8 @@ export class XYWebSocketManager extends EventEmitter {
271
357
  }, () => {
272
358
  this.error(`Heartbeat timeout for ${serverId}, reconnecting...`);
273
359
  this.reconnectServer(serverId);
274
- }, serverId, this.log, this.error);
360
+ }, serverId, this.log, this.error, this.onHealthEvent // ✅ Pass health event callback
361
+ );
275
362
  heartbeat.start();
276
363
  if (serverId === "server1") {
277
364
  this.heartbeat1 = heartbeat;
@@ -317,19 +404,23 @@ export class XYWebSocketManager extends EventEmitter {
317
404
  // Only emit data-event, don't send to openclaw
318
405
  console.log(`[XY-${serverId}] Message contains only data parts, processing as tool result`);
319
406
  for (const dataPart of dataParts) {
320
- const dataArray = dataPart.data;
321
- if (Array.isArray(dataArray)) {
322
- for (const item of dataArray) {
323
- // Check if it's an UploadExeResult (intent execution result)
324
- if (item.header?.name === "UploadExeResult" && item.payload?.intentName) {
325
- const dataEvent = {
326
- intentName: item.payload.intentName,
327
- outputs: item.payload.outputs || {},
328
- status: "success",
329
- };
330
- console.log(`[XY-${serverId}] Emitting data-event:`, dataEvent);
331
- this.emit("data-event", dataEvent);
332
- }
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);
333
424
  }
334
425
  }
335
426
  }
@@ -343,9 +434,12 @@ export class XYWebSocketManager extends EventEmitter {
343
434
  // Wrapped format (InboundWebSocketMessage)
344
435
  const inboundMsg = parsed;
345
436
  console.log(`[XY-${serverId}] Message type: Wrapped, msgType: ${inboundMsg.msgType}`);
346
- // Skip heartbeat responses
437
+ // Handle heartbeat responses
347
438
  if (inboundMsg.msgType === "heartbeat") {
348
- console.log(`[XY-${serverId}] Skipping ${inboundMsg.msgType} message`);
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?.();
349
443
  return;
350
444
  }
351
445
  // Handle data messages (e.g., intent execution results)
@@ -356,19 +450,23 @@ export class XYWebSocketManager extends EventEmitter {
356
450
  const dataParts = a2aRequest.params?.message?.parts?.filter((p) => p.kind === "data");
357
451
  if (dataParts && dataParts.length > 0) {
358
452
  for (const dataPart of dataParts) {
359
- const dataArray = dataPart.data;
360
- if (Array.isArray(dataArray)) {
361
- for (const item of dataArray) {
362
- // Check if it's an UploadExeResult (intent execution result)
363
- if (item.header?.name === "UploadExeResult" && item.payload?.intentName) {
364
- const dataEvent = {
365
- intentName: item.payload.intentName,
366
- outputs: item.payload.outputs || {},
367
- status: "success",
368
- };
369
- console.log(`[XY-${serverId}] Emitting data-event:`, dataEvent);
370
- this.emit("data-event", dataEvent);
371
- }
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);
372
470
  }
373
471
  }
374
472
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.9",
3
+ "version": "0.0.11-beta",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",