@kodelyth/feishu 2026.5.39 → 2026.5.42

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 (238) hide show
  1. package/api.ts +32 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/dist/accounts-D0ow-lRb.js +429 -0
  6. package/dist/api.js +2308 -0
  7. package/dist/app-registration-DBSnysKJ.js +184 -0
  8. package/dist/audio-preflight.runtime-Dpjbn-7r.js +7 -0
  9. package/dist/channel-13WQvQ0u.js +2115 -0
  10. package/dist/channel-entry.js +22 -0
  11. package/dist/channel-plugin-api.js +2 -0
  12. package/dist/channel.runtime-JMJonrJ4.js +729 -0
  13. package/dist/client-D1pzbBGo.js +157 -0
  14. package/dist/contract-api.js +9 -0
  15. package/dist/conversation-id-_58ecqlx.js +139 -0
  16. package/dist/drive-CgHOluXx.js +883 -0
  17. package/dist/index.js +68 -0
  18. package/dist/monitor-oWptK0zL.js +60 -0
  19. package/dist/monitor.account-DHaWlslg.js +5207 -0
  20. package/dist/monitor.state-C211a4tX.js +100 -0
  21. package/dist/probe-CF4duEpK.js +149 -0
  22. package/dist/rolldown-runtime-DUslC3ob.js +14 -0
  23. package/dist/runtime-DSh5rL_d.js +8 -0
  24. package/dist/runtime-api.js +14 -0
  25. package/dist/secret-contract-NSee-WzN.js +119 -0
  26. package/dist/secret-contract-api.js +2 -0
  27. package/dist/security-audit-DWVC0vSK.js +11 -0
  28. package/dist/security-audit-shared-Dpcwxeft.js +38 -0
  29. package/dist/security-contract-api.js +2 -0
  30. package/dist/send-DfZuV4Fi.js +1212 -0
  31. package/dist/session-conversation-Duaukbnl.js +27 -0
  32. package/dist/session-key-api.js +2 -0
  33. package/dist/setup-api.js +2 -0
  34. package/dist/setup-entry.js +15 -0
  35. package/dist/subagent-hooks-Dtegs0kh.js +235 -0
  36. package/dist/subagent-hooks-api.js +23 -0
  37. package/dist/targets-DFskxX4p.js +48 -0
  38. package/dist/thread-bindings-DI7lVSOE.js +222 -0
  39. package/index.ts +82 -0
  40. package/klaw.plugin.json +47 -1712
  41. package/package.json +4 -4
  42. package/runtime-api.ts +52 -0
  43. package/secret-contract-api.ts +5 -0
  44. package/security-contract-api.ts +1 -0
  45. package/session-key-api.ts +1 -0
  46. package/setup-api.ts +3 -0
  47. package/setup-entry.test.ts +19 -0
  48. package/setup-entry.ts +13 -0
  49. package/src/accounts.test.ts +480 -0
  50. package/src/accounts.ts +333 -0
  51. package/src/agent-config.ts +21 -0
  52. package/src/app-registration.ts +331 -0
  53. package/src/approval-auth.test.ts +24 -0
  54. package/src/approval-auth.ts +25 -0
  55. package/src/async.test.ts +35 -0
  56. package/src/async.ts +104 -0
  57. package/src/audio-preflight.runtime.ts +9 -0
  58. package/src/bitable.test.ts +136 -0
  59. package/src/bitable.ts +762 -0
  60. package/src/bot-content.ts +485 -0
  61. package/src/bot-group-name.test.ts +116 -0
  62. package/src/bot-runtime-api.ts +12 -0
  63. package/src/bot-sender-name.ts +125 -0
  64. package/src/bot.broadcast.test.ts +523 -0
  65. package/src/bot.card-action.test.ts +552 -0
  66. package/src/bot.checkBotMentioned.test.ts +265 -0
  67. package/src/bot.helpers.test.ts +135 -0
  68. package/src/bot.stripBotMention.test.ts +126 -0
  69. package/src/bot.test.ts +3671 -0
  70. package/src/bot.ts +1703 -0
  71. package/src/card-action.ts +447 -0
  72. package/src/card-interaction.test.ts +131 -0
  73. package/src/card-interaction.ts +159 -0
  74. package/src/card-test-helpers.ts +54 -0
  75. package/src/card-ux-approval.ts +65 -0
  76. package/src/card-ux-launcher.test.ts +106 -0
  77. package/src/card-ux-launcher.ts +121 -0
  78. package/src/card-ux-shared.ts +33 -0
  79. package/src/channel-runtime-api.ts +16 -0
  80. package/src/channel.runtime.ts +47 -0
  81. package/src/channel.test.ts +1151 -0
  82. package/src/channel.ts +1423 -0
  83. package/src/chat-schema.ts +25 -0
  84. package/src/chat.test.ts +240 -0
  85. package/src/chat.ts +188 -0
  86. package/src/client-timeout.ts +42 -0
  87. package/src/client.test.ts +447 -0
  88. package/src/client.ts +262 -0
  89. package/src/comment-dispatcher-runtime-api.ts +6 -0
  90. package/src/comment-dispatcher.test.ts +185 -0
  91. package/src/comment-dispatcher.ts +107 -0
  92. package/src/comment-handler-runtime-api.ts +3 -0
  93. package/src/comment-handler.test.ts +592 -0
  94. package/src/comment-handler.ts +303 -0
  95. package/src/comment-reaction.test.ts +138 -0
  96. package/src/comment-reaction.ts +259 -0
  97. package/src/comment-shared.test.ts +183 -0
  98. package/src/comment-shared.ts +406 -0
  99. package/src/comment-target.ts +44 -0
  100. package/src/config-schema.test.ts +326 -0
  101. package/src/config-schema.ts +335 -0
  102. package/src/conversation-id.test.ts +18 -0
  103. package/src/conversation-id.ts +199 -0
  104. package/src/dedup-runtime-api.ts +1 -0
  105. package/src/dedup.ts +141 -0
  106. package/src/dedupe-key.ts +72 -0
  107. package/src/directory.static.ts +61 -0
  108. package/src/directory.test.ts +141 -0
  109. package/src/directory.ts +124 -0
  110. package/src/doc-schema.ts +182 -0
  111. package/src/docx-batch-insert.test.ts +116 -0
  112. package/src/docx-batch-insert.ts +223 -0
  113. package/src/docx-color-text.ts +154 -0
  114. package/src/docx-table-ops.test.ts +53 -0
  115. package/src/docx-table-ops.ts +316 -0
  116. package/src/docx-types.ts +38 -0
  117. package/src/docx.account-selection.test.ts +95 -0
  118. package/src/docx.test.ts +701 -0
  119. package/src/docx.ts +1596 -0
  120. package/src/drive-schema.ts +92 -0
  121. package/src/drive.test.ts +1237 -0
  122. package/src/drive.ts +829 -0
  123. package/src/dynamic-agent.test.ts +155 -0
  124. package/src/dynamic-agent.ts +143 -0
  125. package/src/event-types.ts +45 -0
  126. package/src/external-keys.test.ts +20 -0
  127. package/src/external-keys.ts +19 -0
  128. package/src/lifecycle.test-support.ts +220 -0
  129. package/src/media.test.ts +955 -0
  130. package/src/media.ts +1105 -0
  131. package/src/mention-target.types.ts +5 -0
  132. package/src/mention.ts +114 -0
  133. package/src/message-action-contract.ts +13 -0
  134. package/src/monitor-state-runtime-api.ts +7 -0
  135. package/src/monitor-transport-runtime-api.ts +10 -0
  136. package/src/monitor.account.ts +492 -0
  137. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  138. package/src/monitor.bot-identity.ts +86 -0
  139. package/src/monitor.bot-menu-handler.ts +165 -0
  140. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  141. package/src/monitor.bot-menu.test.ts +188 -0
  142. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  143. package/src/monitor.card-action.lifecycle.test-support.ts +421 -0
  144. package/src/monitor.cleanup.test.ts +383 -0
  145. package/src/monitor.comment-notice-handler.ts +105 -0
  146. package/src/monitor.comment.test.ts +967 -0
  147. package/src/monitor.comment.ts +1386 -0
  148. package/src/monitor.lifecycle.test.ts +4 -0
  149. package/src/monitor.message-handler.ts +350 -0
  150. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  151. package/src/monitor.reaction.test.ts +739 -0
  152. package/src/monitor.startup.test.ts +213 -0
  153. package/src/monitor.startup.ts +74 -0
  154. package/src/monitor.state.defaults.test.ts +46 -0
  155. package/src/monitor.state.ts +170 -0
  156. package/src/monitor.synthetic-error.ts +18 -0
  157. package/src/monitor.test-mocks.ts +46 -0
  158. package/src/monitor.transport.ts +451 -0
  159. package/src/monitor.ts +100 -0
  160. package/src/monitor.webhook-e2e.test.ts +279 -0
  161. package/src/monitor.webhook-security.test.ts +389 -0
  162. package/src/monitor.webhook.test-helpers.ts +116 -0
  163. package/src/outbound-runtime-api.ts +1 -0
  164. package/src/outbound.test.ts +1118 -0
  165. package/src/outbound.ts +785 -0
  166. package/src/perm-schema.ts +52 -0
  167. package/src/perm.ts +170 -0
  168. package/src/pins.ts +108 -0
  169. package/src/policy.test.ts +223 -0
  170. package/src/policy.ts +318 -0
  171. package/src/post.test.ts +105 -0
  172. package/src/post.ts +275 -0
  173. package/src/probe.test.ts +283 -0
  174. package/src/probe.ts +166 -0
  175. package/src/processing-claims.ts +59 -0
  176. package/src/qr-terminal.ts +1 -0
  177. package/src/reactions.ts +123 -0
  178. package/src/reasoning-preview.test.ts +113 -0
  179. package/src/reasoning-preview.ts +28 -0
  180. package/src/reply-dispatcher-runtime-api.ts +7 -0
  181. package/src/reply-dispatcher.test.ts +1513 -0
  182. package/src/reply-dispatcher.ts +748 -0
  183. package/src/runtime.ts +9 -0
  184. package/src/secret-contract.ts +145 -0
  185. package/src/secret-input.ts +1 -0
  186. package/src/security-audit-shared.ts +69 -0
  187. package/src/security-audit.test.ts +59 -0
  188. package/src/security-audit.ts +1 -0
  189. package/src/send-result.ts +80 -0
  190. package/src/send-target.test.ts +86 -0
  191. package/src/send-target.ts +35 -0
  192. package/src/send.reply-fallback.test.ts +417 -0
  193. package/src/send.test.ts +621 -0
  194. package/src/send.ts +861 -0
  195. package/src/sequential-key.test.ts +72 -0
  196. package/src/sequential-key.ts +25 -0
  197. package/src/sequential-queue.test.ts +165 -0
  198. package/src/sequential-queue.ts +86 -0
  199. package/src/session-conversation.ts +42 -0
  200. package/src/session-route.ts +48 -0
  201. package/src/setup-core.ts +51 -0
  202. package/src/setup-surface.test.ts +484 -0
  203. package/src/setup-surface.ts +618 -0
  204. package/src/streaming-card.test.ts +397 -0
  205. package/src/streaming-card.ts +571 -0
  206. package/src/subagent-hooks.test.ts +627 -0
  207. package/src/subagent-hooks.ts +413 -0
  208. package/src/targets.ts +97 -0
  209. package/src/test-support/lifecycle-test-support.ts +454 -0
  210. package/src/thread-bindings.test.ts +180 -0
  211. package/src/thread-bindings.ts +331 -0
  212. package/src/tool-account-routing.test.ts +250 -0
  213. package/src/tool-account.test.ts +44 -0
  214. package/src/tool-account.ts +93 -0
  215. package/src/tool-factory-test-harness.ts +79 -0
  216. package/src/tool-result.test.ts +32 -0
  217. package/src/tool-result.ts +16 -0
  218. package/src/tools-config.test.ts +21 -0
  219. package/src/tools-config.ts +22 -0
  220. package/src/types.ts +106 -0
  221. package/src/typing.test.ts +144 -0
  222. package/src/typing.ts +214 -0
  223. package/src/wiki-schema.ts +69 -0
  224. package/src/wiki.ts +270 -0
  225. package/subagent-hooks-api.ts +31 -0
  226. package/tsconfig.json +16 -0
  227. package/api.js +0 -7
  228. package/channel-entry.js +0 -7
  229. package/channel-plugin-api.js +0 -7
  230. package/contract-api.js +0 -7
  231. package/index.js +0 -7
  232. package/runtime-api.js +0 -7
  233. package/secret-contract-api.js +0 -7
  234. package/security-contract-api.js +0 -7
  235. package/session-key-api.js +0 -7
  236. package/setup-api.js +0 -7
  237. package/setup-entry.js +0 -7
  238. package/subagent-hooks-api.js +0 -7
