@ynhcj/xiaoyi-channel 0.0.41-beta → 0.0.41-next

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 (76) hide show
  1. package/dist/index.d.ts +0 -2
  2. package/dist/index.js +42 -2
  3. package/dist/src/bot.d.ts +1 -0
  4. package/dist/src/bot.js +97 -60
  5. package/dist/src/channel.js +30 -4
  6. package/dist/src/client.js +0 -9
  7. package/dist/src/cspl/call-api.d.ts +3 -0
  8. package/dist/src/cspl/call-api.js +86 -0
  9. package/dist/src/cspl/config.d.ts +19 -0
  10. package/dist/src/cspl/config.js +50 -0
  11. package/dist/src/cspl/constants.d.ts +43 -0
  12. package/dist/src/cspl/constants.js +22 -0
  13. package/dist/src/cspl/utils.d.ts +10 -0
  14. package/dist/src/cspl/utils.js +57 -0
  15. package/dist/src/file-upload.d.ts +5 -0
  16. package/dist/src/file-upload.js +88 -6
  17. package/dist/src/formatter.d.ts +16 -0
  18. package/dist/src/formatter.js +59 -30
  19. package/dist/src/heartbeat.js +0 -4
  20. package/dist/src/monitor.js +8 -10
  21. package/dist/src/onboarding.d.ts +3 -4
  22. package/dist/src/onboarding.js +2 -2
  23. package/dist/src/outbound.d.ts +2 -1
  24. package/dist/src/outbound.js +3 -21
  25. package/dist/src/parser.d.ts +13 -0
  26. package/dist/src/parser.js +38 -0
  27. package/dist/src/push.d.ts +2 -1
  28. package/dist/src/push.js +6 -34
  29. package/dist/src/reply-dispatcher.d.ts +4 -0
  30. package/dist/src/reply-dispatcher.js +46 -8
  31. package/dist/src/steer-injector.d.ts +16 -0
  32. package/dist/src/steer-injector.js +74 -0
  33. package/dist/src/thread-bindings.d.ts +54 -0
  34. package/dist/src/thread-bindings.js +214 -0
  35. package/dist/src/tools/calendar-tool.js +2 -37
  36. package/dist/src/tools/call-phone-tool.js +3 -60
  37. package/dist/src/tools/create-alarm-tool.js +8 -109
  38. package/dist/src/tools/delete-alarm-tool.js +5 -69
  39. package/dist/src/tools/device-tool-map.d.ts +4 -0
  40. package/dist/src/tools/device-tool-map.js +23 -0
  41. package/dist/src/tools/image-reading-tool.d.ts +5 -0
  42. package/dist/src/tools/image-reading-tool.js +328 -0
  43. package/dist/src/tools/location-tool.js +6 -40
  44. package/dist/src/tools/modify-alarm-tool.js +8 -114
  45. package/dist/src/tools/modify-note-tool.js +3 -41
  46. package/dist/src/tools/note-tool.js +4 -16
  47. package/dist/src/tools/search-alarm-tool.js +12 -118
  48. package/dist/src/tools/search-calendar-tool.js +4 -81
  49. package/dist/src/tools/search-contact-tool.js +2 -55
  50. package/dist/src/tools/search-file-tool.js +4 -61
  51. package/dist/src/tools/search-message-tool.js +2 -59
  52. package/dist/src/tools/search-note-tool.js +4 -22
  53. package/dist/src/tools/search-photo-gallery-tool.js +8 -57
  54. package/dist/src/tools/send-command-to-car-tool.d.ts +5 -0
  55. package/dist/src/tools/send-command-to-car-tool.js +85 -0
  56. package/dist/src/tools/send-file-to-user-tool.js +1 -39
  57. package/dist/src/tools/send-message-tool.js +5 -56
  58. package/dist/src/tools/session-manager.d.ts +1 -0
  59. package/dist/src/tools/session-manager.js +0 -45
  60. package/dist/src/tools/timestamp-to-utc8-tool.d.ts +12 -0
  61. package/dist/src/tools/timestamp-to-utc8-tool.js +104 -0
  62. package/dist/src/tools/upload-file-tool.js +0 -49
  63. package/dist/src/tools/upload-photo-tool.js +0 -42
  64. package/dist/src/tools/view-push-result-tool.js +0 -11
  65. package/dist/src/tools/xiaoyi-collection-tool.js +4 -82
  66. package/dist/src/tools/xiaoyi-gui-tool.js +0 -34
  67. package/dist/src/trigger-handler.d.ts +3 -0
  68. package/dist/src/trigger-handler.js +18 -50
  69. package/dist/src/utils/runtime-manager.d.ts +7 -0
  70. package/dist/src/utils/runtime-manager.js +42 -0
  71. package/dist/src/websocket.js +33 -13
  72. package/package.json +3 -4
  73. package/dist/src/tools/search-photo-tool.d.ts +0 -9
  74. package/dist/src/tools/search-photo-tool.js +0 -270
  75. package/dist/src/utils/session.d.ts +0 -34
  76. package/dist/src/utils/session.js +0 -50
