@openclaw/feishu 2026.2.12 → 2026.2.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.2.12",
3
+ "version": "2026.2.14",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -8,6 +8,9 @@
8
8
  "@sinclair/typebox": "0.34.48",
9
9
  "zod": "^4.3.6"
10
10
  },
11
+ "devDependencies": {
12
+ "openclaw": "workspace:*"
13
+ },
11
14
  "openclaw": {
12
15
  "extensions": [
13
16
  "./index.ts"
package/src/accounts.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
3
  import type {
4
4
  FeishuConfig,
5
5
  FeishuAccountConfig,
@@ -0,0 +1,265 @@
1
+ import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { FeishuMessageEvent } from "./bot.js";
4
+ import { handleFeishuMessage } from "./bot.js";
5
+ import { setFeishuRuntime } from "./runtime.js";
6
+
7
+ const { mockCreateFeishuReplyDispatcher, mockSendMessageFeishu, mockGetMessageFeishu } = vi.hoisted(
8
+ () => ({
9
+ mockCreateFeishuReplyDispatcher: vi.fn(() => ({
10
+ dispatcher: vi.fn(),
11
+ replyOptions: {},
12
+ markDispatchIdle: vi.fn(),
13
+ })),
14
+ mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
15
+ mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
16
+ }),
17
+ );
18
+
19
+ vi.mock("./reply-dispatcher.js", () => ({
20
+ createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
21
+ }));
22
+
23
+ vi.mock("./send.js", () => ({
24
+ sendMessageFeishu: mockSendMessageFeishu,
25
+ getMessageFeishu: mockGetMessageFeishu,
26
+ }));
27
+
28
+ describe("handleFeishuMessage command authorization", () => {
29
+ const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
30
+ const mockDispatchReplyFromConfig = vi
31
+ .fn()
32
+ .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
33
+ const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
34
+ const mockShouldComputeCommandAuthorized = vi.fn(() => true);
35
+ const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
36
+ const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
37
+ const mockBuildPairingReply = vi.fn(() => "Pairing response");
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ setFeishuRuntime({
42
+ system: {
43
+ enqueueSystemEvent: vi.fn(),
44
+ },
45
+ channel: {
46
+ routing: {
47
+ resolveAgentRoute: vi.fn(() => ({
48
+ agentId: "main",
49
+ accountId: "default",
50
+ sessionKey: "agent:main:feishu:dm:ou-attacker",
51
+ matchedBy: "default",
52
+ })),
53
+ },
54
+ reply: {
55
+ resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
56
+ formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
57
+ finalizeInboundContext: mockFinalizeInboundContext,
58
+ dispatchReplyFromConfig: mockDispatchReplyFromConfig,
59
+ },
60
+ commands: {
61
+ shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
62
+ resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
63
+ },
64
+ pairing: {
65
+ readAllowFromStore: mockReadAllowFromStore,
66
+ upsertPairingRequest: mockUpsertPairingRequest,
67
+ buildPairingReply: mockBuildPairingReply,
68
+ },
69
+ },
70
+ } as unknown as PluginRuntime);
71
+ });
72
+
73
+ it("uses authorizer resolution instead of hardcoded CommandAuthorized=true", async () => {
74
+ const cfg: ClawdbotConfig = {
75
+ commands: { useAccessGroups: true },
76
+ channels: {
77
+ feishu: {
78
+ dmPolicy: "open",
79
+ allowFrom: ["ou-admin"],
80
+ },
81
+ },
82
+ } as ClawdbotConfig;
83
+
84
+ const event: FeishuMessageEvent = {
85
+ sender: {
86
+ sender_id: {
87
+ open_id: "ou-attacker",
88
+ },
89
+ },
90
+ message: {
91
+ message_id: "msg-auth-bypass-regression",
92
+ chat_id: "oc-dm",
93
+ chat_type: "p2p",
94
+ message_type: "text",
95
+ content: JSON.stringify({ text: "/status" }),
96
+ },
97
+ };
98
+
99
+ await handleFeishuMessage({
100
+ cfg,
101
+ event,
102
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
103
+ });
104
+
105
+ expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
106
+ useAccessGroups: true,
107
+ authorizers: [{ configured: true, allowed: false }],
108
+ });
109
+ expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
110
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ CommandAuthorized: false,
113
+ SenderId: "ou-attacker",
114
+ Surface: "feishu",
115
+ }),
116
+ );
117
+ });
118
+
119
+ it("reads pairing allow store for non-command DMs when dmPolicy is pairing", async () => {
120
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
121
+ mockReadAllowFromStore.mockResolvedValue(["ou-attacker"]);
122
+
123
+ const cfg: ClawdbotConfig = {
124
+ commands: { useAccessGroups: true },
125
+ channels: {
126
+ feishu: {
127
+ dmPolicy: "pairing",
128
+ allowFrom: [],
129
+ },
130
+ },
131
+ } as ClawdbotConfig;
132
+
133
+ const event: FeishuMessageEvent = {
134
+ sender: {
135
+ sender_id: {
136
+ open_id: "ou-attacker",
137
+ },
138
+ },
139
+ message: {
140
+ message_id: "msg-read-store-non-command",
141
+ chat_id: "oc-dm",
142
+ chat_type: "p2p",
143
+ message_type: "text",
144
+ content: JSON.stringify({ text: "hello there" }),
145
+ },
146
+ };
147
+
148
+ await handleFeishuMessage({
149
+ cfg,
150
+ event,
151
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
152
+ });
153
+
154
+ expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu");
155
+ expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
156
+ expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
157
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
161
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
162
+ mockReadAllowFromStore.mockResolvedValue([]);
163
+ mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
164
+
165
+ const cfg: ClawdbotConfig = {
166
+ channels: {
167
+ feishu: {
168
+ dmPolicy: "pairing",
169
+ allowFrom: [],
170
+ },
171
+ },
172
+ } as ClawdbotConfig;
173
+
174
+ const event: FeishuMessageEvent = {
175
+ sender: {
176
+ sender_id: {
177
+ open_id: "ou-unapproved",
178
+ },
179
+ },
180
+ message: {
181
+ message_id: "msg-pairing-flow",
182
+ chat_id: "oc-dm",
183
+ chat_type: "p2p",
184
+ message_type: "text",
185
+ content: JSON.stringify({ text: "hello" }),
186
+ },
187
+ };
188
+
189
+ await handleFeishuMessage({
190
+ cfg,
191
+ event,
192
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
193
+ });
194
+
195
+ expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
196
+ channel: "feishu",
197
+ id: "ou-unapproved",
198
+ meta: { name: undefined },
199
+ });
200
+ expect(mockBuildPairingReply).toHaveBeenCalledWith({
201
+ channel: "feishu",
202
+ idLine: "Your Feishu user id: ou-unapproved",
203
+ code: "ABCDEFGH",
204
+ });
205
+ expect(mockSendMessageFeishu).toHaveBeenCalledWith(
206
+ expect.objectContaining({
207
+ to: "user:ou-unapproved",
208
+ accountId: "default",
209
+ }),
210
+ );
211
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
212
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it("computes group command authorization from group allowFrom", async () => {
216
+ mockShouldComputeCommandAuthorized.mockReturnValue(true);
217
+ mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
218
+
219
+ const cfg: ClawdbotConfig = {
220
+ commands: { useAccessGroups: true },
221
+ channels: {
222
+ feishu: {
223
+ groups: {
224
+ "oc-group": {
225
+ requireMention: false,
226
+ },
227
+ },
228
+ },
229
+ },
230
+ } as ClawdbotConfig;
231
+
232
+ const event: FeishuMessageEvent = {
233
+ sender: {
234
+ sender_id: {
235
+ open_id: "ou-attacker",
236
+ },
237
+ },
238
+ message: {
239
+ message_id: "msg-group-command-auth",
240
+ chat_id: "oc-group",
241
+ chat_type: "group",
242
+ message_type: "text",
243
+ content: JSON.stringify({ text: "/status" }),
244
+ },
245
+ };
246
+
247
+ await handleFeishuMessage({
248
+ cfg,
249
+ event,
250
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
251
+ });
252
+
253
+ expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
254
+ useAccessGroups: true,
255
+ authorizers: [{ configured: false, allowed: false }],
256
+ });
257
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
258
+ expect.objectContaining({
259
+ ChatType: "group",
260
+ CommandAuthorized: false,
261
+ SenderId: "ou-attacker",
262
+ }),
263
+ );
264
+ });
265
+ });
package/src/bot.ts CHANGED
@@ -10,6 +10,7 @@ import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } fro
10
10
  import type { DynamicAgentCreationConfig } from "./types.js";
