@openclaw/feishu 2026.3.11 → 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.11",
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
  };
@@ -241,6 +255,25 @@ describe("resolveFeishuCredentials", () => {
241
255
  domain: "feishu",
242
256
  });
243
257
  });
258
+
259
+ it("does not resolve encryptKey SecretRefs outside webhook mode", () => {
260
+ const creds = resolveFeishuCredentials(
261
+ asConfig({
262
+ connectionMode: "websocket",
263
+ appId: "cli_123",
264
+ appSecret: "secret_456",
265
+ encryptKey: { source: "file", provider: "default", id: "path/to/secret" } as never,
266
+ }),
267
+ );
268
+
269
+ expect(creds).toEqual({
270
+ appId: "cli_123",
271
+ appSecret: "secret_456", // pragma: allowlist secret
272
+ encryptKey: undefined,
273
+ verificationToken: undefined,
274
+ domain: "feishu",
275
+ });
276
+ });
244
277
  });
245
278
 
246
279
  describe("resolveFeishuAccount", () => {
@@ -259,10 +292,7 @@ describe("resolveFeishuAccount", () => {
259
292
  };
260
293
 
261
294
  const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
262
- expect(account.accountId).toBe("router-d");
263
- expect(account.selectionSource).toBe("explicit-default");
264
- expect(account.configured).toBe(true);
265
- expect(account.appId).toBe("top_level_app");
295
+ expectExplicitDefaultAccountSelection(account, "top_level_app");
266
296
  });
267
297
 
268
298
  it("uses configured default account when accountId is omitted", () => {
@@ -279,10 +309,7 @@ describe("resolveFeishuAccount", () => {
279
309
  };
280
310
 
281
311
  const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
282
- expect(account.accountId).toBe("router-d");
283
- expect(account.selectionSource).toBe("explicit-default");
284
- expect(account.configured).toBe(true);
285
- expect(account.appId).toBe("cli_router");
312
+ expectExplicitDefaultAccountSelection(account, "cli_router");
286
313
  });
287
314
 
288
315
  it("keeps explicit accountId selection", () => {
@@ -290,10 +317,7 @@ describe("resolveFeishuAccount", () => {
290
317
  channels: {
291
318
  feishu: {
292
319
  defaultAccount: "router-d",
293
- accounts: {
294
- default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
295
- "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
296
- },
320
+ accounts: makeDefaultAndRouterAccounts(),
297
321
  },
298
322
  },
299
323
  };
package/src/accounts.ts CHANGED
@@ -169,10 +169,14 @@ export function resolveFeishuCredentials(
169
169
  if (!appId || !appSecret) {
170
170
  return null;
171
171
  }
172
+ const connectionMode = cfg?.connectionMode ?? "websocket";
172
173
  return {
173
174
  appId,
174
175
  appSecret,
175
- encryptKey: normalizeString(cfg?.encryptKey),
176
+ encryptKey:
177
+ connectionMode === "webhook"
178
+ ? resolveSecretLike(cfg?.encryptKey, "channels.feishu.encryptKey")
179
+ : normalizeString(cfg?.encryptKey),
176
180
  verificationToken: resolveSecretLike(
177
181
  cfg?.verificationToken,
178
182
  "channels.feishu.verificationToken",
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
  }
package/src/channel.ts CHANGED
@@ -129,7 +129,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
129
129
  defaultAccount: { type: "string" },
130
130
  appId: { type: "string" },
131
131
  appSecret: secretInputJsonSchema,
132
- encryptKey: { type: "string" },
132
+ encryptKey: secretInputJsonSchema,
133
133
  verificationToken: secretInputJsonSchema,
134
134
  domain: {
135
135
  oneOf: [
@@ -170,7 +170,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
170
170
  name: { type: "string" },
171
171
  appId: { type: "string" },
172
172
  appSecret: secretInputJsonSchema,
173
- encryptKey: { type: "string" },
173
+ encryptKey: secretInputJsonSchema,
174
174
  verificationToken: secretInputJsonSchema,
175
175
  domain: { type: "string", enum: ["feishu", "lark"] },
176
176
  connectionMode: { type: "string", enum: ["websocket", "webhook"] },
@@ -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,18 +49,25 @@ 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");
53
+ });
54
+
55
+ it("rejects top-level webhook mode without encryptKey", () => {
56
+ const result = FeishuConfigSchema.safeParse({
57
+ connectionMode: "webhook",
58
+ verificationToken: "token_top",
59
+ appId: "cli_top",
60
+ appSecret: "secret_top", // pragma: allowlist secret
61
+ });
62
+
63
+ expectSchemaIssue(result, "encryptKey");
48
64
  });
49
65
 
50
- it("accepts top-level webhook mode with verificationToken", () => {
66
+ it("accepts top-level webhook mode with verificationToken and encryptKey", () => {
51
67
  const result = FeishuConfigSchema.safeParse({
52
68
  connectionMode: "webhook",
53
69
  verificationToken: "token_top",
70
+ encryptKey: "encrypt_top",
54
71
  appId: "cli_top",
55
72
  appSecret: "secret_top", // pragma: allowlist secret
56
73
  });
@@ -69,19 +86,28 @@ describe("FeishuConfigSchema webhook validation", () => {
69
86
  },
70
87
  });
71
88
 
72
- expect(result.success).toBe(false);
73
- if (!result.success) {
74
- expect(
75
- result.error.issues.some(
76
- (issue) => issue.path.join(".") === "accounts.main.verificationToken",
77
- ),
78
- ).toBe(true);
79
- }
89
+ expectSchemaIssue(result, "accounts.main.verificationToken");
80
90
  });
81
91
 
82
- it("accepts account webhook mode inheriting top-level verificationToken", () => {
92
+ it("rejects account webhook mode without encryptKey", () => {
93
+ const result = FeishuConfigSchema.safeParse({
94
+ accounts: {
95
+ main: {
96
+ connectionMode: "webhook",
97
+ verificationToken: "token_main",
98
+ appId: "cli_main",
99
+ appSecret: "secret_main", // pragma: allowlist secret
100
+ },
101
+ },
102
+ });
103
+
104
+ expectSchemaIssue(result, "accounts.main.encryptKey");
105
+ });
106
+
107
+ it("accepts account webhook mode inheriting top-level verificationToken and encryptKey", () => {
83
108
  const result = FeishuConfigSchema.safeParse({
84
109
  verificationToken: "token_top",
110
+ encryptKey: "encrypt_top",
85
111
  accounts: {
86
112
  main: {
87
113
  connectionMode: "webhook",
@@ -102,6 +128,31 @@ describe("FeishuConfigSchema webhook validation", () => {
102
128
  provider: "default",
103
129
  id: "FEISHU_VERIFICATION_TOKEN",
104
130
  },
131
+ encryptKey: "encrypt_top",
132
+ appId: "cli_top",
133
+ appSecret: {
134
+ source: "env",
135
+ provider: "default",
136
+ id: "FEISHU_APP_SECRET",
137
+ },
138
+ });
139
+
140
+ expect(result.success).toBe(true);
141
+ });
142
+
143
+ it("accepts SecretRef encryptKey in webhook mode", () => {
144
+ const result = FeishuConfigSchema.safeParse({
145
+ connectionMode: "webhook",
146
+ verificationToken: {
147
+ source: "env",
148
+ provider: "default",
149
+ id: "FEISHU_VERIFICATION_TOKEN",
150
+ },
151
+ encryptKey: {
152
+ source: "env",
153
+ provider: "default",
154
+ id: "FEISHU_ENCRYPT_KEY",
155
+ },
105
156
  appId: "cli_top",
106
157
  appSecret: {
107
158
  source: "env",
@@ -186,7 +186,7 @@ export const FeishuAccountConfigSchema = z
186
186
  name: z.string().optional(), // Display name for this account
187
187
  appId: z.string().optional(),
188
188
  appSecret: buildSecretInputSchema().optional(),
189
- encryptKey: z.string().optional(),
189
+ encryptKey: buildSecretInputSchema().optional(),
190
190
  verificationToken: buildSecretInputSchema().optional(),
191
191
  domain: FeishuDomainSchema.optional(),
192
192
  connectionMode: FeishuConnectionModeSchema.optional(),
@@ -204,7 +204,7 @@ export const FeishuConfigSchema = z
204
204
  // Top-level credentials (backward compatible for single-account mode)
205
205
  appId: z.string().optional(),
206
206
  appSecret: buildSecretInputSchema().optional(),
207
- encryptKey: z.string().optional(),
207
+ encryptKey: buildSecretInputSchema().optional(),
208
208
  verificationToken: buildSecretInputSchema().optional(),
209
209
  domain: FeishuDomainSchema.optional().default("feishu"),
210
210
  connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
@@ -240,13 +240,23 @@ export const FeishuConfigSchema = z
240
240
 
241
241
  const defaultConnectionMode = value.connectionMode ?? "websocket";
242
242
  const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
243
- if (defaultConnectionMode === "webhook" && !defaultVerificationTokenConfigured) {
244
- ctx.addIssue({
245
- code: z.ZodIssueCode.custom,
246
- path: ["verificationToken"],
247
- message:
248
- 'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
249
- });
243
+ const defaultEncryptKeyConfigured = hasConfiguredSecretInput(value.encryptKey);
244
+ if (defaultConnectionMode === "webhook") {
245
+ if (!defaultVerificationTokenConfigured) {
246
+ ctx.addIssue({
247
+ code: z.ZodIssueCode.custom,
248
+ path: ["verificationToken"],
249
+ message:
250
+ 'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
251
+ });
252
+ }
253
+ if (!defaultEncryptKeyConfigured) {
254
+ ctx.addIssue({
255
+ code: z.ZodIssueCode.custom,
256
+ path: ["encryptKey"],
257
+ message: 'channels.feishu.connectionMode="webhook" requires channels.feishu.encryptKey',
258
+ });
259
+ }
250
260
  }
251
261
 
252
262
  for (const [accountId, account] of Object.entries(value.accounts ?? {})) {
@@ -259,6 +269,8 @@ export const FeishuConfigSchema = z
259
269
  }
260
270
  const accountVerificationTokenConfigured =
261
271
  hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
272
+ const accountEncryptKeyConfigured =
273
+ hasConfiguredSecretInput(account.encryptKey) || defaultEncryptKeyConfigured;
262
274
  if (!accountVerificationTokenConfigured) {
263
275
  ctx.addIssue({
264
276
  code: z.ZodIssueCode.custom,
@@ -268,6 +280,15 @@ export const FeishuConfigSchema = z
268
280
  "a verificationToken (account-level or top-level)",
269
281
  });
270
282
  }
283
+ if (!accountEncryptKeyConfigured) {
284
+ ctx.addIssue({
285
+ code: z.ZodIssueCode.custom,
286
+ path: ["accounts", accountId, "encryptKey"],
287
+ message:
288
+ `channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` +
289
+ "an encryptKey (account-level or top-level)",
290
+ });
291
+ }
271
292
  }
272
293
 
273
294
  if (value.dmPolicy === "open") {
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: {