@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 +1 -1
- package/src/accounts.test.ts +21 -16
- package/src/bot.ts +20 -11
- package/src/config-schema.test.ts +14 -24
- package/src/dedup.ts +103 -0
- package/src/media.test.ts +38 -61
- package/src/media.ts +64 -76
- package/src/monitor.account.ts +19 -18
- package/src/monitor.reaction.test.ts +106 -64
- package/src/monitor.startup.test.ts +16 -30
- package/src/monitor.transport.ts +104 -6
- package/src/monitor.webhook-e2e.test.ts +214 -0
- package/src/monitor.webhook-security.test.ts +9 -97
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/outbound.test.ts +11 -16
- package/src/probe.test.ts +112 -113
- package/src/reactions.ts +20 -27
- package/src/reply-dispatcher.test.ts +65 -143
- package/src/reply-dispatcher.ts +37 -40
- package/src/send.reply-fallback.test.ts +50 -40
- package/src/send.ts +91 -82
package/package.json
CHANGED
package/src/accounts.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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).
|
|
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("
|
|
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).
|
|
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("
|
|
431
|
-
|
|
432
|
-
expect(
|
|
433
|
-
|
|
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("
|
|
437
|
-
|
|
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("
|
|
445
|
-
|
|
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("
|
|
453
|
-
|
|
454
|
-
expect(result).toBe(encodeURIComponent("测试文件"));
|
|
445
|
+
it("preserves filenames without extension", () => {
|
|
446
|
+
expect(sanitizeFileNameForUpload("测试文件")).toBe("测试文件");
|
|
455
447
|
});
|
|
456
448
|
|
|
457
|
-
it("
|
|
458
|
-
|
|
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("
|
|
464
|
-
|
|
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("
|
|
470
|
-
|
|
471
|
-
expect(
|
|
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("
|
|
476
|
-
|
|
477
|
-
expect(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* the
|
|
225
|
-
*
|
|
226
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
/**
|