@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/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,
@@ -46,6 +43,22 @@ type PermissionError = {
46
43
 
47
44
  const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
48
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
+
49
62
  function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
50
63
  const message = permissionError.message.toLowerCase();
51
64
  return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
@@ -71,7 +84,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
71
84
  // Extract the grant URL from the error message (contains the direct link)
72
85
  const msg = feishuErr.msg ?? "";
73
86
  const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
74
- const grantUrl = urlMatch?.[0];
87
+ const grantUrl = urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined;
75
88
 
76
89
  return {
77
90
  code: feishuErr.code,
@@ -440,8 +453,12 @@ function formatSubMessageContent(content: string, contentType: string): string {
440
453
 
441
454
  function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
442
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;
443
459
  const mentions = event.message.mentions ?? [];
444
460
  if (mentions.length > 0) {
461
+ // Rely on Feishu mention IDs; display names can vary by alias/context.
445
462
  return mentions.some((m) => m.id.open_id === botOpenId);
446
463
  }
447
464
  // Post (rich text) messages may have empty message.mentions when they contain docs/paste
@@ -452,17 +469,41 @@ function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boole
452
469
  return false;
453
470
  }
454
471
 
455
- export function stripBotMention(
472
+ function normalizeMentions(
456
473
  text: string,
457
474
  mentions?: FeishuMessageEvent["message"]["mentions"],
475
+ botStripId?: string,
458
476
  ): string {
459
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;");
460
481
  let result = text;
482
+
461
483
  for (const mention of mentions) {
462
- result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), "");
463
- 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();
464
493
  }
465
- return result.trim();
494
+
495
+ return result;
496
+ }
497
+
498
+ function normalizeFeishuCommandProbeBody(text: string): string {
499
+ if (!text) {
500
+ return "";
501
+ }
502
+ return text
503
+ .replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
504
+ .replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
505
+ .replace(/\s+/g, " ")
506
+ .trim();
466
507
  }
467
508
 
468
509
  /**
@@ -698,6 +739,31 @@ async function resolveFeishuMediaList(params: {
698
739
  return out;
699
740
  }
700
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
+
701
767
  /**
702
768
  * Build media payload for inbound context.
703
769
  * Similar to Discord's buildDiscordMediaPayload().
@@ -705,10 +771,17 @@ async function resolveFeishuMediaList(params: {
705
771
  export function parseFeishuMessageEvent(
706
772
  event: FeishuMessageEvent,
707
773
  botOpenId?: string,
774
+ _botName?: string,
708
775
  ): FeishuMessageContext {
709
776
  const rawContent = parseMessageContent(event.message.content, event.message.message_type);
710
777
  const mentionedBot = checkBotMentioned(event, botOpenId);
711
- 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);
712
785
  const senderOpenId = event.sender.sender_id.open_id?.trim();
713
786
  const senderUserId = event.sender.sender_id.user_id?.trim();
714
787
  const senderFallbackId = senderOpenId || senderUserId || "";
@@ -722,6 +795,7 @@ export function parseFeishuMessageEvent(
722
795
  senderOpenId: senderFallbackId,
723
796
  chatType: event.message.chat_type,
724
797
  mentionedBot,
798
+ hasAnyMention,
725
799
  rootId: event.message.root_id || undefined,
726
800
  parentId: event.message.parent_id || undefined,
727
801
  threadId: event.message.thread_id || undefined,
@@ -734,9 +808,6 @@ export function parseFeishuMessageEvent(
734
808
  const mentionTargets = extractMentionTargets(event, botOpenId);
735
809
  if (mentionTargets.length > 0) {
736
810
  ctx.mentionTargets = mentionTargets;
737
- // Extract message body (remove all @ placeholders)
738
- const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
739
- ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
740
811
  }
741
812
  }
742
813
 
@@ -746,12 +817,13 @@ export function parseFeishuMessageEvent(
746
817
  export function buildFeishuAgentBody(params: {
747
818
  ctx: Pick<
748
819
  FeishuMessageContext,
749
- "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
820
+ "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
750
821
  >;
751
822
  quotedContent?: string;
752
823
  permissionErrorForAgent?: PermissionError;
824
+ botOpenId?: string;
753
825
  }): string {
754
- const { ctx, quotedContent, permissionErrorForAgent } = params;
826
+ const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
755
827
  let messageBody = ctx.content;
756
828
  if (quotedContent) {
757
829
  messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
@@ -761,6 +833,16 @@ export function buildFeishuAgentBody(params: {
761
833
  const speaker = ctx.senderName ?? ctx.senderOpenId;
762
834
  messageBody = `${speaker}: ${messageBody}`;
763
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
+
764
846
  if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
765
847
  const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
766
848
  messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
@@ -781,11 +863,12 @@ export async function handleFeishuMessage(params: {
781
863
  cfg: ClawdbotConfig;
782
864
  event: FeishuMessageEvent;
783
865
  botOpenId?: string;
866
+ botName?: string;
784
867
  runtime?: RuntimeEnv;
785
868
  chatHistories?: Map<string, HistoryEntry[]>;
786
869
  accountId?: string;
787
870
  }): Promise<void> {
788
- const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
871
+ const { cfg, event, botOpenId, botName, runtime, chatHistories, accountId } = params;
789
872
 
790
873
  // Resolve account with merged config
791
874
  const account = resolveFeishuAccount({ cfg, accountId });
@@ -808,7 +891,7 @@ export async function handleFeishuMessage(params: {
808
891
  return;
809
892
  }
810
893
 
811
- let ctx = parseFeishuMessageEvent(event, botOpenId);
894
+ let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
812
895
  const isGroup = ctx.chatType === "group";
813
896
  const isDirect = !isGroup;
814
897
  const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
@@ -901,7 +984,12 @@ export async function handleFeishuMessage(params: {
901
984
  const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
902
985
  const configAllowFrom = feishuCfg?.allowFrom ?? [];
903
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;
904
991
 
992
+ let requireMention = false; // DMs never require mention; groups may override below
905
993
  if (isGroup) {
906
994
  if (groupConfig?.enabled === false) {
907
995
  log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
@@ -956,17 +1044,19 @@ export async function handleFeishuMessage(params: {
956
1044
  }
957
1045
  }
958
1046
 
959
- const { requireMention } = resolveFeishuReplyPolicy({
1047
+ ({ requireMention } = resolveFeishuReplyPolicy({
960
1048
  isDirectMessage: false,
961
1049
  globalConfig: feishuCfg,
962
1050
  groupConfig,
963
- });
1051
+ }));
964
1052
 
965
1053
  if (requireMention && !ctx.mentionedBot) {
966
- log(
967
- `feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
968
- );
969
- if (chatHistories && groupHistoryKey) {
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) {
970
1060
  recordPendingHistoryEntryIfEnabled({
971
1061
  historyMap: chatHistories,
972
1062
  historyKey: groupHistoryKey,
@@ -991,8 +1081,9 @@ export async function handleFeishuMessage(params: {
991
1081
  channel: "feishu",
992
1082
  accountId: account.accountId,
993
1083
  });
1084
+ const commandProbeBody = isGroup ? normalizeFeishuCommandProbeBody(ctx.content) : ctx.content;
994
1085
  const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
995
- ctx.content,
1086
+ commandProbeBody,
996
1087
  cfg,
997
1088
  );
998
1089
  const storeAllowFrom =
@@ -1011,29 +1102,29 @@ export async function handleFeishuMessage(params: {
1011
1102
 
1012
1103
  if (isDirect && dmPolicy !== "open" && !dmAllowed) {
1013
1104
  if (dmPolicy === "pairing") {
1014
- const { code, created } = await pairing.upsertPairingRequest({
1015
- id: ctx.senderOpenId,
1105
+ await issuePairingChallenge({
1106
+ channel: "feishu",
1107
+ senderId: ctx.senderOpenId,
1108
+ senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
1016
1109
  meta: { name: ctx.senderName },
1017
- });
1018
- if (created) {
1019
- log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
1020
- try {
1110
+ upsertPairingRequest: pairing.upsertPairingRequest,
1111
+ onCreated: () => {
1112
+ log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
1113
+ },
1114
+ sendPairingReply: async (text) => {
1021
1115
  await sendMessageFeishu({
1022
1116
  cfg,
1023
1117
  to: `chat:${ctx.chatId}`,
1024
- text: core.channel.pairing.buildPairingReply({
1025
- channel: "feishu",
1026
- idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
1027
- code,
1028
- }),
1118
+ text,
1029
1119
  accountId: account.accountId,
1030
1120
  });
1031
- } catch (err) {
1121
+ },
1122
+ onReplyError: (err) => {
1032
1123
  log(
1033
1124
  `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
1034
1125
  );
1035
- }
1036
- }
1126
+ },
1127
+ });
1037
1128
  } else {
1038
1129
  log(
1039
1130
  `feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
@@ -1163,6 +1254,7 @@ export async function handleFeishuMessage(params: {
1163
1254
  ctx,
1164
1255
  quotedContent,
1165
1256
  permissionErrorForAgent,
1257
+ botOpenId,
1166
1258
  });
1167
1259
  const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
1168
1260
  if (permissionErrorForAgent) {
@@ -1208,82 +1300,247 @@ export async function handleFeishuMessage(params: {
1208
1300
  }))
1209
1301
  : undefined;
1210
1302
 
1211
- const ctxPayload = core.channel.reply.finalizeInboundContext({
1212
- Body: combinedBody,
1213
- BodyForAgent: messageBody,
1214
- InboundHistory: inboundHistory,
1215
- // Quote/reply message support: use standard ReplyToId for parent,
1216
- // and pass root_id for thread reconstruction.
1217
- ReplyToId: ctx.parentId,
1218
- RootMessageId: ctx.rootId,
1219
- RawBody: ctx.content,
1220
- CommandBody: ctx.content,
1221
- From: feishuFrom,
1222
- To: feishuTo,
1223
- SessionKey: route.sessionKey,
1224
- AccountId: route.accountId,
1225
- ChatType: isGroup ? "group" : "direct",
1226
- GroupSubject: isGroup ? ctx.chatId : undefined,
1227
- SenderName: ctx.senderName ?? ctx.senderOpenId,
1228
- SenderId: ctx.senderOpenId,
1229
- Provider: "feishu" as const,
1230
- Surface: "feishu" as const,
1231
- MessageSid: ctx.messageId,
1232
- ReplyToBody: quotedContent ?? undefined,
1233
- Timestamp: Date.now(),
1234
- WasMentioned: ctx.mentionedBot,
1235
- CommandAuthorized: commandAuthorized,
1236
- OriginatingChannel: "feishu" as const,
1237
- OriginatingTo: feishuTo,
1238
- ...mediaPayload,
1239
- });
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
+ });
1240
1337
 
1241
1338
  // Parse message create_time (Feishu uses millisecond epoch string).
1242
1339
  const messageCreateTimeMs = event.message.create_time
1243
1340
  ? parseInt(event.message.create_time, 10)
1244
1341
  : undefined;
1245
- const replyTargetMessageId = ctx.rootId ?? ctx.messageId;
1246
- const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1247
- cfg,
1248
- agentId: route.agentId,
1249
- runtime: runtime as RuntimeEnv,
1250
- chatId: ctx.chatId,
1251
- replyToMessageId: replyTargetMessageId,
1252
- skipReplyToInMessages: !isGroup,
1253
- replyInThread,
1254
- rootId: ctx.rootId,
1255
- threadReply: isGroup ? (groupSession?.threadReply ?? false) : false,
1256
- mentionTargets: ctx.mentionTargets,
1257
- accountId: account.accountId,
1258
- messageCreateTimeMs,
1259
- });
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
+ }
1260
1373
 
1261
- log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
1262
- const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
1263
- dispatcher,
1264
- onSettled: () => {
1265
- markDispatchIdle();
1266
- },
1267
- run: () =>
1268
- core.channel.reply.dispatchReplyFromConfig({
1269
- ctx: ctxPayload,
1270
- cfg,
1271
- dispatcher,
1272
- replyOptions,
1273
- }),
1274
- });
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;
1275
1382
 
1276
- if (isGroup && historyKey && chatHistories) {
1277
- clearHistoryEntriesIfEnabled({
1278
- historyMap: chatHistories,
1279
- historyKey,
1280
- limit: historyLimit,
1383
+ log(
1384
+ `feishu[${account.accountId}]: broadcasting to ${broadcastAgents.length} agents (strategy=${strategy}, active=${activeAgentId ?? "none"})`,
1385
+ );
1386
+
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,
1281
1515
  });
1282
- }
1283
1516
 
1284
- log(
1285
- `feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
1286
- );
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
+ }
1287
1544
  } catch (err) {
1288
1545
  error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
1289
1546
  }
@@ -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 { resolveFeishuAccount } from "./accounts.js";
3
3
  import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
 
4
4
  const probeFeishuMock = vi.hoisted(() => vi.fn());