@openclaw/feishu 2026.3.2 → 2026.3.7

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.
Files changed (70) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +199 -13
  4. package/src/accounts.ts +45 -17
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +8 -0
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +516 -9
  9. package/src/bot.ts +366 -109
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +52 -64
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +207 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +14 -6
  18. package/src/config-schema.ts +5 -1
  19. package/src/dedup.ts +1 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/docx-batch-insert.test.ts +90 -0
  23. package/src/docx-batch-insert.ts +8 -11
  24. package/src/docx.account-selection.test.ts +3 -3
  25. package/src/docx.ts +1 -1
  26. package/src/drive.ts +13 -17
  27. package/src/dynamic-agent.ts +1 -1
  28. package/src/feishu-command-handler.ts +59 -0
  29. package/src/media.test.ts +60 -13
  30. package/src/media.ts +23 -9
  31. package/src/monitor.account.ts +19 -8
  32. package/src/monitor.reaction.test.ts +111 -105
  33. package/src/monitor.startup.test.ts +11 -10
  34. package/src/monitor.startup.ts +20 -7
  35. package/src/monitor.state.ts +4 -1
  36. package/src/monitor.test-mocks.ts +42 -9
  37. package/src/monitor.transport.ts +4 -1
  38. package/src/monitor.ts +4 -4
  39. package/src/monitor.webhook-security.test.ts +8 -23
  40. package/src/onboarding.status.test.ts +1 -1
  41. package/src/onboarding.test.ts +143 -0
  42. package/src/onboarding.ts +86 -71
  43. package/src/outbound.test.ts +178 -0
  44. package/src/outbound.ts +39 -6
  45. package/src/perm.ts +11 -15
  46. package/src/policy.test.ts +40 -0
  47. package/src/policy.ts +9 -10
  48. package/src/probe.test.ts +18 -18
  49. package/src/reactions.ts +1 -1
  50. package/src/reply-dispatcher.test.ts +175 -0
  51. package/src/reply-dispatcher.ts +69 -21
  52. package/src/runtime.ts +1 -1
  53. package/src/secret-input.ts +8 -14
  54. package/src/send-message.ts +71 -0
  55. package/src/send-target.test.ts +1 -1
  56. package/src/send-target.ts +1 -1
  57. package/src/send.reply-fallback.test.ts +74 -0
  58. package/src/send.test.ts +1 -1
  59. package/src/send.ts +88 -49
  60. package/src/streaming-card.test.ts +54 -0
  61. package/src/streaming-card.ts +96 -28
  62. package/src/targets.ts +5 -1
  63. package/src/tool-account-routing.test.ts +3 -3
  64. package/src/tool-account.ts +1 -1
  65. package/src/tool-factory-test-harness.ts +1 -1
  66. package/src/tool-result.test.ts +32 -0
  67. package/src/tool-result.ts +14 -0
  68. package/src/types.ts +2 -3
  69. package/src/typing.ts +1 -1
  70. package/src/wiki.ts +15 -19
package/src/media.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { Readable } from "stream";
4
- import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk";
4
+ import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
5
5
  import { resolveFeishuAccount } from "./accounts.js";
6
6
  import { createFeishuClient } from "./client.js";
7
7
  import { normalizeFeishuExternalKey } from "./external-keys.js";
@@ -9,6 +9,8 @@ import { getFeishuRuntime } from "./runtime.js";
9
9
  import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
10
10
  import { resolveFeishuSendTarget } from "./send-target.js";
11
11
 