11
11
  import { resolveFeishuAccount } from "./accounts.js";
12
12
  import { createFeishuClient } from "./client.js";
13
+ import { tryRecordMessage } from "./dedup.js";
13
14
  import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
14
15
  import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
15
16
  import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
@@ -21,38 +22,7 @@ import {
21
22
  } from "./policy.js";
22
23
  import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
23
24
  import { getFeishuRuntime } from "./runtime.js";
24
- import { getMessageFeishu } from "./send.js";
25
-
26
- // --- Message deduplication ---
27
- // Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
28
- const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
29
- const DEDUP_MAX_SIZE = 1_000;
30
- const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
31
- const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
32
- let lastCleanupTime = Date.now();
33
-
34
- function tryRecordMessage(messageId: string): boolean {
35
- const now = Date.now();
36
-
37
- // Throttled cleanup: evict expired entries at most once per interval
38
- if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
39
- for (const [id, ts] of processedMessageIds) {
40
- if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
41
- }
42
- lastCleanupTime = now;
43
- }
44
-
45
- if (processedMessageIds.has(messageId)) return false;
46
-
47
- // Evict oldest entries if cache is full
48
- if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
49
- const first = processedMessageIds.keys().next().value!;
50
- processedMessageIds.delete(first);
51
- }
52
-
53
- processedMessageIds.set(messageId, now);
54
- return true;
55
- }
25
+ import { getMessageFeishu, sendMessageFeishu } from "./send.js";
56
26
 
