@ynhcj/xiaoyi-channel 0.0.51-beta → 0.0.53-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 (46) hide show
  1. package/dist/index.d.ts +0 -2
  2. package/dist/index.js +0 -2
  3. package/dist/src/bot.js +0 -16
  4. package/dist/src/channel.js +16 -2
  5. package/dist/src/client.js +0 -2
  6. package/dist/src/cspl/call-api.js +30 -2
  7. package/dist/src/cspl/constants.d.ts +1 -1
  8. package/dist/src/cspl/constants.js +1 -1
  9. package/dist/src/formatter.js +3 -23
  10. package/dist/src/heartbeat.js +0 -1
  11. package/dist/src/onboarding.d.ts +3 -4
  12. package/dist/src/onboarding.js +2 -2
  13. package/dist/src/outbound.d.ts +2 -1
  14. package/dist/src/reply-dispatcher.js +38 -2
  15. package/dist/src/thread-bindings.d.ts +54 -0
  16. package/dist/src/thread-bindings.js +214 -0
  17. package/dist/src/tools/calendar-tool.js +2 -37
  18. package/dist/src/tools/call-phone-tool.js +1 -42
  19. package/dist/src/tools/create-alarm-tool.js +3 -74
  20. package/dist/src/tools/delete-alarm-tool.js +3 -45
  21. package/dist/src/tools/image-reading-tool.js +0 -47
  22. package/dist/src/tools/location-tool.js +1 -32
  23. package/dist/src/tools/modify-alarm-tool.js +3 -77
  24. package/dist/src/tools/modify-note-tool.js +1 -34
  25. package/dist/src/tools/note-tool.js +2 -4
  26. package/dist/src/tools/search-alarm-tool.js +3 -61
  27. package/dist/src/tools/search-calendar-tool.js +2 -39
  28. package/dist/src/tools/search-contact-tool.js +0 -30
  29. package/dist/src/tools/search-file-tool.js +0 -33
  30. package/dist/src/tools/search-message-tool.js +0 -33
  31. package/dist/src/tools/search-note-tool.js +1 -26
  32. package/dist/src/tools/search-photo-gallery-tool.js +2 -31
  33. package/dist/src/tools/send-file-to-user-tool.js +0 -39
  34. package/dist/src/tools/send-message-tool.js +1 -39
  35. package/dist/src/tools/session-manager.js +0 -45
  36. package/dist/src/tools/upload-file-tool.js +0 -49
  37. package/dist/src/tools/upload-photo-tool.js +0 -42
  38. package/dist/src/tools/view-push-result-tool.js +0 -11
  39. package/dist/src/tools/xiaoyi-collection-tool.js +4 -82
  40. package/dist/src/tools/xiaoyi-gui-tool.js +0 -34
  41. package/dist/src/websocket.js +24 -8
  42. package/package.json +2 -2
  43. package/dist/src/tools/search-photo-tool.d.ts +0 -9
  44. package/dist/src/tools/search-photo-tool.js +0 -270
  45. package/dist/src/utils/session.d.ts +0 -34
  46. package/dist/src/utils/session.js +0 -50
package/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { xyPlugin } from "./src/channel.js";
3
2
  /**
4
3
  * Xiaoyi Channel Plugin Entry Point.
5
4
  * Exports the plugin for OpenClaw to load.
@@ -13,4 +12,3 @@ declare const plugin: {
13
12
  register(api: OpenClawPluginApi): void;
14
13
  };
15
14
  export default plugin;
16
- export { xyPlugin };
package/dist/index.js CHANGED
@@ -59,5 +59,3 @@ const plugin = {
59
59
  },
60
60
  };
61
61
  export default plugin;
62
- // Also export the plugin directly for testing
63
- export { xyPlugin };
package/dist/src/bot.js CHANGED
@@ -336,19 +336,3 @@ function buildXYMediaPayload(mediaList) {
336
336
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
337
337
  };
338
338
  }
339
- /**
340
- * Infer OpenClaw media type from file type string.
341
- */
342
- function inferMediaType(fileType) {
343
- const lower = fileType.toLowerCase();
344
- if (lower.includes("image") || /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(lower)) {
345
- return "image";
346
- }
347
- if (lower.includes("video") || /\.(mp4|avi|mov|mkv|webm)$/i.test(lower)) {
348
- return "video";
349
- }
350
- if (lower.includes("audio") || /\.(mp3|wav|ogg|m4a)$/i.test(lower)) {
351
- return "audio";
352
- }
353
- return "file";
354
- }
@@ -1,7 +1,6 @@
1
1
  import { resolveXYConfig, listXYAccountIds, getDefaultXYAccountId } from "./config.js";
