@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
package/src/bot.ts CHANGED
@@ -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 {
3
3
  buildAgentMediaPayload,
4
4
  buildPendingHistoryContextFromMap,
@@ -6,23 +6,20 @@ import {
6
6
  createScopedPairingAccess,
7
7
  DEFAULT_GROUP_HISTORY_LIMIT,
8
8
  type HistoryEntry,
9
+ issuePairingChallenge,
10
+ normalizeAgentId,
9
11
  recordPendingHistoryEntryIfEnabled,
10
12
  resolveOpenProviderRuntimeGroupPolicy,
11
13
  resolveDefaultGroupPolicy,
12
14
  warnMissingProviderGroupPolicyFallbackOnce,
13
- } from "openclaw/plugin-sdk";
15
+ } from "openclaw/plugin-sdk/feishu";
14
16
  import { resolveFeishuAccount } from "./accounts.js";
15
17
  import { createFeishuClient } from "./client.js";
16
18
  import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
17
19
  import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
18
20
  import { normalizeFeishuExternalKey } from "./external-keys.js";
19
21
  import { downloadMessageResourceFeishu } from "./media.js";
20
- import {
21
- escapeRegExp,
22
- extractMentionTargets,
23
- extractMessageBody,
24
- isMentionForwardRequest,
25
- } from "./mention.js";
22
+ import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
26
23
  import {
27
24
  resolveFeishuGroupConfig,
28
25
  resolveFeishuReplyPolicy,
@@ -44,6 +41,29 @@ type PermissionError = {
44
41
  grantUrl?: string;
45
42
  };
46
43
 
44
+ const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
45
+
46
+ // Feishu API sometimes returns incorrect scope names in permission error
47
+ // responses (e.g. "contact:contact.base:readonly" instead of the valid
48
+ // "contact:user.base:readonly"). This map corrects known mismatches.
49
+ const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
50
+ "contact:contact.base:readonly": "contact:user.base:readonly",
51
+ };
52
+
53
+ function correctFeishuScopeInUrl(url: string): string {
54
+ let corrected = url;
55
+ for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
56
+ corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
57
+ corrected = corrected.replaceAll(wrong, right);
58
+ }
59
+ return corrected;
60
+ }
61
+
62
+ function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
63
+ const message = permissionError.message.toLowerCase();
64
+ return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
65
+ }
66
+
47
67
  function extractPermissionError(err: unknown): PermissionError | null {
48
68
  if (!err || typeof err !== "object") return null;
49
69
 
@@ -64,7 +84,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
64
84
  // Extract the grant URL from the error message (contains the direct link)
65
85
  const msg = feishuErr.msg ?? "";
66
86
  const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
67
- const grantUrl = urlMatch?.[0];
87
+ const grantUrl = urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined;
68
88
 
69
89
  return {
70
90
  code: feishuErr.code,
@@ -140,6 +160,10 @@ async function resolveFeishuSenderName(params: {
140
160
  // Check if this is a permission error
141
161
  const permErr = extractPermissionError(err);
142
162
  if (permErr) {
163
+ if (shouldSuppressPermissionErrorNotice(permErr)) {
164
+ log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
165
+ return {};
166
+ }
143
167
  log(`feishu: permission error resolving sender name: code=${permErr.code}`);
144
168
  return { permissionError: permErr };
145
169
  }
@@ -164,8 +188,9 @@ export type FeishuMessageEvent = {
164
188
  message_id: string;
165
189
  root_id?: string;
166
190
  parent_id?: string;
191
+ thread_id?: string;
167
192
  chat_id: string;
168
- chat_type: "p2p" | "group";
193
+ chat_type: "p2p" | "group" | "private";
169
194
  message_type: string;
170
195
  content: string;
171
196
  create_time?: string;
@@ -193,6 +218,94 @@ export type FeishuBotAddedEvent = {
193
218
  operator_tenant_key?: string;
194
219
  };
195
220
 
221
+ type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
222
+
223
+ type ResolvedFeishuGroupSession = {
224
+ peerId: string;
225
+ parentPeer: { kind: "group"; id: string } | null;
226
+ groupSessionScope: GroupSessionScope;
227
+ replyInThread: boolean;
228
+ threadReply: boolean;
229
+ };
230
+
231
+ function resolveFeishuGroupSession(params: {
232
+ chatId: string;
233
+ senderOpenId: string;
234
+ messageId: string;
235
+ rootId?: string;
236
+ threadId?: string;
237
+ groupConfig?: {
238
+ groupSessionScope?: GroupSessionScope;
239
+ topicSessionMode?: "enabled" | "disabled";
240
+ replyInThread?: "enabled" | "disabled";
241
+ };
242
+ feishuCfg?: {
243
+ groupSessionScope?: GroupSessionScope;
244
+ topicSessionMode?: "enabled" | "disabled";
245
+ replyInThread?: "enabled" | "disabled";
246
+ };
247
+ }): ResolvedFeishuGroupSession {
248
+ const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
249
+
250
+ const normalizedThreadId = threadId?.trim();
251
+ const normalizedRootId = rootId?.trim();
252
+ const threadReply = Boolean(normalizedThreadId || normalizedRootId);
253
+ const replyInThread =
254
+ (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
255
+ threadReply;
256
+
257
+ const legacyTopicSessionMode =
258
+ groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
259
+ const groupSessionScope: GroupSessionScope =
260
+ groupConfig?.groupSessionScope ??
261
+ feishuCfg?.groupSessionScope ??
262
+ (legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
263
+
264
+ // Keep topic session keys stable across the "first turn creates thread" flow:
265
+ // first turn may only have message_id, while the next turn carries root_id/thread_id.
266
+ // Prefer root_id first so both turns stay on the same peer key.
267
+ const topicScope =
268
+ groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
269
+ ? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
270
+ : null;
271
+
272
+ let peerId = chatId;
273
+ switch (groupSessionScope) {
274
+ case "group_sender":
275
+ peerId = `${chatId}:sender:${senderOpenId}`;
276
+ break;
277
+ case "group_topic":
278
+ peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
279
+ break;
280
+ case "group_topic_sender":
281
+ peerId = topicScope
282
+ ? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
283
+ : `${chatId}:sender:${senderOpenId}`;
284
+ break;
285
+ case "group":
286
+ default:
287
+ peerId = chatId;
288
+ break;
289
+ }
290
+
291
+ const parentPeer =
292
+ topicScope &&
293
+ (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
294
+ ? {
295
+ kind: "group" as const,
296
+ id: chatId,
297
+ }
298
+ : null;
299
+
300
+ return {
301
+ peerId,
302
+ parentPeer,
303
+ groupSessionScope,
304
+ replyInThread,
305
+ threadReply,
306
+ };
307
+ }
308
+
196
309
  function parseMessageContent(content: string, messageType: string): string {
197
310
  if (messageType === "post") {
198
311
  // Extract text content from rich text post
@@ -340,8 +453,12 @@ function formatSubMessageContent(content: string, contentType: string): string {
340
453
 
341
454
  function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
342
455
  if (!botOpenId) return false;
456
+ // Check for @all (@_all in Feishu) — treat as mentioning every bot
457
+ const rawContent = event.message.content ?? "";
458
+ if (rawContent.includes("@_all")) return true;
343
459
  const mentions = event.message.mentions ?? [];
344
460
  if (mentions.length > 0) {
461
+ // Rely on Feishu mention IDs; display names can vary by alias/context.
345
462
  return mentions.some((m) => m.id.open_id === botOpenId);
346
463
  }
347
464
  // Post (rich text) messages may have empty message.mentions when they contain docs/paste
@@ -352,17 +469,41 @@ function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boole
352
469
  return false;
353
470
  }
354
471
 
355
- export function stripBotMention(
472
+ function normalizeMentions(
356
473
  text: string,
357
474
  mentions?: FeishuMessageEvent["message"]["mentions"],
475
+ botStripId?: string,
358
476
  ): string {
359
477
  if (!mentions || mentions.length === 0) return text;
478
+
479
+ const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
480
+ const escapeName = (value: string) => value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
360
481
  let result = text;
482
+
361
483
  for (const mention of mentions) {
362
- result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), "");
363
- result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), "");
484
+ const mentionId = mention.id.open_id;
485
+ const replacement =
486
+ botStripId && mentionId === botStripId
487
+ ? ""
488
+ : mentionId
489
+ ? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
490
+ : `@${mention.name}`;
491
+
492
+ result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
493
+ }
494
+
495
+ return result;
496
+ }
497
+
498
+ function normalizeFeishuCommandProbeBody(text: string): string {
499
+ if (!text) {
500
+ return "";
364
501
  }
365
- return result.trim();
502
+ return text
503
+ .replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
504
+ .replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
505
+ .replace(/\s+/g, " ")
506
+ .trim();
366
507
  }
367
508
 
368
509
  /**
@@ -598,6 +739,31 @@ async function resolveFeishuMediaList(params: {
598
739
  return out;
599
740
  }
600
741
 
742
+ // --- Broadcast support ---
743
+ // Resolve broadcast agent list for a given peer (group) ID.
744
+ // Returns null if no broadcast config exists or the peer is not in the broadcast list.
745
+ export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null {
746
+ const broadcast = (cfg as Record<string, unknown>).broadcast;
747
+ if (!broadcast || typeof broadcast !== "object") return null;
748
+ const agents = (broadcast as Record<string, unknown>)[peerId];
749
+ if (!Array.isArray(agents) || agents.length === 0) return null;
750
+ return agents as string[];
751
+ }
752
+
753
+ // Build a session key for a broadcast target agent by replacing the agent ID prefix.
754
+ // Session keys follow the format: agent:<agentId>:<channel>:<peerKind>:<peerId>
755
+ export function buildBroadcastSessionKey(
756
+ baseSessionKey: string,
757
+ originalAgentId: string,
758
+ targetAgentId: string,
759
+ ): string {
760
+ const prefix = `agent:${originalAgentId}:`;
761
+ if (baseSessionKey.startsWith(prefix)) {
762
+ return `agent:${targetAgentId}:${baseSessionKey.slice(prefix.length)}`;
763
+ }
764
+ return baseSessionKey;
765
+ }
766
+
601
767
  /**
602
768
  * Build media payload for inbound context.
603
769
  * Similar to Discord's buildDiscordMediaPayload().
@@ -605,10 +771,17 @@ async function resolveFeishuMediaList(params: {
605
771
  export function parseFeishuMessageEvent(
606
772
  event: FeishuMessageEvent,
607
773
  botOpenId?: string,
774
+ _botName?: string,
608
775
  ): FeishuMessageContext {
609
776
  const rawContent = parseMessageContent(event.message.content, event.message.message_type);
610
777
  const mentionedBot = checkBotMentioned(event, botOpenId);
611
- const content = stripBotMention(rawContent, event.message.mentions);
778
+ const hasAnyMention = (event.message.mentions?.length ?? 0) > 0;
779
+ // Strip the bot's own mention so slash commands like @Bot /help retain
780
+ // the leading /. This applies in both p2p *and* group contexts — the
781
+ // mentionedBot flag already captures whether the bot was addressed, so
782
+ // keeping the mention tag in content only breaks command detection (#35994).
783
+ // Non-bot mentions (e.g. mention-forward targets) are still normalized to <at> tags.
784
+ const content = normalizeMentions(rawContent, event.message.mentions, botOpenId);
612
785
  const senderOpenId = event.sender.sender_id.open_id?.trim();
613
786
  const senderUserId = event.sender.sender_id.user_id?.trim();
614
787
  const senderFallbackId = senderOpenId || senderUserId || "";
@@ -622,8 +795,10 @@ export function parseFeishuMessageEvent(
622
795
  senderOpenId: senderFallbackId,
623
796
  chatType: event.message.chat_type,
624
797
  mentionedBot,
798
+ hasAnyMention,
625
799
  rootId: event.message.root_id || undefined,
626
800
  parentId: event.message.parent_id || undefined,
801
+ threadId: event.message.thread_id || undefined,
627
802
  content,
628
803
  contentType: event.message.message_type,
629
804
  };
@@ -633,9 +808,6 @@ export function parseFeishuMessageEvent(
633
808
  const mentionTargets = extractMentionTargets(event, botOpenId);
634
809
  if (mentionTargets.length > 0) {
635
810
  ctx.mentionTargets = mentionTargets;
636
- // Extract message body (remove all @ placeholders)
637
- const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
638
- ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
639
811
  }
640
812
  }
641
813
 
@@ -645,12 +817,13 @@ export function parseFeishuMessageEvent(
645
817
  export function buildFeishuAgentBody(params: {
646
818
  ctx: Pick<
647
819
  FeishuMessageContext,
648
- "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
820
+ "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
649
821
  >;
650
822
  quotedContent?: string;
651
823
  permissionErrorForAgent?: PermissionError;
824
+ botOpenId?: string;
652
825
  }): string {
653
- const { ctx, quotedContent, permissionErrorForAgent } = params;
826
+ const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
654
827
  let messageBody = ctx.content;
655
828
  if (quotedContent) {
656
829
  messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
@@ -660,6 +833,16 @@ export function buildFeishuAgentBody(params: {
660
833
  const speaker = ctx.senderName ?? ctx.senderOpenId;
661
834
  messageBody = `${speaker}: ${messageBody}`;
662
835
 
836
+ if (ctx.hasAnyMention) {
837
+ const botIdHint = botOpenId?.trim();
838
+ messageBody +=
839
+ `\n\n[System: The content may include mention tags in the form <at user_id="...">name</at>. ` +
840
+ `Treat these as real mentions of Feishu entities (users or bots).]`;
841
+ if (botIdHint) {
842
+ messageBody += `\n[System: If user_id is "${botIdHint}", that mention refers to you.]`;
843
+ }
844
+ }
845
+
663
846
  if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
664
847
  const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
665
848
  messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
@@ -680,11 +863,12 @@ export async function handleFeishuMessage(params: {
680
863
  cfg: ClawdbotConfig;
681
864
  event: FeishuMessageEvent;
682
865
  botOpenId?: string;
866
+ botName?: string;
683
867
  runtime?: RuntimeEnv;
684
868
  chatHistories?: Map<string, HistoryEntry[]>;
685
869
  accountId?: string;
686
870
  }): Promise<void> {
687
- const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
871
+ const { cfg, event, botOpenId, botName, runtime, chatHistories, accountId } = params;
688
872
 
689
873
  // Resolve account with merged config
690
874
  const account = resolveFeishuAccount({ cfg, accountId });
@@ -707,8 +891,9 @@ export async function handleFeishuMessage(params: {
707
891
  return;
708
892
  }
709
893
 
710
- let ctx = parseFeishuMessageEvent(event, botOpenId);
894
+ let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
711
895
  const isGroup = ctx.chatType === "group";
896
+ const isDirect = !isGroup;
712
897
  const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
713
898
 
714
899
  // Handle merge_forward messages: fetch full message via API then expand sub-messages
@@ -784,10 +969,27 @@ export async function handleFeishuMessage(params: {
784
969
  const groupConfig = isGroup
785
970
  ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
786
971
  : undefined;
972
+ const groupSession = isGroup
973
+ ? resolveFeishuGroupSession({
974
+ chatId: ctx.chatId,
975
+ senderOpenId: ctx.senderOpenId,
976
+ messageId: ctx.messageId,
977
+ rootId: ctx.rootId,
978
+ threadId: ctx.threadId,
979
+ groupConfig,
980
+ feishuCfg,
981
+ })
982
+ : null;
983
+ const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined;
787
984
  const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
788
985
  const configAllowFrom = feishuCfg?.allowFrom ?? [];
789
986
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
987
+ const rawBroadcastAgents = isGroup ? resolveBroadcastAgents(cfg, ctx.chatId) : null;
988
+ const broadcastAgents = rawBroadcastAgents
989
+ ? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
990
+ : null;
790
991
 
992
+ let requireMention = false; // DMs never require mention; groups may override below
791
993
  if (isGroup) {
792
994
  if (groupConfig?.enabled === false) {
793
995
  log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
@@ -842,20 +1044,22 @@ export async function handleFeishuMessage(params: {
842
1044
  }
843
1045
  }
844
1046
 
845
- const { requireMention } = resolveFeishuReplyPolicy({
1047
+ ({ requireMention } = resolveFeishuReplyPolicy({
846
1048
  isDirectMessage: false,
847
1049
  globalConfig: feishuCfg,
848
1050
  groupConfig,
849
- });
1051
+ }));
850
1052
 
851
1053
  if (requireMention && !ctx.mentionedBot) {
852
- log(
853
- `feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
854
- );
855
- if (chatHistories) {
1054
+ log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot`);
1055
+ // Record to pending history for non-broadcast groups only. For broadcast groups,
1056
+ // the mentioned handler's broadcast dispatch writes the turn directly into all
1057
+ // agent sessions — buffering here would cause duplicate replay when this account
1058
+ // later becomes active via buildPendingHistoryContextFromMap.
1059
+ if (!broadcastAgents && chatHistories && groupHistoryKey) {
856
1060
  recordPendingHistoryEntryIfEnabled({
857
1061
  historyMap: chatHistories,
858
- historyKey: ctx.chatId,
1062
+ historyKey: groupHistoryKey,
859
1063
  limit: historyLimit,
860
1064
  entry: {
861
1065
  sender: ctx.senderOpenId,
@@ -877,8 +1081,9 @@ export async function handleFeishuMessage(params: {
877
1081
  channel: "feishu",
878
1082
  accountId: account.accountId,
879
1083
  });
1084
+ const commandProbeBody = isGroup ? normalizeFeishuCommandProbeBody(ctx.content) : ctx.content;
880
1085
  const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
881
- ctx.content,
1086
+ commandProbeBody,
882
1087
  cfg,
883
1088
  );
884
1089
  const storeAllowFrom =
@@ -895,31 +1100,31 @@ export async function handleFeishuMessage(params: {
895
1100
  senderName: ctx.senderName,
896
1101
  }).allowed;
897
1102
 
898
- if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
1103
+ if (isDirect && dmPolicy !== "open" && !dmAllowed) {
899
1104
  if (dmPolicy === "pairing") {
900
- const { code, created } = await pairing.upsertPairingRequest({
901
- id: ctx.senderOpenId,
1105
+ await issuePairingChallenge({
1106
+ channel: "feishu",
1107
+ senderId: ctx.senderOpenId,
1108
+ senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
902
1109
  meta: { name: ctx.senderName },
903
- });
904
- if (created) {
905
- log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
906
- try {
1110
+ upsertPairingRequest: pairing.upsertPairingRequest,
1111
+ onCreated: () => {
1112
+ log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
1113
+ },
1114
+ sendPairingReply: async (text) => {
907
1115
  await sendMessageFeishu({
908
1116
  cfg,
909
- to: `user:${ctx.senderOpenId}`,
910
- text: core.channel.pairing.buildPairingReply({
911
- channel: "feishu",
912
- idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
913
- code,
914
- }),
1117
+ to: `chat:${ctx.chatId}`,
1118
+ text,
915
1119
  accountId: account.accountId,
916
1120
  });
917
- } catch (err) {
1121
+ },
1122
+ onReplyError: (err) => {
918
1123
  log(
919
1124
  `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
920
1125
  );
921
- }
922
- }
1126
+ },
1127
+ });
923
1128
  } else {
924
1129
  log(
925
1130
  `feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
@@ -950,50 +1155,14 @@ export async function handleFeishuMessage(params: {
950
1155
  // Using a group-scoped From causes the agent to treat different users as the same person.
951
1156
  const feishuFrom = `feishu:${ctx.senderOpenId}`;
952
1157
  const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
1158
+ const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
1159
+ const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
1160
+ const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
953
1161
 
954
- // Resolve peer ID for session routing.
955
- // Default is one session per group chat; this can be customized with groupSessionScope.
956
- let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
957
- let groupSessionScope: "group" | "group_sender" | "group_topic" | "group_topic_sender" =
958
- "group";
959
- let topicRootForSession: string | null = null;
960
- const replyInThread =
961
- isGroup &&
962
- (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
963
-
964
- if (isGroup) {
965
- const legacyTopicSessionMode =
966
- groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
967
- groupSessionScope =
968
- groupConfig?.groupSessionScope ??
969
- feishuCfg?.groupSessionScope ??
970
- (legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
971
-
972
- // When topic-scoped sessions are enabled and replyInThread is on, the first
973
- // bot reply creates the thread rooted at the current message ID.
974
- if (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender") {
975
- topicRootForSession = ctx.rootId ?? (replyInThread ? ctx.messageId : null);
976
- }
977
-
978
- switch (groupSessionScope) {
979
- case "group_sender":
980
- peerId = `${ctx.chatId}:sender:${ctx.senderOpenId}`;
981
- break;
982
- case "group_topic":
983
- peerId = topicRootForSession ? `${ctx.chatId}:topic:${topicRootForSession}` : ctx.chatId;
984
- break;
985
- case "group_topic_sender":
986
- peerId = topicRootForSession
987
- ? `${ctx.chatId}:topic:${topicRootForSession}:sender:${ctx.senderOpenId}`
988
- : `${ctx.chatId}:sender:${ctx.senderOpenId}`;
989
- break;
990
- case "group":
991
- default:
992
- peerId = ctx.chatId;
993
- break;
994
- }
995
-
996
- log(`feishu[${account.accountId}]: group session scope=${groupSessionScope}, peer=${peerId}`);
1162
+ if (isGroup && groupSession) {
1163
+ log(
1164
+ `feishu[${account.accountId}]: group session scope=${groupSession.groupSessionScope}, peer=${peerId}`,
1165
+ );
997
1166
  }
998
1167
 
999
1168
  let route = core.channel.routing.resolveAgentRoute({
@@ -1004,16 +1173,7 @@ export async function handleFeishuMessage(params: {
1004
1173
  kind: isGroup ? "group" : "direct",
1005
1174
  id: peerId,
1006
1175
  },
1007
- // Add parentPeer for binding inheritance in topic-scoped modes.
1008
- parentPeer:
1009
- isGroup &&
1010
- topicRootForSession &&
1011
- (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
1012
- ? {
1013
- kind: "group",
1014
- id: ctx.chatId,
1015
- }
1016
- : null,
1176
+ parentPeer,
1017
1177
  });
1018
1178
 
1019
1179
  // Dynamic agent creation for DM users
@@ -1094,6 +1254,7 @@ export async function handleFeishuMessage(params: {
1094
1254
  ctx,
1095
1255
  quotedContent,
1096
1256
  permissionErrorForAgent,
1257
+ botOpenId,
1097
1258
  });
1098
1259
  const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
1099
1260
  if (permissionErrorForAgent) {
@@ -1110,7 +1271,7 @@ export async function handleFeishuMessage(params: {
1110
1271
  });
1111
1272
 
1112
1273
  let combinedBody = body;
1113
- const historyKey = isGroup ? ctx.chatId : undefined;
1274
+ const historyKey = groupHistoryKey;
1114
1275
 
1115
1276
  if (isGroup && historyKey && chatHistories) {
1116
1277
  combinedBody = buildPendingHistoryContextFromMap({
@@ -1139,81 +1300,247 @@ export async function handleFeishuMessage(params: {
1139
1300
  }))
1140
1301
  : undefined;
1141
1302
 
1142
- const ctxPayload = core.channel.reply.finalizeInboundContext({
1143
- Body: combinedBody,
1144
- BodyForAgent: messageBody,
1145
- InboundHistory: inboundHistory,
1146
- // Quote/reply message support: use standard ReplyToId for parent,
1147
- // and pass root_id for thread reconstruction.
1148
- ReplyToId: ctx.parentId,
1149
- RootMessageId: ctx.rootId,
1150
- RawBody: ctx.content,
1151
- CommandBody: ctx.content,
1152
- From: feishuFrom,
1153
- To: feishuTo,
1154
- SessionKey: route.sessionKey,
1155
- AccountId: route.accountId,
1156
- ChatType: isGroup ? "group" : "direct",
1157
- GroupSubject: isGroup ? ctx.chatId : undefined,
1158
- SenderName: ctx.senderName ?? ctx.senderOpenId,
1159
- SenderId: ctx.senderOpenId,
1160
- Provider: "feishu" as const,
1161
- Surface: "feishu" as const,
1162
- MessageSid: ctx.messageId,
1163
- ReplyToBody: quotedContent ?? undefined,
1164
- Timestamp: Date.now(),
1165
- WasMentioned: ctx.mentionedBot,
1166
- CommandAuthorized: commandAuthorized,
1167
- OriginatingChannel: "feishu" as const,
1168
- OriginatingTo: feishuTo,
1169
- ...mediaPayload,
1170
- });
1303
+ // --- Shared context builder for dispatch ---
1304
+ const buildCtxPayloadForAgent = (
1305
+ agentSessionKey: string,
1306
+ agentAccountId: string,
1307
+ wasMentioned: boolean,
1308
+ ) =>
1309
+ core.channel.reply.finalizeInboundContext({
1310
+ Body: combinedBody,
1311
+ BodyForAgent: messageBody,
1312
+ InboundHistory: inboundHistory,
1313
+ ReplyToId: ctx.parentId,
1314
+ RootMessageId: ctx.rootId,
1315
+ RawBody: ctx.content,
1316
+ CommandBody: ctx.content,
1317
+ From: feishuFrom,
1318
+ To: feishuTo,
1319
+ SessionKey: agentSessionKey,
1320
+ AccountId: agentAccountId,
1321
+ ChatType: isGroup ? "group" : "direct",
1322
+ GroupSubject: isGroup ? ctx.chatId : undefined,
1323
+ SenderName: ctx.senderName ?? ctx.senderOpenId,
1324
+ SenderId: ctx.senderOpenId,
1325
+ Provider: "feishu" as const,
1326
+ Surface: "feishu" as const,
1327
+ MessageSid: ctx.messageId,
1328
+ ReplyToBody: quotedContent ?? undefined,
1329
+ Timestamp: Date.now(),
1330
+ WasMentioned: wasMentioned,
1331
+ CommandAuthorized: commandAuthorized,
1332
+ OriginatingChannel: "feishu" as const,
1333
+ OriginatingTo: feishuTo,
1334
+ GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
1335
+ ...mediaPayload,
1336
+ });
1171
1337
 
1172
1338
  // Parse message create_time (Feishu uses millisecond epoch string).
1173
1339
  const messageCreateTimeMs = event.message.create_time
1174
1340
  ? parseInt(event.message.create_time, 10)
1175
1341
  : undefined;
1342
+ // Determine reply target based on group session mode:
1343
+ // - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
1344
+ // root so the bot stays in the same thread.
1345
+ // - Groups with explicit replyInThread config: reply to the root so the bot
1346
+ // stays in the thread the user expects.
1347
+ // - Normal groups (auto-detected threadReply from root_id): reply to the
1348
+ // triggering message itself. Using rootId here would silently push the
1349
+ // reply into a topic thread invisible in the main chat view (#32980).
1350
+ const isTopicSession =
1351
+ isGroup &&
1352
+ (groupSession?.groupSessionScope === "group_topic" ||
1353
+ groupSession?.groupSessionScope === "group_topic_sender");
1354
+ const configReplyInThread =
1355
+ isGroup &&
1356
+ (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
1357
+ const replyTargetMessageId =
1358
+ isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId;
1359
+ const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
1360
+
1361
+ if (broadcastAgents) {
1362
+ // Cross-account dedup: in multi-account setups, Feishu delivers the same
1363
+ // event to every bot account in the group. Only one account should handle
1364
+ // broadcast dispatch to avoid duplicate agent sessions and race conditions.
1365
+ // Uses a shared "broadcast" namespace (not per-account) so the first handler
1366
+ // to reach this point claims the message; subsequent accounts skip.
1367
+ if (!(await tryRecordMessagePersistent(ctx.messageId, "broadcast", log))) {
1368
+ log(
1369
+ `feishu[${account.accountId}]: broadcast already claimed by another account for message ${ctx.messageId}; skipping`,
1370
+ );
1371
+ return;
1372
+ }
1176
1373
 
1177
- const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1178
- cfg,
1179
- agentId: route.agentId,
1180
- runtime: runtime as RuntimeEnv,
1181
- chatId: ctx.chatId,
1182
- replyToMessageId: ctx.messageId,
1183
- skipReplyToInMessages: !isGroup,
1184
- replyInThread,
1185
- rootId: ctx.rootId,
1186
- mentionTargets: ctx.mentionTargets,
1187
- accountId: account.accountId,
1188
- messageCreateTimeMs,
1189
- });
1374
+ // --- Broadcast dispatch: send message to all configured agents ---
1375
+ const strategy =
1376
+ ((cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined)
1377
+ ?.strategy || "parallel";
1378
+ const activeAgentId =
1379
+ ctx.mentionedBot || !requireMention ? normalizeAgentId(route.agentId) : null;
1380
+ const agentIds = (cfg.agents?.list ?? []).map((a: { id: string }) => normalizeAgentId(a.id));
1381
+ const hasKnownAgents = agentIds.length > 0;
1190
1382
 
1191
- log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
1192
- const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
1193
- dispatcher,
1194
- onSettled: () => {
1195
- markDispatchIdle();
1196
- },
1197
- run: () =>
1198
- core.channel.reply.dispatchReplyFromConfig({
1199
- ctx: ctxPayload,
1200
- cfg,
1201
- dispatcher,
1202
- replyOptions,
1203
- }),
1204
- });
1383
+ log(
1384
+ `feishu[${account.accountId}]: broadcasting to ${broadcastAgents.length} agents (strategy=${strategy}, active=${activeAgentId ?? "none"})`,
1385
+ );
1205
1386
 
1206
- if (isGroup && historyKey && chatHistories) {
1207
- clearHistoryEntriesIfEnabled({
1208
- historyMap: chatHistories,
1209
- historyKey,
1210
- limit: historyLimit,
1387
+ const dispatchForAgent = async (agentId: string) => {
1388
+ if (hasKnownAgents && !agentIds.includes(normalizeAgentId(agentId))) {
1389
+ log(
1390
+ `feishu[${account.accountId}]: broadcast agent ${agentId} not found in agents.list; skipping`,
1391
+ );
1392
+ return;
1393
+ }
1394
+
1395
+ const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
1396
+ const agentCtx = buildCtxPayloadForAgent(
1397
+ agentSessionKey,
1398
+ route.accountId,
1399
+ ctx.mentionedBot && agentId === activeAgentId,
1400
+ );
1401
+
1402
+ if (agentId === activeAgentId) {
1403
+ // Active agent: real Feishu dispatcher (responds on Feishu)
1404
+ const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1405
+ cfg,
1406
+ agentId,
1407
+ runtime: runtime as RuntimeEnv,
1408
+ chatId: ctx.chatId,
1409
+ replyToMessageId: replyTargetMessageId,
1410
+ skipReplyToInMessages: !isGroup,
1411
+ replyInThread,
1412
+ rootId: ctx.rootId,
1413
+ threadReply,
1414
+ mentionTargets: ctx.mentionTargets,
1415
+ accountId: account.accountId,
1416
+ messageCreateTimeMs,
1417
+ });
1418
+
1419
+ log(
1420
+ `feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
1421
+ );
1422
+ await core.channel.reply.withReplyDispatcher({
1423
+ dispatcher,
1424
+ onSettled: () => markDispatchIdle(),
1425
+ run: () =>
1426
+ core.channel.reply.dispatchReplyFromConfig({
1427
+ ctx: agentCtx,
1428
+ cfg,
1429
+ dispatcher,
1430
+ replyOptions,
1431
+ }),
1432
+ });
1433
+ } else {
1434
+ // Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
1435
+ // Strip CommandAuthorized so slash commands (e.g. /reset) don't silently
1436
+ // mutate observer sessions — only the active agent should execute commands.
1437
+ delete (agentCtx as Record<string, unknown>).CommandAuthorized;
1438
+ const noopDispatcher = {
1439
+ sendToolResult: () => false,
1440
+ sendBlockReply: () => false,
1441
+ sendFinalReply: () => false,
1442
+ waitForIdle: async () => {},
1443
+ getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
1444
+ markComplete: () => {},
1445
+ };
1446
+
1447
+ log(
1448
+ `feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
1449
+ );
1450
+ await core.channel.reply.withReplyDispatcher({
1451
+ dispatcher: noopDispatcher,
1452
+ run: () =>
1453
+ core.channel.reply.dispatchReplyFromConfig({
1454
+ ctx: agentCtx,
1455
+ cfg,
1456
+ dispatcher: noopDispatcher,
1457
+ }),
1458
+ });
1459
+ }
1460
+ };
1461
+
1462
+ if (strategy === "sequential") {
1463
+ for (const agentId of broadcastAgents) {
1464
+ try {
1465
+ await dispatchForAgent(agentId);
1466
+ } catch (err) {
1467
+ log(
1468
+ `feishu[${account.accountId}]: broadcast dispatch failed for agent=${agentId}: ${String(err)}`,
1469
+ );
1470
+ }
1471
+ }
1472
+ } else {
1473
+ const results = await Promise.allSettled(broadcastAgents.map(dispatchForAgent));
1474
+ for (let i = 0; i < results.length; i++) {
1475
+ if (results[i].status === "rejected") {
1476
+ log(
1477
+ `feishu[${account.accountId}]: broadcast dispatch failed for agent=${broadcastAgents[i]}: ${String((results[i] as PromiseRejectedResult).reason)}`,
1478
+ );
1479
+ }
1480
+ }
1481
+ }
1482
+
1483
+ if (isGroup && historyKey && chatHistories) {
1484
+ clearHistoryEntriesIfEnabled({
1485
+ historyMap: chatHistories,
1486
+ historyKey,
1487
+ limit: historyLimit,
1488
+ });
1489
+ }
1490
+
1491
+ log(
1492
+ `feishu[${account.accountId}]: broadcast dispatch complete for ${broadcastAgents.length} agents`,
1493
+ );
1494
+ } else {
1495
+ // --- Single-agent dispatch (existing behavior) ---
1496
+ const ctxPayload = buildCtxPayloadForAgent(
1497
+ route.sessionKey,
1498
+ route.accountId,
1499
+ ctx.mentionedBot,
1500
+ );
1501
+
1502
+ const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1503
+ cfg,
1504
+ agentId: route.agentId,
1505
+ runtime: runtime as RuntimeEnv,
1506
+ chatId: ctx.chatId,
1507
+ replyToMessageId: replyTargetMessageId,
1508
+ skipReplyToInMessages: !isGroup,
1509
+ replyInThread,
1510
+ rootId: ctx.rootId,
1511
+ threadReply,
1512
+ mentionTargets: ctx.mentionTargets,
1513
+ accountId: account.accountId,
1514
+ messageCreateTimeMs,
1211
1515
  });
1212
- }
1213
1516
 
1214
- log(
1215
- `feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
1216
- );
1517
+ log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
1518
+ const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
1519
+ dispatcher,
1520
+ onSettled: () => {
1521
+ markDispatchIdle();
1522
+ },
1523
+ run: () =>
1524
+ core.channel.reply.dispatchReplyFromConfig({
1525
+ ctx: ctxPayload,
1526
+ cfg,
1527
+ dispatcher,
1528
+ replyOptions,
1529
+ }),
1530
+ });
1531
+
1532
+ if (isGroup && historyKey && chatHistories) {
1533
+ clearHistoryEntriesIfEnabled({
1534
+ historyMap: chatHistories,
1535
+ historyKey,
1536
+ limit: historyLimit,
1537
+ });
1538
+ }
1539
+
1540
+ log(
1541
+ `feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
1542
+ );
1543
+ }
1217
1544
  } catch (err) {
1218
1545
  error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
1219
1546
  }