57
27
  // --- Permission error extraction ---
58
28
  // Extract permission grant URL from Feishu API error response.
@@ -581,12 +551,17 @@ export async function handleFeishuMessage(params: {
581
551
  0,
582
552
  feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
583
553
  );
554
+ const groupConfig = isGroup
555
+ ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
556
+ : undefined;
557
+ const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
558
+ const configAllowFrom = feishuCfg?.allowFrom ?? [];
559
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
584
560
 
585
561
  if (isGroup) {
586
562
  const groupPolicy = feishuCfg?.groupPolicy ?? "open";
587
563
  const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
588
564
  // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
589
- const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
590
565
 
591
566
  // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
592
567
  const groupAllowed = isFeishuGroupAllowed({
@@ -642,23 +617,73 @@ export async function handleFeishuMessage(params: {
642
617
  return;
643
618
  }
644
619
  } else {
645
- const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
646
- const allowFrom = feishuCfg?.allowFrom ?? [];
647
-
648
- if (dmPolicy === "allowlist") {
649
- const match = resolveFeishuAllowlistMatch({
650
- allowFrom,
651
- senderId: ctx.senderOpenId,
652
- });
653
- if (!match.allowed) {
654
- log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`);
655
- return;
656
- }
657
- }
658
620
  }
659
621
 
660
622
  try {
661
623
  const core = getFeishuRuntime();
624
+ const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
625
+ ctx.content,
626
+ cfg,
627
+ );
628
+ const storeAllowFrom =
629
+ !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
630
+ ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
631
+ : [];
632
+ const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
633
+ const dmAllowed = resolveFeishuAllowlistMatch({
634
+ allowFrom: effectiveDmAllowFrom,
635
+ senderId: ctx.senderOpenId,
636
+ senderName: ctx.senderName,
637
+ }).allowed;
638
+
639
+ if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
640
+ if (dmPolicy === "pairing") {
641
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
642
+ channel: "feishu",
643
+ id: ctx.senderOpenId,
644
+ meta: { name: ctx.senderName },
645
+ });
646
+ if (created) {
647
+ log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
648
+ try {
649
+ await sendMessageFeishu({
650
+ cfg,
651
+ to: `user:${ctx.senderOpenId}`,
652
+ text: core.channel.pairing.buildPairingReply({
653
+ channel: "feishu",
654
+ idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
655
+ code,
656
+ }),
657
+ accountId: account.accountId,
658
+ });
659
+ } catch (err) {
660
+ log(
661
+ `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
662
+ );
663
+ }
664
+ }
665
+ } else {
666
+ log(
667
+ `feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
668
+ );
669
+ }
670
+ return;
671
+ }
672
+
673
+ const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom;
674
+ const senderAllowedForCommands = resolveFeishuAllowlistMatch({
675
+ allowFrom: commandAllowFrom,
676
+ senderId: ctx.senderOpenId,
677
+ senderName: ctx.senderName,
678
+ }).allowed;
679
+ const commandAuthorized = shouldComputeCommandAuthorized
680
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
681
+ useAccessGroups,
682
+ authorizers: [
683
+ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
684
+ ],
685
+ })
686
+ : undefined;
662
687
 
663
688
  // In group chats, the session is scoped to the group, but the *speaker* is the sender.
664
689
  // Using a group-scoped From causes the agent to treat different users as the same person.
@@ -815,7 +840,7 @@ export async function handleFeishuMessage(params: {
815
840
  MessageSid: `${ctx.messageId}:permission-error`,
816
841
  Timestamp: Date.now(),
817
842
  WasMentioned: false,
818
- CommandAuthorized: true,
843
+ CommandAuthorized: commandAuthorized,
819
844
  OriginatingChannel: "feishu" as const,
820
845
  OriginatingTo: feishuTo,
821
846
  });
@@ -903,7 +928,7 @@ export async function handleFeishuMessage(params: {
903
928
  ReplyToBody: quotedContent ?? undefined,
904
929
  Timestamp: Date.now(),
905
930
  WasMentioned: ctx.mentionedBot,
906
- CommandAuthorized: true,
931
+ CommandAuthorized: commandAuthorized,
907
932
  OriginatingChannel: "feishu" as const,
908
933
  OriginatingTo: feishuTo,
909
934
  ...mediaPayload,
@@ -36,6 +36,10 @@ const MarkdownConfigSchema = z
36
36
  // Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
37
37
  const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
38
38
 
39
+ // Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API
40
+ // for incremental text display with a "Thinking..." placeholder
41
+ const StreamingModeSchema = z.boolean().optional();
42
+
39
43
  const BlockStreamingCoalesceSchema = z
40
44
  .object({
41
45
  enabled: z.boolean().optional(),
@@ -142,6 +146,7 @@ export const FeishuAccountConfigSchema = z
142
146
  mediaMaxMb: z.number().positive().optional(),
143
147
  heartbeat: ChannelHeartbeatVisibilitySchema,
144
148
  renderMode: RenderModeSchema,
149
+ streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
145
150
  tools: FeishuToolsConfigSchema,
146
151
  })
147
152
  .strict();
@@ -177,6 +182,7 @@ export const FeishuConfigSchema = z
177
182
  mediaMaxMb: z.number().positive().optional(),
178
183
  heartbeat: ChannelHeartbeatVisibilitySchema,
179
184
  renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
185
+ streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
180
186
  tools: FeishuToolsConfigSchema,
181
187
  // Dynamic agent creation for DM users
182
188
  dynamicAgentCreation: DynamicAgentCreationSchema,
package/src/dedup.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
2
+ const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
3
+ const DEDUP_MAX_SIZE = 1_000;
4
+ const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
5
+ const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
6
+ let lastCleanupTime = Date.now();
7
+
8
+ export function tryRecordMessage(messageId: string): boolean {
9
+ const now = Date.now();
10
+
11
+ // Throttled cleanup: evict expired entries at most once per interval.
12
+ if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
13
+ for (const [id, ts] of processedMessageIds) {
14
+ if (now - ts > DEDUP_TTL_MS) {
15
+ processedMessageIds.delete(id);
16
+ }
17
+ }
18
+ lastCleanupTime = now;
19
+ }
20
+
21
+ if (processedMessageIds.has(messageId)) {
22
+ return false;
23
+ }
24
+
25
+ // Evict oldest entries if cache is full.
26
+ if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
27
+ const first = processedMessageIds.keys().next().value!;
28
+ processedMessageIds.delete(first);
29
+ }
30
+
31
+ processedMessageIds.set(messageId, now);
32
+ return true;
33
+ }
@@ -0,0 +1,123 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
4
+ const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
5
+
6
+ vi.mock("./client.js", () => ({
7
+ createFeishuClient: createFeishuClientMock,
8
+ }));
9
+
10
+ vi.mock("./runtime.js", () => ({
11
+ getFeishuRuntime: () => ({
12
+ channel: {
13
+ media: {
14
+ fetchRemoteMedia: fetchRemoteMediaMock,
15
+ },
16
+ },
17
+ }),
18
+ }));
19
+
20
+ import { registerFeishuDocTools } from "./docx.js";
21
+
22
+ describe("feishu_doc image fetch hardening", () => {
23
+ const convertMock = vi.hoisted(() => vi.fn());
24
+ const blockListMock = vi.hoisted(() => vi.fn());
25
+ const blockChildrenCreateMock = vi.hoisted(() => vi.fn());
26
+ const driveUploadAllMock = vi.hoisted(() => vi.fn());
27
+ const blockPatchMock = vi.hoisted(() => vi.fn());
28
+ const scopeListMock = vi.hoisted(() => vi.fn());
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+
33
+ createFeishuClientMock.mockReturnValue({
34
+ docx: {
35
+ document: {
36
+ convert: convertMock,
37
+ },
38
+ documentBlock: {
39
+ list: blockListMock,
40
+ patch: blockPatchMock,
41
+ },
42
+ documentBlockChildren: {
43
+ create: blockChildrenCreateMock,
44
+ },
45
+ },
46
+ drive: {
47
+ media: {
48
+ uploadAll: driveUploadAllMock,
49
+ },
50
+ },
51
+ application: {
52
+ scope: {
53
+ list: scopeListMock,
54
+ },
55
+ },
56
+ });
57
+
58
+ convertMock.mockResolvedValue({
59
+ code: 0,
60
+ data: {
61
+ blocks: [{ block_type: 27 }],
62
+ first_level_block_ids: [],
63
+ },
64
+ });
65
+
66
+ blockListMock.mockResolvedValue({
67
+ code: 0,
68
+ data: {
69
+ items: [],
70
+ },
71
+ });
72
+
73
+ blockChildrenCreateMock.mockResolvedValue({
74
+ code: 0,
75
+ data: {
76
+ children: [{ block_type: 27, block_id: "img_block_1" }],
77
+ },
78
+ });
79
+
80
+ driveUploadAllMock.mockResolvedValue({ file_token: "token_1" });
81
+ blockPatchMock.mockResolvedValue({ code: 0 });
82
+ scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } });
83
+ });
84
+
85
+ it("skips image upload when markdown image URL is blocked", async () => {
86
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
87
+ fetchRemoteMediaMock.mockRejectedValueOnce(
88
+ new Error("Blocked: resolves to private/internal IP address"),
89
+ );
90
+
91
+ const registerTool = vi.fn();
92
+ registerFeishuDocTools({
93
+ config: {
94
+ channels: {
95
+ feishu: {
96
+ appId: "app_id",
97
+ appSecret: "app_secret",
98
+ },
99
+ },
100
+ } as any,
101
+ logger: { debug: vi.fn(), info: vi.fn() } as any,
102
+ registerTool,
103
+ } as any);
104
+
105
+ const feishuDocTool = registerTool.mock.calls
106
+ .map((call) => call[0])
107
+ .find((tool) => tool.name === "feishu_doc");
108
+ expect(feishuDocTool).toBeDefined();
109
+
110
+ const result = await feishuDocTool.execute("tool-call", {
111
+ action: "write",
112
+ doc_token: "doc_1",
113
+ content: "![x](https://x.test/image.png)",
114
+ });
115
+
116
+ expect(fetchRemoteMediaMock).toHaveBeenCalled();
117
+ expect(driveUploadAllMock).not.toHaveBeenCalled();
118
+ expect(blockPatchMock).not.toHaveBeenCalled();
119
+ expect(result.details.images_processed).toBe(0);
120
+ expect(consoleErrorSpy).toHaveBeenCalled();
121
+ consoleErrorSpy.mockRestore();
122
+ });
123
+ });