@openclaw/feishu 2026.3.1 → 2026.3.7

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/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +268 -11
  4. package/src/accounts.ts +101 -14
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +9 -1
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +945 -77
  9. package/src/bot.ts +492 -165
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +72 -68
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +221 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +33 -6
  18. package/src/config-schema.ts +18 -10
  19. package/src/dedup.ts +47 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/doc-schema.ts +16 -22
  23. package/src/docx-batch-insert.test.ts +90 -0
  24. package/src/docx-batch-insert.ts +8 -11
  25. package/src/docx.account-selection.test.ts +10 -16
  26. package/src/docx.test.ts +41 -189
  27. package/src/docx.ts +1 -1
  28. package/src/drive.ts +13 -17
  29. package/src/dynamic-agent.ts +1 -1
  30. package/src/feishu-command-handler.ts +59 -0
  31. package/src/media.test.ts +164 -14
  32. package/src/media.ts +44 -10
  33. package/src/mention.ts +1 -1
  34. package/src/monitor.account.ts +284 -25
  35. package/src/monitor.reaction.test.ts +395 -46
  36. package/src/monitor.startup.test.ts +25 -8
  37. package/src/monitor.startup.ts +20 -7
  38. package/src/monitor.state.defaults.test.ts +46 -0
  39. package/src/monitor.state.ts +88 -9
  40. package/src/monitor.test-mocks.ts +45 -0
  41. package/src/monitor.transport.ts +4 -1
  42. package/src/monitor.ts +4 -4
  43. package/src/monitor.webhook-security.test.ts +13 -11
  44. package/src/onboarding.status.test.ts +25 -0
  45. package/src/onboarding.test.ts +143 -0
  46. package/src/onboarding.ts +213 -106
  47. package/src/outbound.test.ts +178 -0
  48. package/src/outbound.ts +39 -6
  49. package/src/perm.ts +11 -15
  50. package/src/policy.test.ts +40 -0
  51. package/src/policy.ts +9 -10
  52. package/src/probe.test.ts +54 -36
  53. package/src/probe.ts +57 -37
  54. package/src/reactions.ts +1 -1
  55. package/src/reply-dispatcher.test.ts +216 -0
  56. package/src/reply-dispatcher.ts +89 -22
  57. package/src/runtime.ts +1 -1
  58. package/src/secret-input.ts +13 -0
  59. package/src/send-message.ts +71 -0
  60. package/src/send-target.test.ts +74 -0
  61. package/src/send-target.ts +7 -3
  62. package/src/send.reply-fallback.test.ts +74 -0
  63. package/src/send.test.ts +1 -1
  64. package/src/send.ts +88 -49
  65. package/src/streaming-card.test.ts +54 -0
  66. package/src/streaming-card.ts +96 -28
  67. package/src/targets.test.ts +29 -0
  68. package/src/targets.ts +25 -1
  69. package/src/tool-account-routing.test.ts +3 -3
  70. package/src/tool-account.ts +1 -1
  71. package/src/tool-factory-test-harness.ts +1 -1
  72. package/src/tool-result.test.ts +32 -0
  73. package/src/tool-result.ts +14 -0
  74. package/src/types.ts +11 -4
  75. package/src/typing.ts +1 -1
  76. package/src/wiki.ts +15 -19
@@ -24,11 +24,19 @@ describe("FeishuConfigSchema webhook validation", () => {
24
24
  expect(result.accounts?.main?.requireMention).toBeUndefined();
25
25
  });
26
26
 
