@openclaw/feishu 2026.3.12 → 2026.3.13

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.3.12",
3
+ "version": "2026.3.13",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -9,6 +9,23 @@ import type { FeishuConfig } from "./types.js";
9
9
 
10
10
  const asConfig = (value: Partial<FeishuConfig>) => value as FeishuConfig;
11
11
 
12
+ function makeDefaultAndRouterAccounts() {
13
+ return {
14
+ default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
15
+ "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
16
+ };
17
+ }
18
+
19
+ function expectExplicitDefaultAccountSelection(
20
+ account: ReturnType<typeof resolveFeishuAccount>,
21
+ appId: string,
22
+ ) {
23
+ expect(account.accountId).toBe("router-d");
24
+ expect(account.selectionSource).toBe("explicit-default");
25
+ expect(account.configured).toBe(true);
26
+ expect(account.appId).toBe(appId);
27
+ }
28
+
12
29
  function withEnvVar(key: string, value: string | undefined, run: () => void) {
13
30
  const prev = process.env[key];
14
31
  if (value === undefined) {
@@ -44,10 +61,7 @@ describe("resolveDefaultFeishuAccountId", () => {
44
61
  channels: {
45
62
  feishu: {
46
63
  defaultAccount: "router-d",
47
- accounts: {
48
- default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
49
- "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
50
- },
64
+ accounts: makeDefaultAndRouterAccounts(),
51
65
  },
52
66
  },
53
67
  };
@@ -278,10 +292,7 @@ describe("resolveFeishuAccount", () => {
278
292
  };
279
293
 
280
294
  const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
281
- expect(account.accountId).toBe("router-d");
282
- expect(account.selectionSource).toBe("explicit-default");
283
- expect(account.configured).toBe(true);
284
- expect(account.appId).toBe("top_level_app");
295
+ expectExplicitDefaultAccountSelection(account, "top_level_app");
285
296
  });
286
297
 
287
298
  it("uses configured default account when accountId is omitted", () => {
@@ -298,10 +309,7 @@ describe("resolveFeishuAccount", () => {
298
309
  };
299
310
 
300
311
  const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
301
- expect(account.accountId).toBe("router-d");
302
- expect(account.selectionSource).toBe("explicit-default");
303
- expect(account.configured).toBe(true);
304
- expect(account.appId).toBe("cli_router");
312
+ expectExplicitDefaultAccountSelection(account, "cli_router");
305
313
  });
306
314
 
307
315
  it("keeps explicit accountId selection", () => {
@@ -309,10 +317,7 @@ describe("resolveFeishuAccount", () => {
309
317
  channels: {
310
318
  feishu: {
311
319
  defaultAccount: "router-d",
312
- accounts: {
313
- default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
314
- "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
315
- },
320
+ accounts: makeDefaultAndRouterAccounts(),
316
321
  },
317
322
  },
318
323
  };
package/src/bot.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  } from "openclaw/plugin-sdk/feishu";
16
16
  import { resolveFeishuAccount } from "./accounts.js";
17
17
  import { createFeishuClient } from "./client.js";
18
- import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
18
+ import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
19
19
  import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
20
20
  import { normalizeFeishuExternalKey } from "./external-keys.js";
21
21
  import { downloadMessageResourceFeishu } from "./media.js";
@@ -867,8 +867,18 @@ export async function handleFeishuMessage(params: {
867
867
  runtime?: RuntimeEnv;
868
868
  chatHistories?: Map<string, HistoryEntry[]>;
869
869
  accountId?: string;
870
+ processingClaimHeld?: boolean;
870
871
  }): Promise<void> {
871
- const { cfg, event, botOpenId, botName, runtime, chatHistories, accountId } = params;
872
+ const {
873
+ cfg,
874
+ event,
875
+ botOpenId,
876
+ botName,
877
+ runtime,
878
+ chatHistories,
879
+ accountId,
880
+ processingClaimHeld = false,
881
+ } = params;
872
882
 
873
883
  // Resolve account with merged config
874
884
  const account = resolveFeishuAccount({ cfg, accountId });
@@ -877,16 +887,15 @@ export async function handleFeishuMessage(params: {
877
887
  const log = runtime?.log ?? console.log;
878
888
  const error = runtime?.error ?? console.error;
879
889
 
880
- // Dedup: synchronous memory guard prevents concurrent duplicate dispatch
881
- // before the async persistent check completes.
882
890
  const messageId = event.message.message_id;
883
- const memoryDedupeKey = `${account.accountId}:${messageId}`;
884
- if (!tryRecordMessage(memoryDedupeKey)) {
885
- log(`feishu: skipping duplicate message ${messageId} (memory dedup)`);
886
- return;
887
- }
888
- // Persistent dedup survives restarts and reconnects.
889
- if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
891
+ if (
892
+ !(await finalizeFeishuMessageProcessing({
893
+ messageId,
894
+ namespace: account.accountId,
895
+ log,
896
+ claimHeld: processingClaimHeld,
897
+ }))
898
+ ) {
890
899
  log(`feishu: skipping duplicate message ${messageId}`);
891
900
  return;
892
901
  }
@@ -1,6 +1,16 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { FeishuConfigSchema, FeishuGroupSchema } from "./config-schema.js";
3
3
 
4
+ function expectSchemaIssue(
5
+ result: ReturnType<typeof FeishuConfigSchema.safeParse>,
6
+ issuePath: string,
7
+ ) {
8
+ expect(result.success).toBe(false);
9
+ if (!result.success) {
10
+ expect(result.error.issues.some((issue) => issue.path.join(".") === issuePath)).toBe(true);
11
+ }
12
+ }
13
+
4
14
  describe("FeishuConfigSchema webhook validation", () => {
5
15
  it("applies top-level defaults", () => {
6
16
  const result = FeishuConfigSchema.parse({});
@@ -39,12 +49,7 @@ describe("FeishuConfigSchema webhook validation", () => {
39
49
  appSecret: "secret_top", // pragma: allowlist secret
40
50
  });
41
51
 
42
- expect(result.success).toBe(false);
43
- if (!result.success) {
44
- expect(
45
- result.error.issues.some((issue) => issue.path.join(".") === "verificationToken"),
46
- ).toBe(true);
47
- }
52
+ expectSchemaIssue(result, "verificationToken");
48
53
  });
49
54
 
50
55
  it("rejects top-level webhook mode without encryptKey", () => {
@@ -55,10 +60,7 @@ describe("FeishuConfigSchema webhook validation", () => {
55
60
  appSecret: "secret_top", // pragma: allowlist secret
56
61
  });
57
62
 
58
- expect(result.success).toBe(false);
59
- if (!result.success) {
60
- expect(result.error.issues.some((issue) => issue.path.join(".") === "encryptKey")).toBe(true);
61
- }
63
+ expectSchemaIssue(result, "encryptKey");
62
64
  });
63
65
 
64
66
  it("accepts top-level webhook mode with verificationToken and encryptKey", () => {
@@ -84,14 +86,7 @@ describe("FeishuConfigSchema webhook validation", () => {
84
86
  },
85
87
  });
86
88
 
87
- expect(result.success).toBe(false);
88
- if (!result.success) {
89
- expect(
90
- result.error.issues.some(
91
- (issue) => issue.path.join(".") === "accounts.main.verificationToken",
92
- ),
93
- ).toBe(true);
94
- }
89
+ expectSchemaIssue(result, "accounts.main.verificationToken");
95
90
  });
96
91
 
97
92
  it("rejects account webhook mode without encryptKey", () => {
@@ -106,12 +101,7 @@ describe("FeishuConfigSchema webhook validation", () => {
106
101
  },
107
102
  });
108
103
 
109
- expect(result.success).toBe(false);
110
- if (!result.success) {
111
- expect(
112
- result.error.issues.some((issue) => issue.path.join(".") === "accounts.main.encryptKey"),
113
- ).toBe(true);
114
- }
104
+ expectSchemaIssue(result, "accounts.main.encryptKey");
115
105
  });
116
106
 
117
107
  it("accepts account webhook mode inheriting top-level verificationToken and encryptKey", () => {
package/src/dedup.ts CHANGED
@@ -10,9 +10,15 @@ import {
10
10
  const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
11
11
  const MEMORY_MAX_SIZE = 1_000;
12
12
  const FILE_MAX_ENTRIES = 10_000;
13
+ const EVENT_DEDUP_TTL_MS = 5 * 60 * 1000;
14
+ const EVENT_MEMORY_MAX_SIZE = 2_000;
13
15
  type PersistentDedupeData = Record<string, number>;
14
16
 
15
17
  const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
18
+ const processingClaims = createDedupeCache({
19
+ ttlMs: EVENT_DEDUP_TTL_MS,
20
+ maxSize: EVENT_MEMORY_MAX_SIZE,
21
+ });
16
22
 
17
23
  function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
18
24
  const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
@@ -37,6 +43,103 @@ const persistentDedupe = createPersistentDedupe({
37
43
  resolveFilePath: resolveNamespaceFilePath,
38
44
  });
39
45
 
46
+ function resolveEventDedupeKey(
47
+ namespace: string,
48
+ messageId: string | undefined | null,
49
+ ): string | null {
50
+ const trimmed = messageId?.trim();
51
+ if (!trimmed) {
52
+ return null;
53
+ }
54
+ return `${namespace}:${trimmed}`;
55
+ }
56
+
57
+ function normalizeMessageId(messageId: string | undefined | null): string | null {
58
+ const trimmed = messageId?.trim();
59
+ return trimmed ? trimmed : null;
60
+ }
61
+
62
+ function resolveMemoryDedupeKey(
63
+ namespace: string,
64
+ messageId: string | undefined | null,
65
+ ): string | null {
66
+ const trimmed = normalizeMessageId(messageId);
67
+ if (!trimmed) {
68
+ return null;
69
+ }
70
+ return `${namespace}:${trimmed}`;
71
+ }
72
+
73
+ export function tryBeginFeishuMessageProcessing(
74
+ messageId: string | undefined | null,
75
+ namespace = "global",
76
+ ): boolean {
77
+ return !processingClaims.check(resolveEventDedupeKey(namespace, messageId));
78
+ }
79
+
80
+ export function releaseFeishuMessageProcessing(
81
+ messageId: string | undefined | null,
82
+ namespace = "global",
83
+ ): void {
84
+ processingClaims.delete(resolveEventDedupeKey(namespace, messageId));
85
+ }
86
+
87
+ export async function finalizeFeishuMessageProcessing(params: {
88
+ messageId: string | undefined | null;
89
+ namespace?: string;
90
+ log?: (...args: unknown[]) => void;
91
+ claimHeld?: boolean;
92
+ }): Promise<boolean> {
93
+ const { messageId, namespace = "global", log, claimHeld = false } = params;
94
+ const normalizedMessageId = normalizeMessageId(messageId);
95
+ const memoryKey = resolveMemoryDedupeKey(namespace, messageId);
96
+ if (!memoryKey || !normalizedMessageId) {
97
+ return false;
98
+ }
99
+ if (!claimHeld && !tryBeginFeishuMessageProcessing(normalizedMessageId, namespace)) {
100
+ return false;
101
+ }
102
+ if (!tryRecordMessage(memoryKey)) {
103
+ releaseFeishuMessageProcessing(normalizedMessageId, namespace);
104
+ return false;
105
+ }
106
+ if (!(await tryRecordMessagePersistent(normalizedMessageId, namespace, log))) {
107
+ releaseFeishuMessageProcessing(normalizedMessageId, namespace);
108
+ return false;
109
+ }
110
+ return true;
111
+ }
112
+
113
+ export async function recordProcessedFeishuMessage(
114
+ messageId: string | undefined | null,
115
+ namespace = "global",
116
+ log?: (...args: unknown[]) => void,
117
+ ): Promise<boolean> {
118
+ const normalizedMessageId = normalizeMessageId(messageId);
119
+ const memoryKey = resolveMemoryDedupeKey(namespace, messageId);
120
+ if (!memoryKey || !normalizedMessageId) {
121
+ return false;
122
+ }
123
+ tryRecordMessage(memoryKey);
124
+ return await tryRecordMessagePersistent(normalizedMessageId, namespace, log);
125
+ }
126
+
127
+ export async function hasProcessedFeishuMessage(
128
+ messageId: string | undefined | null,
129
+ namespace = "global",
130
+ log?: (...args: unknown[]) => void,
131
+ ): Promise<boolean> {
132
+ const normalizedMessageId = normalizeMessageId(messageId);
133
+ const memoryKey = resolveMemoryDedupeKey(namespace, messageId);
134
+ if (!memoryKey || !normalizedMessageId) {
135
+ return false;
136
+ }
137
+ if (hasRecordedMessage(memoryKey)) {
138
+ return true;
139
+ }
140
+ return hasRecordedMessagePersistent(normalizedMessageId, namespace, log);
141
+ }
142
+
40
143
  /**
41
144
  * Synchronous dedup — memory only.
42
145
  * Kept for backward compatibility; prefer {@link tryRecordMessagePersistent}.
package/src/media.test.ts CHANGED
@@ -64,18 +64,21 @@ function expectMediaTimeoutClientConfigured(): void {
64
64
  );
65
65
  }
66
66
 
67
+ function mockResolvedFeishuAccount() {
68
+ resolveFeishuAccountMock.mockReturnValue({
69
+ configured: true,
70
+ accountId: "main",
71
+ config: {},
72
+ appId: "app_id",
73
+ appSecret: "app_secret",
74
+ domain: "feishu",
75
+ });
76
+ }
77
+
67
78
  describe("sendMediaFeishu msg_type routing", () => {
68
79
  beforeEach(() => {
69
80
  vi.clearAllMocks();
70
-
71
- resolveFeishuAccountMock.mockReturnValue({
72
- configured: true,
73
- accountId: "main",
74
- config: {},
75
- appId: "app_id",
76
- appSecret: "app_secret",
77
- domain: "feishu",
78
- });
81
+ mockResolvedFeishuAccount();
79
82
 
80
83
  normalizeFeishuTargetMock.mockReturnValue("ou_target");
81
84
  resolveReceiveIdTypeMock.mockReturnValue("open_id");
@@ -381,7 +384,7 @@ describe("sendMediaFeishu msg_type routing", () => {
381
384
  expect(messageResourceGetMock).not.toHaveBeenCalled();
382
385
  });
383
386
 
384
- it("encodes Chinese filenames for file uploads", async () => {
387
+ it("preserves Chinese filenames for file uploads", async () => {
385
388
  await sendMediaFeishu({
386
389
  cfg: {} as any,
387
390
  to: "user:ou_target",
@@ -390,8 +393,7 @@ describe("sendMediaFeishu msg_type routing", () => {
390
393
  });
391
394
 
392
395
  const createCall = fileCreateMock.mock.calls[0][0];
393
- expect(createCall.data.file_name).not.toBe("测试文档.pdf");
394
- expect(createCall.data.file_name).toBe(encodeURIComponent("测试文档") + ".pdf");
396
+ expect(createCall.data.file_name).toBe("测试文档.pdf");
395
397
  });
396
398
 
397
399
  it("preserves ASCII filenames unchanged for file uploads", async () => {
@@ -406,7 +408,7 @@ describe("sendMediaFeishu msg_type routing", () => {
406
408
  expect(createCall.data.file_name).toBe("report-2026.pdf");
407
409
  });
408
410
 
409
- it("encodes special characters (em-dash, full-width brackets) in filenames", async () => {
411
+ it("preserves special Unicode characters (em-dash, full-width brackets) in filenames", async () => {
410
412
  await sendMediaFeishu({
411
413
  cfg: {} as any,
412
414
  to: "user:ou_target",
@@ -415,9 +417,7 @@ describe("sendMediaFeishu msg_type routing", () => {
415
417
  });
416
418
 
417
419
  const createCall = fileCreateMock.mock.calls[0][0];
418
- expect(createCall.data.file_name).toMatch(/\.md$/);
419
- expect(createCall.data.file_name).not.toContain("—");
420
- expect(createCall.data.file_name).not.toContain("(");
420
+ expect(createCall.data.file_name).toBe("报告—详情(2026).md");
421
421
  });
422
422
  });
423
423
 
@@ -427,71 +427,48 @@ describe("sanitizeFileNameForUpload", () => {
427
427
  expect(sanitizeFileNameForUpload("my-file_v2.txt")).toBe("my-file_v2.txt");
428
428
  });
429
429
 
430
- it("encodes Chinese characters in basename, preserves extension", () => {
431
- const result = sanitizeFileNameForUpload("测试文件.md");
432
- expect(result).toBe(encodeURIComponent("测试文件") + ".md");
433
- expect(result).toMatch(/\.md$/);
430
+ it("preserves Chinese characters", () => {
431
+ expect(sanitizeFileNameForUpload("测试文件.md")).toBe("测试文件.md");
432
+ expect(sanitizeFileNameForUpload("武汉15座山登山信息汇总.csv")).toBe(
433
+ "武汉15座山登山信息汇总.csv",
434
+ );
434
435
  });
435
436
 
436
- it("encodes em-dash and full-width brackets", () => {
437
- const result = sanitizeFileNameForUpload("文件—说明(v2).pdf");
438
- expect(result).toMatch(/\.pdf$/);
439
- expect(result).not.toContain("—");
440
- expect(result).not.toContain("(");
441
- expect(result).not.toContain(")");
437
+ it("preserves em-dash and full-width brackets", () => {
438
+ expect(sanitizeFileNameForUpload("文件—说明(v2).pdf")).toBe("文件—说明(v2).pdf");
442
439
  });
443
440
 
444
- it("encodes single quotes and parentheses per RFC 5987", () => {
445
- const result = sanitizeFileNameForUpload("文件'(test).txt");
446
- expect(result).toContain("%27");
447
- expect(result).toContain("%28");
448
- expect(result).toContain("%29");
449
- expect(result).toMatch(/\.txt$/);
441
+ it("preserves single quotes and parentheses", () => {
442
+ expect(sanitizeFileNameForUpload("文件'(test).txt")).toBe("文件'(test).txt");
450
443
  });
451
444
 
452
- it("handles filenames without extension", () => {
453
- const result = sanitizeFileNameForUpload("测试文件");
454
- expect(result).toBe(encodeURIComponent("测试文件"));
445
+ it("preserves filenames without extension", () => {
446
+ expect(sanitizeFileNameForUpload("测试文件")).toBe("测试文件");
455
447
  });
456
448
 
457
- it("handles mixed ASCII and non-ASCII", () => {
458
- const result = sanitizeFileNameForUpload("Report_报告_2026.xlsx");
459
- expect(result).toMatch(/\.xlsx$/);
460
- expect(result).not.toContain("报告");
449
+ it("preserves mixed ASCII and non-ASCII", () => {
450
+ expect(sanitizeFileNameForUpload("Report_报告_2026.xlsx")).toBe("Report_报告_2026.xlsx");
461
451
  });
462
452
 
463
- it("encodes non-ASCII extensions", () => {
464
- const result = sanitizeFileNameForUpload("报告.文档");
465
- expect(result).toContain("%E6%96%87%E6%A1%A3");
466
- expect(result).not.toContain("文档");
453
+ it("preserves emoji filenames", () => {
454
+ expect(sanitizeFileNameForUpload("report_😀.txt")).toBe("report_😀.txt");
467
455
  });
468
456
 
469
- it("encodes emoji filenames", () => {
470
- const result = sanitizeFileNameForUpload("report_😀.txt");
471
- expect(result).toContain("%F0%9F%98%80");
472
- expect(result).toMatch(/\.txt$/);
457
+ it("strips control characters", () => {
458
+ expect(sanitizeFileNameForUpload("bad\x00file.txt")).toBe("bad_file.txt");
459
+ expect(sanitizeFileNameForUpload("inject\r\nheader.txt")).toBe("inject__header.txt");
473
460
  });
474
461
 
475
- it("encodes mixed ASCII and non-ASCII extensions", () => {
476
- const result = sanitizeFileNameForUpload("notes_总结.v测试");
477
- expect(result).toContain("notes_");
478
- expect(result).toContain("%E6%B5%8B%E8%AF%95");
479
- expect(result).not.toContain("测试");
462
+ it("strips quotes and backslashes to prevent header injection", () => {
463
+ expect(sanitizeFileNameForUpload('file"name.txt')).toBe("file_name.txt");
464
+ expect(sanitizeFileNameForUpload("file\\name.txt")).toBe("file_name.txt");
480
465
  });
481
466
  });
482
467
 
483
468
  describe("downloadMessageResourceFeishu", () => {
484
469
  beforeEach(() => {
485
470
  vi.clearAllMocks();
486
-
487
- resolveFeishuAccountMock.mockReturnValue({
488
- configured: true,
489
- accountId: "main",
490
- config: {},
491
- appId: "app_id",
492
- appSecret: "app_secret",
493
- domain: "feishu",
494
- });
471
+ mockResolvedFeishuAccount();
495
472
 
496
473
  createFeishuClientMock.mockReturnValue({
497
474
  im: {
package/src/media.ts CHANGED
@@ -22,6 +22,45 @@ export type DownloadMessageResourceResult = {
22
22
  fileName?: string;
23
23
  };
24
24
 
25
+ function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
26
+ account: ReturnType<typeof resolveFeishuAccount>;
27
+ client: ReturnType<typeof createFeishuClient>;
28
+ } {
29
+ const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
30
+ if (!account.configured) {
31
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
32
+ }
33
+
34
+ return {
35
+ account,
36
+ client: createFeishuClient({
37
+ ...account,
38
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
39
+ }),
40
+ };
41
+ }
42
+
43
+ function extractFeishuUploadKey(
44
+ response: unknown,
45
+ params: {
46
+ key: "image_key" | "file_key";
47
+ errorPrefix: string;
48
+ },
49
+ ): string {
50
+ // SDK v1.30+ returns data directly without code wrapper on success.
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
52
+ const responseAny = response as any;
53
+ if (responseAny.code !== undefined && responseAny.code !== 0) {
54
+ throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
55
+ }
56
+
57
+ const key = responseAny[params.key] ?? responseAny.data?.[params.key];
58
+ if (!key) {
59
+ throw new Error(`${params.errorPrefix}: no ${params.key} returned`);
60
+ }
61
+ return key;
62
+ }
63
+
25
64
  async function readFeishuResponseBuffer(params: {
26
65
  response: unknown;
27
66
  tmpDirPrefix: string;
@@ -94,15 +133,7 @@ export async function downloadImageFeishu(params: {
94
133
  if (!normalizedImageKey) {
95
134
  throw new Error("Feishu image download failed: invalid image_key");
96
135
  }
97
- const account = resolveFeishuAccount({ cfg, accountId });
98
- if (!account.configured) {
99
- throw new Error(`Feishu account "${account.accountId}" not configured`);
100
- }
101
-
102
- const client = createFeishuClient({
103
- ...account,
104
- httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
105
- });
136
+ const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
106
137
 
107
138
  const response = await client.im.image.get({
108
139
  path: { image_key: normalizedImageKey },
@@ -132,15 +163,7 @@ export async function downloadMessageResourceFeishu(params: {
132
163
  if (!normalizedFileKey) {
133
164
  throw new Error("Feishu message resource download failed: invalid file_key");
134
165
  }
135
- const account = resolveFeishuAccount({ cfg, accountId });
136
- if (!account.configured) {
137
- throw new Error(`Feishu account "${account.accountId}" not configured`);
138
- }
139
-
140
- const client = createFeishuClient({
141
- ...account,
142
- httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
143
- });
166
+ const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
144
167
 
145
168
  const response = await client.im.messageResource.get({
146
169
  path: { message_id: messageId, file_key: normalizedFileKey },
@@ -179,15 +202,7 @@ export async function uploadImageFeishu(params: {
179
202
  accountId?: string;
180
203
  }): Promise<UploadImageResult> {
181
204
  const { cfg, image, imageType = "message", accountId } = params;
182
- const account = resolveFeishuAccount({ cfg, accountId });
183
- if (!account.configured) {
184
- throw new Error(`Feishu account "${account.accountId}" not configured`);
185
- }
186
-
187
- const client = createFeishuClient({
188
- ...account,
189
- httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
190
- });
205
+ const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
191
206
 
192
207
  // SDK accepts Buffer directly or fs.ReadStream for file paths
193
208
  // Using Readable.from(buffer) causes issues with form-data library
@@ -202,38 +217,26 @@ export async function uploadImageFeishu(params: {
202
217
  },
203
218
  });
204
219
 
205
- // SDK v1.30+ returns data directly without code wrapper on success
206
- // On error, it throws or returns { code, msg }
207
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
208
- const responseAny = response as any;
209
- if (responseAny.code !== undefined && responseAny.code !== 0) {
210
- throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
211
- }
212
-
213
- const imageKey = responseAny.image_key ?? responseAny.data?.image_key;
214
- if (!imageKey) {
215
- throw new Error("Feishu image upload failed: no image_key returned");
216
- }
217
-
218
- return { imageKey };
220
+ return {
221
+ imageKey: extractFeishuUploadKey(response, {
222
+ key: "image_key",
223
+ errorPrefix: "Feishu image upload failed",
224
+ }),
225
+ };
219
226
  }
220
227
 
221
228
  /**
222
- * Encode a filename for safe use in Feishu multipart/form-data uploads.
223
- * Non-ASCII characters (Chinese, em-dash, full-width brackets, etc.) cause
224
- * the upload to silently fail when passed raw through the SDK's form-data
225
- * serialization. RFC 5987 percent-encoding keeps headers 7-bit clean while
226
- * Feishu's server decodes and preserves the original display name.
229
+ * Sanitize a filename for safe use in Feishu multipart/form-data uploads.
230
+ * Strips control characters and multipart-injection vectors (CWE-93) while
231
+ * preserving the original UTF-8 display name (Chinese, emoji, etc.).
232
+ *
233
+ * Previous versions percent-encoded non-ASCII characters, but the Feishu
234
+ * `im.file.create` API uses `file_name` as a literal display name — it does
235
+ * NOT decode percent-encoding — so encoded filenames appeared as garbled text
236
+ * in chat (regression in v2026.3.2).
227
237
  */
228
238
  export function sanitizeFileNameForUpload(fileName: string): string {
229
- const ASCII_ONLY = /^[\x20-\x7E]+$/;
230
- if (ASCII_ONLY.test(fileName)) {
231
- return fileName;
232
- }
233
- return encodeURIComponent(fileName)
234
- .replace(/'/g, "%27")
235
- .replace(/\(/g, "%28")
236
- .replace(/\)/g, "%29");
239
+ return fileName.replace(/[\x00-\x1F\x7F\r\n"\\]/g, "_");
237
240
  }
238
241
 
239
242
  /**
@@ -249,15 +252,7 @@ export async function uploadFileFeishu(params: {
249
252
  accountId?: string;
250
253
  }): Promise<UploadFileResult> {
251
254
  const { cfg, file, fileName, fileType, duration, accountId } = params;
252
- const account = resolveFeishuAccount({ cfg, accountId });
253
- if (!account.configured) {
254
- throw new Error(`Feishu account "${account.accountId}" not configured`);
255
- }
256
-
257
- const client = createFeishuClient({
258
- ...account,
259
- httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
260
- });
255
+ const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
261
256
 
262
257
  // SDK accepts Buffer directly or fs.ReadStream for file paths
263
258
  // Using Readable.from(buffer) causes issues with form-data library
@@ -276,19 +271,12 @@ export async function uploadFileFeishu(params: {
276
271
  },
277
272
  });
278
273
 
279
- // SDK v1.30+ returns data directly without code wrapper on success
280
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
281
- const responseAny = response as any;
282
- if (responseAny.code !== undefined && responseAny.code !== 0) {
283
- throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
284
- }
285
-
286
- const fileKey = responseAny.file_key ?? responseAny.data?.file_key;
287
- if (!fileKey) {
288
- throw new Error("Feishu file upload failed: no file_key returned");
289
- }
290
-
291
- return { fileKey };
274
+ return {
275
+ fileKey: extractFeishuUploadKey(response, {
276
+ key: "file_key",
277
+ errorPrefix: "Feishu file upload failed",
278
+ }),
279
+ };
292
280
  }
293
281
 
294
282
  /**