@openclaw/feishu 2026.3.2 → 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 (70) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +199 -13
  4. package/src/accounts.ts +45 -17
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +8 -0
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +516 -9
  9. package/src/bot.ts +366 -109
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +52 -64
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +207 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +14 -6
  18. package/src/config-schema.ts +5 -1
  19. package/src/dedup.ts +1 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/docx-batch-insert.test.ts +90 -0
  23. package/src/docx-batch-insert.ts +8 -11
  24. package/src/docx.account-selection.test.ts +3 -3
  25. package/src/docx.ts +1 -1
  26. package/src/drive.ts +13 -17
  27. package/src/dynamic-agent.ts +1 -1
  28. package/src/feishu-command-handler.ts +59 -0
  29. package/src/media.test.ts +60 -13
  30. package/src/media.ts +23 -9
  31. package/src/monitor.account.ts +19 -8
  32. package/src/monitor.reaction.test.ts +111 -105
  33. package/src/monitor.startup.test.ts +11 -10
  34. package/src/monitor.startup.ts +20 -7
  35. package/src/monitor.state.ts +4 -1
  36. package/src/monitor.test-mocks.ts +42 -9
  37. package/src/monitor.transport.ts +4 -1
  38. package/src/monitor.ts +4 -4
  39. package/src/monitor.webhook-security.test.ts +8 -23
  40. package/src/onboarding.status.test.ts +1 -1
  41. package/src/onboarding.test.ts +143 -0
  42. package/src/onboarding.ts +86 -71
  43. package/src/outbound.test.ts +178 -0
  44. package/src/outbound.ts +39 -6
  45. package/src/perm.ts +11 -15
  46. package/src/policy.test.ts +40 -0
  47. package/src/policy.ts +9 -10
  48. package/src/probe.test.ts +18 -18
  49. package/src/reactions.ts +1 -1
  50. package/src/reply-dispatcher.test.ts +175 -0
  51. package/src/reply-dispatcher.ts +69 -21
  52. package/src/runtime.ts +1 -1
  53. package/src/secret-input.ts +8 -14
  54. package/src/send-message.ts +71 -0
  55. package/src/send-target.test.ts +1 -1
  56. package/src/send-target.ts +1 -1
  57. package/src/send.reply-fallback.test.ts +74 -0
  58. package/src/send.test.ts +1 -1
  59. package/src/send.ts +88 -49
  60. package/src/streaming-card.test.ts +54 -0
  61. package/src/streaming-card.ts +96 -28
  62. package/src/targets.ts +5 -1
  63. package/src/tool-account-routing.test.ts +3 -3
  64. package/src/tool-account.ts +1 -1
  65. package/src/tool-factory-test-harness.ts +1 -1
  66. package/src/tool-result.test.ts +32 -0
  67. package/src/tool-result.ts +14 -0
  68. package/src/types.ts +2 -3
  69. package/src/typing.ts +1 -1
  70. package/src/wiki.ts +15 -19
package/src/dedup.ts CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  createDedupeCache,
5
5
  createPersistentDedupe,
6
6
  readJsonFileWithFallback,
7
- } from "openclaw/plugin-sdk";
7
+ } from "openclaw/plugin-sdk/feishu";
8
8
 
9
9
  // Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
10
10
  const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
@@ -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: {
@@ -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";
@@ -27,8 +27,8 @@ describe("feishu_doc account selection", () => {
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
  },
package/src/docx.ts CHANGED
@@ -4,7 +4,7 @@ import { isAbsolute } from "node:path";
4
4
  import { basename } from "node:path";
5
5
  import type * as Lark from "@larksuiteoapi/node-sdk";
6
6
  import { Type } from "@sinclair/typebox";
7
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
7
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
8
8
  import { listEnabledFeishuAccounts } from "./accounts.js";
9
9
  import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
10
10
  import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js";
package/src/drive.ts CHANGED
@@ -1,17 +1,13 @@
1
1
  import type * as Lark from "@larksuiteoapi/node-sdk";
2
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
3
3
  import { listEnabledFeishuAccounts } from "./accounts.js";
4
4
  import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
5
5
  import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
6
-
7
- // ============ Helpers ============
8
-
9
- function json(data: unknown) {
10
- return {
11
- content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
12
- details: data,
13
- };
14
- }
6
+ import {
7
+ jsonToolResult,
8
+ toolExecutionErrorResult,
9
+ unknownToolActionResult,
10
+ } from "./tool-result.js";
15
11
 
16
12
  // ============ Actions ============
17
13
 
@@ -206,21 +202,21 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
206
202
  });
