@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/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
  /**
@@ -12,10 +12,10 @@ import {
12
12
  import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
13
13
  import { createEventDispatcher } from "./client.js";
14
14
  import {
15
- hasRecordedMessage,
16
- hasRecordedMessagePersistent,
17
- tryRecordMessage,
18
- tryRecordMessagePersistent,
15
+ hasProcessedFeishuMessage,
16
+ recordProcessedFeishuMessage,
17
+ releaseFeishuMessageProcessing,
18
+ tryBeginFeishuMessageProcessing,
19
19
  warmupDedupFromDisk,
20
20
  } from "./dedup.js";
21
21
  import { isMentionForwardRequest } from "./mention.js";
@@ -24,14 +24,14 @@ import { botNames, botOpenIds } from "./monitor.state.js";
24
24
  import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
25
25
  import { getFeishuRuntime } from "./runtime.js";
26
26
  import { getMessageFeishu } from "./send.js";
27
- import type { ResolvedFeishuAccount } from "./types.js";
27
+ import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
28
28
 
29
29
  const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
30
30
 
31
31
  export type FeishuReactionCreatedEvent = {
32
32
  message_id: string;
33
33
  chat_id?: string;
34
- chat_type?: "p2p" | "group" | "private";
34
+ chat_type?: string;
35
35
  reaction_type?: { emoji_type?: string };
36
36
  operator_type?: string;
37
37
  user_id?: { open_id?: string };
@@ -105,10 +105,19 @@ export async function resolveReactionSyntheticEvent(
105
105
  return null;
106
106
  }
107
107
 
108
+ const fallbackChatType = reactedMsg.chatType;
109
+ const normalizedEventChatType = normalizeFeishuChatType(event.chat_type);
110
+ const resolvedChatType = normalizedEventChatType ?? fallbackChatType;
111
+ if (!resolvedChatType) {
112
+ logger?.(
113
+ `feishu[${accountId}]: skipping reaction ${emoji} on ${messageId} without chat type context`,
114
+ );
115
+ return null;
116
+ }
117
+
108
118
  const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
109
119
  const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
110
- const syntheticChatType: "p2p" | "group" | "private" =
111
- event.chat_type === "group" ? "group" : "p2p";
120
+ const syntheticChatType: FeishuChatType = resolvedChatType;
112
121
  return {
113
122
  sender: {
114
123
  sender_id: { open_id: senderId },
@@ -126,6 +135,10 @@ export async function resolveReactionSyntheticEvent(
126
135
  };
127
136
  }
128
137
 
138
+ function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
139
+ return value === "group" || value === "private" || value === "p2p" ? value : undefined;
140
+ }
141
+
129
142
  type RegisterEventHandlersContext = {
130
143
  cfg: ClawdbotConfig;
131
144
  accountId: string;
@@ -251,6 +264,7 @@ function registerEventHandlers(
251
264
  runtime,
252
265
  chatHistories,
253
266
  accountId,
267
+ processingClaimHeld: true,
254
268
  });
255
269
  await enqueue(chatId, task);
256
270
  };
@@ -278,10 +292,8 @@ function registerEventHandlers(
278
292
  return;
279
293
  }
280
294
  for (const messageId of suppressedIds) {
281
- // Keep in-memory dedupe in sync with handleFeishuMessage's keying.
282
- tryRecordMessage(`${accountId}:${messageId}`);
283
295
  try {
284
- await tryRecordMessagePersistent(messageId, accountId, log);
296
+ await recordProcessedFeishuMessage(messageId, accountId, log);
285
297
  } catch (err) {
286
298
  error(
287
299
  `feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
@@ -290,15 +302,7 @@ function registerEventHandlers(
290
302
  }
291
303
  };
292
304
  const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
293
- const messageId = entry.message.message_id?.trim();
294
- if (!messageId) {
295
- return false;
296
- }
297
- const memoryKey = `${accountId}:${messageId}`;
298
- if (hasRecordedMessage(memoryKey)) {
299
- return true;
300
- }
301
- return hasRecordedMessagePersistent(messageId, accountId, log);
305
+ return await hasProcessedFeishuMessage(entry.message.message_id, accountId, log);
302
306
  };
303
307
  const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
304
308
  debounceMs: inboundDebounceMs,
@@ -371,19 +375,28 @@ function registerEventHandlers(
371
375
  },
372
376
  });
373
377
  },
374
- onError: (err) => {
378
+ onError: (err, entries) => {
379
+ for (const entry of entries) {
380
+ releaseFeishuMessageProcessing(entry.message.message_id, accountId);
381
+ }
375
382
  error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
376
383
  },
377
384
  });
378
385
 
379
386
  eventDispatcher.register({
380
387
  "im.message.receive_v1": async (data) => {
388
+ const event = data as unknown as FeishuMessageEvent;
389
+ const messageId = event.message?.message_id?.trim();
390
+ if (!tryBeginFeishuMessageProcessing(messageId, accountId)) {
391
+ log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);
392
+ return;
393
+ }
381
394
  const processMessage = async () => {
382
- const event = data as unknown as FeishuMessageEvent;
383
395
  await inboundDebouncer.enqueue(event);
384
396
  };
385
397
  if (fireAndForget) {
386
398
  void processMessage().catch((err) => {
399
+ releaseFeishuMessageProcessing(messageId, accountId);
387
400
  error(`feishu[${accountId}]: error handling message: ${String(err)}`);
388
401
  });
389
402
  return;
@@ -391,6 +404,7 @@ function registerEventHandlers(
391
404
  try {
392
405
  await processMessage();
393
406
  } catch (err) {
407
+ releaseFeishuMessageProcessing(messageId, accountId);
394
408
  error(`feishu[${accountId}]: error handling message: ${String(err)}`);
395
409
  }
396
410
  },
@@ -521,6 +535,9 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
521
535
  if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
522
536
  throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
523
537
  }
538
+ if (connectionMode === "webhook" && !account.encryptKey?.trim()) {
539
+ throw new Error(`Feishu account "${accountId}" webhook mode requires encryptKey`);
540
+ }
524
541
 
525
542
  const warmupCount = await warmupDedupFromDisk(accountId, log);
526
543
  if (warmupCount > 0) {
@@ -51,10 +51,11 @@ function makeReactionEvent(
51
51
  };
52
52
  }
53
53
 
54
- function createFetchedReactionMessage(chatId: string) {
54
+ function createFetchedReactionMessage(chatId: string, chatType?: "p2p" | "group" | "private") {
55
55
  return {
56
56
  messageId: "om_msg1",
57
57
  chatId,
58
+ chatType,
58
59
  senderOpenId: "ou_bot",
59
60
  content: "hello",
60
61
  contentType: "text",
@@ -64,17 +65,38 @@ function createFetchedReactionMessage(chatId: string) {
64
65
  async function resolveReactionWithLookup(params: {
65
66
  event?: FeishuReactionCreatedEvent;
66
67
  lookupChatId: string;
68
+ lookupChatType?: "p2p" | "group" | "private";
67
69
  }) {
68
70
  return await resolveReactionSyntheticEvent({
69
71
  cfg,
70
72
  accountId: "default",
71
73
  event: params.event ?? makeReactionEvent(),
72
74
  botOpenId: "ou_bot",
73
- fetchMessage: async () => createFetchedReactionMessage(params.lookupChatId),
75
+ fetchMessage: async () =>
76
+ createFetchedReactionMessage(params.lookupChatId, params.lookupChatType),
74
77
  uuid: () => "fixed-uuid",
75
78
  });
76
79
  }
77
80
 
81
+ async function resolveNonBotReaction(params?: { cfg?: ClawdbotConfig; uuid?: () => string }) {
82
+ return await resolveReactionSyntheticEvent({
83
+ cfg: params?.cfg ?? cfg,
84
+ accountId: "default",
85
+ event: makeReactionEvent(),
86
+ botOpenId: "ou_bot",
87
+ fetchMessage: async () => ({
88
+ messageId: "om_msg1",
89
+ chatId: "oc_group",
90
+ chatType: "group",
91
+ senderOpenId: "ou_other",
92
+ senderType: "user",
93
+ content: "hello",
94
+ contentType: "text",
95
+ }),
96
+ ...(params?.uuid ? { uuid: params.uuid } : {}),
97
+ });
98
+ }
99
+
78
100
  type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
79
101
 
80
102
  function buildDebounceConfig(): ClawdbotConfig {
@@ -176,11 +198,23 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
176
198
  return firstParams.event;
177
199
  }
178
200
 
201
+ function expectSingleDispatchedEvent(): FeishuMessageEvent {
202
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
203
+ return getFirstDispatchedEvent();
204
+ }
205
+
206
+ function expectParsedFirstDispatchedEvent(botOpenId = "ou_bot") {
207
+ const dispatched = expectSingleDispatchedEvent();
208
+ return {
209
+ dispatched,
210
+ parsed: parseFeishuMessageEvent(dispatched, botOpenId),
211
+ };
212
+ }
213
+
179
214
  function setDedupPassThroughMocks(): void {
180
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
181
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
182
- vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
183
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
215
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
216
+ vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
217
+ vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
184
218
  }
185
219
 
186
220
  function createMention(params: { openId: string; name: string; key?: string }): FeishuMention {
@@ -200,6 +234,12 @@ async function enqueueDebouncedMessage(
200
234
  await Promise.resolve();
201
235
  }
202
236
 
237
+ function setStaleRetryMocks(messageId = "om_old") {
238
+ vi.spyOn(dedup, "hasProcessedFeishuMessage").mockImplementation(
239
+ async (currentMessageId) => currentMessageId === messageId,
240
+ );
241
+ }
242
+
203
243
  describe("resolveReactionSyntheticEvent", () => {
204
244
  it("filters app self-reactions", async () => {
205
245
  const event = makeReactionEvent({ operator_type: "app" });
@@ -259,27 +299,12 @@ describe("resolveReactionSyntheticEvent", () => {
259
299
  });
260
300
 
261
301
  it("filters reactions on non-bot messages", async () => {
262
- const event = makeReactionEvent();
263
- const result = await resolveReactionSyntheticEvent({
264
- cfg,
265
- accountId: "default",
266
- event,
267
- botOpenId: "ou_bot",
268
- fetchMessage: async () => ({
269
- messageId: "om_msg1",
270
- chatId: "oc_group",
271
- senderOpenId: "ou_other",
272
- senderType: "user",
273
- content: "hello",
274
- contentType: "text",
275
- }),
276
- });
302
+ const result = await resolveNonBotReaction();
277
303
  expect(result).toBeNull();
278
304
  });
279
305
 
280
306
  it("allows non-bot reactions when reactionNotifications is all", async () => {
281
- const event = makeReactionEvent();
282
- const result = await resolveReactionSyntheticEvent({
307
+ const result = await resolveNonBotReaction({
283
308
  cfg: {
284
309
  channels: {
285
310
  feishu: {
@@ -287,17 +312,6 @@ describe("resolveReactionSyntheticEvent", () => {
287
312
  },
288
313
  },
289
314
  } as ClawdbotConfig,
290
- accountId: "default",
291
- event,
292
- botOpenId: "ou_bot",
293
- fetchMessage: async () => ({
294
- messageId: "om_msg1",
295
- chatId: "oc_group",
296
- senderOpenId: "ou_other",
297
- senderType: "user",
298
- content: "hello",
299
- contentType: "text",
300
- }),
301
315
  uuid: () => "fixed-uuid",
302
316
  });
303
317
  expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid");
@@ -348,21 +362,43 @@ describe("resolveReactionSyntheticEvent", () => {
348
362
  it("falls back to reacted message chat_id when event chat_id is absent", async () => {
349
363
  const result = await resolveReactionWithLookup({
350
364
  lookupChatId: "oc_group_from_lookup",
365
+ lookupChatType: "group",
351
366
  });
352
367
 
353
368
  expect(result?.message.chat_id).toBe("oc_group_from_lookup");
354
- expect(result?.message.chat_type).toBe("p2p");
369
+ expect(result?.message.chat_type).toBe("group");
355
370
  });
356
371
 
357
372
  it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
358
373
  const result = await resolveReactionWithLookup({
359
374
  lookupChatId: "",
375
+ lookupChatType: "p2p",
360
376
  });
361
377
 
362
378
  expect(result?.message.chat_id).toBe("p2p:ou_user1");
363
379
  expect(result?.message.chat_type).toBe("p2p");
364
380
  });
365
381
 
382
+ it("drops reactions without chat context when lookup does not provide chat_type", async () => {
383
+ const result = await resolveReactionWithLookup({
384
+ lookupChatId: "oc_group_from_lookup",
385
+ });
386
+
387
+ expect(result).toBeNull();
388
+ });
389
+
390
+ it("drops reactions when event chat_type is invalid and lookup cannot recover it", async () => {
391
+ const result = await resolveReactionWithLookup({
392
+ event: makeReactionEvent({
393
+ chat_id: "oc_group_from_event",
394
+ chat_type: "bogus" as "group",
395
+ }),
396
+ lookupChatId: "oc_group_from_lookup",
397
+ });
398
+
399
+ expect(result).toBeNull();
400
+ });
401
+
366
402
  it("logs and drops reactions when lookup throws", async () => {
367
403
  const log = vi.fn();
368
404
  const event = makeReactionEvent();
@@ -430,18 +466,16 @@ describe("Feishu inbound debounce regressions", () => {
430
466
  );
431
467
  await vi.advanceTimersByTimeAsync(25);
432
468
 
433
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
434
- const dispatched = getFirstDispatchedEvent();
469
+ const dispatched = expectSingleDispatchedEvent();
435
470
  const mergedMentions = dispatched.message.mentions ?? [];
436
471
  expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
437
472
  expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
438
473
  });
439
474
 
440
475
  it("passes prefetched botName through to handleFeishuMessage", async () => {
441
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
442
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
443
- vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
444
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
476
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
477
+ vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
478
+ vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
445
479
  const onMessage = await setupDebounceMonitor({ botName: "OpenClaw Bot" });
446
480
 
447
481
  await onMessage(
@@ -490,9 +524,7 @@ describe("Feishu inbound debounce regressions", () => {
490
524
  );
491
525
  await vi.advanceTimersByTimeAsync(25);
492
526
 
493
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
494
- const dispatched = getFirstDispatchedEvent();
495
- const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
527
+ const { dispatched, parsed } = expectParsedFirstDispatchedEvent();
496
528
  expect(parsed.mentionedBot).toBe(true);
497
529
  expect(parsed.mentionTargets).toBeUndefined();
498
530
  const mergedMentions = dispatched.message.mentions ?? [];
@@ -520,19 +552,14 @@ describe("Feishu inbound debounce regressions", () => {
520
552
  );
521
553
  await vi.advanceTimersByTimeAsync(25);
522
554
 
523
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
524
- const dispatched = getFirstDispatchedEvent();
525
- const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
555
+ const { parsed } = expectParsedFirstDispatchedEvent();
526
556
  expect(parsed.mentionedBot).toBe(true);
527
557
  });
528
558
 
529
559
  it("excludes previously processed retries from combined debounce text", async () => {
530
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
531
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
532
- vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
533
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
534
- async (messageId) => messageId === "om_old",
535
- );
560
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
561
+ vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
562
+ setStaleRetryMocks();
536
563
  const onMessage = await setupDebounceMonitor();
537
564
 
538
565
  await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
@@ -549,20 +576,16 @@ describe("Feishu inbound debounce regressions", () => {
549
576
  await Promise.resolve();
550
577
  await vi.advanceTimersByTimeAsync(25);
551
578
 
552
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
553
- const dispatched = getFirstDispatchedEvent();
579
+ const dispatched = expectSingleDispatchedEvent();
554
580
  expect(dispatched.message.message_id).toBe("om_new_2");
555
581
  const combined = JSON.parse(dispatched.message.content) as { text?: string };
556
582
  expect(combined.text).toBe("first\nsecond");
557
583
  });
558
584
 
559
585
  it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
560
- const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
561
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
562
- vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
563
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
564
- async (messageId) => messageId === "om_old",
565
- );
586
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
587
+ const recordSpy = vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
588
+ setStaleRetryMocks();
566
589
  const onMessage = await setupDebounceMonitor();
567
590
 
568
591
  await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
@@ -573,12 +596,58 @@ describe("Feishu inbound debounce regressions", () => {
573
596
  await Promise.resolve();
574
597
  await vi.advanceTimersByTimeAsync(25);
575
598
 
576
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
577
- const dispatched = getFirstDispatchedEvent();
599
+ const dispatched = expectSingleDispatchedEvent();
578
600
  expect(dispatched.message.message_id).toBe("om_new");
579
601
  const combined = JSON.parse(dispatched.message.content) as { text?: string };
580
602
  expect(combined.text).toBe("fresh");
581
- expect(recordSpy).toHaveBeenCalledWith("default:om_old");
582
- expect(recordSpy).not.toHaveBeenCalledWith("default:om_new");
603
+ expect(recordSpy).toHaveBeenCalledWith("om_old", "default", expect.any(Function));
604
+ expect(recordSpy).not.toHaveBeenCalledWith("om_new", "default", expect.any(Function));
605
+ });
606
+
607
+ it("releases early event dedupe when debounced dispatch fails", async () => {
608
+ setDedupPassThroughMocks();
609
+ const enqueueMock = vi.fn();
610
+ setFeishuRuntime(
611
+ createPluginRuntimeMock({
612
+ channel: {
613
+ debounce: {
614
+ createInboundDebouncer: <T>(params: {
615
+ onError?: (err: unknown, items: T[]) => void;
616
+ }) => ({
617
+ enqueue: async (item: T) => {
618
+ enqueueMock(item);
619
+ params.onError?.(new Error("dispatch failed"), [item]);
620
+ },
621
+ flushKey: async () => {},
622
+ }),
623
+ resolveInboundDebounceMs,
624
+ },
625
+ text: {
626
+ hasControlCommand,
627
+ },
628
+ },
629
+ }),
630
+ );
631
+ const onMessage = await setupDebounceMonitor();
632
+ const event = createTextEvent({ messageId: "om_retryable", text: "hello" });
633
+
634
+ await enqueueDebouncedMessage(onMessage, event);
635
+ expect(enqueueMock).toHaveBeenCalledTimes(1);
636
+
637
+ await enqueueDebouncedMessage(onMessage, event);
638
+ expect(enqueueMock).toHaveBeenCalledTimes(2);
639
+ expect(handleFeishuMessageMock).not.toHaveBeenCalled();
640
+ });
641
+
642
+ it("drops duplicate inbound events before they re-enter the debounce pipeline", async () => {
643
+ const onMessage = await setupDebounceMonitor();
644
+ const event = createTextEvent({ messageId: "om_duplicate", text: "hello" });
645
+
646
+ await enqueueDebouncedMessage(onMessage, event);
647
+ await vi.advanceTimersByTimeAsync(25);
648
+ await enqueueDebouncedMessage(onMessage, event);
649
+ await vi.advanceTimersByTimeAsync(25);
650
+
651
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
583
652
  });
584
653
  });