27
+ it("normalizes legacy groupPolicy allowall to open", () => {
28
+ const result = FeishuConfigSchema.parse({
29
+ groupPolicy: "allowall",
30
+ });
31
+
32
+ expect(result.groupPolicy).toBe("open");
33
+ });
34
+
27
35
  it("rejects top-level webhook mode without verificationToken", () => {
28
36
  const result = FeishuConfigSchema.safeParse({
29
37
  connectionMode: "webhook",
30
38
  appId: "cli_top",
31
- appSecret: "secret_top",
39
+ appSecret: "secret_top", // pragma: allowlist secret
32
40
  });
33
41
 
34
42
  expect(result.success).toBe(false);
@@ -44,7 +52,7 @@ describe("FeishuConfigSchema webhook validation", () => {
44
52
  connectionMode: "webhook",
45
53
  verificationToken: "token_top",
46
54
  appId: "cli_top",
47
- appSecret: "secret_top",
55
+ appSecret: "secret_top", // pragma: allowlist secret
48
56
  });
49
57
 
50
58
  expect(result.success).toBe(true);
@@ -56,7 +64,7 @@ describe("FeishuConfigSchema webhook validation", () => {
56
64
  main: {
57
65
  connectionMode: "webhook",
58
66
  appId: "cli_main",
59
- appSecret: "secret_main",
67
+ appSecret: "secret_main", // pragma: allowlist secret
60
68
  },
61
69
  },
62
70
  });
@@ -78,13 +86,32 @@ describe("FeishuConfigSchema webhook validation", () => {
78
86
  main: {
79
87
  connectionMode: "webhook",
80
88
  appId: "cli_main",
81
- appSecret: "secret_main",
89
+ appSecret: "secret_main", // pragma: allowlist secret
82
90
  },
83
91
  },
84
92
  });
85
93
 
86
94
  expect(result.success).toBe(true);
87
95
  });
96
+
97
+ it("accepts SecretRef verificationToken in webhook mode", () => {
98
+ const result = FeishuConfigSchema.safeParse({
99
+ connectionMode: "webhook",
100
+ verificationToken: {
101
+ source: "env",
102
+ provider: "default",
103
+ id: "FEISHU_VERIFICATION_TOKEN",
104
+ },
105
+ appId: "cli_top",
106
+ appSecret: {
107
+ source: "env",
108
+ provider: "default",
109
+ id: "FEISHU_APP_SECRET",
110
+ },
111
+ });
112
+
113
+ expect(result.success).toBe(true);
114
+ });
88
115
  });
89
116
 