207
203
  switch (p.action) {
208
204
  case "list":
209
- return json(await listFolder(client, p.folder_token));
205
+ return jsonToolResult(await listFolder(client, p.folder_token));
210
206
  case "info":
211
- return json(await getFileInfo(client, p.file_token));
207
+ return jsonToolResult(await getFileInfo(client, p.file_token));
212
208
  case "create_folder":
213
- return json(await createFolder(client, p.name, p.folder_token));
209
+ return jsonToolResult(await createFolder(client, p.name, p.folder_token));
214
210
  case "move":
215
- return json(await moveFile(client, p.file_token, p.type, p.folder_token));
211
+ return jsonToolResult(await moveFile(client, p.file_token, p.type, p.folder_token));
216
212
  case "delete":
217
- return json(await deleteFile(client, p.file_token, p.type));
213
+ return jsonToolResult(await deleteFile(client, p.file_token, p.type));
218
214
  default:
219
215
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
220
- return json({ error: `Unknown action: ${(p as any).action}` });
216
+ return unknownToolActionResult((p as { action?: unknown }).action);
221
217
  }
222
218
  } catch (err) {
223
- return json({ error: err instanceof Error ? err.message : String(err) });
219
+ return toolExecutionErrorResult(err);
224
220
  }
225
221
  },
226
222
  };
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
4
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/feishu";
5
5
  import type { DynamicAgentCreationConfig } from "./types.js";
6
6
 
7
7
  export type MaybeCreateDynamicAgentResult = {
@@ -0,0 +1,59 @@
1
+ const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"] as const;
2
+
3
+ type FeishuBeforeResetContext = {
4
+ cfg: Record<string, unknown>;
5
+ sessionEntry: Record<string, unknown>;
6
+ previousSessionEntry?: Record<string, unknown>;
7
+ commandSource: string;
8
+ timestamp: number;
9
+ };
10
+
11
+ type FeishuBeforeResetEvent = {
12
+ type: "command";
13
+ action: "new" | "reset";
14
+ context: FeishuBeforeResetContext;
15
+ };
16
+
17
+ type FeishuBeforeResetRunner = {
18
+ runBeforeReset: (
19
+ event: FeishuBeforeResetEvent,
20
+ ctx: { agentId: string; sessionKey: string },
21
+ ) => Promise<void>;
22
+ };
23
+
24
+ /**
25
+ * Handle Feishu command messages and trigger reset hooks.
26
+ */
27
+ export async function handleFeishuCommand(
28
+ messageText: string,
29
+ sessionKey: string,
30
+ hookRunner: FeishuBeforeResetRunner,
31
+ context: FeishuBeforeResetContext,
32
+ ): Promise<boolean> {
33
+ const trimmed = messageText.trim().toLowerCase();
34
+ const isResetCommand = DEFAULT_RESET_TRIGGERS.some(
35
+ (trigger) => trimmed === trigger || trimmed.startsWith(`${trigger} `),
36
+ );
37
+ if (!isResetCommand) {
38
+ return false;
39
+ }
40
+
41
+ const command = trimmed.split(" ")[0];
42
+ const action: "new" | "reset" = command === "/new" ? "new" : "reset";
43
+ await hookRunner.runBeforeReset(
44
+ {
45
+ type: "command",
46
+ action,
47
+ context: {
48
+ ...context,
49
+ commandSource: "feishu",
50
+ },
51
+ },
52
+ {
53
+ agentId: "main",
54
+ sessionKey,
55
+ },
56
+ );
57
+
58
+ return true;
59
+ }
package/src/media.test.ts CHANGED
@@ -10,11 +10,14 @@ const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
10
10
  const loadWebMediaMock = vi.hoisted(() => vi.fn());
11
11
 
12
12
  const fileCreateMock = vi.hoisted(() => vi.fn());
13
+ const imageCreateMock = vi.hoisted(() => vi.fn());
13
14
  const imageGetMock = vi.hoisted(() => vi.fn());
14
15
  const messageCreateMock = vi.hoisted(() => vi.fn());
15
16
  const messageResourceGetMock = vi.hoisted(() => vi.fn());
16
17
  const messageReplyMock = vi.hoisted(() => vi.fn());
17
18
 
19
+ const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
20
+
18
21
  vi.mock("./client.js", () => ({
19
22
  createFeishuClient: createFeishuClientMock,
20
23
  }));
@@ -53,6 +56,14 @@ function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
53
56
  expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
54
57
  }
55
58
 
