@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,16 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { xyPlugin } from "./src/channel.js";
3
+ /**
4
+ * Xiaoyi Channel Plugin Entry Point.
5
+ * Exports the plugin for OpenClaw to load.
6
+ * Located at root level following feishu pattern for proper plugin registration.
7
+ */
8
+ declare const plugin: {
9
+ id: string;
10
+ name: string;
11
+ description: string;
12
+ configSchema: import("openclaw/plugin-sdk").OpenClawPluginConfigSchema;
13
+ register(api: OpenClawPluginApi): void;
14
+ };
15
+ export default plugin;
16
+ export { xyPlugin };
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
2
+ import { xyPlugin } from "./src/channel.js";
3
+ import { setXYRuntime } from "./src/runtime.js";
4
+ /**
5
+ * Xiaoyi Channel Plugin Entry Point.
6
+ * Exports the plugin for OpenClaw to load.
7
+ * Located at root level following feishu pattern for proper plugin registration.
8
+ */
9
+ const plugin = {
10
+ id: "xiaoyi-channel",
11
+ name: "Xiaoyi Channel",
12
+ description: "Xiaoyi channel plugin - Xiaoyi A2A protocol integration",
13
+ configSchema: emptyPluginConfigSchema(),
14
+ register(api) {
15
+ setXYRuntime(api.runtime);
16
+ api.registerChannel({ plugin: xyPlugin });
17
+ },
18
+ };
19
+ export default plugin;
20
+ // Also export the plugin directly for testing
21
+ export { xyPlugin };
@@ -0,0 +1,17 @@
1
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import type { A2AJsonRpcRequest } from "./types.js";
3
+ /**
4
+ * Parameters for handling an XY message.
5
+ */
6
+ export interface HandleXYMessageParams {
7
+ cfg: ClawdbotConfig;
8
+ runtime: RuntimeEnv;
9
+ message: A2AJsonRpcRequest;
10
+ accountId: string;
11
+ }
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
+ export declare function handleXYMessage(params: HandleXYMessageParams): Promise<void>;
@@ -0,0 +1,260 @@
1
+ import { getXYRuntime } from "./runtime.js";
2
+ import { createXYReplyDispatcher } from "./reply-dispatcher.js";
3
+ import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId } from "./parser.js";
4
+ import { downloadFilesFromParts } from "./file-download.js";
5
+ import { resolveXYConfig } from "./config.js";
6
+ import { sendStatusUpdate, sendClearContextResponse, sendTasksCancelResponse } from "./formatter.js";
7
+ import { registerSession, unregisterSession } from "./tools/session-manager.js";
8
+ import { configManager } from "./utils/config-manager.js";
9
+ /**
10
+ * Handle an incoming A2A message.
11
+ * This is the main entry point for message processing.
12
+ * Runtime is expected to be validated before calling this function.
13
+ */
14
+ export async function handleXYMessage(params) {
15
+ const { cfg, runtime, message, accountId } = params;
16
+ const log = runtime?.log ?? console.log;
17
+ const error = runtime?.error ?? console.error;
18
+ // Get runtime (already validated in monitor.ts, but get reference for use)
19
+ const core = getXYRuntime();
20
+ try {
21
+ // Check for special messages BEFORE parsing (these have different param structures)
22
+ const messageMethod = message.method;
23
+ log(`[BOT-ENTRY] <<<<<<< Received message with method: ${messageMethod}, id: ${message.id} >>>>>>>`);
24
+ log(`[BOT-ENTRY] Stack trace for debugging:`, new Error().stack?.split('\n').slice(1, 4).join('\n'));
25
+ // Handle clearContext messages (params only has sessionId)
26
+ if (messageMethod === "clearContext" || messageMethod === "clear_context") {
27
+ const sessionId = message.params?.sessionId;
28
+ if (!sessionId) {
29
+ throw new Error("clearContext request missing sessionId in params");
30
+ }
31
+ log(`Clear context request for session ${sessionId}`);
32
+ const config = resolveXYConfig(cfg);
33
+ await sendClearContextResponse({
34
+ config,
35
+ sessionId,
36
+ messageId: message.id,
37
+ });
38
+ return;
39
+ }
40
+ // Handle tasks/cancel messages
41
+ if (messageMethod === "tasks/cancel" || messageMethod === "tasks_cancel") {
42
+ const sessionId = message.params?.sessionId;
43
+ const taskId = message.params?.id || message.id;
44
+ if (!sessionId) {
45
+ throw new Error("tasks/cancel request missing sessionId in params");
46
+ }
47
+ log(`Tasks cancel request for session ${sessionId}, task ${taskId}`);
48
+ const config = resolveXYConfig(cfg);
49
+ await sendTasksCancelResponse({
50
+ config,
51
+ sessionId,
52
+ taskId,
53
+ messageId: message.id,
54
+ });
55
+ return;
56
+ }
57
+ // Parse the A2A message (for regular messages)
58
+ const parsed = parseA2AMessage(message);
59
+ // Extract and update push_id if present
60
+ const pushId = extractPushId(parsed.parts);
61
+ if (pushId) {
62
+ log(`[BOT] ๐Ÿ“Œ Extracted push_id from user message`);
63
+ log(`[BOT] - Session ID: ${parsed.sessionId}`);
64
+ log(`[BOT] - Push ID preview: ${pushId.substring(0, 20)}...`);
65
+ log(`[BOT] - Full push_id: ${pushId}`);
66
+ configManager.updatePushId(parsed.sessionId, pushId);
67
+ }
68
+ else {
69
+ log(`[BOT] โ„น๏ธ No push_id found in message, will use config default`);
70
+ }
71
+ // Resolve configuration (needed for status updates)
72
+ const config = resolveXYConfig(cfg);
73
+ // โœ… Resolve agent route (following feishu pattern)
74
+ // accountId is "default" for XY (single account mode)
75
+ // Use sessionId as peer.id to ensure all messages in the same session share context
76
+ let route = core.channel.routing.resolveAgentRoute({
77
+ cfg,
78
+ channel: "xiaoyi-channel",
79
+ accountId, // "default"
80
+ peer: {
81
+ kind: "direct",
82
+ id: parsed.sessionId, // โœ… Use sessionId to share context within the same conversation session
83
+ },
84
+ });
85
+ log(`xy: resolved route accountId=${route.accountId}, sessionKey=${route.sessionKey}`);
86
+ // Register session context for tools
87
+ log(`[BOT] ๐Ÿ“ About to register session for tools...`);
88
+ log(`[BOT] - sessionKey: ${route.sessionKey}`);
89
+ log(`[BOT] - sessionId: ${parsed.sessionId}`);
90
+ log(`[BOT] - taskId: ${parsed.taskId}`);
91
+ registerSession(route.sessionKey, {
92
+ config,
93
+ sessionId: parsed.sessionId,
94
+ taskId: parsed.taskId,
95
+ messageId: parsed.messageId,
96
+ agentId: route.accountId,
97
+ });
98
+ log(`[BOT] โœ… Session registered for tools`);
99
+ // Extract text and files from parts
100
+ const text = extractTextFromParts(parsed.parts);
101
+ const fileParts = extractFileParts(parsed.parts);
102
+ // Download files if present (using core's media download)
103
+ const mediaList = await downloadFilesFromParts(fileParts);
104
+ // Build media payload for inbound context (following feishu pattern)
105
+ const mediaPayload = buildXYMediaPayload(mediaList);
106
+ // Resolve envelope format options (following feishu pattern)
107
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
108
+ // Build message body with speaker prefix (following feishu pattern)
109
+ let messageBody = text || "";
110
+ // Add speaker prefix for clarity
111
+ const speaker = parsed.sessionId;
112
+ messageBody = `${speaker}: ${messageBody}`;
113
+ // Format agent envelope (following feishu pattern)
114
+ const body = core.channel.reply.formatAgentEnvelope({
115
+ channel: "xiaoyi-channel",
116
+ from: speaker,
117
+ timestamp: new Date(),
118
+ envelope: envelopeOptions,
119
+ body: messageBody,
120
+ });
121
+ // โœ… Finalize inbound context (following feishu pattern)
122
+ // Use route.accountId and route.sessionKey instead of parsed fields
123
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
124
+ Body: body,
125
+ RawBody: text || "",
126
+ CommandBody: text || "",
127
+ From: parsed.sessionId,
128
+ To: parsed.sessionId, // โœ… Simplified: use sessionId as target (context is managed by SessionKey)
129
+ SessionKey: route.sessionKey, // โœ… Use route.sessionKey
130
+ AccountId: route.accountId, // โœ… Use route.accountId ("default")
131
+ ChatType: "direct",
132
+ GroupSubject: undefined,
133
+ SenderName: parsed.sessionId,
134
+ SenderId: parsed.sessionId,
135
+ Provider: "xiaoyi-channel",
136
+ Surface: "xiaoyi-channel",
137
+ MessageSid: parsed.messageId,
138
+ Timestamp: Date.now(),
139
+ WasMentioned: false,
140
+ CommandAuthorized: true,
141
+ OriginatingChannel: "xiaoyi-channel",
142
+ OriginatingTo: parsed.sessionId, // Original message target
143
+ ReplyToBody: undefined, // A2A protocol doesn't support reply/quote
144
+ ...mediaPayload,
145
+ });
146
+ // Send initial status update immediately after parsing message
147
+ log(`[STATUS] Sending initial status update for session ${parsed.sessionId}`);
148
+ void sendStatusUpdate({
149
+ config,
150
+ sessionId: parsed.sessionId,
151
+ taskId: parsed.taskId,
152
+ messageId: parsed.messageId,
153
+ text: "ไปปๅŠกๆญฃๅœจๅค„็†ไธญ๏ผŒ่ฏท็จๅŽ~",
154
+ state: "working",
155
+ }).catch((err) => {
156
+ error(`Failed to send initial status update:`, err);
157
+ });
158
+ // Create reply dispatcher (following feishu pattern)
159
+ log(`[BOT-DISPATCHER] ๐ŸŽฏ Creating reply dispatcher for session=${parsed.sessionId}, taskId=${parsed.taskId}, messageId=${parsed.messageId}`);
160
+ const { dispatcher, replyOptions, markDispatchIdle, startStatusInterval } = createXYReplyDispatcher({
161
+ cfg,
162
+ runtime,
163
+ sessionId: parsed.sessionId,
164
+ taskId: parsed.taskId,
165
+ messageId: parsed.messageId,
166
+ accountId: route.accountId, // โœ… Use route.accountId
167
+ });
168
+ log(`[BOT-DISPATCHER] โœ… Reply dispatcher created successfully`);
169
+ // Start status update interval (will send updates every 60 seconds)
170
+ // Interval will be automatically stopped when onIdle/onCleanup is triggered
171
+ startStatusInterval();
172
+ log(`xy: dispatching to agent (session=${parsed.sessionId})`);
173
+ // Dispatch to OpenClaw core using correct API (following feishu pattern)
174
+ log(`[BOT] ๐Ÿš€ Starting dispatcher with session: ${route.sessionKey}`);
175
+ await core.channel.reply.withReplyDispatcher({
176
+ dispatcher,
177
+ onSettled: () => {
178
+ log(`[BOT] ๐Ÿ onSettled called for session: ${route.sessionKey}`);
179
+ log(`[BOT] - About to unregister session...`);
180
+ markDispatchIdle();
181
+ // Unregister session context when done
182
+ unregisterSession(route.sessionKey);
183
+ log(`[BOT] โœ… Session unregistered in onSettled`);
184
+ },
185
+ run: () => core.channel.reply.dispatchReplyFromConfig({
186
+ ctx: ctxPayload,
187
+ cfg,
188
+ dispatcher,
189
+ replyOptions,
190
+ }),
191
+ });
192
+ log(`[BOT] โœ… Dispatcher completed for session: ${parsed.sessionId}`);
193
+ log(`xy: dispatch complete (session=${parsed.sessionId})`);
194
+ }
195
+ catch (err) {
196
+ // โœ… Only log error, don't re-throw to prevent gateway restart
197
+ error("Failed to handle XY message:", err);
198
+ runtime.error?.(`xy: Failed to handle message: ${String(err)}`);
199
+ log(`[BOT] โŒ Error occurred, attempting cleanup...`);
200
+ // Try to unregister session on error (if route was established)
201
+ try {
202
+ const core = getXYRuntime();
203
+ const params = message.params;
204
+ const sessionId = params?.sessionId;
205
+ if (sessionId) {
206
+ log(`[BOT] ๐Ÿงน Cleaning up session after error: ${sessionId}`);
207
+ const route = core.channel.routing.resolveAgentRoute({
208
+ cfg,
209
+ channel: "xiaoyi-channel",
210
+ accountId,
211
+ peer: {
212
+ kind: "direct",
213
+ id: sessionId, // โœ… Use sessionId for cleanup consistency
214
+ },
215
+ });
216
+ log(`[BOT] - Unregistering session: ${route.sessionKey}`);
217
+ unregisterSession(route.sessionKey);
218
+ log(`[BOT] โœ… Session unregistered after error`);
219
+ }
220
+ }
221
+ catch (cleanupErr) {
222
+ log(`[BOT] โš ๏ธ Cleanup failed:`, cleanupErr);
223
+ // Ignore cleanup errors
224
+ }
225
+ // โŒ Don't re-throw: message processing error should not affect gateway stability
226
+ }
227
+ }
228
+ /**
229
+ * Build media payload for inbound context.
230
+ * Following feishu pattern: buildFeishuMediaPayload().
231
+ */
232
+ function buildXYMediaPayload(mediaList) {
233
+ const first = mediaList[0];
234
+ const mediaPaths = mediaList.map((media) => media.path);
235
+ const mediaTypes = mediaList.map((media) => media.mimeType).filter(Boolean);
236
+ return {
237
+ MediaPath: first?.path,
238
+ MediaType: first?.mimeType,
239
+ MediaUrl: first?.path,
240
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
241
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
242
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
243
+ };
244
+ }
245
+ /**
246
+ * Infer OpenClaw media type from file type string.
247
+ */
248
+ function inferMediaType(fileType) {
249
+ const lower = fileType.toLowerCase();
250
+ if (lower.includes("image") || /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(lower)) {
251
+ return "image";
252
+ }
253
+ if (lower.includes("video") || /\.(mp4|avi|mov|mkv|webm)$/i.test(lower)) {
254
+ return "video";
255
+ }
256
+ if (lower.includes("audio") || /\.(mp3|wav|ogg|m4a)$/i.test(lower)) {
257
+ return "audio";
258
+ }
259
+ return "file";
260
+ }
@@ -0,0 +1,6 @@
1
+ import type { ChannelPlugin } from "openclaw/plugin-sdk";
2
+ /**
3
+ * Xiaoyi Channel Plugin for OpenClaw.
4
+ * Implements Xiaoyi A2A protocol with dual WebSocket connections.
5
+ */
6
+ export declare const xyPlugin: ChannelPlugin;
@@ -0,0 +1,87 @@
1
+ import { resolveXYConfig, listXYAccountIds, getDefaultXYAccountId } from "./config.js";
2
+ import { xyConfigSchema } from "./config-schema.js";
3
+ import { xyOutbound } from "./outbound.js";
4
+ import { xyOnboardingAdapter } from "./onboarding.js";
5
+ import { locationTool } from "./tools/location-tool.js";
6
+ import { noteTool } from "./tools/note-tool.js";
7
+ import { searchNoteTool } from "./tools/search-note-tool.js";
8
+ import { calendarTool } from "./tools/calendar-tool.js";
9
+ /**
10
+ * Xiaoyi Channel Plugin for OpenClaw.
11
+ * Implements Xiaoyi A2A protocol with dual WebSocket connections.
12
+ */
13
+ export const xyPlugin = {
14
+ id: "xiaoyi-channel",
15
+ meta: {
16
+ id: "xiaoyi-channel",
17
+ label: "Xiaoyi Channel",
18
+ selectionLabel: "Xiaoyi Channel (ๅฐ่‰บ)",
19
+ docsPath: "/channels/xiaoyi-channel",
20
+ blurb: "ๅฐ่‰บ A2A ๅ่ฎฎๆ”ฏๆŒ๏ผŒๅŒ WebSocket ้•ฟ่ฟžๆŽฅ",
21
+ order: 85,
22
+ },
23
+ agentPrompt: {
24
+ messageToolHints: () => [
25
+ "- xiaoyi targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `default`",
26
+ "- sendMedia requires a text reply"
27
+ ],
28
+ },
29
+ capabilities: {
30
+ chatTypes: ["direct"], // Only private chat (no group support)
31
+ polls: false,
32
+ threads: false,
33
+ media: true,
34
+ reactions: false,
35
+ edit: false,
36
+ reply: true,
37
+ },
38
+ config: {
39
+ listAccountIds: listXYAccountIds,
40
+ resolveAccount: resolveXYConfig,
41
+ defaultAccountId: getDefaultXYAccountId,
42
+ },
43
+ configSchema: {
44
+ schema: xyConfigSchema,
45
+ },
46
+ outbound: xyOutbound,
47
+ onboarding: xyOnboardingAdapter,
48
+ agentTools: [locationTool, noteTool, searchNoteTool, calendarTool],
49
+ messaging: {
50
+ normalizeTarget: (raw) => {
51
+ const trimmed = raw.trim();
52
+ if (!trimmed)
53
+ return undefined;
54
+ return trimmed;
55
+ },
56
+ targetResolver: {
57
+ looksLikeId: (raw) => {
58
+ // ไฟกไปปๆ‰€ๆœ‰้ž็ฉบๅญ—็ฌฆไธฒไฝœไธบๆœ‰ๆ•ˆ็š„ sessionId
59
+ const trimmed = raw.trim();
60
+ return trimmed.length > 0;
61
+ },
62
+ hint: "<sessionId>",
63
+ },
64
+ },
65
+ reload: {
66
+ configPrefixes: ["channels.xiaoyi-channel"],
67
+ },
68
+ // Gateway adapter for receiving messages
69
+ gateway: {
70
+ async startAccount(context) {
71
+ const { monitorXYProvider } = await import("./monitor.js");
72
+ const account = resolveXYConfig(context.cfg);
73
+ context.setStatus?.({
74
+ accountId: context.accountId,
75
+ wsUrl1: account.wsUrl1,
76
+ wsUrl2: account.wsUrl2,
77
+ });
78
+ context.log?.info(`[${context.accountId}] starting xiaoyi channel (wsUrl1: ${account.wsUrl1}, wsUrl2: ${account.wsUrl2})`);
79
+ return monitorXYProvider({
80
+ config: context.cfg,
81
+ runtime: context.runtime,
82
+ abortSignal: context.abortSignal,
83
+ accountId: context.accountId,
84
+ });
85
+ },
86
+ },
87
+ };
@@ -0,0 +1,35 @@
1
+ import { XYWebSocketManager } from "./websocket.js";
2
+ import type { XYChannelConfig } from "./types.js";
3
+ import type { RuntimeEnv } from "openclaw/plugin-sdk";
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
+ */
12
+ export declare function getXYWebSocketManager(config: XYChannelConfig): XYWebSocketManager;
13
+ /**
14
+ * Remove a specific WebSocket manager from cache.
15
+ * Disconnects the manager and removes it from the cache.
16
+ */
17
+ export declare function removeXYWebSocketManager(config: XYChannelConfig): void;
18
+ /**
19
+ * Clear all cached WebSocket managers.
20
+ */
21
+ export declare function clearXYWebSocketManagers(): void;
22
+ /**
23
+ * Get the number of cached managers.
24
+ */
25
+ export declare function getCachedManagerCount(): number;
26
+ /**
27
+ * Diagnose all cached WebSocket managers.
28
+ * Helps identify connection issues and orphan connections.
29
+ */
30
+ export declare function diagnoseAllManagers(): void;
31
+ /**
32
+ * Clean up orphan connections across all managers.
33
+ * Returns the number of managers that had orphan connections.
34
+ */
35
+ export declare function cleanupOrphanConnections(): number;
@@ -0,0 +1,147 @@
1
+ // WebSocket client cache management
2
+ // Follows feishu/client.ts pattern for caching client instances
3
+ import { XYWebSocketManager } from "./websocket.js";
4
+ // Runtime reference for logging
5
+ let runtime;
6
+ /**
7
+ * Set the runtime for logging in client module.
8
+ */
9
+ export function setClientRuntime(rt) {
10
+ runtime = rt;
11
+ }
12
+ /**
13
+ * Global cache for WebSocket managers.
14
+ * Key format: `${apiKey}-${agentId}`
15
+ */
16
+ const wsManagerCache = new Map();
17
+ /**
18
+ * Get or create a WebSocket manager for the given configuration.
19
+ * Reuses existing managers if config matches.
20
+ */
21
+ export function getXYWebSocketManager(config) {
22
+ const cacheKey = `${config.apiKey}-${config.agentId}`;
23
+ let cached = wsManagerCache.get(cacheKey);
24
+ if (cached && cached.isConfigMatch(config)) {
25
+ const log = runtime?.log ?? console.log;
26
+ log(`[WS-MANAGER-CACHE] โœ… Reusing cached WebSocket manager: ${cacheKey}, total managers: ${wsManagerCache.size}`);
27
+ return cached;
28
+ }
29
+ // Create new manager
30
+ const log = runtime?.log ?? console.log;
31
+ log(`[WS-MANAGER-CACHE] ๐Ÿ†• Creating new WebSocket manager: ${cacheKey}, total managers before: ${wsManagerCache.size}`);
32
+ cached = new XYWebSocketManager(config, runtime);
33
+ wsManagerCache.set(cacheKey, cached);
34
+ log(`[WS-MANAGER-CACHE] ๐Ÿ“Š Total managers after creation: ${wsManagerCache.size}`);
35
+ return cached;
36
+ }
37
+ /**
38
+ * Remove a specific WebSocket manager from cache.
39
+ * Disconnects the manager and removes it from the cache.
40
+ */
41
+ export function removeXYWebSocketManager(config) {
42
+ const cacheKey = `${config.apiKey}-${config.agentId}`;
43
+ const manager = wsManagerCache.get(cacheKey);
44
+ if (manager) {
45
+ console.log(`๐Ÿ—‘๏ธ [WS-MANAGER-CACHE] Removing manager from cache: ${cacheKey}`);
46
+ manager.disconnect();
47
+ wsManagerCache.delete(cacheKey);
48
+ console.log(`๐Ÿ—‘๏ธ [WS-MANAGER-CACHE] Manager removed, remaining managers: ${wsManagerCache.size}`);
49
+ }
50
+ else {
51
+ console.log(`โš ๏ธ [WS-MANAGER-CACHE] Manager not found in cache: ${cacheKey}`);
52
+ }
53
+ }
54
+ /**
55
+ * Clear all cached WebSocket managers.
56
+ */
57
+ export function clearXYWebSocketManagers() {
58
+ const log = runtime?.log ?? console.log;
59
+ log("Clearing all WebSocket manager caches");
60
+ for (const manager of wsManagerCache.values()) {
61
+ manager.disconnect();
62
+ }
63
+ wsManagerCache.clear();
64
+ }
65
+ /**
66
+ * Get the number of cached managers.
67
+ */
68
+ export function getCachedManagerCount() {
69
+ return wsManagerCache.size;
70
+ }
71
+ /**
72
+ * Diagnose all cached WebSocket managers.
73
+ * Helps identify connection issues and orphan connections.
74
+ */
75
+ export function diagnoseAllManagers() {
76
+ console.log("========================================");
77
+ console.log("๐Ÿ“Š WebSocket Manager Global Diagnostics");
78
+ console.log("========================================");
79
+ console.log(`Total cached managers: ${wsManagerCache.size}`);
80
+ console.log("");
81
+ if (wsManagerCache.size === 0) {
82
+ console.log("โ„น๏ธ No managers in cache");
83
+ console.log("========================================");
84
+ return;
85
+ }
86
+ let orphanCount = 0;
87
+ wsManagerCache.forEach((manager, key) => {
88
+ const diag = manager.getConnectionDiagnostics();
89
+ console.log(`๐Ÿ“Œ Manager: ${key}`);
90
+ console.log(` Shutting down: ${diag.isShuttingDown}`);
91
+ console.log(` Total event listeners on manager: ${diag.totalEventListeners}`);
92
+ // Server 1
93
+ console.log(` ๐Ÿ”Œ Server1:`);
94
+ console.log(` - Exists: ${diag.server1.exists}`);
95
+ console.log(` - ReadyState: ${diag.server1.readyState}`);
96
+ console.log(` - State connected/ready: ${diag.server1.stateConnected}/${diag.server1.stateReady}`);
97
+ console.log(` - Reconnect attempts: ${diag.server1.reconnectAttempts}`);
98
+ console.log(` - Listeners on WebSocket: ${diag.server1.listenerCount}`);
99
+ console.log(` - Heartbeat active: ${diag.server1.heartbeatActive}`);
100
+ console.log(` - Has reconnect timer: ${diag.server1.hasReconnectTimer}`);
101
+ if (diag.server1.isOrphan) {
102
+ console.log(` โš ๏ธ ORPHAN CONNECTION DETECTED!`);
103
+ orphanCount++;
104
+ }
105
+ // Server 2
106
+ console.log(` ๐Ÿ”Œ Server2:`);
107
+ console.log(` - Exists: ${diag.server2.exists}`);
108
+ console.log(` - ReadyState: ${diag.server2.readyState}`);
109
+ console.log(` - State connected/ready: ${diag.server2.stateConnected}/${diag.server2.stateReady}`);
110
+ console.log(` - Reconnect attempts: ${diag.server2.reconnectAttempts}`);
111
+ console.log(` - Listeners on WebSocket: ${diag.server2.listenerCount}`);
112
+ console.log(` - Heartbeat active: ${diag.server2.heartbeatActive}`);
113
+ console.log(` - Has reconnect timer: ${diag.server2.hasReconnectTimer}`);
114
+ if (diag.server2.isOrphan) {
115
+ console.log(` โš ๏ธ ORPHAN CONNECTION DETECTED!`);
116
+ orphanCount++;
117
+ }
118
+ console.log("");
119
+ });
120
+ if (orphanCount > 0) {
121
+ console.log(`โš ๏ธ Total orphan connections found: ${orphanCount}`);
122
+ console.log(`๐Ÿ’ก Suggestion: These connections should be cleaned up`);
123
+ }
124
+ else {
125
+ console.log(`โœ… No orphan connections found`);
126
+ }
127
+ console.log("========================================");
128
+ }
129
+ /**
130
+ * Clean up orphan connections across all managers.
131
+ * Returns the number of managers that had orphan connections.
132
+ */
133
+ export function cleanupOrphanConnections() {
134
+ let cleanedCount = 0;
135
+ wsManagerCache.forEach((manager, key) => {
136
+ const diag = manager.getConnectionDiagnostics();
137
+ if (diag.server1.isOrphan || diag.server2.isOrphan) {
138
+ console.log(`๐Ÿงน Cleaning up orphan connections in manager: ${key}`);
139
+ manager.disconnect();
140
+ cleanedCount++;
141
+ }
142
+ });
143
+ if (cleanedCount > 0) {
144
+ console.log(`๐Ÿงน Cleaned up ${cleanedCount} manager(s) with orphan connections`);
145
+ }
146
+ return cleanedCount;
147
+ }
@@ -0,0 +1,54 @@
1
+ export declare const xyConfigSchema: {
2
+ readonly type: "object";
3
+ readonly properties: {
4
+ readonly enabled: {
5
+ readonly type: "boolean";
6
+ readonly description: "Enable/disable the XY channel";
7
+ readonly default: false;
8
+ };
9
+ readonly wsUrl1: {
10
+ readonly type: "string";
11
+ readonly description: "Primary WebSocket URL";
12
+ readonly default: "ws://localhost:8765/ws/link";
13
+ };
14
+ readonly wsUrl2: {
15
+ readonly type: "string";
16
+ readonly description: "Secondary WebSocket URL";
17
+ readonly default: "ws://localhost:8768/ws/link";
18
+ };
19
+ readonly apiKey: {
20
+ readonly type: "string";
21
+ readonly description: "API key for authentication";
22
+ };
23
+ readonly uid: {
24
+ readonly type: "string";
25
+ readonly description: "User ID for file upload";
26
+ };
27
+ readonly agentId: {
28
+ readonly type: "string";
29
+ readonly description: "Agent ID for this bot instance";
30
+ };
31
+ readonly apiId: {
32
+ readonly type: "string";
33
+ readonly description: "API ID for push messages";
34
+ };
35
+ readonly pushId: {
36
+ readonly type: "string";
37
+ readonly description: "Push ID for push messages";
38
+ };
39
+ readonly fileUploadUrl: {
40
+ readonly type: "string";
41
+ readonly description: "Base URL for file upload service";
42
+ readonly default: "http://localhost:8767";
43
+ };
44
+ readonly pushUrl: {
45
+ readonly type: "string";
46
+ readonly description: "URL for push message service";
47
+ };
48
+ readonly defaultSessionId: {
49
+ readonly type: "string";
50
+ readonly description: "Default session ID for push notifications (used when no target is specified, e.g., in cron jobs)";
51
+ };
52
+ };
53
+ readonly required: readonly ["apiKey", "agentId", "uid", "apiId", "pushId"];
54
+ };