@openclaw/feishu 2026.3.1 → 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 (76) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +268 -11
  4. package/src/accounts.ts +101 -14
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +9 -1
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +945 -77
  9. package/src/bot.ts +492 -165
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +72 -68
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +221 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +33 -6
  18. package/src/config-schema.ts +18 -10
  19. package/src/dedup.ts +47 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/doc-schema.ts +16 -22
  23. package/src/docx-batch-insert.test.ts +90 -0
  24. package/src/docx-batch-insert.ts +8 -11
  25. package/src/docx.account-selection.test.ts +10 -16
  26. package/src/docx.test.ts +41 -189
  27. package/src/docx.ts +1 -1
  28. package/src/drive.ts +13 -17
  29. package/src/dynamic-agent.ts +1 -1
  30. package/src/feishu-command-handler.ts +59 -0
  31. package/src/media.test.ts +164 -14
  32. package/src/media.ts +44 -10
  33. package/src/mention.ts +1 -1
  34. package/src/monitor.account.ts +284 -25
  35. package/src/monitor.reaction.test.ts +395 -46
  36. package/src/monitor.startup.test.ts +25 -8
  37. package/src/monitor.startup.ts +20 -7
  38. package/src/monitor.state.defaults.test.ts +46 -0
  39. package/src/monitor.state.ts +88 -9
  40. package/src/monitor.test-mocks.ts +45 -0
  41. package/src/monitor.transport.ts +4 -1
  42. package/src/monitor.ts +4 -4
  43. package/src/monitor.webhook-security.test.ts +13 -11
  44. package/src/onboarding.status.test.ts +25 -0
  45. package/src/onboarding.test.ts +143 -0
  46. package/src/onboarding.ts +213 -106
  47. package/src/outbound.test.ts +178 -0
  48. package/src/outbound.ts +39 -6
  49. package/src/perm.ts +11 -15
  50. package/src/policy.test.ts +40 -0
  51. package/src/policy.ts +9 -10
  52. package/src/probe.test.ts +54 -36
  53. package/src/probe.ts +57 -37
  54. package/src/reactions.ts +1 -1
  55. package/src/reply-dispatcher.test.ts +216 -0
  56. package/src/reply-dispatcher.ts +89 -22
  57. package/src/runtime.ts +1 -1
  58. package/src/secret-input.ts +13 -0
  59. package/src/send-message.ts +71 -0
  60. package/src/send-target.test.ts +74 -0
  61. package/src/send-target.ts +7 -3
  62. package/src/send.reply-fallback.test.ts +74 -0
  63. package/src/send.test.ts +1 -1
  64. package/src/send.ts +88 -49
  65. package/src/streaming-card.test.ts +54 -0
  66. package/src/streaming-card.ts +96 -28
  67. package/src/targets.test.ts +29 -0
  68. package/src/targets.ts +25 -1
  69. package/src/tool-account-routing.test.ts +3 -3
  70. package/src/tool-account.ts +1 -1
  71. package/src/tool-factory-test-harness.ts +1 -1
  72. package/src/tool-result.test.ts +32 -0
  73. package/src/tool-result.ts +14 -0
  74. package/src/types.ts +11 -4
  75. package/src/typing.ts +1 -1
  76. package/src/wiki.ts +15 -19
@@ -1,14 +1,28 @@
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
- import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
6
+ import {
7
+ handleFeishuMessage,
8
+ parseFeishuMessageEvent,
9
+ type FeishuMessageEvent,
10
+ type FeishuBotAddedEvent,
11
+ } from "./bot.js";
7
12
  import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
8
13
  import { createEventDispatcher } from "./client.js";
9
- import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
10
- import { botOpenIds } from "./monitor.state.js";
14
+ import {
15
+ hasRecordedMessage,
16
+ hasRecordedMessagePersistent,
17
+ tryRecordMessage,
18
+ tryRecordMessagePersistent,
19
+ warmupDedupFromDisk,
20
+ } from "./dedup.js";
21
+ import { isMentionForwardRequest } from "./mention.js";
22
+ import { fetchBotIdentityForMonitor } from "./monitor.startup.js";
23
+ import { botNames, botOpenIds } from "./monitor.state.js";
11
24
  import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