59
+ function expectMediaTimeoutClientConfigured(): void {
60
+ expect(createFeishuClientMock).toHaveBeenCalledWith(
61
+ expect.objectContaining({
62
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
63
+ }),
64
+ );
65
+ }
66
+
56
67
  describe("sendMediaFeishu msg_type routing", () => {
57
68
  beforeEach(() => {
58
69
  vi.clearAllMocks();
@@ -75,6 +86,7 @@ describe("sendMediaFeishu msg_type routing", () => {
75
86
  create: fileCreateMock,
76
87
  },
77
88
  image: {
89
+ create: imageCreateMock,
78
90
  get: imageGetMock,
79
91
  },
80
92
  message: {
@@ -91,6 +103,10 @@ describe("sendMediaFeishu msg_type routing", () => {
91
103
  code: 0,
92
104
  data: { file_key: "file_key_1" },
93
105
  });
106
+ imageCreateMock.mockResolvedValue({
107
+ code: 0,
108
+ data: { image_key: "image_key_1" },
109
+ });
94
110
 
95
111
  messageCreateMock.mockResolvedValue({
96
112
  code: 0,
@@ -113,7 +129,7 @@ describe("sendMediaFeishu msg_type routing", () => {
113
129
  messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
114
130
  });
115
131
 
116
- it("uses msg_type=file for mp4", async () => {
132
+ it("uses msg_type=media for mp4 video", async () => {
117
133
  await sendMediaFeishu({
118
134
  cfg: {} as any,
119
135
  to: "user:ou_target",
@@ -129,7 +145,7 @@ describe("sendMediaFeishu msg_type routing", () => {
129
145
 
130
146
  expect(messageCreateMock).toHaveBeenCalledWith(
131
147
  expect.objectContaining({
132
- data: expect.objectContaining({ msg_type: "file" }),
148
+ data: expect.objectContaining({ msg_type: "media" }),
133
149
  }),
134
150
  );
135
151
  });
@@ -176,7 +192,23 @@ describe("sendMediaFeishu msg_type routing", () => {
176
192
  );
177
193
  });
178
194
 
179
- it("uses msg_type=file when replying with mp4", async () => {
195
+ it("configures the media client timeout for image uploads", async () => {
196
+ await sendMediaFeishu({
197
+ cfg: {} as any,
198
+ to: "user:ou_target",
199
+ mediaBuffer: Buffer.from("image"),
200
+ fileName: "photo.png",
201
+ });
202
+
203
+ expectMediaTimeoutClientConfigured();
204
+ expect(messageCreateMock).toHaveBeenCalledWith(
205
+ expect.objectContaining({
206
+ data: expect.objectContaining({ msg_type: "image" }),
207
+ }),
208
+ );
209
+ });
210
+
211
+ it("uses msg_type=media when replying with mp4", async () => {
180
212
  await sendMediaFeishu({
181
213
  cfg: {} as any,
182
214
  to: "user:ou_target",
@@ -188,7 +220,7 @@ describe("sendMediaFeishu msg_type routing", () => {
188
220
  expect(messageReplyMock).toHaveBeenCalledWith(
189
221
  expect.objectContaining({
190
222
  path: { message_id: "om_parent" },
191
- data: expect.objectContaining({ msg_type: "file" }),
223
+ data: expect.objectContaining({ msg_type: "media" }),
192
224
  }),
193
225
  );
194
226
 
@@ -208,7 +240,10 @@ describe("sendMediaFeishu msg_type routing", () => {
208
240
  expect(messageReplyMock).toHaveBeenCalledWith(
209
241
  expect.objectContaining({
210
242
  path: { message_id: "om_parent" },
211
- data: expect.objectContaining({ msg_type: "file", reply_in_thread: true }),
243
+ data: expect.objectContaining({
244
+ msg_type: "media",
245
+ reply_in_thread: true,
246
+ }),
212
247
  }),
213
248
  );
214
249
  });
@@ -288,6 +323,12 @@ describe("sendMediaFeishu msg_type routing", () => {
288
323
  imageKey,
289
324
  });
290
325
 
326
+ expect(imageGetMock).toHaveBeenCalledWith(
327
+ expect.objectContaining({
328
+ path: { image_key: imageKey },
329
+ }),
330
+ );
331
+ expectMediaTimeoutClientConfigured();
291
332
  expect(result.buffer).toEqual(Buffer.from("image-data"));
292
333
  expect(capturedPath).toBeDefined();
293
334
  expectPathIsolatedToTmpRoot(capturedPath as string, imageKey);
@@ -473,10 +514,13 @@ describe("downloadMessageResourceFeishu", () => {
473
514
  type: "file",
474
515
  });
475
516
 
476
- expect(messageResourceGetMock).toHaveBeenCalledWith({
477
- path: { message_id: "om_audio_msg", file_key: "file_key_audio" },
478
- params: { type: "file" },
479
- });
517
+ expect(messageResourceGetMock).toHaveBeenCalledWith(
518
+ expect.objectContaining({
519
+ path: { message_id: "om_audio_msg", file_key: "file_key_audio" },
520
+ params: { type: "file" },
521
+ }),
522
+ );
523
+ expectMediaTimeoutClientConfigured();
480
524
  expect(result.buffer).toBeInstanceOf(Buffer);
481
525
  });
482
526
 
@@ -490,10 +534,13 @@ describe("downloadMessageResourceFeishu", () => {
490
534
  type: "image",
491
535
  });
492
536
 
493
- expect(messageResourceGetMock).toHaveBeenCalledWith({
494
- path: { message_id: "om_img_msg", file_key: "img_key_1" },
495
- params: { type: "image" },
496
- });
537
+ expect(messageResourceGetMock).toHaveBeenCalledWith(
538
+ expect.objectContaining({
539
+ path: { message_id: "om_img_msg", file_key: "img_key_1" },
540
+ params: { type: "image" },
541
+ }),
542
+ );
543
+ expectMediaTimeoutClientConfigured();
497
544
  expect(result.buffer).toBeInstanceOf(Buffer);
498
545
  });
499
546
  });