@@ -20,8 +20,9 @@ export declare class XYPushService {
20
20
  * @param data - Optional additional data
21
21
  * @param sessionId - Optional session ID
22
22
  * @param pushDataId - Optional pushDataId for kind="data" format
23
+ * @param pushId - Push ID to use (required)
23
24
  */
24
- sendPush(content: string, title: string, data?: Record<string, any>, sessionId?: string, pushDataId?: string): Promise<void>;
25
+ sendPush(content: string, title: string, data?: Record<string, any>, sessionId?: string, pushDataId?: string, pushId?: string): Promise<void>;
25
26
  /**
26
27
  * Send a push message with file attachments.
27
28
  */
package/dist/src/push.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // Push message service for scheduled tasks
2
2
  import fetch from "node-fetch";
3
3
  import { randomUUID } from "crypto";
4
- import { configManager } from "./utils/config-manager.js";
5
4
  /**
6
5
  * Service for sending push messages to users.
7
6
  * Used for outbound messages and scheduled tasks.
@@ -27,29 +26,15 @@ export class XYPushService {
27
26
  * @param data - Optional additional data
28
27
  * @param sessionId - Optional session ID
29
28
  * @param pushDataId - Optional pushDataId for kind="data" format
29
+ * @param pushId - Push ID to use (required)
30
30
  */
31
- async sendPush(content, title, data, sessionId, pushDataId) {
31
+ async sendPush(content, title, data, sessionId, pushDataId, pushId) {
32
32
  const pushUrl = this.config.pushUrl || this.DEFAULT_PUSH_URL;
33
33
  const traceId = this.generateTraceId();
34
- // Get dynamic pushId for the session (falls back to config pushId)
35
- const dynamicPushId = configManager.getPushId(sessionId);
36
- const pushId = dynamicPushId || this.config.pushId;
34
+ // Use provided pushId or fall back to config pushId
35
+ const actualPushId = pushId || this.config.pushId;
37
36
  console.log(`[PUSH] 📤 Preparing to send push message`);
38
- console.log(`[PUSH] - Title: "${title}"`);
39
- console.log(`[PUSH] - Content length: ${content.length} chars`);
40
- console.log(`[PUSH] - Session ID: ${sessionId || 'none'}`);
41
- console.log(`[PUSH] - Trace ID: ${traceId}`);
42
- console.log(`[PUSH] - Push URL: ${pushUrl}`);
43
- if (dynamicPushId) {
44
- console.log(`[PUSH] - Using dynamic pushId (from session): ${pushId.substring(0, 20)}...`);
45
- console.log(`[PUSH] - Full dynamic pushId: ${pushId}`);
46
- }
47
- else {
48
- console.log(`[PUSH] - Using config pushId (fallback): ${pushId.substring(0, 20)}...`);
49
- console.log(`[PUSH] - Full config pushId: ${pushId}`);
50
- }
51
- console.log(`[PUSH] - API ID: ${this.config.apiId}`);
52
- console.log(`[PUSH] - UID: ${this.config.uid}`);
37
+ console.log(`[PUSH] - Using pushId: ${actualPushId.substring(0, 20)}...`);
53
38
  try {
54
39
  const requestBody = {
55
40
  jsonrpc: "2.0",
@@ -57,7 +42,7 @@ export class XYPushService {
57
42
  result: {
58
43
  id: randomUUID(),
59
44
  apiId: this.config.apiId,
60
- pushId: pushId, // Use dynamic pushId
45
+ pushId: actualPushId,
61
46
  pushText: title,
62
47
  kind: "task",
63
48
  artifacts: [
@@ -82,7 +67,6 @@ export class XYPushService {
82
67
  ],
83
68
  },
84
69
  };
85
- console.log(`[PUSH] Full request body:`, JSON.stringify(requestBody, null, 2));
86
70
  const response = await fetch(pushUrl, {
87
71
  method: "POST",
88
72
  headers: {
@@ -98,21 +82,16 @@ export class XYPushService {
98
82
  // Log response status and headers
99
83
  console.log(`[PUSH] 📥 Response received`);
100
84
  console.log(`[PUSH] - HTTP Status: ${response.status} ${response.statusText}`);
101
- console.log(`[PUSH] - Content-Type: ${response.headers.get('content-type')}`);
102
- console.log(`[PUSH] - Content-Length: ${response.headers.get('content-length')}`);
103
85
  if (!response.ok) {
104
86
  const errorText = await response.text();
105
87
  console.log(`[PUSH] ❌ Push request failed`);
106
88
  console.log(`[PUSH] - HTTP Status: ${response.status}`);
107
- console.log(`[PUSH] - Response body: ${errorText}`);
108
89
  throw new Error(`Push failed: HTTP ${response.status} - ${errorText}`);
109
90
  }
110
91
  // Try to parse JSON response with detailed error handling
111
92
  let result;
112
93
  try {
113
94
  const responseText = await response.text();
114
- console.log(`[PUSH] 📄 Response body length: ${responseText.length} chars`);
115
- console.log(`[PUSH] 📄 Response body preview: ${responseText.substring(0, 200)}`);
116
95
  if (!responseText || responseText.trim() === '') {
117
96
  console.log(`[PUSH] ⚠️ Received empty response body`);
118
97
  result = {};
@@ -127,20 +106,13 @@ export class XYPushService {
127
106
  throw new Error(`Invalid JSON response from push service: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
128
107
  }
129
108
  console.log(`[PUSH] ✅ Push message sent successfully`);
130
- console.log(`[PUSH] - Title: "${title}"`);
131
109
  console.log(`[PUSH] - Trace ID: ${traceId}`);
132
- console.log(`[PUSH] - Used pushId: ${pushId.substring(0, 20)}...`);
133
- console.log(`[PUSH] - Response:`, result);
134
110
  }
135
111
  catch (error) {
136
112
  console.log(`[PUSH] ❌ Failed to send push message`);
137
- console.log(`[PUSH] - Trace ID: ${traceId}`);
138
- console.log(`[PUSH] - Target URL: ${pushUrl}`);
139
- console.log(`[PUSH] - Push ID: ${pushId.substring(0, 20)}...`);
140
113
  if (error instanceof Error) {
141
114
  console.log(`[PUSH] - Error name: ${error.name}`);
142
115
  console.log(`[PUSH] - Error message: ${error.message}`);
143
- console.log(`[PUSH] - Error stack:`, error.stack);
144
116
  }
145
117
  else {
146
118
  console.log(`[PUSH] - Error:`, error);
@@ -8,6 +8,10 @@ export interface CreateXYReplyDispatcherParams {
8
8
  accountId: string;
9
9
  isSteerFollower?: boolean;
10
10
  }
11
+ /**
12
+ * 清理 /tmp/xy_channel 目录中超过 24 小时的旧文件
13
+ */
14
+ export declare function cleanupStaleTempFiles(tempDir?: string): Promise<void>;
11
15
  /**
12
16
  * Create a reply dispatcher for XY channel messages.
13
17
  * Follows feishu pattern with status updates and streaming support.
@@ -1,8 +1,43 @@
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
+ const TEMP_FILE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
8
+ /**
9
+ * 清理 /tmp/xy_channel 目录中超过 24 小时的旧文件
10
+ */
11
+ export async function cleanupStaleTempFiles(tempDir = "/tmp/xy_channel") {
12
+ try {
13
+ const stats = await fs.stat(tempDir).catch(() => null);
14
+ if (!stats?.isDirectory()) {
15
+ return;
16
+ }
17
+ const files = await fs.readdir(tempDir);
18
+ const now = Date.now();
19
+ let cleanedCount = 0;
20
+ for (const file of files) {
21
+ const filePath = path.join(tempDir, file);
22
+ try {
23
+ const fileStat = await fs.stat(filePath);
24
+ if (now - fileStat.mtimeMs > TEMP_FILE_TTL_MS) {
25
+ await fs.unlink(filePath);
26
+ cleanedCount++;
27
+ }
28
+ }
29
+ catch (err) {
30
+ // 忽略单个文件处理失败
31
+ }
32
+ }
33
+ if (cleanedCount > 0) {
34
+ console.log(`[CLEANUP] 🧹 Cleaned ${cleanedCount} stale files (>${TEMP_FILE_TTL_MS / 1000 / 3600}h) from ${tempDir}`);
35
+ }
36
+ }
37
+ catch (err) {
38
+ console.error(`[CLEANUP] ❌ Failed to cleanup temp dir:`, err);
39
+ }
40
+ }
6
41
  /**
7
42
  * Create a reply dispatcher for XY channel messages.
8
43
  * Follows feishu pattern with status updates and streaming support.
@@ -13,9 +48,7 @@ export function createXYReplyDispatcher(params) {
13
48
  const log = runtime?.log ?? console.log;
14
49
  const error = runtime?.error ?? console.error;
15
50
  log(`[DISPATCHER-CREATE] ******* Creating dispatcher *******`);
16
- log(`[DISPATCHER-CREATE] - sessionId: ${sessionId}`);
17
51
  log(`[DISPATCHER-CREATE] - taskId: ${taskId}`);
18
- log(`[DISPATCHER-CREATE] - messageId: ${messageId}`);
19
52
  log(`[DISPATCHER-CREATE] - isSteerFollower: ${isSteerFollower ?? false}`);
20
53
  // 初始taskId和messageId(作为fallback)
21
54
  const initialTaskId = taskId;
@@ -32,7 +65,12 @@ export function createXYReplyDispatcher(params) {
32
65
  };
33
66
  const core = getXYRuntime();
34
67
  const config = resolveXYConfig(cfg);
35
- const prefixContext = createReplyPrefixContext({ cfg, agentId: accountId });
68
+ // Simplified prefix context for single-account Xiaoyi channel
69
+ const prefixContext = {
70
+ responsePrefix: undefined,
71
+ responsePrefixContextProvider: undefined,
72
+ onModelSelected: undefined,
73
+ };
36
74
  let statusUpdateInterval = null;
37
75
  let hasSentResponse = false;
38
76
  let finalSent = false;
@@ -54,7 +92,7 @@ export function createXYReplyDispatcher(params) {
54
92
  sessionId,
55
93
  taskId: currentTaskId, // 🔑 动态taskId
56
94
  messageId: currentMessageId, // 🔑 动态messageId
57
- text: "任务正在处理中,请稍后~",
95
+ text: "任务正在处理中,请稍候~",
58
96
  state: "working",
59
97
  }).catch((err) => {
60
98
  error(`Failed to send status update:`, err);
@@ -197,9 +235,11 @@ export function createXYReplyDispatcher(params) {
197
235
  text: "任务执行异常,请重试~",
198
236
  append: false,
199
237
  final: true,
238
+ errorCode: 99921111,
239
+ errorMessage: "任务执行异常,请重试",
200
240
  });
201
241
  finalSent = true;
202
- log(`[ON_IDLE] ✅ Sent error response`);
242
+ log(`[ON_IDLE] ✅ Sent error response with code: 99921111`);
203
243
  }
204
244
  catch (err) {
205
245
  error(`[ON_IDLE] Failed to send error response:`, err);
@@ -289,7 +329,6 @@ export function createXYReplyDispatcher(params) {
289
329
  const currentTaskId = getActiveTaskId();
290
330
  const currentMessageId = getActiveMessageId();
291
331
  const text = payload.text ?? "";
292
- log(`[PARTIAL REPLY] Partial reply chunk received, taskId: ${currentTaskId}`);
293
332
  try {
294
333
  if (text.length > 0) {
295
334
  await sendReasoningTextUpdate({
@@ -300,7 +339,6 @@ export function createXYReplyDispatcher(params) {
300
339
  text,
301
340
  append: false,
302
341
  });
303
- log(`[PARTIAL REPLY] ✅ Sent partial reply as reasoningText update`);
304
342
  }
305
343
  }
306
344
  catch (err) {
@@ -0,0 +1,16 @@
1
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ /**
3
+ * 在 handleXYMessage 入口处调用,缓存 cfg/runtime 供 steer 注入使用。
4
+ */
5
+ export declare function setCachedContext(cfg: ClawdbotConfig, runtime: RuntimeEnv, accountId: string): void;
6
+ /**
7
+ * 尝试向当前活跃会话注入 steer 消息。
8
+ * 两层保险:
9
+ * 1. getSessionContext(sessionKey) 确认是当前 XY 活跃 session
10
+ * 2. hasActiveTask(sessionId) 确认任务仍在运行
11
+ *
12
+ * @param sessionKey 来自 after_tool_call ctx.sessionKey(per-peer 下精确对应一个 XY session)
13
+ * @param message 要注入的用户消息文本
14
+ * @returns true=已注入,false=跳过
15
+ */
16
+ export declare function tryInjectSteer(sessionKey: string | undefined, message: string): Promise<boolean>;
@@ -0,0 +1,74 @@
1
+ // Steer message injector for CSPL hook integration
2
+ import { getSessionContext } from "./tools/session-manager.js";
3
+ import { hasActiveTask, getCurrentTaskId } from "./task-manager.js";
4
+ import { handleXYMessage } from "./bot.js";
5
+ import { logger } from "./utils/logger.js";
6
+ import { randomUUID } from "node:crypto";
7
+ let cachedCfg = null;
8
+ let cachedRuntime = null;
9
+ let cachedAccountId = "default";
10
+ /**
11
+ * 在 handleXYMessage 入口处调用,缓存 cfg/runtime 供 steer 注入使用。
12
+ */
13
+ export function setCachedContext(cfg, runtime, accountId) {
14
+ cachedCfg = cfg;
15
+ cachedRuntime = runtime;
16
+ cachedAccountId = accountId;
17
+ }
18
+ /**
19
+ * 尝试向当前活跃会话注入 steer 消息。
20
+ * 两层保险:
21
+ * 1. getSessionContext(sessionKey) 确认是当前 XY 活跃 session
22
+ * 2. hasActiveTask(sessionId) 确认任务仍在运行
23
+ *
24
+ * @param sessionKey 来自 after_tool_call ctx.sessionKey(per-peer 下精确对应一个 XY session)
25
+ * @param message 要注入的用户消息文本
26
+ * @returns true=已注入,false=跳过
27
+ */
28
+ export async function tryInjectSteer(sessionKey, message) {
29
+ if (!sessionKey) {
30
+ return false;
31
+ }
32
+ const sessionCtx = getSessionContext(sessionKey);
33
+ if (!sessionCtx) {
34
+ return false;
35
+ }
36
+ const { sessionId } = sessionCtx;
37
+ const activeTaskId = getCurrentTaskId(sessionId);
38
+ if (!hasActiveTask(sessionId)) {
39
+ return false;
40
+ }
41
+ if (!cachedCfg || !cachedRuntime) {
42
+ logger.error("[STEER] No cached cfg/runtime available, cannot inject");
43
+ return false;
44
+ }
45
+ // 3. 构造合成 A2A 消息(伪装成用户在当前会话中发送的新消息)
46
+ const syntheticMessage = {
47
+ jsonrpc: "2.0",
48
+ method: "tasks/send",
49
+ id: `steer-msg-${randomUUID()}`,
50
+ params: {
51
+ sessionId,
52
+ id: activeTaskId ?? `steer-task-${randomUUID()}`,
53
+ agentLoginSessionId: "",
54
+ message: {
55
+ role: "user",
56
+ parts: [{ kind: "text", text: message }],
57
+ },
58
+ },
59
+ };
60
+ console.log(`[STEER] Injecting steer for sessionId=${sessionId}, taskId=${syntheticMessage.params.id}`);
61
+ try {
62
+ await handleXYMessage({
63
+ cfg: cachedCfg,
64
+ runtime: cachedRuntime,
65
+ message: syntheticMessage,
66
+ accountId: cachedAccountId,
67
+ });
68
+ return true;
69
+ }
70
+ catch (err) {
71
+ logger.error(`[STEER] ❌ Failed to inject steer: ${err}`);
72
+ return false;
73
+ }
74
+ }
@@ -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
+ };