25
+ import { getFeishuRuntime } from "./runtime.js";
12
26
  import { getMessageFeishu } from "./send.js";
13
27
  import type { ResolvedFeishuAccount } from "./types.js";
14
28
 
@@ -17,7 +31,7 @@ const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
17
31
  export type FeishuReactionCreatedEvent = {
18
32
  message_id: string;
19
33
  chat_id?: string;
20
- chat_type?: "p2p" | "group";
34
+ chat_type?: "p2p" | "group" | "private";
21
35
  reaction_type?: { emoji_type?: string };
22
36
  operator_type?: string;
23
37
  user_id?: { open_id?: string };
@@ -93,7 +107,8 @@ export async function resolveReactionSyntheticEvent(
93
107
 
94
108
  const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
95
109
  const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
96
- const syntheticChatType: "p2p" | "group" = event.chat_type ?? "p2p";
110
+ const syntheticChatType: "p2p" | "group" | "private" =
111
+ event.chat_type === "group" ? "group" : "p2p";
97
112
  return {
98
113
  sender: {
99
114
  sender_id: { open_id: senderId },
@@ -119,33 +134,262 @@ type RegisterEventHandlersContext = {
119
134
  fireAndForget?: boolean;
120
135
  };
121
136
 
137
+ /**
138
+ * Per-chat serial queue that ensures messages from the same chat are processed
139
+ * in arrival order while allowing different chats to run concurrently.
140
+ */
141
+ function createChatQueue() {
142
+ const queues = new Map<string, Promise<void>>();
143
+ return (chatId: string, task: () => Promise<void>): Promise<void> => {
144
+ const prev = queues.get(chatId) ?? Promise.resolve();
145
+ const next = prev.then(task, task);
146
+ queues.set(chatId, next);
147
+ void next.finally(() => {
148
+ if (queues.get(chatId) === next) {
149
+ queues.delete(chatId);
150
+ }
151
+ });
152
+ return next;
153
+ };
154
+ }
155
+
156
+ function mergeFeishuDebounceMentions(
157
+ entries: FeishuMessageEvent[],
158
+ ): FeishuMessageEvent["message"]["mentions"] | undefined {
159
+ const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
160
+ for (const entry of entries) {
161
+ for (const mention of entry.message.mentions ?? []) {
162
+ const stableId =
163
+ mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
164
+ const mentionName = mention.name?.trim();
165
+ const mentionKey = mention.key?.trim();
166
+ const fallback =
167
+ mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
168
+ const key = stableId || fallback;
169
+ if (!key || merged.has(key)) {
170
+ continue;
171
+ }
172
+ merged.set(key, mention);
173
+ }
174
+ }
175
+ if (merged.size === 0) {
176
+ return undefined;
177
+ }
178
+ return Array.from(merged.values());
179
+ }
180
+
181
+ function dedupeFeishuDebounceEntriesByMessageId(
182
+ entries: FeishuMessageEvent[],
183
+ ): FeishuMessageEvent[] {
184
+ const seen = new Set<string>();
185
+ const deduped: FeishuMessageEvent[] = [];
186
+ for (const entry of entries) {
187
+ const messageId = entry.message.message_id?.trim();
188
+ if (!messageId) {
189
+ deduped.push(entry);
190
+ continue;
191
+ }
192
+ if (seen.has(messageId)) {
193
+ continue;
194
+ }
195
+ seen.add(messageId);
196
+ deduped.push(entry);
197
+ }
198
+ return deduped;
199
+ }
200
+
201
+ function resolveFeishuDebounceMentions(params: {
202
+ entries: FeishuMessageEvent[];
203
+ botOpenId?: string;
204
+ }): FeishuMessageEvent["message"]["mentions"] | undefined {
205
+ const { entries, botOpenId } = params;
206
+ if (entries.length === 0) {
207
+ return undefined;
208
+ }
209
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
210
+ const entry = entries[index];
211
+ if (isMentionForwardRequest(entry, botOpenId)) {
212
+ // Keep mention-forward semantics scoped to a single source message.
213
+ return mergeFeishuDebounceMentions([entry]);
214
+ }
215
+ }
216
+ const merged = mergeFeishuDebounceMentions(entries);
217
+ if (!merged) {
218
+ return undefined;
219
+ }
220
+ const normalizedBotOpenId = botOpenId?.trim();
221
+ if (!normalizedBotOpenId) {
222
+ return undefined;
223
+ }
224
+ const botMentions = merged.filter(
225
+ (mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
226
+ );
227
+ return botMentions.length > 0 ? botMentions : undefined;
228
+ }
229
+
122
230
  function registerEventHandlers(
123
231
  eventDispatcher: Lark.EventDispatcher,
124
232
  context: RegisterEventHandlersContext,
125
233
  ): void {
126
234
  const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
235
+ const core = getFeishuRuntime();
236
+ const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
237
+ cfg,
238
+ channel: "feishu",
239
+ });
127
240
  const log = runtime?.log ?? console.log;
128
241
  const error = runtime?.error ?? console.error;
242
+ const enqueue = createChatQueue();
243
+ const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
244
+ const chatId = event.message.chat_id?.trim() || "unknown";
245
+ const task = () =>
246
+ handleFeishuMessage({
247
+ cfg,
248
+ event,
249
+ botOpenId: botOpenIds.get(accountId),
250
+ botName: botNames.get(accountId),
251
+ runtime,
252
+ chatHistories,
253
+ accountId,
254
+ });
255
+ await enqueue(chatId, task);
256
+ };
257
+ const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
258
+ const senderId =
259
+ event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
260
+ return senderId || undefined;
261
+ };
262
+ const resolveDebounceText = (event: FeishuMessageEvent): string => {
263
+ const botOpenId = botOpenIds.get(accountId);
264
+ const parsed = parseFeishuMessageEvent(event, botOpenId, botNames.get(accountId));
265
+ return parsed.content.trim();
266
+ };
267
+ const recordSuppressedMessageIds = async (
268
+ entries: FeishuMessageEvent[],
269
+ dispatchMessageId?: string,
270
+ ) => {
271
+ const keepMessageId = dispatchMessageId?.trim();
272
+ const suppressedIds = new Set(
273
+ entries
274
+ .map((entry) => entry.message.message_id?.trim())
275
+ .filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
276
+ );
277
+ if (suppressedIds.size === 0) {
278
+ return;
279
+ }
280
+ for (const messageId of suppressedIds) {
281
+ // Keep in-memory dedupe in sync with handleFeishuMessage's keying.
282
+ tryRecordMessage(`${accountId}:${messageId}`);
283
+ try {
284
+ await tryRecordMessagePersistent(messageId, accountId, log);
285
+ } catch (err) {
286
+ error(
287
+ `feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
288
+ );
289
+ }
290
+ }
291
+ };
292
+ 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);
302
+ };
303
+ const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
304
+ debounceMs: inboundDebounceMs,
305
+ buildKey: (event) => {
306
+ const chatId = event.message.chat_id?.trim();
307
+ const senderId = resolveSenderDebounceId(event);
308
+ if (!chatId || !senderId) {
309
+ return null;
310
+ }
311
+ const rootId = event.message.root_id?.trim();
312
+ const threadKey = rootId ? `thread:${rootId}` : "chat";
313
+ return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
314
+ },
315
+ shouldDebounce: (event) => {
316
+ if (event.message.message_type !== "text") {
317
+ return false;
318
+ }
319
+ const text = resolveDebounceText(event);
320
+ if (!text) {
321
+ return false;
322
+ }
323
+ return !core.channel.text.hasControlCommand(text, cfg);
324
+ },
325
+ onFlush: async (entries) => {
326
+ const last = entries.at(-1);
327
+ if (!last) {
328
+ return;
329
+ }
330
+ if (entries.length === 1) {
331
+ await dispatchFeishuMessage(last);
332
+ return;
333
+ }
334
+ const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
335
+ const freshEntries: FeishuMessageEvent[] = [];
336
+ for (const entry of dedupedEntries) {
337
+ if (!(await isMessageAlreadyProcessed(entry))) {
338
+ freshEntries.push(entry);
339
+ }
340
+ }
341
+ const dispatchEntry = freshEntries.at(-1);
342
+ if (!dispatchEntry) {
343
+ return;
344
+ }
345
+ await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
346
+ const combinedText = freshEntries
347
+ .map((entry) => resolveDebounceText(entry))
348
+ .filter(Boolean)
349
+ .join("\n");
350
+ const mergedMentions = resolveFeishuDebounceMentions({
351
+ entries: freshEntries,
352
+ botOpenId: botOpenIds.get(accountId),
353
+ });
354
+ if (!combinedText.trim()) {
355
+ await dispatchFeishuMessage({
356
+ ...dispatchEntry,
357
+ message: {
358
+ ...dispatchEntry.message,
359
+ mentions: mergedMentions ?? dispatchEntry.message.mentions,
360
+ },
361
+ });
362
+ return;
363
+ }
364
+ await dispatchFeishuMessage({
365
+ ...dispatchEntry,
366
+ message: {
367
+ ...dispatchEntry.message,
368
+ message_type: "text",
369
+ content: JSON.stringify({ text: combinedText }),
370
+ mentions: mergedMentions ?? dispatchEntry.message.mentions,
371
+ },
372
+ });
373
+ },
374
+ onError: (err) => {
375
+ error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
376
+ },
377
+ });
129
378
 
130
379
  eventDispatcher.register({
131
380
  "im.message.receive_v1": async (data) => {
132
- try {
381
+ const processMessage = async () => {
133
382
  const event = data as unknown as FeishuMessageEvent;
134
- const promise = handleFeishuMessage({
135
- cfg,
136
- event,
137
- botOpenId: botOpenIds.get(accountId),
138
- runtime,
139
- chatHistories,
140
- accountId,
383
+ await inboundDebouncer.enqueue(event);
384
+ };
385
+ if (fireAndForget) {
386
+ void processMessage().catch((err) => {
387
+ error(`feishu[${accountId}]: error handling message: ${String(err)}`);
141
388
  });
142
- if (fireAndForget) {
143
- promise.catch((err) => {
144
- error(`feishu[${accountId}]: error handling message: ${String(err)}`);
145
- });
146
- } else {
147
- await promise;
148
- }
389
+ return;
390
+ }
391
+ try {
392
+ await processMessage();
149
393
  } catch (err) {
150
394
  error(`feishu[${accountId}]: error handling message: ${String(err)}`);
151
395
  }
@@ -187,6 +431,7 @@ function registerEventHandlers(
187
431
  cfg,
188
432
  event: syntheticEvent,
189
433
  botOpenId: myBotId,
434
+ botName: botNames.get(accountId),
190
435
  runtime,
191
436
  chatHistories,
192
437
  accountId,
@@ -240,7 +485,9 @@ function registerEventHandlers(
240
485
  });
241
486
  }
242
487
 
243
- export type BotOpenIdSource = { kind: "prefetched"; botOpenId?: string } | { kind: "fetch" };
488
+ export type BotOpenIdSource =
489
+ | { kind: "prefetched"; botOpenId?: string; botName?: string }
490
+ | { kind: "fetch" };
244
491
 
245
492
  export type MonitorSingleAccountParams = {
246
493
  cfg: ClawdbotConfig;
@@ -256,11 +503,18 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
256
503
  const log = runtime?.log ?? console.log;
257
504
 
258
505
  const botOpenIdSource = params.botOpenIdSource ?? { kind: "fetch" };
259
- const botOpenId =
506
+ const botIdentity =
260
507
  botOpenIdSource.kind === "prefetched"
261
- ? botOpenIdSource.botOpenId
262
- : 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();
263
512
  botOpenIds.set(accountId, botOpenId ?? "");
513
+ if (botName) {
514
+ botNames.set(accountId, botName);
515
+ } else {
516
+ botNames.delete(accountId);
517
+ }
264
518
  log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
265
519
 
266
520
  const connectionMode = account.config.connectionMode ?? "websocket";
@@ -268,6 +522,11 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
268
522
  throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
269
523
  }
270
524
 
525
+ const warmupCount = await warmupDedupFromDisk(accountId, log);
526
+ if (warmupCount > 0) {
527
+ log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
528
+ }
529
+
271
530
  const eventDispatcher = createEventDispatcher(account);
272
531
  const chatHistories = new Map<string, HistoryEntry[]>();
273
532