@@ -0,0 +1,155 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { KlawConfig, PluginRuntime } from "../runtime-api.js";
6
+ import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
7
+
8
+ let tempRoot: string;
9
+
10
+ beforeEach(async () => {
11
+ tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "klaw-feishu-agent-"));
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await fs.promises.rm(tempRoot, { recursive: true, force: true });
16
+ });
17
+
18
+ function createRuntime() {
19
+ const replaceConfigFile = vi.fn(async () => {});
20
+ return {
21
+ runtime: {
22
+ config: {
23
+ replaceConfigFile,
24
+ },
25
+ } as unknown as PluginRuntime,
26
+ replaceConfigFile,
27
+ };
28
+ }
29
+
30
+ function createDynamicConfig() {
31
+ return {
32
+ enabled: true,
33
+ workspaceTemplate: path.join(tempRoot, "workspace-{agentId}"),
34
+ agentDirTemplate: path.join(tempRoot, "agent-{agentId}"),
35
+ };
36
+ }
37
+
38
+ async function pathExists(target: string): Promise<boolean> {
39
+ return fs.promises
40
+ .stat(target)
41
+ .then(() => true)
42
+ .catch((err: unknown) => {
43
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
44
+ return false;
45
+ }
46
+ throw err;
47
+ });
48
+ }
49
+
50
+ describe("maybeCreateDynamicAgent", () => {
51
+ it("does not persist dynamic agents when config writes are disabled", async () => {
52
+ const { runtime, replaceConfigFile } = createRuntime();
53
+ const dynamicCfg = createDynamicConfig();
54
+
55
+ const result = await maybeCreateDynamicAgent({
56
+ cfg: {
57
+ channels: { feishu: { configWrites: false } },
58
+ agents: { list: [] },
59
+ bindings: [],
60
+ } as KlawConfig,
61
+ runtime,
62
+ senderOpenId: "ou_sender",
63
+ dynamicCfg,
64
+ configWritesAllowed: false,
65
+ log: vi.fn(),
66
+ });
67
+
68
+ expect(result).toEqual({
69
+ created: false,
70
+ updatedCfg: {
71
+ channels: { feishu: { configWrites: false } },
72
+ agents: { list: [] },
73
+ bindings: [],
74
+ },
75
+ });
76
+ expect(replaceConfigFile).not.toHaveBeenCalled();
77
+ expect(await pathExists(path.join(tempRoot, "workspace-feishu-ou_sender"))).toBe(false);
78
+ expect(await pathExists(path.join(tempRoot, "agent-feishu-ou_sender"))).toBe(false);
79
+ });
80
+
81
+ it("persists a sender agent and direct binding when config writes are allowed", async () => {
82
+ const { runtime, replaceConfigFile } = createRuntime();
83
+
84
+ const result = await maybeCreateDynamicAgent({
85
+ cfg: {
86
+ agents: { list: [] },
87
+ bindings: [],
88
+ } as KlawConfig,
89
+ runtime,
90
+ senderOpenId: "ou_sender",
91
+ dynamicCfg: createDynamicConfig(),
92
+ configWritesAllowed: true,
93
+ log: vi.fn(),
94
+ });
95
+
96
+ expect(result.created).toBe(true);
97
+ expect(result.agentId).toBe("feishu-ou_sender");
98
+ expect(replaceConfigFile).toHaveBeenCalledTimes(1);
99
+ expect(replaceConfigFile).toHaveBeenCalledWith({
100
+ nextConfig: {
101
+ agents: {
102
+ list: [
103
+ {
104
+ id: "feishu-ou_sender",
105
+ workspace: path.join(tempRoot, "workspace-feishu-ou_sender"),
106
+ agentDir: path.join(tempRoot, "agent-feishu-ou_sender"),
107
+ },
108
+ ],
109
+ },
110
+ bindings: [
111
+ {
112
+ agentId: "feishu-ou_sender",
113
+ match: {
114
+ channel: "feishu",
115
+ peer: { kind: "direct", id: "ou_sender" },
116
+ },
117
+ },
118
+ ],
119
+ },
120
+ afterWrite: { mode: "auto" },
121
+ });
122
+ expect(await pathExists(path.join(tempRoot, "workspace-feishu-ou_sender"))).toBe(true);
123
+ expect(await pathExists(path.join(tempRoot, "agent-feishu-ou_sender"))).toBe(true);
124
+ });
125
+
126
+ it("keeps the maxAgents limit before adding a missing binding", async () => {
127
+ const { runtime, replaceConfigFile } = createRuntime();
128
+
129
+ const result = await maybeCreateDynamicAgent({
130
+ cfg: {
131
+ agents: {
132
+ list: [
133
+ {
134
+ id: "feishu-ou_sender",
135
+ workspace: path.join(tempRoot, "existing-workspace"),
136
+ agentDir: path.join(tempRoot, "existing-agent"),
137
+ },
138
+ ],
139
+ },
140
+ bindings: [],
141
+ } as KlawConfig,
142
+ runtime,
143
+ senderOpenId: "ou_sender",
144
+ dynamicCfg: {
145
+ ...createDynamicConfig(),
146
+ maxAgents: 1,
147
+ },
148
+ configWritesAllowed: true,
149
+ log: vi.fn(),
150
+ });
151
+
152
+ expect(result.created).toBe(false);
153
+ expect(replaceConfigFile).not.toHaveBeenCalled();
154
+ });
155
+ });
@@ -0,0 +1,143 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { KlawConfig, PluginRuntime } from "../runtime-api.js";
5
+ import type { DynamicAgentCreationConfig } from "./types.js";
6
+
7
+ type MaybeCreateDynamicAgentResult = {
8
+ created: boolean;
9
+ updatedCfg: KlawConfig;
10
+ agentId?: string;
11
+ };
12
+
13
+ /**
14
+ * Check if a dynamic agent should be created for a DM user and create it if needed.
15
+ * This creates a unique agent instance with its own workspace for each DM user.
16
+ */
17
+ export async function maybeCreateDynamicAgent(params: {
18
+ cfg: KlawConfig;
19
+ runtime: PluginRuntime;
20
+ senderOpenId: string;
21
+ dynamicCfg: DynamicAgentCreationConfig;
22
+ configWritesAllowed: boolean;
23
+ log: (msg: string) => void;
24
+ }): Promise<MaybeCreateDynamicAgentResult> {
25
+ const { cfg, runtime, senderOpenId, dynamicCfg, configWritesAllowed, log } = params;
26
+
27
+ if (!configWritesAllowed) {
28
+ log(`feishu: config writes disabled, not creating agent for ${senderOpenId}`);
29
+ return { created: false, updatedCfg: cfg };
30
+ }
31
+
32
+ // Check if there's already a binding for this user
33
+ const existingBindings = cfg.bindings ?? [];
34
+ const hasBinding = existingBindings.some(
35
+ (b) =>
36
+ b.match?.channel === "feishu" &&
37
+ b.match?.peer?.kind === "direct" &&
38
+ b.match?.peer?.id === senderOpenId,
39
+ );
40
+
41
+ if (hasBinding) {
42
+ return { created: false, updatedCfg: cfg };
43
+ }
44
+
45
+ // Check maxAgents limit if configured
46
+ if (dynamicCfg.maxAgents !== undefined) {
47
+ const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>
48
+ a.id.startsWith("feishu-"),
49
+ ).length;
50
+ if (feishuAgentCount >= dynamicCfg.maxAgents) {
51
+ log(
52
+ `feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
53
+ );
54
+ return { created: false, updatedCfg: cfg };
55
+ }
56
+ }
57
+
58
+ // Use full OpenID as agent ID suffix (OpenID format: ou_xxx is already filesystem-safe)
59
+ const agentId = `feishu-${senderOpenId}`;
60
+
61
+ // Check if agent already exists (but binding was missing)
62
+ const existingAgent = (cfg.agents?.list ?? []).find((a) => a.id === agentId);
63
+ if (existingAgent) {
64
+ // Agent exists but binding doesn't - just add the binding
65
+ log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);
66
+
67
+ const updatedCfg: KlawConfig = {
68
+ ...cfg,
69
+ bindings: [
70
+ ...existingBindings,
71
+ {
72
+ agentId,
73
+ match: {
74
+ channel: "feishu",
75
+ peer: { kind: "direct", id: senderOpenId },
76
+ },
77
+ },
78
+ ],
79
+ };
80
+
81
+ await runtime.config.replaceConfigFile({
82
+ nextConfig: updatedCfg,
83
+ afterWrite: { mode: "auto" },
84
+ });
85
+ return { created: true, updatedCfg, agentId };
86
+ }
87
+
88
+ // Resolve path templates with substitutions
89
+ const workspaceTemplate = dynamicCfg.workspaceTemplate ?? "~/.klaw/workspace-{agentId}";
90
+ const agentDirTemplate = dynamicCfg.agentDirTemplate ?? "~/.klaw/agents/{agentId}/agent";
91
+
92
+ const workspace = resolveUserPath(
93
+ workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
94
+ );
95
+ const agentDir = resolveUserPath(
96
+ agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
97
+ );
98
+
99
+ log(`feishu: creating dynamic agent "${agentId}" for user ${senderOpenId}`);
100
+ log(` workspace: ${workspace}`);
101
+ log(` agentDir: ${agentDir}`);
102
+
103
+ // Create directories
104
+ await fs.promises.mkdir(workspace, { recursive: true });
105
+ await fs.promises.mkdir(agentDir, { recursive: true });
106
+
107
+ // Update configuration with new agent and binding
108
+ const updatedCfg: KlawConfig = {
109
+ ...cfg,
110
+ agents: {
111
+ ...cfg.agents,
112
+ list: [...(cfg.agents?.list ?? []), { id: agentId, workspace, agentDir }],
113
+ },
114
+ bindings: [
115
+ ...existingBindings,
116
+ {
117
+ agentId,
118
+ match: {
119
+ channel: "feishu",
120
+ peer: { kind: "direct", id: senderOpenId },
121
+ },
122
+ },
123
+ ],
124
+ };
125
+
126
+ // Write updated config using PluginRuntime API
127
+ await runtime.config.replaceConfigFile({
128
+ nextConfig: updatedCfg,
129
+ afterWrite: { mode: "auto" },
130
+ });
131
+
132
+ return { created: true, updatedCfg, agentId };
133
+ }
134
+
135
+ /**
136
+ * Resolve a path that may start with ~ to the user's home directory.
137
+ */
138
+ function resolveUserPath(p: string): string {
139
+ if (p.startsWith("~/")) {
140
+ return path.join(os.homedir(), p.slice(2));
141
+ }
142
+ return p;
143
+ }
@@ -0,0 +1,45 @@
1
+ export type FeishuMessageEvent = {
2
+ sender: {
3
+ sender_id: {
4
+ open_id?: string;
5
+ user_id?: string;
6
+ union_id?: string;
7
+ };
8
+ sender_type?: string;
9
+ tenant_key?: string;
10
+ };
11
+ message: {
12
+ message_id: string;
13
+ reply_target_message_id?: string;
14
+ suppress_reply_target?: boolean;
15
+ root_id?: string;
16
+ parent_id?: string;
17
+ thread_id?: string;
18
+ chat_id: string;
19
+ chat_type: "p2p" | "group" | "topic_group" | "private";
20
+ message_type: string;
21
+ content: string;
22
+ create_time?: string;
23
+ mentions?: Array<{
24
+ key: string;
25
+ id: {
26
+ open_id?: string;
27
+ user_id?: string;
28
+ union_id?: string;
29
+ };
30
+ name: string;
31
+ tenant_key?: string;
32
+ }>;
33
+ };
34
+ };
35
+
36
+ export type FeishuBotAddedEvent = {
37
+ chat_id: string;
38
+ operator_id: {
39
+ open_id?: string;
40
+ user_id?: string;
41
+ union_id?: string;
42
+ };
43
+ external: boolean;
44
+ operator_tenant_key?: string;
45
+ };
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeFeishuExternalKey } from "./external-keys.js";
3
+
4
+ describe("normalizeFeishuExternalKey", () => {
5
+ it("accepts a normal feishu key and trims surrounding spaces", () => {
6
+ expect(normalizeFeishuExternalKey(" img_v3_01abcDEF123 ")).toBe("img_v3_01abcDEF123");
7
+ });
8
+
9
+ it("rejects traversal and path separator patterns", () => {
10
+ expect(normalizeFeishuExternalKey("../etc/passwd")).toBeUndefined();
11
+ expect(normalizeFeishuExternalKey("a/../../b")).toBeUndefined();
12
+ expect(normalizeFeishuExternalKey("a\\..\\b")).toBeUndefined();
13
+ });
14
+
15
+ it("rejects empty, non-string, and control-char values", () => {
16
+ expect(normalizeFeishuExternalKey(" ")).toBeUndefined();
17
+ expect(normalizeFeishuExternalKey(123)).toBeUndefined();
18
+ expect(normalizeFeishuExternalKey("abc\u0000def")).toBeUndefined();
19
+ });
20
+ });
@@ -0,0 +1,19 @@
1
+ const CONTROL_CHARS_RE = /\p{Cc}/u;
2
+ const MAX_EXTERNAL_KEY_LENGTH = 512;
3
+
4
+ export function normalizeFeishuExternalKey(value: unknown): string | undefined {
5
+ if (typeof value !== "string") {
6
+ return undefined;
7
+ }
8
+ const normalized = value.trim();
9
+ if (!normalized || normalized.length > MAX_EXTERNAL_KEY_LENGTH) {
10
+ return undefined;
11
+ }
12
+ if (CONTROL_CHARS_RE.test(normalized)) {
13
+ return undefined;
14
+ }
15
+ if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("..")) {
16
+ return undefined;
17
+ }
18
+ return normalized;
19
+ }
@@ -0,0 +1,220 @@
1
+ import { vi, type Mock } from "vitest";
2
+
3
+ type BoundConversation = {
4
+ bindingId: string;
5
+ targetSessionKey: string;
6
+ };
7
+ type UnknownMock = Mock<(...args: unknown[]) => unknown>;
8
+ type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
9
+ type FinalizeInboundContextMock = Mock<
10
+ (ctx: Record<string, unknown>, opts?: unknown) => Record<string, unknown>
11
+ >;
12
+ type DispatchReplyCounts = {
13
+ final: number;
14
+ block?: number;
15
+ tool?: number;
16
+ };
17
+ type DispatchReplyContext = Record<string, unknown> & {
18
+ SessionKey?: string;
19
+ };
20
+ type DispatchReplyDispatcher = {
21
+ sendFinalReply: (payload: { text: string }) => unknown;
22
+ };
23
+ type FeishuReplyDispatcherMockValue = {
24
+ dispatcher: DispatchReplyDispatcher;
25
+ replyOptions: Record<string, never>;
26
+ markDispatchIdle: () => unknown;
27
+ };
28
+ type CreateFeishuReplyDispatcherMock = Mock<(params?: unknown) => FeishuReplyDispatcherMockValue>;
29
+ type DispatchReplyFromConfigMock = Mock<
30
+ (params: {
31
+ ctx: DispatchReplyContext;
32
+ dispatcher: DispatchReplyDispatcher;
33
+ }) => Promise<{ queuedFinal: boolean; counts: DispatchReplyCounts }>
34
+ >;
35
+ type WithReplyDispatcherMock = Mock<
36
+ (params: {
37
+ dispatcher?: DispatchReplyDispatcher;
38
+ onSettled?: () => unknown;
39
+ run: () => unknown;
40
+ }) => Promise<unknown>
41
+ >;
42
+ type FeishuLifecycleTestMocks = {
43
+ createEventDispatcherMock: UnknownMock;
44
+ monitorWebSocketMock: AsyncUnknownMock;
45
+ monitorWebhookMock: AsyncUnknownMock;
46
+ createFeishuThreadBindingManagerMock: UnknownMock;
47
+ createFeishuReplyDispatcherMock: CreateFeishuReplyDispatcherMock;
48
+ resolveBoundConversationMock: Mock<(ref?: unknown) => BoundConversation | null>;
49
+ touchBindingMock: UnknownMock;
50
+ resolveAgentRouteMock: UnknownMock;
51
+ resolveConfiguredBindingRouteMock: UnknownMock;
52
+ ensureConfiguredBindingRouteReadyMock: UnknownMock;
53
+ dispatchReplyFromConfigMock: DispatchReplyFromConfigMock;
54
+ withReplyDispatcherMock: WithReplyDispatcherMock;
55
+ finalizeInboundContextMock: FinalizeInboundContextMock;
56
+ getMessageFeishuMock: AsyncUnknownMock;
57
+ listFeishuThreadMessagesMock: AsyncUnknownMock;
58
+ sendMessageFeishuMock: AsyncUnknownMock;
59
+ sendCardFeishuMock: AsyncUnknownMock;
60
+ };
61
+
62
+ const feishuLifecycleTestMocks = vi.hoisted(
63
+ (): FeishuLifecycleTestMocks => ({
64
+ createEventDispatcherMock: vi.fn(),
65
+ monitorWebSocketMock: vi.fn(async () => {}),
66
+ monitorWebhookMock: vi.fn(async () => {}),
67
+ createFeishuThreadBindingManagerMock: vi.fn(() => ({ stop: vi.fn() })),
68
+ createFeishuReplyDispatcherMock: vi.fn(),
69
+ resolveBoundConversationMock: vi.fn<(ref?: unknown) => BoundConversation | null>(() => null),
70
+ touchBindingMock: vi.fn(),
71
+ resolveAgentRouteMock: vi.fn(),
72
+ resolveConfiguredBindingRouteMock: vi.fn(),
73
+ ensureConfiguredBindingRouteReadyMock: vi.fn(),
74
+ dispatchReplyFromConfigMock: vi.fn(),
75
+ withReplyDispatcherMock: vi.fn(),
76
+ finalizeInboundContextMock: vi.fn((ctx) => ctx),
77
+ getMessageFeishuMock: vi.fn(async () => null),
78
+ listFeishuThreadMessagesMock: vi.fn(async () => []),
79
+ sendMessageFeishuMock: vi.fn(async () => ({ messageId: "om_sent", chatId: "chat_default" })),
80
+ sendCardFeishuMock: vi.fn(async () => ({ messageId: "om_card", chatId: "chat_default" })),
81
+ }),
82
+ );
83
+
84
+ export function getFeishuLifecycleTestMocks(): FeishuLifecycleTestMocks {
85
+ return feishuLifecycleTestMocks;
86
+ }
87
+
88
+ export function resetFeishuLifecycleTestMocks(): void {
89
+ for (const mock of Object.values(feishuLifecycleTestMocks)) {
90
+ mock.mockReset();
91
+ }
92
+ feishuLifecycleTestMocks.monitorWebSocketMock.mockResolvedValue(undefined);
93
+ feishuLifecycleTestMocks.monitorWebhookMock.mockResolvedValue(undefined);
94
+ feishuLifecycleTestMocks.createFeishuThreadBindingManagerMock.mockReturnValue({ stop: vi.fn() });
95
+ feishuLifecycleTestMocks.resolveBoundConversationMock.mockReturnValue(null);
96
+ feishuLifecycleTestMocks.finalizeInboundContextMock.mockImplementation((ctx) => ctx);
97
+ feishuLifecycleTestMocks.getMessageFeishuMock.mockResolvedValue(null);
98
+ feishuLifecycleTestMocks.listFeishuThreadMessagesMock.mockResolvedValue([]);
99
+ feishuLifecycleTestMocks.sendMessageFeishuMock.mockResolvedValue({
100
+ messageId: "om_sent",
101
+ chatId: "chat_default",
102
+ });
103
+ feishuLifecycleTestMocks.sendCardFeishuMock.mockResolvedValue({
104
+ messageId: "om_card",
105
+ chatId: "chat_default",
106
+ });
107
+ }
108
+
109
+ const {
110
+ createEventDispatcherMock,
111
+ monitorWebSocketMock,
112
+ monitorWebhookMock,
113
+ createFeishuThreadBindingManagerMock,
114
+ createFeishuReplyDispatcherMock,
115
+ resolveBoundConversationMock,
116
+ touchBindingMock,
117
+ resolveConfiguredBindingRouteMock,
118
+ ensureConfiguredBindingRouteReadyMock,
119
+ getMessageFeishuMock,
120
+ listFeishuThreadMessagesMock,
121
+ sendMessageFeishuMock,
122
+ sendCardFeishuMock,
123
+ } = feishuLifecycleTestMocks;
124
+
125
+ vi.mock("./client.js", () => {
126
+ return {
127
+ FEISHU_HTTP_TIMEOUT_ENV_VAR: "KLAW_FEISHU_HTTP_TIMEOUT_MS",
128
+ FEISHU_HTTP_TIMEOUT_MAX_MS: 300_000,
129
+ FEISHU_HTTP_TIMEOUT_MS: 30_000,
130
+ FEISHU_USER_AGENT: "klaw-feishu-test",
131
+ clearClientCache: vi.fn(),
132
+ createFeishuClient: vi.fn(() => {
133
+ throw new Error("unexpected Feishu client call in lifecycle test");
134
+ }),
135
+ createFeishuWSClient: vi.fn(async () => ({
136
+ close: vi.fn(),
137
+ start: vi.fn(),
138
+ })),
139
+ createEventDispatcher: createEventDispatcherMock,
140
+ getFeishuClient: vi.fn(() => null),
141
+ getFeishuUserAgent: vi.fn(() => "klaw-feishu-test"),
142
+ pluginVersion: "test",
143
+ setFeishuClientRuntimeForTest: vi.fn(),
144
+ };
145
+ });
146
+
147
+ vi.mock("./monitor.transport.js", () => ({
148
+ monitorWebSocket: monitorWebSocketMock,
149
+ monitorWebhook: monitorWebhookMock,
150
+ }));
151
+
152
+ vi.mock("./thread-bindings.js", () => ({
153
+ createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
154
+ }));
155
+
156
+ vi.mock("./reply-dispatcher.js", () => ({
157
+ createFeishuReplyDispatcher: createFeishuReplyDispatcherMock,
158
+ }));
159
+
160
+ vi.mock("./send.js", () => ({
161
+ sendCardFeishu: sendCardFeishuMock,
162
+ getMessageFeishu: getMessageFeishuMock,
163
+ listFeishuThreadMessages: listFeishuThreadMessagesMock,
164
+ sendMessageFeishu: sendMessageFeishuMock,
165
+ }));
166
+
167
+ vi.mock("klaw/plugin-sdk/conversation-runtime", async () => {
168
+ const actual = await vi.importActual<typeof import("klaw/plugin-sdk/conversation-runtime")>(
169
+ "klaw/plugin-sdk/conversation-runtime",
170
+ );
171
+ return {
172
+ ...actual,
173
+ resolveConfiguredBindingRoute: (
174
+ params: Parameters<typeof actual.resolveConfiguredBindingRoute>[0],
175
+ ) =>
176
+ resolveConfiguredBindingRouteMock.getMockImplementation()
177
+ ? resolveConfiguredBindingRouteMock(params)
178
+ : actual.resolveConfiguredBindingRoute(params),
179
+ resolveRuntimeConversationBindingRoute: (
180
+ params: Parameters<typeof actual.resolveRuntimeConversationBindingRoute>[0],
181
+ ) => {
182
+ const conversation =
183
+ "conversation" in params
184
+ ? params.conversation
185
+ : {
186
+ channel: params.channel,
187
+ accountId: params.accountId,
188
+ conversationId: params.conversationId,
189
+ parentConversationId: params.parentConversationId,
190
+ };
191
+ const bindingRecord = resolveBoundConversationMock(conversation);
192
+ const boundSessionKey = bindingRecord?.targetSessionKey?.trim();
193
+ if (!bindingRecord || !boundSessionKey) {
194
+ return { bindingRecord: null, route: params.route };
195
+ }
196
+ touchBindingMock(bindingRecord.bindingId);
197
+ return {
198
+ bindingRecord,
199
+ boundSessionKey,
200
+ boundAgentId: params.route.agentId,
201
+ route: {
202
+ ...params.route,
203
+ sessionKey: boundSessionKey,
204
+ lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session",
205
+ matchedBy: "binding.channel",
206
+ },
207
+ };
208
+ },
209
+ ensureConfiguredBindingRouteReady: (
210
+ params: Parameters<typeof actual.ensureConfiguredBindingRouteReady>[0],
211
+ ) =>
212
+ ensureConfiguredBindingRouteReadyMock.getMockImplementation()
213
+ ? ensureConfiguredBindingRouteReadyMock(params)
214
+ : actual.ensureConfiguredBindingRouteReady(params),
215
+ getSessionBindingService: () => ({
216
+ resolveByConversation: resolveBoundConversationMock,
217
+ touch: touchBindingMock,
218
+ }),
219
+ };
220
+ });