12
+ const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
13
+
12
14
  export type DownloadImageResult = {
13
15
  buffer: Buffer;
14
16
  contentType?: string;
@@ -97,7 +99,10 @@ export async function downloadImageFeishu(params: {
97
99
  throw new Error(`Feishu account "${account.accountId}" not configured`);
98
100
  }
99
101
 
100
- const client = createFeishuClient(account);
102
+ const client = createFeishuClient({
103
+ ...account,
104
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
105
+ });
101
106
 
102
107
  const response = await client.im.image.get({
103
108
  path: { image_key: normalizedImageKey },
@@ -132,7 +137,10 @@ export async function downloadMessageResourceFeishu(params: {
132
137
  throw new Error(`Feishu account "${account.accountId}" not configured`);
133
138
  }
134
139
 
135
- const client = createFeishuClient(account);
140
+ const client = createFeishuClient({
141
+ ...account,
142
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
143
+ });
136
144
 
137
145
  const response = await client.im.messageResource.get({
138
146
  path: { message_id: messageId, file_key: normalizedFileKey },
@@ -176,7 +184,10 @@ export async function uploadImageFeishu(params: {
176
184
  throw new Error(`Feishu account "${account.accountId}" not configured`);
177
185
  }
178
186
 
179
- const client = createFeishuClient(account);
187
+ const client = createFeishuClient({
188
+ ...account,
189
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
190
+ });
180
191
 
181
192
  // SDK accepts Buffer directly or fs.ReadStream for file paths
182
193
  // Using Readable.from(buffer) causes issues with form-data library
@@ -243,7 +254,10 @@ export async function uploadFileFeishu(params: {
243
254
  throw new Error(`Feishu account "${account.accountId}" not configured`);
244
255
  }
245
256
 
246
- const client = createFeishuClient(account);
257
+ const client = createFeishuClient({
258
+ ...account,
259
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
260
+ });
247
261
 
248
262
  // SDK accepts Buffer directly or fs.ReadStream for file paths
249
263
  // Using Readable.from(buffer) causes issues with form-data library
@@ -328,8 +342,8 @@ export async function sendFileFeishu(params: {
328
342
  cfg: ClawdbotConfig;
329
343
  to: string;
330
344
  fileKey: string;
331
- /** Use "audio" for audio files, "file" for documents and video */
332
- msgType?: "file" | "audio";
345
+ /** Use "audio" for audio, "media" for video (mp4), "file" for documents */
346
+ msgType?: "file" | "audio" | "media";
333
347
  replyToMessageId?: string;
334
348
  replyInThread?: boolean;
335
349
  accountId?: string;
@@ -467,8 +481,8 @@ export async function sendMediaFeishu(params: {
467
481
  fileType,
468
482
  accountId,
469
483
  });
470
- // Feishu API: opus -> "audio", everything else (including video) -> "file"
471
- const msgType = fileType === "opus" ? "audio" : "file";
484
+ // Feishu API: opus -> "audio", mp4/video -> "media" (playable), others -> "file"
485
+ const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
472
486
  return sendFileFeishu({
473
487
  cfg,
474
488
  to,
@@ -1,6 +1,6 @@
1
1
  import * as crypto from "crypto";
2
2
  import * as Lark from "@larksuiteoapi/node-sdk";
3
- import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
3
+ import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk/feishu";
4
4
  import { resolveFeishuAccount } from "./accounts.js";
5
5
  import { raceWithTimeoutAndAbort } from "./async.js";
6
6
  import {
@@ -19,8 +19,8 @@ import {
19
19
  warmupDedupFromDisk,
20
20
  } from "./dedup.js";
21
21
  import { isMentionForwardRequest } from "./mention.js";
22
- import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
23
- import { botOpenIds } from "./monitor.state.js";
22
+ import { fetchBotIdentityForMonitor } from "./monitor.startup.js";
23
+ 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";
@@ -247,6 +247,7 @@ function registerEventHandlers(
247
247
  cfg,
248
248
  event,
249
249
  botOpenId: botOpenIds.get(accountId),
250
+ botName: botNames.get(accountId),
250
251
  runtime,
251
252
  chatHistories,
252
253
  accountId,
@@ -260,7 +261,7 @@ function registerEventHandlers(
260
261
  };
261
262
  const resolveDebounceText = (event: FeishuMessageEvent): string => {
262
263
  const botOpenId = botOpenIds.get(accountId);
263
- const parsed = parseFeishuMessageEvent(event, botOpenId);
264
+ const parsed = parseFeishuMessageEvent(event, botOpenId, botNames.get(accountId));
264
265
  return parsed.content.trim();
265
266
  };
266
267
  const recordSuppressedMessageIds = async (
@@ -430,6 +431,7 @@ function registerEventHandlers(
430
431
  cfg,
431
432
  event: syntheticEvent,
432
433
  botOpenId: myBotId,
434
+ botName: botNames.get(accountId),
433
435
  runtime,
434
436
  chatHistories,
435
437
  accountId,
@@ -483,7 +485,9 @@ function registerEventHandlers(
483
485
  });
484
486
  }
485
487
 
486
- export type BotOpenIdSource = { kind: "prefetched"; botOpenId?: string } | { kind: "fetch" };
488
+ export type BotOpenIdSource =
489
+ | { kind: "prefetched"; botOpenId?: string; botName?: string }
490
+ | { kind: "fetch" };
487
491
 
488
492
  export type MonitorSingleAccountParams = {
489
493
  cfg: ClawdbotConfig;
@@ -499,11 +503,18 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
499
503
  const log = runtime?.log ?? console.log;
500
504
 
501
505
  const botOpenIdSource = params.botOpenIdSource ?? { kind: "fetch" };
502
- const botOpenId =
506
+ const botIdentity =
503
507
  botOpenIdSource.kind === "prefetched"
504
- ? botOpenIdSource.botOpenId
505
- : await fetchBotOpenIdForMonitor(account, { runtime, abortSignal });
508
+ ? { botOpenId: botOpenIdSource.botOpenId, botName: botOpenIdSource.botName }
509
+ : await fetchBotIdentityForMonitor(account, { runtime, abortSignal });
510
+ const botOpenId = botIdentity.botOpenId;
511
+ const botName = botIdentity.botName?.trim();
506
512
  botOpenIds.set(accountId, botOpenId ?? "");
513
+ if (botName) {
514
+ botNames.set(accountId, botName);
515
+ } else {
516
+ botNames.delete(accountId);
517
+ }
507
518
  log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
508
519
 
509
520
  const connectionMode = account.config.connectionMode ?? "websocket";
@@ -1,4 +1,4 @@
1
- import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
1
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
4
4
  import {
@@ -51,6 +51,30 @@ function makeReactionEvent(
51
51
  };
52
52
  }
53
53
 
54
+ function createFetchedReactionMessage(chatId: string) {
55
+ return {
56
+ messageId: "om_msg1",
57
+ chatId,
58
+ senderOpenId: "ou_bot",
59
+ content: "hello",
60
+ contentType: "text",
61
+ };
62
+ }
63
+
64
+ async function resolveReactionWithLookup(params: {
65
+ event?: FeishuReactionCreatedEvent;
66
+ lookupChatId: string;
67
+ }) {
68
+ return await resolveReactionSyntheticEvent({
69
+ cfg,
70
+ accountId: "default",
71
+ event: params.event ?? makeReactionEvent(),
72
+ botOpenId: "ou_bot",
73
+ fetchMessage: async () => createFetchedReactionMessage(params.lookupChatId),
74
+ uuid: () => "fixed-uuid",
75
+ });
76
+ }
77
+
54
78
  type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
55
79
 
56
80
  function buildDebounceConfig(): ClawdbotConfig {
@@ -77,7 +101,7 @@ function buildDebounceAccount(): ResolvedFeishuAccount {
77
101
  enabled: true,
78
102
  configured: true,
79
103
  appId: "cli_test",
80
- appSecret: "secret_test",
104
+ appSecret: "secret_test", // pragma: allowlist secret
81
105
  domain: "feishu",
82
106
  config: {
83
107
  enabled: true,
@@ -109,7 +133,10 @@ function createTextEvent(params: {
109
133
  };
110
134
  }
111
135
 
112
- async function setupDebounceMonitor(): Promise<(data: unknown) => Promise<void>> {
136
+ async function setupDebounceMonitor(params?: {
137
+ botOpenId?: string;
138
+ botName?: string;
139
+ }): Promise<(data: unknown) => Promise<void>> {
113
140
  const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
114
141
  handlers = registered;
115
142
  });
@@ -123,7 +150,11 @@ async function setupDebounceMonitor(): Promise<(data: unknown) => Promise<void>>
123
150
  error: vi.fn(),
124
151
  exit: vi.fn(),
125
152
  } as RuntimeEnv,
126
- botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot" },
153
+ botOpenIdSource: {
154
+ kind: "prefetched",
155
+ botOpenId: params?.botOpenId ?? "ou_bot",
156
+ botName: params?.botName,
157
+ },
127
158
  });
128
159
 
129
160
  const onMessage = handlers["im.message.receive_v1"];
@@ -145,6 +176,30 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
145
176
  return firstParams.event;
146
177
  }
147
178
 
179
+ 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);
184
+ }
185
+
186
+ function createMention(params: { openId: string; name: string; key?: string }): FeishuMention {
187
+ return {
188
+ key: params.key ?? "@_user_1",
189
+ id: { open_id: params.openId },
190
+ name: params.name,
191
+ };
192
+ }
193
+
194
+ async function enqueueDebouncedMessage(
195
+ onMessage: (data: unknown) => Promise<void>,
196
+ event: FeishuMessageEvent,
197
+ ): Promise<void> {
198
+ await onMessage(event);
199
+ await Promise.resolve();
200
+ await Promise.resolve();
201
+ }
202
+
148
203
  describe("resolveReactionSyntheticEvent", () => {
149
204
  it("filters app self-reactions", async () => {
150
205
  const event = makeReactionEvent({ operator_type: "app" });
@@ -265,23 +320,12 @@ describe("resolveReactionSyntheticEvent", () => {
265
320
  });
266
321
 
267
322
  it("uses event chat context when provided", async () => {
268
- const event = makeReactionEvent({
269
- chat_id: "oc_group_from_event",
270
- chat_type: "group",
271
- });
272
- const result = await resolveReactionSyntheticEvent({
273
- cfg,
274
- accountId: "default",
275
- event,
276
- botOpenId: "ou_bot",
277
- fetchMessage: async () => ({
278
- messageId: "om_msg1",
279
- chatId: "oc_group_from_lookup",
280
- senderOpenId: "ou_bot",
281
- content: "hello",
282
- contentType: "text",
323
+ const result = await resolveReactionWithLookup({
324
+ event: makeReactionEvent({
325
+ chat_id: "oc_group_from_event",
326
+ chat_type: "group",
283
327
  }),
284
- uuid: () => "fixed-uuid",
328
+ lookupChatId: "oc_group_from_lookup",
285
329
  });
286
330
 
287
331
  expect(result).toEqual({
@@ -302,20 +346,8 @@ describe("resolveReactionSyntheticEvent", () => {
302
346
  });
303
347
 
304
348
  it("falls back to reacted message chat_id when event chat_id is absent", async () => {
305
- const event = makeReactionEvent();
306
- const result = await resolveReactionSyntheticEvent({
307
- cfg,
308
- accountId: "default",
309
- event,
310
- botOpenId: "ou_bot",
311
- fetchMessage: async () => ({
312
- messageId: "om_msg1",
313
- chatId: "oc_group_from_lookup",
314
- senderOpenId: "ou_bot",
315
- content: "hello",
316
- contentType: "text",
317
- }),
318
- uuid: () => "fixed-uuid",
349
+ const result = await resolveReactionWithLookup({
350
+ lookupChatId: "oc_group_from_lookup",
319
351
  });
320
352
 
321
353
  expect(result?.message.chat_id).toBe("oc_group_from_lookup");
@@ -323,20 +355,8 @@ describe("resolveReactionSyntheticEvent", () => {
323
355
  });
324
356
 
325
357
  it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
326
- const event = makeReactionEvent();
327
- const result = await resolveReactionSyntheticEvent({
328
- cfg,
329
- accountId: "default",
330
- event,
331
- botOpenId: "ou_bot",
332
- fetchMessage: async () => ({
333
- messageId: "om_msg1",
334
- chatId: "",
335
- senderOpenId: "ou_bot",
336
- content: "hello",
337
- contentType: "text",
338
- }),
339
- uuid: () => "fixed-uuid",
358
+ const result = await resolveReactionWithLookup({
359
+ lookupChatId: "",
340
360
  });
341
361
 
342
362
  expect(result?.message.chat_id).toBe("p2p:ou_user1");
@@ -389,42 +409,25 @@ describe("Feishu inbound debounce regressions", () => {
389
409
  });
390
410
 
391
411
  it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
392
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
393
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
394
- vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
395
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
412
+ setDedupPassThroughMocks();
396
413
  const onMessage = await setupDebounceMonitor();
397
414
 
398
- await onMessage(
415
+ await enqueueDebouncedMessage(
416
+ onMessage,
399
417
  createTextEvent({
400
418
  messageId: "om_1",
401
419
  text: "first",
402
- mentions: [
403
- {
404
- key: "@_user_1",
405
- id: { open_id: "ou_user_a" },
406
- name: "user-a",
407
- },
408
- ],
420
+ mentions: [createMention({ openId: "ou_user_a", name: "user-a" })],
409
421
  }),
410
422
  );
411
- await Promise.resolve();
412
- await Promise.resolve();
413
- await onMessage(
423
+ await enqueueDebouncedMessage(
424
+ onMessage,
414
425
  createTextEvent({
415
426
  messageId: "om_2",
416
427
  text: "@bot second",
417
- mentions: [
418
- {
419
- key: "@_user_1",
420
- id: { open_id: "ou_bot" },
421
- name: "bot",
422
- },
423
- ],
428
+ mentions: [createMention({ openId: "ou_bot", name: "bot" })],
424
429
  }),
425
430
  );
426
- await Promise.resolve();
427
- await Promise.resolve();
428
431
  await vi.advanceTimersByTimeAsync(25);
429
432
 
430
433
  expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
@@ -434,43 +437,57 @@ describe("Feishu inbound debounce regressions", () => {
434
437
  expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
435
438
  });
436
439
 
437
- it("does not synthesize mention-forward intent across separate messages", async () => {
440
+ it("passes prefetched botName through to handleFeishuMessage", async () => {
438
441
  vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
439
442
  vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
440
443
  vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
441
444
  vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
442
- const onMessage = await setupDebounceMonitor();
445
+ const onMessage = await setupDebounceMonitor({ botName: "OpenClaw Bot" });
443
446
 
444
447
  await onMessage(
445
448
  createTextEvent({
446
- messageId: "om_user_mention",
447
- text: "@alice first",
449
+ messageId: "om_name_passthrough",
450
+ text: "@bot hello",
448
451
  mentions: [
449
452
  {
450
453
  key: "@_user_1",
451
- id: { open_id: "ou_alice" },
452
- name: "alice",
454
+ id: { open_id: "ou_bot" },
455
+ name: "OpenClaw Bot",
453
456
  },
454
457
  ],
455
458
  }),
456
459
  );
457
460
  await Promise.resolve();
458
461
  await Promise.resolve();
459
- await onMessage(
462
+ await vi.advanceTimersByTimeAsync(25);
463
+
464
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
465
+ const firstParams = handleFeishuMessageMock.mock.calls[0]?.[0] as
466
+ | { botName?: string }
467
+ | undefined;
468
+ expect(firstParams?.botName).toBe("OpenClaw Bot");
469
+ });
470
+
471
+ it("does not synthesize mention-forward intent across separate messages", async () => {
472
+ setDedupPassThroughMocks();
473
+ const onMessage = await setupDebounceMonitor();
474
+
475
+ await enqueueDebouncedMessage(
476
+ onMessage,
477
+ createTextEvent({
478
+ messageId: "om_user_mention",
479
+ text: "@alice first",
480
+ mentions: [createMention({ openId: "ou_alice", name: "alice" })],
481
+ }),
482
+ );
483
+ await enqueueDebouncedMessage(
484
+ onMessage,
460
485
  createTextEvent({
461
486
  messageId: "om_bot_mention",
462
487
  text: "@bot second",
463
- mentions: [
464
- {
465
- key: "@_user_1",
466
- id: { open_id: "ou_bot" },
467
- name: "bot",
468
- },
469
- ],
488
+ mentions: [createMention({ openId: "ou_bot", name: "bot" })],
470
489
  }),
471
490
  );
472
- await Promise.resolve();
473
- await Promise.resolve();
474
491
  await vi.advanceTimersByTimeAsync(25);
475
492
 
476
493
  expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
@@ -483,35 +500,24 @@ describe("Feishu inbound debounce regressions", () => {
483
500
  });
484
501
 
485
502
  it("preserves bot mention signal when the latest merged message has no mentions", async () => {
486
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
487
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
488
- vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
489
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
503
+ setDedupPassThroughMocks();
490
504
  const onMessage = await setupDebounceMonitor();
491
505
 
492
- await onMessage(
506
+ await enqueueDebouncedMessage(
507
+ onMessage,
493
508
  createTextEvent({
494
509
  messageId: "om_bot_first",
495
510
  text: "@bot first",
496
- mentions: [
497
- {
498
- key: "@_user_1",
499
- id: { open_id: "ou_bot" },
500
- name: "bot",
501
- },
502
- ],
511
+ mentions: [createMention({ openId: "ou_bot", name: "bot" })],
503
512
  }),
504
513
  );
505
- await Promise.resolve();
506
- await Promise.resolve();
507
- await onMessage(
514
+ await enqueueDebouncedMessage(
515
+ onMessage,
508
516
  createTextEvent({
509
517
  messageId: "om_plain_second",
510
518
  text: "plain follow-up",
511
519
  }),
512
520
  );
513
- await Promise.resolve();
514
- await Promise.resolve();
515
521
  await vi.advanceTimersByTimeAsync(25);
516
522
 
517
523
  expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
@@ -1,19 +1,13 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
4
4
 
5
5
  const probeFeishuMock = vi.hoisted(() => vi.fn());
6
-
7
- vi.mock("./probe.js", () => ({
8
- probeFeishu: probeFeishuMock,
9
- }));
10
-
11
- vi.mock("./client.js", () => ({
6
+ const feishuClientMockModule = vi.hoisted(() => ({
12
7
  createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
13
8
  createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
14
9
  }));
15
-
16
- vi.mock("./runtime.js", () => ({
10
+ const feishuRuntimeMockModule = vi.hoisted(() => ({
17
11
  getFeishuRuntime: () => ({
18
12
  channel: {
19
13
  debounce: {
@@ -30,6 +24,13 @@ vi.mock("./runtime.js", () => ({
30
24
  }),
31
25
  }));
32
26
 
27
+ vi.mock("./probe.js", () => ({
28
+ probeFeishu: probeFeishuMock,
29
+ }));
30
+
31
+ vi.mock("./client.js", () => feishuClientMockModule);
32
+ vi.mock("./runtime.js", () => feishuRuntimeMockModule);
33
+
33
34
  function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
34
35
  return {
35
36
  channels: {
@@ -41,7 +42,7 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig
41
42
  {
42
43
  enabled: true,
43
44
  appId: `cli_${accountId}`,
44
- appSecret: `secret_${accountId}`,
45
+ appSecret: `secret_${accountId}`, // pragma: allowlist secret
45
46
  connectionMode: "websocket",
46
47
  },
47
48
  ]),
@@ -1,4 +1,4 @@
1
- import type { RuntimeEnv } from "openclaw/plugin-sdk";
1
+ import type { RuntimeEnv } from "openclaw/plugin-sdk/feishu";
2
2
  import { probeFeishu } from "./probe.js";
3
3
  import type { ResolvedFeishuAccount } from "./types.js";
4
4
 
@@ -10,6 +10,11 @@ type FetchBotOpenIdOptions = {
10
10
  timeoutMs?: number;
11
11
  };
12
12
 
13
+ export type FeishuMonitorBotIdentity = {
14
+ botOpenId?: string;
15
+ botName?: string;
16
+ };
17
+
13
18
  function isTimeoutErrorMessage(message: string | undefined): boolean {
14
19
  return message?.toLowerCase().includes("timeout") || message?.toLowerCase().includes("timed out")
15
20
  ? true
@@ -20,12 +25,12 @@ function isAbortErrorMessage(message: string | undefined): boolean {
20
25
  return message?.toLowerCase().includes("aborted") ?? false;
21
26
  }
22
27
 
23
- export async function fetchBotOpenIdForMonitor(
28
+ export async function fetchBotIdentityForMonitor(
24
29
  account: ResolvedFeishuAccount,
25
30
  options: FetchBotOpenIdOptions = {},
26
- ): Promise<string | undefined> {
31
+ ): Promise<FeishuMonitorBotIdentity> {
27
32
  if (options.abortSignal?.aborted) {
28
- return undefined;
33
+ return {};
29
34
  }
30
35
 
31
36
  const timeoutMs = options.timeoutMs ?? FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS;
@@ -34,11 +39,11 @@ export async function fetchBotOpenIdForMonitor(
34
39
  abortSignal: options.abortSignal,
35
40
  });
36
41
  if (result.ok) {
37
- return result.botOpenId;
42
+ return { botOpenId: result.botOpenId, botName: result.botName };
38
43
  }
39
44
 
40
45
  if (options.abortSignal?.aborted || isAbortErrorMessage(result.error)) {
41
- return undefined;
46
+ return {};
42
47
  }
43
48
 
44
49
  if (isTimeoutErrorMessage(result.error)) {
@@ -47,5 +52,13 @@ export async function fetchBotOpenIdForMonitor(
47
52
  `feishu[${account.accountId}]: bot info probe timed out after ${timeoutMs}ms; continuing startup`,
48
53
  );
49
54
  }
50
- return undefined;
55
+ return {};
56
+ }
57
+
58
+ export async function fetchBotOpenIdForMonitor(
59
+ account: ResolvedFeishuAccount,
60
+ options: FetchBotOpenIdOptions = {},
61
+ ): Promise<string | undefined> {
62
+ const identity = await fetchBotIdentityForMonitor(account, options);
63
+ return identity.botOpenId;
51
64
  }
@@ -6,11 +6,12 @@ import {
6
6
  type RuntimeEnv,
7
7
  WEBHOOK_ANOMALY_COUNTER_DEFAULTS as WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK,
8
8
  WEBHOOK_RATE_LIMIT_DEFAULTS as WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK,
9
- } from "openclaw/plugin-sdk";
9
+ } from "openclaw/plugin-sdk/feishu";
10
10
 
11
11
  export const wsClients = new Map<string, Lark.WSClient>();
12
12
  export const httpServers = new Map<string, http.Server>();
13
13
  export const botOpenIds = new Map<string, string>();
14
+ export const botNames = new Map<string, string>();
14
15
 
15
16
  export const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
16
17
  export const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
@@ -140,6 +141,7 @@ export function stopFeishuMonitorState(accountId?: string): void {
140
141
  httpServers.delete(accountId);
141
142
  }
142
143
  botOpenIds.delete(accountId);
144
+ botNames.delete(accountId);
143
145
  return;
144
146
  }
145
147
 
@@ -149,4 +151,5 @@ export function stopFeishuMonitorState(accountId?: string): void {
149
151
  }
150
152
  httpServers.clear();
151
153
  botOpenIds.clear();
154
+ botNames.clear();
152
155
  }