@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 +1 -1
- package/src/accounts.test.ts +40 -16
- package/src/accounts.ts +5 -1
- package/src/bot.ts +20 -11
- package/src/channel.ts +2 -2
- package/src/config-schema.test.ts +67 -16
- package/src/config-schema.ts +30 -9
- package/src/dedup.ts +103 -0
- package/src/media.test.ts +38 -61
- package/src/media.ts +64 -76
- package/src/monitor.account.ts +39 -22
- package/src/monitor.reaction.test.ts +134 -65
- 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 +23 -92
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/onboarding.ts +31 -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 +95 -91
- package/src/types.ts +14 -0
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
|
};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
}
|
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:
|
|
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:
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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("
|
|
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",
|
package/src/config-schema.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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("
|
|
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: {
|