@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.
- package/dist/index.d.ts +16 -0
- package/dist/index.js +21 -0
- package/dist/src/bot.d.ts +17 -0
- package/dist/src/bot.js +260 -0
- package/dist/src/channel.d.ts +6 -0
- package/dist/src/channel.js +87 -0
- package/dist/src/client.d.ts +35 -0
- package/dist/src/client.js +147 -0
- package/dist/src/config-schema.d.ts +54 -0
- package/dist/src/config-schema.js +55 -0
- package/dist/src/config.d.ts +17 -0
- package/dist/src/config.js +45 -0
- package/dist/src/file-download.d.ts +17 -0
- package/dist/src/file-download.js +53 -0
- package/dist/src/file-upload.d.ts +23 -0
- package/dist/src/file-upload.js +129 -0
- package/dist/src/formatter.d.ts +77 -0
- package/dist/src/formatter.js +252 -0
- package/dist/src/heartbeat.d.ts +39 -0
- package/dist/src/heartbeat.js +102 -0
- package/dist/src/monitor.d.ts +17 -0
- package/dist/src/monitor.js +191 -0
- package/dist/src/onboarding.d.ts +6 -0
- package/dist/src/onboarding.js +173 -0
- package/dist/src/outbound.d.ts +6 -0
- package/dist/src/outbound.js +208 -0
- package/dist/src/parser.d.ts +49 -0
- package/dist/src/parser.js +99 -0
- package/dist/src/push.d.ts +23 -0
- package/dist/src/push.js +146 -0
- package/dist/src/reply-dispatcher.d.ts +15 -0
- package/dist/src/reply-dispatcher.js +160 -0
- package/dist/src/runtime.d.ts +11 -0
- package/dist/src/runtime.js +18 -0
- package/dist/src/tools/calendar-tool.d.ts +6 -0
- package/dist/src/tools/calendar-tool.js +167 -0
- package/dist/src/tools/location-tool.d.ts +5 -0
- package/dist/src/tools/location-tool.js +136 -0
- package/dist/src/tools/note-tool.d.ts +5 -0
- package/dist/src/tools/note-tool.js +130 -0
- package/dist/src/tools/search-note-tool.d.ts +5 -0
- package/dist/src/tools/search-note-tool.js +130 -0
- package/dist/src/tools/session-manager.d.ts +29 -0
- package/dist/src/tools/session-manager.js +74 -0
- package/dist/src/tools/tool-context.d.ts +16 -0
- package/dist/src/tools/tool-context.js +7 -0
- package/dist/src/types.d.ts +163 -0
- package/dist/src/types.js +2 -0
- package/dist/src/utils/config-manager.d.ts +26 -0
- package/dist/src/utils/config-manager.js +56 -0
- package/dist/src/utils/crypto.d.ts +8 -0
- package/dist/src/utils/crypto.js +14 -0
- package/dist/src/utils/logger.d.ts +6 -0
- package/dist/src/utils/logger.js +34 -0
- package/dist/src/utils/session.d.ts +34 -0
- package/dist/src/utils/session.js +50 -0
- package/dist/src/websocket.d.ts +123 -0
- package/dist/src/websocket.js +547 -0
- package/openclaw.plugin.json +10 -0
- 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
|
+
}
|