2
2
  import { xyConfigSchema } from "./config-schema.js";
3
3
  import { xyOutbound } from "./outbound.js";
4
- import { xyOnboardingAdapter } from "./onboarding.js";
5
4
  import { locationTool } from "./tools/location-tool.js";
6
5
  import { noteTool } from "./tools/note-tool.js";
7
6
  import { searchNoteTool } from "./tools/search-note-tool.js";
@@ -63,7 +62,6 @@ export const xyPlugin = {
63
62
  schema: xyConfigSchema,
64
63
  },
65
64
  outbound: xyOutbound,
66
- onboarding: xyOnboardingAdapter,
67
65
  agentTools: [locationTool, noteTool, searchNoteTool, modifyNoteTool, calendarTool, searchCalendarTool, searchContactTool, searchPhotoGalleryTool, uploadPhotoTool, xiaoyiGuiTool, xiaoyiCollectionTool, callPhoneTool, searchMessageTool, sendMessageTool, searchFileTool, uploadFileTool, createAlarmTool, searchAlarmTool, modifyAlarmTool, deleteAlarmTool, sendFileToUserTool, viewPushResultTool, imageReadingTool],
68
66
  messaging: {
69
67
  normalizeTarget: (raw) => {
@@ -81,6 +79,22 @@ export const xyPlugin = {
81
79
  hint: "<sessionId>",
82
80
  },
83
81
  },
82
+ bindings: {
83
+ compileConfiguredBinding: ({ conversationId }) => {
84
+ const sessionId = conversationId.trim();
85
+ if (!sessionId)
86
+ return null;
87
+ return {
88
+ conversationId: sessionId,
89
+ parentConversationId: undefined,
90
+ };
91
+ },
92
+ matchInboundConversation: ({ compiledBinding, conversationId }) => {
93
+ return compiledBinding.conversationId === conversationId
94
+ ? { conversationId, matchPriority: 2 }
95
+ : null;
96
+ },
97
+ },
84
98
  reload: {
85
99
  configPrefixes: ["channels.xiaoyi-channel"],
86
100
  },
@@ -85,8 +85,6 @@ export function diagnoseAllManagers() {
85
85
  let orphanCount = 0;
86
86
  wsManagerCache.forEach((manager, key) => {
87
87
  const diag = manager.getConnectionDiagnostics();
88
- console.log(`📌 Manager: ${key}`);
89
- console.log(` Shutting down: ${diag.isShuttingDown}`);
90
88
  console.log(` Total event listeners on manager: ${diag.totalEventListeners}`);
91
89
  // Connection
92
90
  console.log(` 🔌 Connection:`);
@@ -48,9 +48,27 @@ export async function callCsplApi(questionText, cfg) {
48
48
  textSource: config.textSource,
49
49
  action: config.action,
50
50
  };
51
+ // 打印请求信息
52
+ console.log(`[CSPL API] ==================== 发起请求 ====================`);
53
+ console.log(`[CSPL API] URL: ${config.api.url}`);
54
+ console.log(`[CSPL API] Method: POST`);
55
+ console.log(`[CSPL API] Headers:`);
56
+ console.log(`[CSPL API] - x-hag-trace-id: ${headers["x-hag-trace-id"]}`);
57
+ console.log(`[CSPL API] - x-uid: ${headers["x-uid"]}`);
58
+ console.log(`[CSPL API] - x-api-key: ${headers["x-api-key"] ? "***" + headers["x-api-key"].slice(-8) : "undefined"}`);
59
+ console.log(`[CSPL API] - x-request-from: ${headers["x-request-from"]}`);
60
+ console.log(`[CSPL API] - x-skill-id: ${headers["x-skill-id"]}`);
61
+ console.log(`[CSPL API] - content-type: ${headers["content-type"]}`);
62
+ console.log(`[CSPL API] Body:`);
63
+ console.log(`[CSPL API] - questionText: ${questionText.substring(0, 100)}${questionText.length > 100 ? "..." : ""}`);
64
+ console.log(`[CSPL API] - textSource: ${payload.textSource}`);
65
+ console.log(`[CSPL API] - action: ${payload.action}`);
66
+ console.log(`[CSPL API] =================================================`);
51
67
  return new Promise((resolve, reject) => {
52
68
  const options = buildRequestOptions(config.api.url, headers, config.api.timeout);
53
69
  const req = https.request(options, (res) => {
70
+ console.log(`[CSPL API] Response Status: ${res.statusCode}`);
71
+ console.log(`[CSPL API] Response Headers: ${JSON.stringify(res.headers)}`);
54
72
  if (res.statusCode && res.statusCode >= HTTP_STATUS_BAD_REQUEST) {
55
73
  reject(new Error(`[CSPL] HTTP error: ${res.statusCode}`));
56
74
  return;
@@ -61,15 +79,25 @@ export async function callCsplApi(questionText, cfg) {
61
79
  });
62
80
  res.on("end", () => {
63
81
  try {
64
- resolve(parseResponse(data));
82
+ const result = parseResponse(data);
83
+ console.log(`[CSPL API] ✅ 请求成功`);
84
+ console.log(`[CSPL API] Response Body: ${data.substring(0, 200)}${data.length > 200 ? "..." : ""}`);
85
+ console.log(`[CSPL API] =================================================`);
86
+ resolve(result);
65
87
  }
66
88
  catch (e) {
89
+ console.error(`[CSPL API] ❌ 请求失败: ${e instanceof Error ? e.message : String(e)}`);
90
+ console.error(`[CSPL API] Response Body: ${data}`);
67
91
  reject(e);
68
92
  }
69
93
  });
70
94
  });
71
- req.on("error", reject);
95
+ req.on("error", (error) => {
96
+ console.error(`[CSPL API] ❌ 请求错误: ${error instanceof Error ? error.message : String(error)}`);
97
+ reject(error);
98
+ });
72
99
  req.on("timeout", () => {
100
+ console.error(`[CSPL API] ⏰ 请求超时 (${config.api.timeout}ms)`);
73
101
  req.destroy();
74
102
  reject(new Error("[CSPL] Request timeout"));
75
103
  });
@@ -25,7 +25,7 @@ export declare const MIN_TEXT_LENGTH = 0;
25
25
  export declare const MAX_TEXT_LENGTH = 4096;
26
26
  export declare const MAX_TOTAL_LENGTH = 40960;
27
27
  export declare const regex: RegExp;
28
- export declare const DEFAULT_HTTP_PORT = 80;
28
+ export declare const DEFAULT_HTTP_PORT = 443;
29
29
  export declare const HTTP_STATUS_BAD_REQUEST = 400;
30
30
  export declare const ENV_FILE_PATH = "/home/sandbox/.openclaw/.xiaoyienv";
31
31
  export declare const API_URL_SUFFIX = "/celia-claw/v1/rest-api/skill/execute";
@@ -3,7 +3,7 @@ export const MIN_TEXT_LENGTH = 0;
3
3
  export const MAX_TEXT_LENGTH = 4096;
4
4
  export const MAX_TOTAL_LENGTH = 40960;
5
5
  export const regex = /[^\u4e00-\u9fa5a-zA-Z0-9\s\.,!?;:,。!?;:""\'\'()()\[\]【】]/;
6
- export const DEFAULT_HTTP_PORT = 80;
6
+ export const DEFAULT_HTTP_PORT = 443;
7
7
  export const HTTP_STATUS_BAD_REQUEST = 400;
8
8
  export const ENV_FILE_PATH = "/home/sandbox/.openclaw/.xiaoyienv";
9
9
  export const API_URL_SUFFIX = "/celia-claw/v1/rest-api/skill/execute";
@@ -52,18 +52,11 @@ export async function sendA2AResponse(params) {
52
52
  msgDetail: JSON.stringify(jsonRpcResponse),
53
53
  };
54
54
  // 📋 Log complete response body
55
- log(`[A2A_RESPONSE] 📤 Sending A2A artifact-update response:`);
56
- log(`[A2A_RESPONSE] - sessionId: ${sessionId}`);
57
- log(`[A2A_RESPONSE] - taskId: ${taskId}`);
58
- log(`[A2A_RESPONSE] - messageId: ${messageId}`);
55
+ log(`[A2A_RESPONSE] 📤 Sending A2A artifact-update response: taskId: ${taskId}`);
59
56
  log(`[A2A_RESPONSE] - append: ${append}`);
60
57
  log(`[A2A_RESPONSE] - final: ${final}`);
61
- log(`[A2A_RESPONSE] - text length: ${text?.length ?? 0}`);
58
+ log(`[A2A_RESPONSE] - text: ${text}`);
62
59
  log(`[A2A_RESPONSE] - files count: ${files?.length ?? 0}`);
63
- log(`[A2A_RESPONSE] 📦 Complete outbound message:`);
64
- log(JSON.stringify(outboundMessage, null, 2));
65
- log(`[A2A_RESPONSE] 📦 JSON-RPC response body:`);
66
- log(JSON.stringify(jsonRpcResponse, null, 2));
67
60
  await wsManager.sendMessage(sessionId, outboundMessage);
68
61
  log(`[A2A_RESPONSE] ✅ Message sent successfully`);
69
62
  }
@@ -152,15 +145,10 @@ export async function sendStatusUpdate(params) {
152
145
  };
153
146
  // 📋 Log complete response body
154
147
  log(`[A2A_STATUS] 📤 Sending A2A status-update:`);
155
- log(`[A2A_STATUS] - sessionId: ${sessionId}`);
156
148
  log(`[A2A_STATUS] - taskId: ${taskId}`);
157
149
  log(`[A2A_STATUS] - messageId: ${messageId}`);
158
150
  log(`[A2A_STATUS] - state: ${state}`);
159
151
  log(`[A2A_STATUS] - text: "${text}"`);
160
- log(`[A2A_STATUS] 📦 Complete outbound message:`);
161
- log(JSON.stringify(outboundMessage, null, 2));
162
- log(`[A2A_STATUS] 📦 JSON-RPC response body:`);
163
- log(JSON.stringify(jsonRpcResponse, null, 2));
164
152
  await wsManager.sendMessage(sessionId, outboundMessage);
165
153
  log(`[A2A_STATUS] ✅ Status update sent successfully`);
166
154
  }
@@ -208,15 +196,7 @@ export async function sendCommand(params) {
208
196
  msgDetail: JSON.stringify(jsonRpcResponse),
209
197
  };
210
198
  // 📋 Log complete response body
211
- log(`[A2A_COMMAND] 📤 Sending A2A command:`);
212
- log(`[A2A_COMMAND] - sessionId: ${sessionId}`);
213
- log(`[A2A_COMMAND] - taskId: ${taskId}`);
214
- log(`[A2A_COMMAND] - messageId: ${messageId}`);
215
- log(`[A2A_COMMAND] - command: ${command.header.namespace}::${command.header.name}`);
216
- log(`[A2A_COMMAND] 📦 Complete outbound message:`);
217
- log(JSON.stringify(outboundMessage, null, 2));
218
- log(`[A2A_COMMAND] 📦 JSON-RPC response body:`);
219
- log(JSON.stringify(jsonRpcResponse, null, 2));
199
+ log(`[A2A_COMMAND] 📤 Sending A2A command: taskId: ${taskId}`);
220
200
  await wsManager.sendMessage(sessionId, outboundMessage);
221
201
  log(`[A2A_COMMAND] ✅ Command sent successfully`);
222
202
  }
@@ -80,7 +80,6 @@ export class HeartbeatManager {
80
80
  this.error(`Heartbeat timeout for ${this.serverName}`);
81
81
  this.onTimeout();
82
82
  }, this.config.timeout);
83
- this.log(`[DEBUG] Heartbeat sent for ${this.serverName}`);
84
83
  }
85
84
  catch (error) {
86
85
  this.error(`Failed to send heartbeat for ${this.serverName}:`, error);
@@ -1,6 +1,5 @@
1
- import type { ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
2
1
  /**
3
- * XY Channel Onboarding Adapter
4
- * Implements the ChannelOnboardingAdapter interface for OpenClaw's onboarding system
2
+ * XY Channel Onboarding Adapter (DISABLED - not currently used)
3
+ * NOTE: This is kept for future reference. Xiaoyi uses simple single-account config.
5
4
  */
6
- export declare const xyOnboardingAdapter: ChannelOnboardingAdapter;
5
+ export declare const xyOnboardingAdapter: any;
@@ -152,8 +152,8 @@ async function configure({ cfg, prompter, }) {
152
152
  };
153
153
  }
154
154
  /**
155
- * XY Channel Onboarding Adapter
156
- * Implements the ChannelOnboardingAdapter interface for OpenClaw's onboarding system
155
+ * XY Channel Onboarding Adapter (DISABLED - not currently used)
156
+ * NOTE: This is kept for future reference. Xiaoyi uses simple single-account config.
157
157
  */
158
158
  export const xyOnboardingAdapter = {
159
159
  channel,
@@ -1,6 +1,7 @@
1
- import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
1
+ type ChannelOutboundAdapter = any;
2
2
  /**
3
3
  * Outbound adapter for sending messages from OpenClaw to XY.
4
4
  * Uses Push service for direct message delivery.
5
5
  */
6
6
  export declare const xyOutbound: ChannelOutboundAdapter;
7
+ export {};
@@ -1,8 +1,38 @@
1
- import { createReplyPrefixContext } from "openclaw/plugin-sdk";
2
1
  import { getXYRuntime } from "./runtime.js";
3
2
  import { sendA2AResponse, sendStatusUpdate, sendReasoningTextUpdate } from "./formatter.js";
4
3
  import { resolveXYConfig } from "./config.js";
5
4
  import { getCurrentTaskId, getCurrentMessageId } from "./task-manager.js";
5
+ import fs from "fs/promises";
6
+ import path from "path";
7
+ /**
8
+ * 清理 /tmp/xy_channel 目录中的所有文件
9
+ */
10
+ async function cleanupTempDir(tempDir = "/tmp/xy_channel") {
11
+ try {
12
+ const stats = await fs.stat(tempDir).catch(() => null);
13
+ if (!stats?.isDirectory()) {
14
+ return; // 目录不存在,直接返回
15
+ }
16
+ const files = await fs.readdir(tempDir);
17
+ let cleanedCount = 0;
18
+ for (const file of files) {
19
+ const filePath = path.join(tempDir, file);
20
+ try {
21
+ await fs.unlink(filePath);
22
+ cleanedCount++;
23
+ }
24
+ catch (err) {
25
+ // 忽略单个文件删除失败,继续处理其他文件
26
+ }
27
+ }
28
+ if (cleanedCount > 0) {
29
+ console.log(`[CLEANUP] 🧹 Cleaned ${cleanedCount} files from ${tempDir}`);
30
+ }
31
+ }
32
+ catch (err) {
33
+ console.error(`[CLEANUP] ❌ Failed to cleanup temp dir:`, err);
34
+ }
35
+ }
6
36
  /**
7
37
  * Create a reply dispatcher for XY channel messages.
8
38
  * Follows feishu pattern with status updates and streaming support.
@@ -32,7 +62,12 @@ export function createXYReplyDispatcher(params) {
32
62
  };
33
63
  const core = getXYRuntime();
34
64
  const config = resolveXYConfig(cfg);
35
- const prefixContext = createReplyPrefixContext({ cfg, agentId: accountId });
65
+ // Simplified prefix context for single-account Xiaoyi channel
66
+ const prefixContext = {
67
+ responsePrefix: undefined,
68
+ responsePrefixContextProvider: undefined,
69
+ onModelSelected: undefined,
70
+ };
36
71
  let statusUpdateInterval = null;
37
72
  let hasSentResponse = false;
38
73
  let finalSent = false;
@@ -206,6 +241,7 @@ export function createXYReplyDispatcher(params) {
206
241
  }
207
242
  }
208
243
  stopStatusInterval();
244
+ void cleanupTempDir();
209
245
  },
210
246
  onCleanup: () => {
211
247
  const currentTaskId = getActiveTaskId();
@@ -0,0 +1,54 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
2
+ import { type BindingTargetKind } from "openclaw/plugin-sdk/conversation-runtime";
3
+ type XYBindingTargetKind = "subagent" | "session";
4
+ /**
5
+ * Xiaoyi thread binding record.
6
+ * Simplified from feishu - uses sessionId as conversationId, no parentConversationId.
7
+ */
8
+ type XYThreadBindingRecord = {
9
+ accountId: string;
10
+ sessionId: string;
11
+ targetKind: XYBindingTargetKind;
12
+ targetSessionKey: string;
13
+ agentId?: string;
14
+ boundAt: number;
15
+ lastActivityAt: number;
16
+ };
17
+ /**
18
+ * Thread binding manager for Xiaoyi channel.
19
+ * Manages session bindings for single-account mode.
20
+ */
21
+ type XYThreadBindingManager = {
22
+ accountId: string;
23
+ getBySessionId: (sessionId: string) => XYThreadBindingRecord | undefined;
24
+ listBySessionKey: (targetSessionKey: string) => XYThreadBindingRecord[];
25
+ bindSession: (params: {
26
+ sessionId: string;
27
+ targetKind: BindingTargetKind;
28
+ targetSessionKey: string;
29
+ metadata?: Record<string, unknown>;
30
+ }) => XYThreadBindingRecord | null;
31
+ touchSession: (sessionId: string, at?: number) => XYThreadBindingRecord | null;
32
+ unbindSession: (sessionId: string) => XYThreadBindingRecord | null;
33
+ unbindBySessionKey: (targetSessionKey: string) => XYThreadBindingRecord[];
34
+ stop: () => void;
35
+ };
36
+ /**
37
+ * Creates a thread binding manager for Xiaoyi channel.
38
+ * Based on feishu implementation but simplified for single-account mode.
39
+ */
40
+ export declare function createXYThreadBindingManager(params: {
41
+ accountId?: string;
42
+ cfg: OpenClawConfig;
43
+ }): XYThreadBindingManager;
44
+ /**
45
+ * Gets the thread binding manager for a given account ID.
46
+ */
47
+ export declare function getXYThreadBindingManager(accountId?: string): XYThreadBindingManager | null;
48
+ /**
49
+ * Testing utilities for thread bindings.
50
+ */
51
+ export declare const __testing: {
52
+ resetXYThreadBindingsForTests(): void;
53
+ };
54
+ export {};
@@ -0,0 +1,214 @@
1
+ import { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, registerSessionBindingAdapter, resolveThreadBindingConversationIdFromBindingId, unregisterSessionBindingAdapter, } from "openclaw/plugin-sdk/conversation-runtime";
2
+ import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
3
+ import { resolveGlobalSingleton } from "openclaw/plugin-sdk/text-runtime";
4
+ const XY_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.xyThreadBindingsState");
5
+ const state = resolveGlobalSingleton(XY_THREAD_BINDINGS_STATE_KEY, () => ({
6
+ managersByAccountId: new Map(),
7
+ bindingsByAccountSession: new Map(),
8
+ }));
9
+ function getState() {
10
+ return state;
11
+ }
12
+ function resolveBindingKey(params) {
13
+ return `${params.accountId}:${params.sessionId}`;
14
+ }
15
+ function toSessionBindingTargetKind(raw) {
16
+ return raw === "subagent" ? "subagent" : "session";
17
+ }
18
+ function toXYTargetKind(raw) {
19
+ return raw === "subagent" ? "subagent" : "session";
20
+ }
21
+ function toSessionBindingRecord(record, defaults) {
22
+ const idleExpiresAt = defaults.idleTimeoutMs > 0 ? record.lastActivityAt + defaults.idleTimeoutMs : undefined;
23
+ const maxAgeExpiresAt = defaults.maxAgeMs > 0 ? record.boundAt + defaults.maxAgeMs : undefined;
24
+ const expiresAt = idleExpiresAt != null && maxAgeExpiresAt != null
25
+ ? Math.min(idleExpiresAt, maxAgeExpiresAt)
26
+ : (idleExpiresAt ?? maxAgeExpiresAt);
27
+ return {
28
+ bindingId: resolveBindingKey({
29
+ accountId: record.accountId,
30
+ sessionId: record.sessionId,
31
+ }),
32
+ targetSessionKey: record.targetSessionKey,
33
+ targetKind: toSessionBindingTargetKind(record.targetKind),
34
+ conversation: {
35
+ channel: "xiaoyi-channel",
36
+ accountId: record.accountId,
37
+ conversationId: record.sessionId, // sessionId is the conversationId for Xiaoyi
38
+ parentConversationId: undefined, // Xiaoyi doesn't have parent conversations
39
+ },
40
+ status: "active",
41
+ boundAt: record.boundAt,
42
+ expiresAt,
43
+ metadata: {
44
+ agentId: record.agentId,
45
+ lastActivityAt: record.lastActivityAt,
46
+ idleTimeoutMs: defaults.idleTimeoutMs,
47
+ maxAgeMs: defaults.maxAgeMs,
48
+ },
49
+ };
50
+ }
51
+ /**
52
+ * Creates a thread binding manager for Xiaoyi channel.
53
+ * Based on feishu implementation but simplified for single-account mode.
54
+ */
55
+ export function createXYThreadBindingManager(params) {
56
+ const accountId = normalizeAccountId(params.accountId);
57
+ const existing = getState().managersByAccountId.get(accountId);
58
+ if (existing) {
59
+ return existing;
60
+ }
61
+ const idleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({
62
+ cfg: params.cfg,
63
+ channel: "xiaoyi-channel",
64
+ accountId,
65
+ });
66
+ const maxAgeMs = resolveThreadBindingMaxAgeMsForChannel({
67
+ cfg: params.cfg,
68
+ channel: "xiaoyi-channel",
69
+ accountId,
70
+ });
71
+ const manager = {
72
+ accountId,
73
+ getBySessionId: (sessionId) => getState().bindingsByAccountSession.get(resolveBindingKey({ accountId, sessionId })),
74
+ listBySessionKey: (targetSessionKey) => [...getState().bindingsByAccountSession.values()].filter((record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey),
75
+ bindSession: ({ sessionId, targetKind, targetSessionKey, metadata, }) => {
76
+ const normalizedSessionId = sessionId.trim();
77
+ if (!normalizedSessionId || !targetSessionKey.trim()) {
78
+ return null;
79
+ }
80
+ const now = Date.now();
81
+ const record = {
82
+ accountId,
83
+ sessionId: normalizedSessionId,
84
+ targetKind: toXYTargetKind(targetKind),
85
+ targetSessionKey: targetSessionKey.trim(),
86
+ agentId: typeof metadata?.agentId === "string" && metadata.agentId.trim()
87
+ ? metadata.agentId.trim()
88
+ : resolveAgentIdFromSessionKey(targetSessionKey),
89
+ boundAt: now,
90
+ lastActivityAt: now,
91
+ };
92
+ getState().bindingsByAccountSession.set(resolveBindingKey({ accountId, sessionId: normalizedSessionId }), record);
93
+ return record;
94
+ },
95
+ touchSession: (sessionId, at = Date.now()) => {
96
+ const key = resolveBindingKey({ accountId, sessionId });
97
+ const existingRecord = getState().bindingsByAccountSession.get(key);
98
+ if (!existingRecord) {
99
+ return null;
100
+ }
101
+ const updated = { ...existingRecord, lastActivityAt: at };
102
+ getState().bindingsByAccountSession.set(key, updated);
103
+ return updated;
104
+ },
105
+ unbindSession: (sessionId) => {
106
+ const key = resolveBindingKey({ accountId, sessionId });
107
+ const existingRecord = getState().bindingsByAccountSession.get(key);
108
+ if (!existingRecord) {
109
+ return null;
110
+ }
111
+ getState().bindingsByAccountSession.delete(key);
112
+ return existingRecord;
113
+ },
114
+ unbindBySessionKey: (targetSessionKey) => {
115
+ const removed = [];
116
+ for (const record of [...getState().bindingsByAccountSession.values()]) {
117
+ if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
118
+ continue;
119
+ }
120
+ getState().bindingsByAccountSession.delete(resolveBindingKey({ accountId, sessionId: record.sessionId }));
121
+ removed.push(record);
122
+ }
123
+ return removed;
124
+ },
125
+ stop: () => {
126
+ for (const key of [...getState().bindingsByAccountSession.keys()]) {
127
+ if (key.startsWith(`${accountId}:`)) {
128
+ getState().bindingsByAccountSession.delete(key);
129
+ }
130
+ }
131
+ getState().managersByAccountId.delete(accountId);
132
+ unregisterSessionBindingAdapter({
133
+ channel: "xiaoyi-channel",
134
+ accountId,
135
+ adapter: sessionBindingAdapter,
136
+ });
137
+ },
138
+ };
139
+ const sessionBindingAdapter = {
140
+ channel: "xiaoyi-channel",
141
+ accountId,
142
+ capabilities: {
143
+ placements: ["current"],
144
+ },
145
+ bind: async (input) => {
146
+ if (input.conversation.channel !== "xiaoyi-channel" || input.placement === "child") {
147
+ return null;
148
+ }
149
+ const bound = manager.bindSession({
150
+ sessionId: input.conversation.conversationId,
151
+ targetKind: input.targetKind,
152
+ targetSessionKey: input.targetSessionKey,
153
+ metadata: input.metadata,
154
+ });
155
+ return bound ? toSessionBindingRecord(bound, { idleTimeoutMs, maxAgeMs }) : null;
156
+ },
157
+ listBySession: (targetSessionKey) => manager
158
+ .listBySessionKey(targetSessionKey)
159
+ .map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })),
160
+ resolveByConversation: (ref) => {
161
+ if (ref.channel !== "xiaoyi-channel") {
162
+ return null;
163
+ }
164
+ const found = manager.getBySessionId(ref.conversationId);
165
+ return found ? toSessionBindingRecord(found, { idleTimeoutMs, maxAgeMs }) : null;
166
+ },
167
+ touch: (bindingId, at) => {
168
+ const sessionId = resolveThreadBindingConversationIdFromBindingId({
169
+ accountId,
170
+ bindingId,
171
+ });
172
+ if (sessionId) {
173
+ manager.touchSession(sessionId, at);
174
+ }
175
+ },
176
+ unbind: async (input) => {
177
+ if (input.targetSessionKey?.trim()) {
178
+ return manager
179
+ .unbindBySessionKey(input.targetSessionKey.trim())
180
+ .map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs }));
181
+ }
182
+ const sessionId = resolveThreadBindingConversationIdFromBindingId({
183
+ accountId,
184
+ bindingId: input.bindingId,
185
+ });
186
+ if (!sessionId) {
187
+ return [];
188
+ }
189
+ const removed = manager.unbindSession(sessionId);
190
+ return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : [];
191
+ },
192
+ };
193
+ registerSessionBindingAdapter(sessionBindingAdapter);
194
+ getState().managersByAccountId.set(accountId, manager);
195
+ return manager;
196
+ }
197
+ /**
198
+ * Gets the thread binding manager for a given account ID.
199
+ */
200
+ export function getXYThreadBindingManager(accountId) {
201
+ return getState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
202
+ }
203
+ /**
204
+ * Testing utilities for thread bindings.
205
+ */
206
+ export const __testing = {
207
+ resetXYThreadBindingsForTests() {
208
+ for (const manager of getState().managersByAccountId.values()) {
209
+ manager.stop();
210
+ }
211
+ getState().managersByAccountId.clear();
212
+ getState().bindingsByAccountSession.clear();
213
+ },
214
+ };