90
117
  describe("FeishuConfigSchema replyInThread", () => {
@@ -144,7 +171,7 @@ describe("FeishuConfigSchema defaultAccount", () => {
144
171
  const result = FeishuConfigSchema.safeParse({
145
172
  defaultAccount: "router-d",
146
173
  accounts: {
147
- "router-d": { appId: "cli_router", appSecret: "secret_router" },
174
+ "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
148
175
  },
149
176
  });
150
177
 
@@ -155,7 +182,7 @@ describe("FeishuConfigSchema defaultAccount", () => {
155
182
  const result = FeishuConfigSchema.safeParse({
156
183
  defaultAccount: "router-d",
157
184
  accounts: {
158
- backup: { appId: "cli_backup", appSecret: "secret_backup" },
185
+ backup: { appId: "cli_backup", appSecret: "secret_backup" }, // pragma: allowlist secret
159
186
  },
160
187
  });
161
188
 
@@ -1,9 +1,13 @@
1
1
  import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
2
  import { z } from "zod";
3
3
  export { z };
4
+ import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
4
5
 
5
6
  const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
6
- const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
7
+ const GroupPolicySchema = z.union([
8
+ z.enum(["open", "allowlist", "disabled"]),
9
+ z.literal("allowall").transform(() => "open" as const),
10
+ ]);
7
11
  const FeishuDomainSchema = z.union([
8
12
  z.enum(["feishu", "lark"]),
9
13
  z.string().url().startsWith("https://"),
@@ -110,6 +114,9 @@ const GroupSessionScopeSchema = z
110
114
  * Topic session isolation mode for group chats.
111
115
  * - "disabled" (default): All messages in a group share one session
112
116
  * - "enabled": Messages in different topics get separate sessions
117
+ *
118
+ * Topic routing uses `root_id` when present to keep session continuity and
119
+ * falls back to `thread_id` when `root_id` is unavailable.
113
120
  */
114
121
  const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
115
122
  const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional();
@@ -158,6 +165,7 @@ const FeishuSharedConfigShape = {
158
165
  chunkMode: z.enum(["length", "newline"]).optional(),
159
166
  blockStreamingCoalesce: BlockStreamingCoalesceSchema,
160
167
  mediaMaxMb: z.number().positive().optional(),
168
+ httpTimeoutMs: z.number().int().positive().max(300_000).optional(),
161
169
  heartbeat: ChannelHeartbeatVisibilitySchema,
162
170
  renderMode: RenderModeSchema,
163
171
  streaming: StreamingModeSchema,
@@ -177,9 +185,9 @@ export const FeishuAccountConfigSchema = z
177
185
  enabled: z.boolean().optional(),
178
186
  name: z.string().optional(), // Display name for this account
179
187
  appId: z.string().optional(),
180
- appSecret: z.string().optional(),
188
+ appSecret: buildSecretInputSchema().optional(),
181
189
  encryptKey: z.string().optional(),
182
- verificationToken: z.string().optional(),
190
+ verificationToken: buildSecretInputSchema().optional(),
183
191
  domain: FeishuDomainSchema.optional(),
184
192
  connectionMode: FeishuConnectionModeSchema.optional(),
185
193
  webhookPath: z.string().optional(),
@@ -195,9 +203,9 @@ export const FeishuConfigSchema = z
195
203
  defaultAccount: z.string().optional(),
196
204
  // Top-level credentials (backward compatible for single-account mode)
197
205
  appId: z.string().optional(),
198
- appSecret: z.string().optional(),
206
+ appSecret: buildSecretInputSchema().optional(),
199
207
  encryptKey: z.string().optional(),
200
- verificationToken: z.string().optional(),
208
+ verificationToken: buildSecretInputSchema().optional(),
201
209
  domain: FeishuDomainSchema.optional().default("feishu"),
202
210
  connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
203
211
  webhookPath: z.string().optional().default("/feishu/events"),
@@ -231,8 +239,8 @@ export const FeishuConfigSchema = z
231
239
  }
232
240
 
233
241
  const defaultConnectionMode = value.connectionMode ?? "websocket";
234
- const defaultVerificationToken = value.verificationToken?.trim();
235
- if (defaultConnectionMode === "webhook" && !defaultVerificationToken) {
242
+ const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
243
+ if (defaultConnectionMode === "webhook" && !defaultVerificationTokenConfigured) {
236
244
  ctx.addIssue({
237
245
  code: z.ZodIssueCode.custom,
238
246
  path: ["verificationToken"],
@@ -249,9 +257,9 @@ export const FeishuConfigSchema = z
249
257
  if (accountConnectionMode !== "webhook") {
250
258
  continue;
251
259
  }
252
- const accountVerificationToken =
253
- account.verificationToken?.trim() || defaultVerificationToken;
254
- if (!accountVerificationToken) {
260
+ const accountVerificationTokenConfigured =
261
+ hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
262
+ if (!accountVerificationTokenConfigured) {
255
263
  ctx.addIssue({
256
264
  code: z.ZodIssueCode.custom,
257
265
  path: ["accounts", accountId, "verificationToken"],
package/src/dedup.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
3
+ import {
4
+ createDedupeCache,
5
+ createPersistentDedupe,
6
+ readJsonFileWithFallback,
7
+ } from "openclaw/plugin-sdk/feishu";
4
8
 
5
9
  // Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
6
10
  const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
7
11
  const MEMORY_MAX_SIZE = 1_000;
8
12
  const FILE_MAX_ENTRIES = 10_000;
13
+ type PersistentDedupeData = Record<string, number>;
9
14
 
10
15
  const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
11
16
 
@@ -40,6 +45,14 @@ export function tryRecordMessage(messageId: string): boolean {
40
45
  return !memoryDedupe.check(messageId);
41
46
  }
42
47
 
48
+ export function hasRecordedMessage(messageId: string): boolean {
49
+ const trimmed = messageId.trim();
50
+ if (!trimmed) {
51
+ return false;
52
+ }
53
+ return memoryDedupe.peek(trimmed);
54
+ }
55
+
43
56
  export async function tryRecordMessagePersistent(
44
57
  messageId: string,
45
58
  namespace = "global",
@@ -52,3 +65,36 @@ export async function tryRecordMessagePersistent(
52
65
  },
53
66
  });
54
67
  }
68
+
69
+ export async function hasRecordedMessagePersistent(
70
+ messageId: string,
71
+ namespace = "global",
72
+ log?: (...args: unknown[]) => void,
73
+ ): Promise<boolean> {
74
+ const trimmed = messageId.trim();
75
+ if (!trimmed) {
76
+ return false;
77
+ }
78
+ const now = Date.now();
79
+ const filePath = resolveNamespaceFilePath(namespace);
80
+ try {
81
+ const { value } = await readJsonFileWithFallback<PersistentDedupeData>(filePath, {});
82
+ const seenAt = value[trimmed];
83
+ if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) {
84
+ return false;
85
+ }
86
+ return DEDUP_TTL_MS <= 0 || now - seenAt < DEDUP_TTL_MS;
87
+ } catch (error) {
88
+ log?.(`feishu-dedup: persistent peek failed: ${String(error)}`);
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export async function warmupDedupFromDisk(
94
+ namespace: string,
95
+ log?: (...args: unknown[]) => void,
96
+ ): Promise<number> {
97
+ return persistentDedupe.warmup(namespace, (error) => {
98
+ log?.(`feishu-dedup: warmup disk error: ${String(error)}`);
99
+ });
100
+ }
@@ -0,0 +1,40 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("./accounts.js", () => ({
5
+ resolveFeishuAccount: vi.fn(() => ({
6
+ configured: false,
7
+ config: {
8
+ allowFrom: ["user:alice", "user:bob"],
9
+ dms: {
10
+ "user:carla": {},
11
+ },
12
+ groups: {
13
+ "chat-1": {},
14
+ },
15
+ groupAllowFrom: ["chat-2"],
16
+ },
17
+ })),
18
+ }));
19
+
20
+ import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.js";
21
+
22
+ describe("feishu directory (config-backed)", () => {
23
+ const cfg = {} as ClawdbotConfig;
24
+
25
+ it("merges allowFrom + dms into peer entries", async () => {
26
+ const peers = await listFeishuDirectoryPeers({ cfg, query: "a" });
27
+ expect(peers).toEqual([
28
+ { kind: "user", id: "alice" },
29
+ { kind: "user", id: "carla" },
30
+ ]);
31
+ });
32
+
33
+ it("merges groups map + groupAllowFrom into group entries", async () => {
34
+ const groups = await listFeishuDirectoryGroups({ cfg });
35
+ expect(groups).toEqual([
36
+ { kind: "group", id: "chat-1" },
37
+ { kind: "group", id: "chat-2" },
38
+ ]);
39
+ });
40
+ });
package/src/directory.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
1
+ import {
2
+ listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
3
+ listDirectoryUserEntriesFromAllowFromAndMapKeys,
4
+ } from "openclaw/plugin-sdk/compat";
5
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
6
  import { resolveFeishuAccount } from "./accounts.js";
3
7
  import { createFeishuClient } from "./client.js";
4
8
  import { normalizeFeishuTarget } from "./targets.js";
@@ -15,6 +19,14 @@ export type FeishuDirectoryGroup = {
15
19
  name?: string;
16
20
  };
17
21
 
22
+ function toFeishuDirectoryPeers(ids: string[]): FeishuDirectoryPeer[] {
23
+ return ids.map((id) => ({ kind: "user", id }));
24
+ }
25
+
26
+ function toFeishuDirectoryGroups(ids: string[]): FeishuDirectoryGroup[] {
27
+ return ids.map((id) => ({ kind: "group", id }));
28
+ }
29
+
18
30
  export async function listFeishuDirectoryPeers(params: {
19
31
  cfg: ClawdbotConfig;
20
32
  query?: string;
@@ -22,31 +34,15 @@ export async function listFeishuDirectoryPeers(params: {
22
34
  accountId?: string;
23
35
  }): Promise<FeishuDirectoryPeer[]> {
24
36
  const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
25
- const feishuCfg = account.config;
26
- const q = params.query?.trim().toLowerCase() || "";
27
- const ids = new Set<string>();
28
-
29
- for (const entry of feishuCfg?.allowFrom ?? []) {
30
- const trimmed = String(entry).trim();
31
- if (trimmed && trimmed !== "*") {
32
- ids.add(trimmed);
33
- }
34
- }
35
-
36
- for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
37
- const trimmed = userId.trim();
38
- if (trimmed) {
39
- ids.add(trimmed);
40
- }
41
- }
42
-
43
- return Array.from(ids)
44
- .map((raw) => raw.trim())
45
- .filter(Boolean)
46
- .map((raw) => normalizeFeishuTarget(raw) ?? raw)
47
- .filter((id) => (q ? id.toLowerCase().includes(q) : true))
48
- .slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
49
- .map((id) => ({ kind: "user" as const, id }));
37
+ const entries = listDirectoryUserEntriesFromAllowFromAndMapKeys({
38
+ allowFrom: account.config.allowFrom,
39
+ map: account.config.dms,
40
+ query: params.query,
41
+ limit: params.limit,
42
+ normalizeAllowFromId: (entry) => normalizeFeishuTarget(entry) ?? entry,
43
+ normalizeMapKeyId: (entry) => normalizeFeishuTarget(entry) ?? entry,
44
+ });
45
+ return toFeishuDirectoryPeers(entries.map((entry) => entry.id));
50
46
  }
51
47
 
52
48
  export async function listFeishuDirectoryGroups(params: {
@@ -56,30 +52,13 @@ export async function listFeishuDirectoryGroups(params: {
56
52
  accountId?: string;
57
53
  }): Promise<FeishuDirectoryGroup[]> {
58
54
  const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
59
- const feishuCfg = account.config;
60
- const q = params.query?.trim().toLowerCase() || "";
61
- const ids = new Set<string>();
62
-
63
- for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
64
- const trimmed = groupId.trim();
65
- if (trimmed && trimmed !== "*") {
66
- ids.add(trimmed);
67
- }
68
- }
69
-
70
- for (const entry of feishuCfg?.groupAllowFrom ?? []) {
71
- const trimmed = String(entry).trim();
72
- if (trimmed && trimmed !== "*") {
73
- ids.add(trimmed);
74
- }
75
- }
76
-
77
- return Array.from(ids)
78
- .map((raw) => raw.trim())
79
- .filter(Boolean)
80
- .filter((id) => (q ? id.toLowerCase().includes(q) : true))
81
- .slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
82
- .map((id) => ({ kind: "group" as const, id }));
55
+ const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({
56
+ groups: account.config.groups,
57
+ allowFrom: account.config.groupAllowFrom,
58
+ query: params.query,
59
+ limit: params.limit,
60
+ });
61
+ return toFeishuDirectoryGroups(entries.map((entry) => entry.id));
83
62
  }
84
63
 
85
64
  export async function listFeishuDirectoryPeersLive(params: {
package/src/doc-schema.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import { Type, type Static } from "@sinclair/typebox";
2
2
 
3
+ const tableCreationProperties = {
4
+ doc_token: Type.String({ description: "Document token" }),
5
+ parent_block_id: Type.Optional(
6
+ Type.String({ description: "Parent block ID (default: document root)" }),
7
+ ),
8
+ row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
9
+ column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
10
+ column_width: Type.Optional(
11
+ Type.Array(Type.Number({ minimum: 1 }), {
12
+ description: "Column widths in px (length should match column_size)",
13
+ }),
14
+ ),
15
+ };
16
+
3
17
  export const FeishuDocSchema = Type.Union([
4
18
  Type.Object({
5
19
  action: Type.Literal("read"),
@@ -59,17 +73,7 @@ export const FeishuDocSchema = Type.Union([
59
73
  // Table creation (explicit structure)
60
74
  Type.Object({
61
75
  action: Type.Literal("create_table"),
62
- doc_token: Type.String({ description: "Document token" }),
63
- parent_block_id: Type.Optional(
64
- Type.String({ description: "Parent block ID (default: document root)" }),
65
- ),
66
- row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
67
- column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
68
- column_width: Type.Optional(
69
- Type.Array(Type.Number({ minimum: 1 }), {
70
- description: "Column widths in px (length should match column_size)",
71
- }),
72
- ),
76
+ ...tableCreationProperties,
73
77
  }),
74
78
  Type.Object({
75
79
  action: Type.Literal("write_table_cells"),
@@ -82,17 +86,7 @@ export const FeishuDocSchema = Type.Union([
82
86
  }),
83
87
  Type.Object({
84
88
  action: Type.Literal("create_table_with_values"),
85
- doc_token: Type.String({ description: "Document token" }),
86
- parent_block_id: Type.Optional(
87
- Type.String({ description: "Parent block ID (default: document root)" }),
88
- ),
89
- row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
90
- column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
91
- column_width: Type.Optional(
92
- Type.Array(Type.Number({ minimum: 1 }), {
93
- description: "Column widths in px (length should match column_size)",
94
- }),
95
- ),
89
+ ...tableCreationProperties,
96
90
  values: Type.Array(Type.Array(Type.String()), {
97
91
  description: "2D matrix values[row][col] to write into table cells",
98
92
  minItems: 1,
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js";
3
+
4
+ function createCountingIterable<T>(values: T[]) {
5
+ let iterations = 0;
6
+ return {
7
+ values: {
8
+ [Symbol.iterator]: function* () {
9
+ iterations += 1;
10
+ yield* values;
11
+ },
12
+ },
13
+ getIterations: () => iterations,
14
+ };
15
+ }
16
+
17
+ describe("insertBlocksInBatches", () => {
18
+ it("builds the source block map once for large flat trees", async () => {
19
+ const blockCount = BATCH_SIZE + 200;
20
+ const blocks = Array.from({ length: blockCount }, (_, index) => ({
21
+ block_id: `block_${index}`,
22
+ block_type: 2,
23
+ }));
24
+ const counting = createCountingIterable(blocks);
25
+ const createMock = vi.fn(async ({ data }: { data: { children_id: string[] } }) => ({
26
+ code: 0,
27
+ data: {
28
+ children: data.children_id.map((id) => ({ block_id: id })),
29
+ },
30
+ }));
31
+ const client = {
32
+ docx: {
33
+ documentBlockDescendant: {
34
+ create: createMock,
35
+ },
36
+ },
37
+ } as any;
38
+
39
+ const result = await insertBlocksInBatches(
40
+ client,
41
+ "doc_1",
42
+ counting.values as any[],
43
+ blocks.map((block) => block.block_id),
44
+ );
45
+
46
+ expect(counting.getIterations()).toBe(1);
47
+ expect(createMock).toHaveBeenCalledTimes(2);
48
+ expect(createMock.mock.calls[0]?.[0]?.data.children_id).toHaveLength(BATCH_SIZE);
49
+ expect(createMock.mock.calls[1]?.[0]?.data.children_id).toHaveLength(200);
50
+ expect(result.children).toHaveLength(blockCount);
51
+ });
52
+
53
+ it("keeps nested descendants grouped with their root blocks", async () => {
54
+ const createMock = vi.fn(
55
+ async ({
56
+ data,
57
+ }: {
58
+ data: { children_id: string[]; descendants: Array<{ block_id: string }> };
59
+ }) => ({
60
+ code: 0,
61
+ data: {
62
+ children: data.children_id.map((id) => ({ block_id: id })),
63
+ },
64
+ }),
65
+ );
66
+ const client = {
67
+ docx: {
68
+ documentBlockDescendant: {
69
+ create: createMock,
70
+ },
71
+ },
72
+ } as any;
73
+ const blocks = [
74
+ { block_id: "root_a", block_type: 1, children: ["child_a"] },
75
+ { block_id: "child_a", block_type: 2 },
76
+ { block_id: "root_b", block_type: 1, children: ["child_b"] },
77
+ { block_id: "child_b", block_type: 2 },
78
+ ];
79
+
80
+ await insertBlocksInBatches(client, "doc_1", blocks as any[], ["root_a", "root_b"]);
81
+
82
+ expect(createMock).toHaveBeenCalledTimes(1);
83
+ expect(createMock.mock.calls[0]?.[0]?.data.children_id).toEqual(["root_a", "root_b"]);
84
+ expect(
85
+ createMock.mock.calls[0]?.[0]?.data.descendants.map(
86
+ (block: { block_id: string }) => block.block_id,
87
+ ),
88
+ ).toEqual(["root_a", "child_a", "root_b", "child_b"]);
89
+ });
90
+ });
@@ -14,16 +14,11 @@ export const BATCH_SIZE = 1000; // Feishu API limit per request
14
14
  type Logger = { info?: (msg: string) => void };
15
15
 
16
16
  /**
17
- * Collect all descendant blocks for a given set of first-level block IDs.
17
+ * Collect all descendant blocks for a given first-level block ID.
18
18
  * Recursively traverses the block tree to gather all children.
19
19
  */
20
20
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
21
- function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] {
22
- const blockMap = new Map<string, any>();
23
- for (const block of blocks) {
24
- blockMap.set(block.block_id, block);
25
- }
26
-
21
+ function collectDescendants(blockMap: Map<string, any>, rootId: string): any[] {
27
22
  const result: any[] = [];
28
23
  const visited = new Set<string>();
29
24
 
@@ -47,9 +42,7 @@ function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] {
47
42
  }
48
43
  }
49
44
 
50
- for (const id of firstLevelIds) {
51
- collect(id);
52
- }
45
+ collect(rootId);
53
46
 
54
47
  return result;
55
48
  }
@@ -123,9 +116,13 @@ export async function insertBlocksInBatches(
123
116
  const batches: { firstLevelIds: string[]; blocks: any[] }[] = [];
124
117
  let currentBatch: { firstLevelIds: string[]; blocks: any[] } = { firstLevelIds: [], blocks: [] };
125
118
  const usedBlockIds = new Set<string>();
119
+ const blockMap = new Map<string, any>();
120
+ for (const block of blocks) {
121
+ blockMap.set(block.block_id, block);
122
+ }
126
123
 
127
124
  for (const firstLevelId of firstLevelBlockIds) {
128
- const descendants = collectDescendants(blocks, [firstLevelId]);
125
+ const descendants = collectDescendants(blockMap, firstLevelId);
129
126
  const newBlocks = descendants.filter((b) => !usedBlockIds.has(b.block_id));
130
127
 
131
128
  // A single block whose subtree exceeds the API limit cannot be split
@@ -1,4 +1,4 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
2
2
  import { describe, expect, test, vi } from "vitest";
3
3
  import { registerFeishuDocTools } from "./docx.js";
4
4
  import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
@@ -21,18 +21,22 @@ vi.mock("@larksuiteoapi/node-sdk", () => {
21
21
  });
22
22
 
23
23
  describe("feishu_doc account selection", () => {
24
- test("uses agentAccountId context when params omit accountId", async () => {
25
- const cfg = {
24
+ function createDocEnabledConfig(): OpenClawPluginApi["config"] {
25
+ return {
26
26
  channels: {
27
27
  feishu: {
28
28
  enabled: true,
29
29
  accounts: {
30
- a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
31
- b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
30
+ a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, // pragma: allowlist secret
31
+ b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, // pragma: allowlist secret
32
32
  },
33
33
  },
34
34
  },
35
35
  } as OpenClawPluginApi["config"];
36
+ }
37
+
38
+ test("uses agentAccountId context when params omit accountId", async () => {
39
+ const cfg = createDocEnabledConfig();
36
40
 
37
41
  const { api, resolveTool } = createToolFactoryHarness(cfg);
38
42
  registerFeishuDocTools(api);
@@ -49,17 +53,7 @@ describe("feishu_doc account selection", () => {
49
53
  });
50
54
 
51
55
  test("explicit accountId param overrides agentAccountId context", async () => {
52
- const cfg = {
53
- channels: {
54
- feishu: {
55
- enabled: true,
56
- accounts: {
57
- a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
58
- b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
59
- },
60
- },
61
- },
62
- } as OpenClawPluginApi["config"];
56
+ const cfg = createDocEnabledConfig();
63
57
 
64
58
  const { api, resolveTool } = createToolFactoryHarness(cfg);
65
59
  registerFeishuDocTools(api);