@gakr-gakr/feishu 0.1.0

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 (133) hide show
  1. package/api.ts +32 -0
  2. package/autobot.plugin.json +180 -0
  3. package/channel-entry.ts +20 -0
  4. package/channel-plugin-api.ts +1 -0
  5. package/contract-api.ts +16 -0
  6. package/index.ts +82 -0
  7. package/package.json +62 -0
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.ts +13 -0
  14. package/skills/feishu-doc/SKILL.md +211 -0
  15. package/skills/feishu-doc/references/block-types.md +103 -0
  16. package/skills/feishu-drive/SKILL.md +97 -0
  17. package/skills/feishu-perm/SKILL.md +119 -0
  18. package/skills/feishu-wiki/SKILL.md +113 -0
  19. package/src/accounts.ts +333 -0
  20. package/src/agent-config.ts +21 -0
  21. package/src/app-registration.ts +331 -0
  22. package/src/approval-auth.ts +25 -0
  23. package/src/async.ts +104 -0
  24. package/src/audio-preflight.runtime.ts +9 -0
  25. package/src/bitable.ts +762 -0
  26. package/src/bot-content.ts +485 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.ts +1703 -0
  30. package/src/card-action.ts +447 -0
  31. package/src/card-interaction.ts +159 -0
  32. package/src/card-test-helpers.ts +54 -0
  33. package/src/card-ux-approval.ts +65 -0
  34. package/src/card-ux-launcher.ts +121 -0
  35. package/src/card-ux-shared.ts +33 -0
  36. package/src/channel-runtime-api.ts +16 -0
  37. package/src/channel.runtime.ts +47 -0
  38. package/src/channel.ts +1423 -0
  39. package/src/chat-schema.ts +25 -0
  40. package/src/chat.ts +188 -0
  41. package/src/client-timeout.ts +42 -0
  42. package/src/client.ts +262 -0
  43. package/src/comment-dispatcher-runtime-api.ts +6 -0
  44. package/src/comment-dispatcher.ts +107 -0
  45. package/src/comment-handler-runtime-api.ts +3 -0
  46. package/src/comment-handler.ts +303 -0
  47. package/src/comment-reaction.ts +259 -0
  48. package/src/comment-shared.ts +406 -0
  49. package/src/comment-target.ts +44 -0
  50. package/src/config-schema.ts +335 -0
  51. package/src/conversation-id.ts +199 -0
  52. package/src/dedup-runtime-api.ts +1 -0
  53. package/src/dedup.ts +141 -0
  54. package/src/dedupe-key.ts +72 -0
  55. package/src/directory.static.ts +61 -0
  56. package/src/directory.ts +124 -0
  57. package/src/doc-schema.ts +182 -0
  58. package/src/docx-batch-insert.ts +223 -0
  59. package/src/docx-color-text.ts +154 -0
  60. package/src/docx-table-ops.ts +316 -0
  61. package/src/docx-types.ts +38 -0
  62. package/src/docx.ts +1596 -0
  63. package/src/drive-schema.ts +92 -0
  64. package/src/drive.ts +829 -0
  65. package/src/dynamic-agent.ts +143 -0
  66. package/src/event-types.ts +45 -0
  67. package/src/external-keys.ts +19 -0
  68. package/src/lifecycle.test-support.ts +220 -0
  69. package/src/media.ts +1105 -0
  70. package/src/mention-target.types.ts +5 -0
  71. package/src/mention.ts +114 -0
  72. package/src/message-action-contract.ts +13 -0
  73. package/src/monitor-state-runtime-api.ts +7 -0
  74. package/src/monitor-transport-runtime-api.ts +10 -0
  75. package/src/monitor.account.ts +492 -0
  76. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  77. package/src/monitor.bot-identity.ts +86 -0
  78. package/src/monitor.bot-menu-handler.ts +165 -0
  79. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  80. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  81. package/src/monitor.card-action.lifecycle.test-support.ts +421 -0
  82. package/src/monitor.comment-notice-handler.ts +105 -0
  83. package/src/monitor.comment.ts +1386 -0
  84. package/src/monitor.message-handler.ts +350 -0
  85. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  86. package/src/monitor.startup.ts +74 -0
  87. package/src/monitor.state.ts +170 -0
  88. package/src/monitor.synthetic-error.ts +18 -0
  89. package/src/monitor.test-mocks.ts +46 -0
  90. package/src/monitor.transport.ts +451 -0
  91. package/src/monitor.ts +100 -0
  92. package/src/outbound-runtime-api.ts +1 -0
  93. package/src/outbound.ts +785 -0
  94. package/src/perm-schema.ts +52 -0
  95. package/src/perm.ts +170 -0
  96. package/src/pins.ts +108 -0
  97. package/src/policy.ts +321 -0
  98. package/src/post.ts +275 -0
  99. package/src/probe.ts +166 -0
  100. package/src/processing-claims.ts +59 -0
  101. package/src/qr-terminal.ts +1 -0
  102. package/src/reactions.ts +123 -0
  103. package/src/reasoning-preview.ts +28 -0
  104. package/src/reply-dispatcher-runtime-api.ts +7 -0
  105. package/src/reply-dispatcher.ts +748 -0
  106. package/src/runtime.ts +9 -0
  107. package/src/secret-contract.ts +145 -0
  108. package/src/secret-input.ts +1 -0
  109. package/src/security-audit-shared.ts +69 -0
  110. package/src/security-audit.ts +1 -0
  111. package/src/send-result.ts +80 -0
  112. package/src/send-target.ts +35 -0
  113. package/src/send.ts +861 -0
  114. package/src/sequential-key.ts +28 -0
  115. package/src/sequential-queue.ts +86 -0
  116. package/src/session-conversation.ts +42 -0
  117. package/src/session-route.ts +48 -0
  118. package/src/setup-core.ts +51 -0
  119. package/src/setup-surface.ts +618 -0
  120. package/src/streaming-card.ts +571 -0
  121. package/src/subagent-hooks.ts +413 -0
  122. package/src/targets.ts +97 -0
  123. package/src/thread-bindings.ts +331 -0
  124. package/src/tool-account.ts +93 -0
  125. package/src/tool-factory-test-harness.ts +79 -0
  126. package/src/tool-result.ts +16 -0
  127. package/src/tools-config.ts +22 -0
  128. package/src/types.ts +106 -0
  129. package/src/typing.ts +214 -0
  130. package/src/wiki-schema.ts +69 -0
  131. package/src/wiki.ts +270 -0
  132. package/subagent-hooks-api.ts +31 -0
  133. package/tsconfig.json +16 -0
package/src/bot.ts ADDED
@@ -0,0 +1,1703 @@
1
+ import { resolveChannelConfigWrites } from "autobot/plugin-sdk/channel-config-writes";
2
+ import { createChannelPairingController } from "autobot/plugin-sdk/channel-pairing";
3
+ import {
4
+ ensureConfiguredBindingRouteReady,
5
+ resolveConfiguredBindingRoute,
6
+ resolveRuntimeConversationBindingRoute,
7
+ } from "autobot/plugin-sdk/conversation-runtime";
8
+ import { resolveAgentOutboundIdentity } from "autobot/plugin-sdk/outbound-runtime";
9
+ import {
10
+ DEFAULT_GROUP_HISTORY_LIMIT,
11
+ createChannelHistoryWindow,
12
+ type HistoryEntry,
13
+ } from "autobot/plugin-sdk/reply-history";
14
+ import { resolveInboundLastRouteSessionKey } from "autobot/plugin-sdk/routing";
15
+ import {
16
+ resolveDefaultGroupPolicy,
17
+ resolveOpenProviderRuntimeGroupPolicy,
18
+ warnMissingProviderGroupPolicyFallbackOnce,
19
+ } from "autobot/plugin-sdk/runtime-group-policy";
20
+ import { resolvePinnedMainDmOwnerFromAllowlist } from "autobot/plugin-sdk/security-runtime";
21
+ import { normalizeOptionalString } from "autobot/plugin-sdk/string-coerce-runtime";
22
+ import { resolveFeishuRuntimeAccount } from "./accounts.js";
23
+ import {
24
+ checkBotMentioned,
25
+ normalizeFeishuCommandProbeBody,
26
+ normalizeMentions,
27
+ parseMergeForwardContent,
28
+ parseMessageContent,
29
+ resolveFeishuGroupSession,
30
+ resolveFeishuMediaList,
31
+ } from "./bot-content.js";
32
+ import {
33
+ buildAgentMediaPayload,
34
+ evaluateSupplementalContextVisibility,
35
+ normalizeAgentId,
36
+ resolveChannelContextVisibilityMode,
37
+ } from "./bot-runtime-api.js";
38
+ import type { ClawdbotConfig, RuntimeEnv } from "./bot-runtime-api.js";
39
+ import { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js";
40
+ import { getChatInfo } from "./chat.js";
41
+ import { createFeishuClient } from "./client.js";
42
+ import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
43
+ import { resolveFeishuMessageDedupeKey } from "./dedupe-key.js";
44
+ import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
45
+ import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
46
+ import {
47
+ hasExplicitFeishuGroupConfig,
48
+ normalizeFeishuAllowEntry,
49
+ resolveFeishuDmIngressAccess,
50
+ resolveFeishuGroupConfig,
51
+ resolveFeishuGroupConversationIngressAccess,
52
+ resolveFeishuGroupSenderActivationIngressAccess,
53
+ resolveFeishuReplyPolicy,
54
+ } from "./policy.js";
55
+ import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js";
56
+ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
57
+ import { getFeishuRuntime } from "./runtime.js";
58
+ import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
59
+ export type { FeishuBotAddedEvent, FeishuMessageEvent } from "./event-types.js";
60
+ import type { FeishuMessageEvent } from "./event-types.js";
61
+ import {
62
+ isFeishuGroupChatType,
63
+ type FeishuMessageContext,
64
+ type FeishuMediaInfo,
65
+ type FeishuMessageInfo,
66
+ type ResolvedFeishuAccount,
67
+ } from "./types.js";
68
+ import type { DynamicAgentCreationConfig } from "./types.js";
69
+
70
+ export { toMessageResourceType } from "./bot-content.js";
71
+
72
+ // Cache permission errors to avoid spamming the user with repeated notifications.
73
+ // Key: appId or "default", Value: timestamp of last notification
74
+ const permissionErrorNotifiedAt = new Map<string, number>();
75
+ const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
76
+
77
+ const groupNameCache = new Map<string, { name: string; expiresAt: number }>();
78
+ const GROUP_NAME_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
79
+ const GROUP_NAME_CACHE_MAX_SIZE = 500; // hard cap
80
+
81
+ type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
82
+
83
+ function resolveConfiguredFeishuGroupSessionScope(params: {
84
+ groupConfig?: {
85
+ groupSessionScope?: FeishuGroupSessionScope;
86
+ topicSessionMode?: "enabled" | "disabled";
87
+ };
88
+ feishuCfg?: {
89
+ groupSessionScope?: FeishuGroupSessionScope;
90
+ topicSessionMode?: "enabled" | "disabled";
91
+ };
92
+ }): FeishuGroupSessionScope {
93
+ const legacyTopicSessionMode =
94
+ params.groupConfig?.topicSessionMode ?? params.feishuCfg?.topicSessionMode ?? "disabled";
95
+ return (
96
+ params.groupConfig?.groupSessionScope ??
97
+ params.feishuCfg?.groupSessionScope ??
98
+ (legacyTopicSessionMode === "enabled" ? "group_topic" : "group")
99
+ );
100
+ }
101
+
102
+ function isFeishuTopicSessionScope(scope: FeishuGroupSessionScope): boolean {
103
+ return scope === "group_topic" || scope === "group_topic_sender";
104
+ }
105
+
106
+ function evictGroupNameCache(): void {
107
+ const now = Date.now();
108
+ for (const [key, val] of groupNameCache) {
109
+ if (val.expiresAt <= now) {
110
+ groupNameCache.delete(key);
111
+ }
112
+ }
113
+
114
+ if (groupNameCache.size > GROUP_NAME_CACHE_MAX_SIZE) {
115
+ const excess = groupNameCache.size - GROUP_NAME_CACHE_MAX_SIZE;
116
+ let removed = 0;
117
+ for (const key of groupNameCache.keys()) {
118
+ if (removed >= excess) {
119
+ break;
120
+ }
121
+ groupNameCache.delete(key);
122
+ removed++;
123
+ }
124
+ }
125
+ }
126
+
127
+ function setCacheEntry(key: string, value: { name: string; expiresAt: number }): void {
128
+ groupNameCache.delete(key);
129
+ groupNameCache.set(key, value);
130
+ }
131
+
132
+ export function clearGroupNameCache(): void {
133
+ groupNameCache.clear();
134
+ }
135
+
136
+ export async function resolveGroupName(params: {
137
+ account: ResolvedFeishuAccount;
138
+ chatId: string;
139
+ log: (...args: unknown[]) => void;
140
+ }): Promise<string | undefined> {
141
+ const { account, chatId, log } = params;
142
+ if (!account.configured) {
143
+ return undefined;
144
+ }
145
+
146
+ const cacheKey = `${account.accountId}:${chatId}`;
147
+
148
+ const cached = groupNameCache.get(cacheKey);
149
+ if (cached && cached.expiresAt > Date.now()) {
150
+ return cached.name || undefined;
151
+ }
152
+
153
+ try {
154
+ const client = createFeishuClient(account);
155
+ const chatInfo = await getChatInfo(client, chatId);
156
+ const name = chatInfo?.name?.trim();
157
+ if (name) {
158
+ setCacheEntry(cacheKey, {
159
+ name,
160
+ expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
161
+ });
162
+ } else {
163
+ setCacheEntry(cacheKey, {
164
+ name: "",
165
+ expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
166
+ });
167
+ }
168
+ } catch (err) {
169
+ log(`feishu[${account.accountId}]: getChatInfo failed for ${chatId}: ${String(err)}`);
170
+ setCacheEntry(cacheKey, {
171
+ name: "",
172
+ expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
173
+ });
174
+ }
175
+
176
+ const result = groupNameCache.get(cacheKey)?.name || undefined;
177
+ evictGroupNameCache();
178
+
179
+ return result;
180
+ }
181
+
182
+ async function resolveFeishuAudioPreflightTranscript(params: {
183
+ cfg: ClawdbotConfig;
184
+ mediaList: FeishuMediaInfo[];
185
+ content: string;
186
+ chatType: "direct" | "group";
187
+ log: (msg: string) => void;
188
+ }): Promise<string | undefined> {
189
+ if (params.content.trim() !== "<media:audio>") {
190
+ return undefined;
191
+ }
192
+ const audioMedia = params.mediaList.filter((media) => media.contentType?.startsWith("audio/"));
193
+ if (audioMedia.length === 0) {
194
+ return undefined;
195
+ }
196
+
197
+ try {
198
+ const { transcribeFirstAudio } = await import("./audio-preflight.runtime.js");
199
+ return await transcribeFirstAudio({
200
+ ctx: {
201
+ MediaPaths: audioMedia.map((media) => media.path),
202
+ MediaTypes: audioMedia.map((media) => media.contentType).filter(Boolean) as string[],
203
+ ChatType: params.chatType,
204
+ },
205
+ cfg: params.cfg,
206
+ });
207
+ } catch (err) {
208
+ params.log(`feishu: audio preflight transcription failed: ${String(err)}`);
209
+ return undefined;
210
+ }
211
+ }
212
+
213
+ // --- Broadcast support ---
214
+ // Resolve broadcast agent list for a given peer (group) ID.
215
+ // Returns null if no broadcast config exists or the peer is not in the broadcast list.
216
+ export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null {
217
+ const broadcast = (cfg as Record<string, unknown>).broadcast;
218
+ if (!broadcast || typeof broadcast !== "object") {
219
+ return null;
220
+ }
221
+ const agents = (broadcast as Record<string, unknown>)[peerId];
222
+ if (!Array.isArray(agents) || agents.length === 0) {
223
+ return null;
224
+ }
225
+ return agents as string[];
226
+ }
227
+
228
+ // Build a session key for a broadcast target agent by replacing the agent ID prefix.
229
+ // Session keys follow the format: agent:<agentId>:<channel>:<peerKind>:<peerId>
230
+ export function buildBroadcastSessionKey(
231
+ baseSessionKey: string,
232
+ originalAgentId: string,
233
+ targetAgentId: string,
234
+ ): string {
235
+ const prefix = `agent:${originalAgentId}:`;
236
+ if (baseSessionKey.startsWith(prefix)) {
237
+ return `agent:${targetAgentId}:${baseSessionKey.slice(prefix.length)}`;
238
+ }
239
+ return baseSessionKey;
240
+ }
241
+
242
+ /**
243
+ * Build media payload for inbound context.
244
+ * Similar to Discord's buildDiscordMediaPayload().
245
+ */
246
+ export function parseFeishuMessageEvent(
247
+ event: FeishuMessageEvent,
248
+ botOpenId?: string,
249
+ _botName?: string,
250
+ ): FeishuMessageContext {
251
+ const rawContent = parseMessageContent(event.message.content, event.message.message_type);
252
+ const mentionedBot = checkBotMentioned(event, botOpenId);
253
+ const hasAnyMention = (event.message.mentions?.length ?? 0) > 0;
254
+ // Strip the bot's own mention so slash commands like @Bot /help retain
255
+ // the leading /. This applies in both p2p *and* group contexts — the
256
+ // mentionedBot flag already captures whether the bot was addressed, so
257
+ // keeping the mention tag in content only breaks command detection (#35994).
258
+ // Non-bot mentions (e.g. mention-forward targets) are still normalized to <at> tags.
259
+ const content = normalizeMentions(rawContent, event.message.mentions, botOpenId);
260
+ const senderOpenId = event.sender.sender_id.open_id?.trim();
261
+ const senderUserId = event.sender.sender_id.user_id?.trim();
262
+ const senderFallbackId = senderOpenId || senderUserId || "";
263
+
264
+ const ctx: FeishuMessageContext = {
265
+ chatId: event.message.chat_id,
266
+ messageId: event.message.message_id,
267
+ replyTargetMessageId: event.message.reply_target_message_id?.trim() || undefined,
268
+ suppressReplyTarget: event.message.suppress_reply_target === true,
269
+ senderId: senderUserId || senderOpenId || "",
270
+ // Keep the historical field name, but fall back to user_id when open_id is unavailable
271
+ // (common in some mobile app deliveries).
272
+ senderOpenId: senderFallbackId,
273
+ chatType: event.message.chat_type,
274
+ mentionedBot,
275
+ hasAnyMention,
276
+ rootId: event.message.root_id || undefined,
277
+ parentId: event.message.parent_id || undefined,
278
+ threadId: event.message.thread_id || undefined,
279
+ content,
280
+ contentType: event.message.message_type,
281
+ };
282
+
283
+ // Detect mention forward request: message mentions bot + at least one other user
284
+ if (isMentionForwardRequest(event, botOpenId)) {
285
+ const mentionTargets = extractMentionTargets(event, botOpenId);
286
+ if (mentionTargets.length > 0) {
287
+ ctx.mentionTargets = mentionTargets;
288
+ }
289
+ }
290
+
291
+ return ctx;
292
+ }
293
+
294
+ const MAX_MENTION_CONTEXT_NAME_LENGTH = 80;
295
+
296
+ function formatMentionNameForAgentContext(name: string): string {
297
+ const stripped = Array.from(name, (char) => {
298
+ const code = char.charCodeAt(0);
299
+ return code < 0x20 || char === "[" || char === "]" ? " " : char;
300
+ }).join("");
301
+ const normalized = stripped.replace(/\s+/g, " ").trim();
302
+ const bounded =
303
+ normalized.length > MAX_MENTION_CONTEXT_NAME_LENGTH
304
+ ? `${normalized.slice(0, MAX_MENTION_CONTEXT_NAME_LENGTH - 3)}...`
305
+ : normalized;
306
+ return JSON.stringify(bounded || "unknown");
307
+ }
308
+
309
+ export function buildFeishuAgentBody(params: {
310
+ ctx: Pick<
311
+ FeishuMessageContext,
312
+ "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
313
+ >;
314
+ quotedContent?: string;
315
+ permissionErrorForAgent?: FeishuPermissionError;
316
+ botOpenId?: string;
317
+ }): string {
318
+ const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
319
+ let messageBody = ctx.content;
320
+ if (quotedContent) {
321
+ messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
322
+ }
323
+
324
+ // DMs already have per-sender sessions, but this label still improves attribution.
325
+ const speaker = ctx.senderName ?? ctx.senderOpenId;
326
+ messageBody = `${speaker}: ${messageBody}`;
327
+
328
+ if (ctx.hasAnyMention) {
329
+ const botIdHint = botOpenId?.trim();
330
+ messageBody +=
331
+ `\n\n[System: The content may include mention tags in the form <at user_id="...">name</at>. ` +
332
+ `Treat these as real mentions of Feishu entities (users or bots).]`;
333
+ if (botIdHint) {
334
+ messageBody += `\n[System: If user_id is "${botIdHint}", that mention refers to you.]`;
335
+ }
336
+ }
337
+
338
+ if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
339
+ const targetNames = ctx.mentionTargets
340
+ .map((t) => formatMentionNameForAgentContext(t.name))
341
+ .join(", ");
342
+ messageBody += `\n\n[System: Feishu users mentioned in the incoming message, for context only: ${targetNames}. Do not notify or mention these users solely because they are listed here.]`;
343
+ }
344
+
345
+ // Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
346
+ messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
347
+
348
+ if (permissionErrorForAgent) {
349
+ const grantUrl = permissionErrorForAgent.grantUrl ?? "";
350
+ messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
351
+ }
352
+
353
+ return messageBody;
354
+ }
355
+
356
+ async function shouldIncludeFetchedGroupContextMessage(params: {
357
+ cfg: ClawdbotConfig;
358
+ accountId: string;
359
+ chatId: string;
360
+ isGroup: boolean;
361
+ allowFrom: Array<string | number>;
362
+ mode: "all" | "allowlist" | "allowlist_quote";
363
+ kind: "quote" | "thread" | "history";
364
+ senderId?: string;
365
+ senderType?: string;
366
+ }): Promise<boolean> {
367
+ let senderAllowed =
368
+ !params.isGroup || params.allowFrom.length === 0 || params.senderType === "app";
369
+ const senderId = params.senderId?.trim();
370
+ if (!senderAllowed && senderId) {
371
+ const access = await resolveFeishuGroupSenderActivationIngressAccess({
372
+ cfg: params.cfg,
373
+ accountId: params.accountId,
374
+ chatId: params.chatId,
375
+ allowFrom: params.allowFrom,
376
+ senderOpenId: senderId,
377
+ senderUserId: senderId,
378
+ requireMention: false,
379
+ mentionedBot: true,
380
+ });
381
+ senderAllowed = access.senderAccess.decision === "allow";
382
+ }
383
+ return evaluateSupplementalContextVisibility({
384
+ mode: params.mode,
385
+ kind: params.kind,
386
+ senderAllowed,
387
+ }).include;
388
+ }
389
+
390
+ async function filterFetchedGroupContextMessages<
391
+ T extends Pick<FeishuMessageInfo, "senderId" | "senderType">,
392
+ >(
393
+ messages: readonly T[],
394
+ params: {
395
+ cfg: ClawdbotConfig;
396
+ accountId: string;
397
+ chatId: string;
398
+ isGroup: boolean;
399
+ allowFrom: Array<string | number>;
400
+ mode: "all" | "allowlist" | "allowlist_quote";
401
+ kind: "quote" | "thread" | "history";
402
+ },
403
+ ): Promise<T[]> {
404
+ const results: Array<T | undefined> = await Promise.all(
405
+ messages.map(async (message) =>
406
+ (await shouldIncludeFetchedGroupContextMessage({
407
+ cfg: params.cfg,
408
+ accountId: params.accountId,
409
+ chatId: params.chatId,
410
+ isGroup: params.isGroup,
411
+ allowFrom: params.allowFrom,
412
+ mode: params.mode,
413
+ kind: params.kind,
414
+ senderId: message.senderId,
415
+ senderType: message.senderType,
416
+ }))
417
+ ? message
418
+ : undefined,
419
+ ),
420
+ );
421
+ return results.filter((message): message is T => message !== undefined);
422
+ }
423
+
424
+ export async function handleFeishuMessage(params: {
425
+ cfg: ClawdbotConfig;
426
+ event: FeishuMessageEvent;
427
+ botOpenId?: string;
428
+ botName?: string;
429
+ runtime?: RuntimeEnv;
430
+ chatHistories?: Map<string, HistoryEntry[]>;
431
+ accountId?: string;
432
+ processingClaimHeld?: boolean;
433
+ }): Promise<void> {
434
+ const {
435
+ cfg,
436
+ event,
437
+ botOpenId,
438
+ botName,
439
+ runtime,
440
+ chatHistories,
441
+ accountId,
442
+ processingClaimHeld = false,
443
+ } = params;
444
+
445
+ // Resolve account with merged config
446
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
447
+ const feishuCfg = account.config;
448
+
449
+ const log = runtime?.log ?? console.log;
450
+ const error = runtime?.error ?? console.error;
451
+
452
+ const messageId = event.message.message_id;
453
+ const messageDedupeKey = resolveFeishuMessageDedupeKey(event);
454
+ if (
455
+ !(await finalizeFeishuMessageProcessing({
456
+ messageId: messageDedupeKey,
457
+ namespace: account.accountId,
458
+ log,
459
+ claimHeld: processingClaimHeld,
460
+ }))
461
+ ) {
462
+ log(`feishu: skipping duplicate message ${messageId}`);
463
+ return;
464
+ }
465
+
466
+ let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
467
+ const isGroup = isFeishuGroupChatType(ctx.chatType);
468
+ const isDirect = !isGroup;
469
+ const senderUserId = normalizeOptionalString(event.sender.sender_id.user_id);
470
+
471
+ // Handle merge_forward messages: fetch full message via API then expand sub-messages
472
+ if (event.message.message_type === "merge_forward") {
473
+ log(
474
+ `feishu[${account.accountId}]: processing merge_forward message, fetching full content via API`,
475
+ );
476
+ try {
477
+ // Websocket event doesn't include sub-messages, need to fetch via API
478
+ // The API returns all sub-messages in the items array
479
+ const client = createFeishuClient(account);
480
+ const response = (await client.im.message.get({
481
+ path: { message_id: event.message.message_id },
482
+ })) as { code?: number; data?: { items?: unknown[] } };
483
+
484
+ if (response.code === 0 && response.data?.items && response.data.items.length > 0) {
485
+ log(
486
+ `feishu[${account.accountId}]: merge_forward API returned ${response.data.items.length} items`,
487
+ );
488
+ const expandedContent = parseMergeForwardContent({
489
+ content: JSON.stringify(response.data.items),
490
+ log,
491
+ });
492
+ ctx = { ...ctx, content: expandedContent };
493
+ } else {
494
+ log(`feishu[${account.accountId}]: merge_forward API returned no items`);
495
+ ctx = { ...ctx, content: "[Merged and Forwarded Message - could not fetch]" };
496
+ }
497
+ } catch (err) {
498
+ log(`feishu[${account.accountId}]: merge_forward fetch failed: ${String(err)}`);
499
+ ctx = { ...ctx, content: "[Merged and Forwarded Message - fetch error]" };
500
+ }
501
+ }
502
+
503
+ // Resolve sender display name (best-effort) so the agent can attribute messages correctly.
504
+ // Optimization: skip if disabled to save API quota (Feishu free tier limit).
505
+ let permissionErrorForAgent: FeishuPermissionError | undefined;
506
+ if (feishuCfg?.resolveSenderNames ?? true) {
507
+ const senderResult = await resolveFeishuSenderName({
508
+ account,
509
+ senderId: ctx.senderOpenId,
510
+ log,
511
+ });
512
+ if (senderResult.name) {
513
+ ctx = { ...ctx, senderName: senderResult.name };
514
+ }
515
+
516
+ // Track permission error to inform agent later (with cooldown to avoid repetition)
517
+ if (senderResult.permissionError) {
518
+ const appKey = account.appId ?? "default";
519
+ const now = Date.now();
520
+ const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
521
+
522
+ if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
523
+ permissionErrorNotifiedAt.set(appKey, now);
524
+ permissionErrorForAgent = senderResult.permissionError;
525
+ }
526
+ }
527
+ }
528
+
529
+ log(
530
+ `feishu[${account.accountId}]: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`,
531
+ );
532
+
533
+ // Log mention targets if detected
534
+ if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
535
+ const names = ctx.mentionTargets.map((t) => t.name).join(", ");
536
+ log(`feishu[${account.accountId}]: detected @ forward request, targets: [${names}]`);
537
+ }
538
+
539
+ const historyLimit = Math.max(
540
+ 0,
541
+ feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
542
+ );
543
+ const groupConfig = isGroup
544
+ ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
545
+ : undefined;
546
+ const groupSessionScope = isGroup
547
+ ? resolveConfiguredFeishuGroupSessionScope({ groupConfig, feishuCfg })
548
+ : null;
549
+ let effectiveThreadId = ctx.threadId;
550
+ if (
551
+ isGroup &&
552
+ ctx.chatType === "topic_group" &&
553
+ !effectiveThreadId &&
554
+ isFeishuTopicSessionScope(groupSessionScope ?? "group")
555
+ ) {
556
+ try {
557
+ const messageInfo = await getMessageFeishu({
558
+ cfg,
559
+ accountId: account.accountId,
560
+ messageId: ctx.messageId,
561
+ });
562
+ const hydratedThreadId = messageInfo?.threadId?.trim();
563
+ if (hydratedThreadId) {
564
+ ctx = { ...ctx, threadId: hydratedThreadId };
565
+ effectiveThreadId = hydratedThreadId;
566
+ log(
567
+ `feishu[${account.accountId}]: hydrated topic thread_id=${hydratedThreadId} for message=${ctx.messageId}`,
568
+ );
569
+ }
570
+ } catch (err) {
571
+ log(
572
+ `feishu[${account.accountId}]: failed to hydrate topic thread_id for message=${ctx.messageId}: ${String(err)}`,
573
+ );
574
+ }
575
+ }
576
+ const effectiveGroupSenderAllowFrom = isGroup
577
+ ? (groupConfig?.allowFrom?.length ?? 0) > 0
578
+ ? (groupConfig?.allowFrom ?? [])
579
+ : (feishuCfg?.groupSenderAllowFrom ?? [])
580
+ : [];
581
+ const groupSession = isGroup
582
+ ? resolveFeishuGroupSession({
583
+ chatId: ctx.chatId,
584
+ senderOpenId: ctx.senderOpenId,
585
+ messageId: ctx.messageId,
586
+ rootId: ctx.rootId,
587
+ threadId: effectiveThreadId,
588
+ chatType: ctx.chatType,
589
+ groupConfig,
590
+ feishuCfg,
591
+ })
592
+ : null;
593
+ const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined;
594
+ const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
595
+ const configAllowFrom = feishuCfg?.allowFrom ?? [];
596
+ const rawBroadcastAgents = isGroup ? resolveBroadcastAgents(cfg, ctx.chatId) : null;
597
+ const broadcastAgents = rawBroadcastAgents
598
+ ? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
599
+ : null;
600
+
601
+ // Parse message create_time early so every downstream consumer (pending
602
+ // history, inbound payload, etc.) uses the original authoring timestamp
603
+ // instead of the delivery/processing time. Feishu uses a millisecond
604
+ // epoch string; fall back to Date.now() only when the field is absent.
605
+ const messageCreateTimeMs = event.message.create_time
606
+ ? Number.parseInt(event.message.create_time, 10)
607
+ : Date.now();
608
+
609
+ let requireMention = false; // DMs never require mention; groups may override below
610
+ if (isGroup) {
611
+ if (groupConfig?.enabled === false) {
612
+ log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
613
+ return;
614
+ }
615
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
616
+ const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
617
+ providerConfigPresent: cfg.channels?.feishu !== undefined,
618
+ groupPolicy: feishuCfg?.groupPolicy,
619
+ defaultGroupPolicy,
620
+ });
621
+ warnMissingProviderGroupPolicyFallbackOnce({
622
+ providerMissingFallbackApplied,
623
+ providerKey: "feishu",
624
+ accountId: account.accountId,
625
+ log,
626
+ });
627
+ const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
628
+ // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
629
+
630
+ // A group explicitly configured under `channels.feishu.groups.<chat_id>` is
631
+ // treated as admitted in allowlist mode even when `groupAllowFrom` is empty.
632
+ // Wildcard defaults still configure matching groups, but they are not an
633
+ // admission signal by themselves.
634
+ const groupExplicitlyConfigured = hasExplicitFeishuGroupConfig({
635
+ cfg: feishuCfg,
636
+ groupId: ctx.chatId,
637
+ });
638
+
639
+ const groupIngress = await resolveFeishuGroupConversationIngressAccess({
640
+ cfg,
641
+ accountId: account.accountId,
642
+ chatId: ctx.chatId,
643
+ groupPolicy,
644
+ groupAllowFrom,
645
+ groupExplicitlyConfigured,
646
+ });
647
+
648
+ if (groupIngress.ingress.admission !== "dispatch") {
649
+ log(
650
+ `feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
651
+ );
652
+ return;
653
+ }
654
+
655
+ ({ requireMention } = resolveFeishuReplyPolicy({
656
+ isDirectMessage: false,
657
+ cfg,
658
+ accountId: account.accountId,
659
+ groupId: ctx.chatId,
660
+ groupPolicy,
661
+ }));
662
+
663
+ const groupSenderActivationIngress = await resolveFeishuGroupSenderActivationIngressAccess({
664
+ cfg,
665
+ accountId: account.accountId,
666
+ chatId: ctx.chatId,
667
+ allowFrom: effectiveGroupSenderAllowFrom,
668
+ senderOpenId: ctx.senderOpenId,
669
+ senderUserId,
670
+ requireMention,
671
+ mentionedBot: ctx.mentionedBot,
672
+ });
673
+ if (groupSenderActivationIngress.senderAccess.decision !== "allow") {
674
+ log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
675
+ return;
676
+ }
677
+ if (groupSenderActivationIngress.ingress.admission !== "dispatch") {
678
+ log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot`);
679
+ // Record to pending history for non-broadcast groups only. For broadcast groups,
680
+ // the mentioned handler's broadcast dispatch writes the turn directly into all
681
+ // agent sessions — buffering here would cause duplicate replay when this account
682
+ // later becomes active via the channel history window.
683
+ if (!broadcastAgents && chatHistories && groupHistoryKey) {
684
+ createChannelHistoryWindow({ historyMap: chatHistories }).record({
685
+ historyKey: groupHistoryKey,
686
+ limit: historyLimit,
687
+ entry: {
688
+ sender: ctx.senderOpenId,
689
+ body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
690
+ timestamp: messageCreateTimeMs,
691
+ messageId: ctx.messageId,
692
+ },
693
+ });
694
+ }
695
+ return;
696
+ }
697
+ }
698
+
699
+ try {
700
+ const core = getFeishuRuntime();
701
+ const pairing = createChannelPairingController({
702
+ core,
703
+ channel: "feishu",
704
+ accountId: account.accountId,
705
+ });
706
+ const commandProbeBody = isGroup ? normalizeFeishuCommandProbeBody(ctx.content) : ctx.content;
707
+ const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
708
+ commandProbeBody,
709
+ cfg,
710
+ );
711
+ const dmIngress = isDirect
712
+ ? await resolveFeishuDmIngressAccess({
713
+ cfg,
714
+ accountId: account.accountId,
715
+ dmPolicy,
716
+ allowFrom: configAllowFrom,
717
+ readAllowFromStore: pairing.readAllowFromStore,
718
+ senderOpenId: ctx.senderOpenId,
719
+ senderUserId,
720
+ conversationId: ctx.senderOpenId,
721
+ mayPair: true,
722
+ ...(shouldComputeCommandAuthorized ? { command: { hasControlCommand: true } } : {}),
723
+ })
724
+ : null;
725
+ if (isDirect && dmIngress?.ingress.admission !== "dispatch") {
726
+ if (dmIngress?.ingress.admission === "pairing-required") {
727
+ await pairing.issueChallenge({
728
+ senderId: ctx.senderOpenId,
729
+ senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
730
+ meta: { name: ctx.senderName },
731
+ onCreated: () => {
732
+ log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
733
+ },
734
+ sendPairingReply: async (text) => {
735
+ await sendMessageFeishu({
736
+ cfg,
737
+ to: `chat:${ctx.chatId}`,
738
+ text,
739
+ accountId: account.accountId,
740
+ });
741
+ },
742
+ onReplyError: (err) => {
743
+ log(
744
+ `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
745
+ );
746
+ },
747
+ });
748
+ } else {
749
+ log(
750
+ `feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
751
+ );
752
+ }
753
+ return;
754
+ }
755
+
756
+ const commandAllowFrom = isGroup
757
+ ? (groupConfig?.allowFrom ?? configAllowFrom)
758
+ : (dmIngress?.senderAccess.effectiveAllowFrom ?? configAllowFrom);
759
+
760
+ // In group chats, the session is scoped to the group, but the *speaker* is the sender.
761
+ // Using a group-scoped From causes the agent to treat different users as the same person.
762
+ const feishuFrom = `feishu:${ctx.senderOpenId}`;
763
+ const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
764
+ const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
765
+ const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
766
+ const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
767
+ const feishuAcpConversationSupported =
768
+ !isGroup ||
769
+ groupSession?.groupSessionScope === "group_topic" ||
770
+ groupSession?.groupSessionScope === "group_topic_sender";
771
+
772
+ if (isGroup && groupSession) {
773
+ log(
774
+ `feishu[${account.accountId}]: group session scope=${groupSession.groupSessionScope}, peer=${peerId}`,
775
+ );
776
+ }
777
+
778
+ let route = core.channel.routing.resolveAgentRoute({
779
+ cfg,
780
+ channel: "feishu",
781
+ accountId: account.accountId,
782
+ peer: {
783
+ kind: isGroup ? "group" : "direct",
784
+ id: peerId,
785
+ },
786
+ parentPeer,
787
+ });
788
+
789
+ // Dynamic agent creation for DM users
790
+ // When enabled, creates a unique agent instance with its own workspace for each DM user.
791
+ let effectiveCfg = cfg;
792
+ if (!isGroup && route.matchedBy === "default") {
793
+ const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
794
+ if (dynamicCfg?.enabled) {
795
+ const runtime = getFeishuRuntime();
796
+ const result = await maybeCreateDynamicAgent({
797
+ cfg,
798
+ runtime,
799
+ senderOpenId: ctx.senderOpenId,
800
+ dynamicCfg,
801
+ configWritesAllowed: resolveChannelConfigWrites({
802
+ cfg,
803
+ channelId: "feishu",
804
+ accountId: account.accountId,
805
+ }),
806
+ log: (msg) => log(msg),
807
+ });
808
+ if (result.created) {
809
+ effectiveCfg = result.updatedCfg;
810
+ // Re-resolve route with updated config
811
+ route = core.channel.routing.resolveAgentRoute({
812
+ cfg: result.updatedCfg,
813
+ channel: "feishu",
814
+ accountId: account.accountId,
815
+ peer: { kind: "direct", id: ctx.senderOpenId },
816
+ });
817
+ log(
818
+ `feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
819
+ );
820
+ }
821
+ }
822
+ }
823
+
824
+ const currentConversationId = peerId;
825
+ const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
826
+ let configuredBinding = null;
827
+ if (feishuAcpConversationSupported) {
828
+ const configuredRoute = resolveConfiguredBindingRoute({
829
+ cfg: effectiveCfg,
830
+ route,
831
+ conversation: {
832
+ channel: "feishu",
833
+ accountId: account.accountId,
834
+ conversationId: currentConversationId,
835
+ parentConversationId,
836
+ },
837
+ });
838
+ configuredBinding = configuredRoute.bindingResolution;
839
+ route = configuredRoute.route;
840
+
841
+ // Bound Feishu conversations intentionally require an exact live conversation-id match.
842
+ // Sender-scoped topic sessions therefore bind on `chat:topic:root:sender:user`, while
843
+ // configured ACP bindings may still inherit the shared `chat:topic:root` topic session.
844
+ const runtimeRoute = resolveRuntimeConversationBindingRoute({
845
+ route,
846
+ conversation: {
847
+ channel: "feishu",
848
+ accountId: account.accountId,
849
+ conversationId: currentConversationId,
850
+ ...(parentConversationId ? { parentConversationId } : {}),
851
+ },
852
+ });
853
+ route = runtimeRoute.route;
854
+ if (runtimeRoute.bindingRecord) {
855
+ configuredBinding = null;
856
+ log(
857
+ runtimeRoute.boundSessionKey
858
+ ? `feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${runtimeRoute.boundSessionKey}`
859
+ : `feishu[${account.accountId}]: plugin-bound conversation ${currentConversationId}`,
860
+ );
861
+ }
862
+ }
863
+
864
+ if (configuredBinding) {
865
+ const ensured = await ensureConfiguredBindingRouteReady({
866
+ cfg: effectiveCfg,
867
+ bindingResolution: configuredBinding,
868
+ });
869
+ if (!ensured.ok) {
870
+ const replyTargetMessageId =
871
+ isGroup &&
872
+ (groupSession?.groupSessionScope === "group_topic" ||
873
+ groupSession?.groupSessionScope === "group_topic_sender")
874
+ ? (ctx.rootId ?? ctx.messageId)
875
+ : ctx.messageId;
876
+ await sendMessageFeishu({
877
+ cfg: effectiveCfg,
878
+ to: `chat:${ctx.chatId}`,
879
+ text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
880
+ replyToMessageId: replyTargetMessageId,
881
+ replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
882
+ accountId: account.accountId,
883
+ }).catch((err) => {
884
+ log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
885
+ });
886
+ return;
887
+ }
888
+ }
889
+
890
+ const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
891
+ const inboundLabel = isGroup
892
+ ? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
893
+ : `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
894
+ const contextVisibilityMode = resolveChannelContextVisibilityMode({
895
+ cfg: effectiveCfg,
896
+ channel: "feishu",
897
+ accountId: account.accountId,
898
+ });
899
+
900
+ // Do not enqueue inbound user previews as system events.
901
+ // System events are prepended to future prompts and can be misread as
902
+ // authoritative transcript turns.
903
+ log(`feishu[${account.accountId}]: ${inboundLabel}: ${preview}`);
904
+
905
+ // Resolve media from message
906
+ const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
907
+ const mediaList = await resolveFeishuMediaList({
908
+ cfg,
909
+ messageId: ctx.messageId,
910
+ messageType: event.message.message_type,
911
+ content: event.message.content,
912
+ maxBytes: mediaMaxBytes,
913
+ log,
914
+ accountId: account.accountId,
915
+ });
916
+ // Skip messages with no text content and no media attachments. Feishu can
917
+ // deliver empty-text events (e.g. `{"text":""}`) when a user sends a blank
918
+ // message or when media parsing produces an empty string. Writing a blank
919
+ // user turn to the session causes downstream LLM providers (e.g. MiniMax)
920
+ // to reject the request with "messages must not be empty" errors. Logging
921
+ // the skip avoids silent loss without polluting the agent session.
922
+ if (!ctx.content.trim() && mediaList.length === 0) {
923
+ log(
924
+ `feishu[${account.accountId}]: skipping empty message (no text, no media) from ${ctx.senderOpenId}`,
925
+ );
926
+ return;
927
+ }
928
+
929
+ const mediaPayload = buildAgentMediaPayload(mediaList);
930
+ const audioTranscript = await resolveFeishuAudioPreflightTranscript({
931
+ cfg: effectiveCfg,
932
+ mediaList,
933
+ content: ctx.content,
934
+ chatType: isGroup ? "group" : "direct",
935
+ log,
936
+ });
937
+ const preflightAudioIndex =
938
+ audioTranscript === undefined
939
+ ? -1
940
+ : mediaList.findIndex((media) => media.contentType?.startsWith("audio/"));
941
+ const agentFacingContent = audioTranscript ?? ctx.content;
942
+ const agentFacingCtx =
943
+ audioTranscript === undefined
944
+ ? ctx
945
+ : {
946
+ ...ctx,
947
+ content: audioTranscript,
948
+ };
949
+ const effectiveCommandProbeBody =
950
+ audioTranscript === undefined
951
+ ? commandProbeBody
952
+ : isGroup
953
+ ? normalizeFeishuCommandProbeBody(audioTranscript)
954
+ : audioTranscript;
955
+ const shouldComputeEffectiveCommandAuthorized =
956
+ audioTranscript === undefined
957
+ ? shouldComputeCommandAuthorized
958
+ : core.channel.commands.shouldComputeCommandAuthorized(effectiveCommandProbeBody, cfg);
959
+ const commandAuthorized = shouldComputeEffectiveCommandAuthorized
960
+ ? isDirect && audioTranscript === undefined && dmIngress
961
+ ? dmIngress.commandAccess.authorized
962
+ : isGroup
963
+ ? (
964
+ await resolveFeishuGroupSenderActivationIngressAccess({
965
+ cfg,
966
+ accountId: account.accountId,
967
+ chatId: ctx.chatId,
968
+ allowFrom: commandAllowFrom,
969
+ senderOpenId: ctx.senderOpenId,
970
+ senderUserId,
971
+ requireMention: false,
972
+ mentionedBot: true,
973
+ command: { hasControlCommand: true },
974
+ })
975
+ ).commandAccess.authorized
976
+ : (
977
+ await resolveFeishuDmIngressAccess({
978
+ cfg,
979
+ accountId: account.accountId,
980
+ dmPolicy,
981
+ allowFrom: configAllowFrom,
982
+ readAllowFromStore: pairing.readAllowFromStore,
983
+ senderOpenId: ctx.senderOpenId,
984
+ senderUserId,
985
+ conversationId: ctx.senderOpenId,
986
+ mayPair: false,
987
+ command: { hasControlCommand: true },
988
+ })
989
+ ).commandAccess.authorized
990
+ : undefined;
991
+
992
+ // Fetch quoted/replied message content if parentId exists
993
+ let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
994
+ let quotedContent: string | undefined;
995
+ if (ctx.parentId) {
996
+ try {
997
+ quotedMessageInfo = await getMessageFeishu({
998
+ cfg,
999
+ messageId: ctx.parentId,
1000
+ accountId: account.accountId,
1001
+ });
1002
+ if (
1003
+ quotedMessageInfo &&
1004
+ (await shouldIncludeFetchedGroupContextMessage({
1005
+ cfg,
1006
+ accountId: account.accountId,
1007
+ chatId: ctx.chatId,
1008
+ isGroup,
1009
+ allowFrom: effectiveGroupSenderAllowFrom,
1010
+ mode: contextVisibilityMode,
1011
+ kind: "quote",
1012
+ senderId: quotedMessageInfo.senderId,
1013
+ senderType: quotedMessageInfo.senderType,
1014
+ }))
1015
+ ) {
1016
+ quotedContent = quotedMessageInfo.content;
1017
+ log(
1018
+ `feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
1019
+ );
1020
+ } else if (quotedMessageInfo) {
1021
+ log(
1022
+ `feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
1023
+ );
1024
+ }
1025
+ } catch (err) {
1026
+ log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
1027
+ }
1028
+ }
1029
+
1030
+ const isTopicSessionForThread =
1031
+ isGroup &&
1032
+ (groupSession?.groupSessionScope === "group_topic" ||
1033
+ groupSession?.groupSessionScope === "group_topic_sender");
1034
+
1035
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
1036
+ const messageBody = buildFeishuAgentBody({
1037
+ ctx: agentFacingCtx,
1038
+ quotedContent,
1039
+ permissionErrorForAgent,
1040
+ botOpenId,
1041
+ });
1042
+ const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
1043
+ if (permissionErrorForAgent) {
1044
+ // Keep the notice in a single dispatch to avoid duplicate replies (#27372).
1045
+ log(`feishu[${account.accountId}]: appending permission error notice to message body`);
1046
+ }
1047
+
1048
+ const body = core.channel.reply.formatAgentEnvelope({
1049
+ channel: "Feishu",
1050
+ from: envelopeFrom,
1051
+ timestamp: new Date(),
1052
+ envelope: envelopeOptions,
1053
+ body: messageBody,
1054
+ });
1055
+
1056
+ let combinedBody = body;
1057
+ const historyKey = groupHistoryKey;
1058
+
1059
+ if (isGroup && historyKey && chatHistories) {
1060
+ const channelHistory = createChannelHistoryWindow({ historyMap: chatHistories });
1061
+ combinedBody = channelHistory.buildPendingContext({
1062
+ historyKey,
1063
+ limit: historyLimit,
1064
+ currentMessage: combinedBody,
1065
+ formatEntry: (entry) =>
1066
+ core.channel.reply.formatAgentEnvelope({
1067
+ channel: "Feishu",
1068
+ // Preserve speaker identity in group history as well.
1069
+ from: `${ctx.chatId}:${entry.sender}`,
1070
+ timestamp: entry.timestamp,
1071
+ body: entry.body,
1072
+ envelope: envelopeOptions,
1073
+ }),
1074
+ });
1075
+ }
1076
+
1077
+ const inboundHistory =
1078
+ isGroup && historyKey && historyLimit > 0 && chatHistories
1079
+ ? createChannelHistoryWindow({ historyMap: chatHistories }).buildInboundHistory({
1080
+ historyKey,
1081
+ limit: historyLimit,
1082
+ })
1083
+ : undefined;
1084
+
1085
+ const threadContextBySessionKey = new Map<
1086
+ string,
1087
+ {
1088
+ threadStarterBody?: string;
1089
+ threadHistoryBody?: string;
1090
+ threadLabel?: string;
1091
+ }
1092
+ >();
1093
+ let rootMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> | undefined;
1094
+ let rootMessageThreadId: string | undefined;
1095
+ let rootMessageFetched = false;
1096
+ const getRootMessageInfo = async () => {
1097
+ if (!ctx.rootId) {
1098
+ return null;
1099
+ }
1100
+ if (!rootMessageFetched) {
1101
+ rootMessageFetched = true;
1102
+ if (ctx.rootId === ctx.parentId && quotedMessageInfo) {
1103
+ rootMessageInfo = quotedMessageInfo;
1104
+ } else {
1105
+ try {
1106
+ rootMessageInfo = await getMessageFeishu({
1107
+ cfg,
1108
+ messageId: ctx.rootId,
1109
+ accountId: account.accountId,
1110
+ });
1111
+ } catch (err) {
1112
+ log(`feishu[${account.accountId}]: failed to fetch root message: ${String(err)}`);
1113
+ rootMessageInfo = null;
1114
+ }
1115
+ }
1116
+ rootMessageThreadId = rootMessageInfo?.threadId;
1117
+ if (
1118
+ rootMessageInfo &&
1119
+ !(await shouldIncludeFetchedGroupContextMessage({
1120
+ cfg,
1121
+ accountId: account.accountId,
1122
+ chatId: ctx.chatId,
1123
+ isGroup,
1124
+ allowFrom: effectiveGroupSenderAllowFrom,
1125
+ mode: contextVisibilityMode,
1126
+ kind: "thread",
1127
+ senderId: rootMessageInfo.senderId,
1128
+ senderType: rootMessageInfo.senderType,
1129
+ }))
1130
+ ) {
1131
+ log(
1132
+ `feishu[${account.accountId}]: skipped thread starter from sender ${rootMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
1133
+ );
1134
+ rootMessageInfo = null;
1135
+ }
1136
+ }
1137
+ return rootMessageInfo ?? null;
1138
+ };
1139
+ let groupNamePromise: Promise<string | undefined> | undefined;
1140
+ const resolveGroupNameForLabel = (): Promise<string | undefined> => {
1141
+ if (!isGroup) {
1142
+ return Promise.resolve(undefined);
1143
+ }
1144
+ groupNamePromise ??= resolveGroupName({ account, chatId: ctx.chatId, log });
1145
+ return groupNamePromise;
1146
+ };
1147
+
1148
+ const resolveThreadContextForAgent = async (
1149
+ agentId: string,
1150
+ agentSessionKey: string,
1151
+ groupName: string | undefined,
1152
+ ) => {
1153
+ const cached = threadContextBySessionKey.get(agentSessionKey);
1154
+ if (cached) {
1155
+ return cached;
1156
+ }
1157
+
1158
+ const threadContext: {
1159
+ threadStarterBody?: string;
1160
+ threadHistoryBody?: string;
1161
+ threadLabel?: string;
1162
+ } = {
1163
+ threadLabel:
1164
+ (ctx.rootId || ctx.threadId) && isTopicSessionForThread
1165
+ ? `Feishu thread in ${groupName ?? ctx.chatId}`
1166
+ : undefined,
1167
+ };
1168
+
1169
+ if (!(ctx.rootId || ctx.threadId) || !isTopicSessionForThread) {
1170
+ threadContextBySessionKey.set(agentSessionKey, threadContext);
1171
+ return threadContext;
1172
+ }
1173
+
1174
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
1175
+ const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
1176
+ storePath,
1177
+ sessionKey: agentSessionKey,
1178
+ });
1179
+ if (previousThreadSessionTimestamp) {
1180
+ log(
1181
+ `feishu[${account.accountId}]: skipping thread bootstrap for existing session ${agentSessionKey}`,
1182
+ );
1183
+ threadContextBySessionKey.set(agentSessionKey, threadContext);
1184
+ return threadContext;
1185
+ }
1186
+
1187
+ const rootMsg = await getRootMessageInfo();
1188
+ let feishuThreadId = ctx.threadId ?? rootMessageThreadId ?? rootMsg?.threadId;
1189
+ if (feishuThreadId) {
1190
+ log(`feishu[${account.accountId}]: resolved thread ID: ${feishuThreadId}`);
1191
+ }
1192
+ if (!feishuThreadId) {
1193
+ log(
1194
+ `feishu[${account.accountId}]: no threadId found for root message ${ctx.rootId ?? "none"}, skipping thread history`,
1195
+ );
1196
+ threadContextBySessionKey.set(agentSessionKey, threadContext);
1197
+ return threadContext;
1198
+ }
1199
+
1200
+ try {
1201
+ const threadMessages = await listFeishuThreadMessages({
1202
+ cfg,
1203
+ threadId: feishuThreadId,
1204
+ currentMessageId: ctx.messageId,
1205
+ rootMessageId: ctx.rootId,
1206
+ limit: 20,
1207
+ accountId: account.accountId,
1208
+ });
1209
+ const senderScoped = groupSession?.groupSessionScope === "group_topic_sender";
1210
+ const senderIds = new Set(
1211
+ [ctx.senderOpenId, senderUserId]
1212
+ .map((id) => id?.trim())
1213
+ .filter((id): id is string => id !== undefined && id.length > 0),
1214
+ );
1215
+ const allowlistedMessages = await filterFetchedGroupContextMessages(threadMessages, {
1216
+ cfg,
1217
+ accountId: account.accountId,
1218
+ chatId: ctx.chatId,
1219
+ isGroup,
1220
+ allowFrom: effectiveGroupSenderAllowFrom,
1221
+ mode: contextVisibilityMode,
1222
+ kind: "history",
1223
+ });
1224
+ const relevantMessages =
1225
+ (senderScoped
1226
+ ? allowlistedMessages.filter(
1227
+ (msg) =>
1228
+ msg.senderType === "app" ||
1229
+ (msg.senderId !== undefined && senderIds.has(msg.senderId.trim())),
1230
+ )
1231
+ : allowlistedMessages) ?? [];
1232
+
1233
+ const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content;
1234
+ const includeStarterInHistory = Boolean(rootMsg?.content || ctx.rootId);
1235
+ const historyMessages = includeStarterInHistory
1236
+ ? relevantMessages
1237
+ : relevantMessages.slice(1);
1238
+ const historyParts = historyMessages.map((msg) => {
1239
+ const role = msg.senderType === "app" ? "assistant" : "user";
1240
+ return core.channel.reply.formatAgentEnvelope({
1241
+ channel: "Feishu",
1242
+ from: `${msg.senderId ?? "Unknown"} (${role})`,
1243
+ timestamp: msg.createTime,
1244
+ body: msg.content,
1245
+ envelope: envelopeOptions,
1246
+ });
1247
+ });
1248
+
1249
+ threadContext.threadStarterBody = threadStarterBody;
1250
+ threadContext.threadHistoryBody =
1251
+ historyParts.length > 0 ? historyParts.join("\n\n") : undefined;
1252
+ log(
1253
+ `feishu[${account.accountId}]: populated thread bootstrap with starter=${threadStarterBody ? "yes" : "no"} history=${historyMessages.length}`,
1254
+ );
1255
+ } catch (err) {
1256
+ log(`feishu[${account.accountId}]: failed to fetch thread history: ${String(err)}`);
1257
+ }
1258
+
1259
+ threadContextBySessionKey.set(agentSessionKey, threadContext);
1260
+ return threadContext;
1261
+ };
1262
+
1263
+ // --- Shared context builder for dispatch ---
1264
+ const buildCtxPayloadForAgent = async (
1265
+ agentId: string,
1266
+ agentSessionKey: string,
1267
+ agentAccountId: string,
1268
+ wasMentioned: boolean,
1269
+ ) => {
1270
+ const groupName = await resolveGroupNameForLabel();
1271
+ const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName);
1272
+ return core.channel.reply.finalizeInboundContext({
1273
+ Body: combinedBody,
1274
+ BodyForAgent: messageBody,
1275
+ InboundHistory: inboundHistory,
1276
+ ReplyToId: ctx.parentId,
1277
+ RootMessageId: ctx.rootId,
1278
+ RawBody: agentFacingContent,
1279
+ CommandBody: agentFacingContent,
1280
+ Transcript: audioTranscript,
1281
+ From: feishuFrom,
1282
+ To: feishuTo,
1283
+ SessionKey: agentSessionKey,
1284
+ AccountId: agentAccountId,
1285
+ ChatType: isGroup ? "group" : "direct",
1286
+ GroupSubject: isGroup ? groupName || ctx.chatId : undefined,
1287
+ ConversationLabel: isGroup && groupName && !isTopicSessionForThread ? groupName : undefined,
1288
+ SenderName: ctx.senderName ?? ctx.senderOpenId,
1289
+ SenderId: ctx.senderOpenId,
1290
+ Provider: "feishu" as const,
1291
+ Surface: "feishu" as const,
1292
+ MessageSid: ctx.messageId,
1293
+ ReplyToBody: quotedContent ?? undefined,
1294
+ ThreadStarterBody: threadContext.threadStarterBody,
1295
+ ThreadHistoryBody: threadContext.threadHistoryBody,
1296
+ ThreadLabel: threadContext.threadLabel,
1297
+ // Only use rootId (om_* message anchor) — threadId (omt_*) is a container
1298
+ // ID and would produce invalid reply targets downstream.
1299
+ MessageThreadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
1300
+ Timestamp: messageCreateTimeMs,
1301
+ WasMentioned: wasMentioned,
1302
+ CommandAuthorized: commandAuthorized,
1303
+ OriginatingChannel: "feishu" as const,
1304
+ OriginatingTo: feishuTo,
1305
+ GroupSystemPrompt: isGroup ? normalizeOptionalString(groupConfig?.systemPrompt) : undefined,
1306
+ ...mediaPayload,
1307
+ ...(preflightAudioIndex >= 0 ? { MediaTranscribedIndexes: [preflightAudioIndex] } : {}),
1308
+ });
1309
+ };
1310
+
1311
+ // Determine reply target based on group session mode:
1312
+ // - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
1313
+ // root so the bot stays in the same thread.
1314
+ // - Groups with explicit replyInThread config: reply to the root so the bot
1315
+ // stays in the thread the user expects.
1316
+ // - Normal groups (auto-detected threadReply from root_id): reply to the
1317
+ // triggering message itself. Using rootId here would silently push the
1318
+ // reply into a topic thread invisible in the main chat view (#32980).
1319
+ const isTopicSession =
1320
+ isGroup &&
1321
+ (groupSession?.groupSessionScope === "group_topic" ||
1322
+ groupSession?.groupSessionScope === "group_topic_sender");
1323
+ const configReplyInThread =
1324
+ isGroup &&
1325
+ (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
1326
+ const replyTargetMessageId =
1327
+ isTopicSession || configReplyInThread
1328
+ ? (ctx.rootId ??
1329
+ ctx.replyTargetMessageId ??
1330
+ (ctx.suppressReplyTarget ? undefined : ctx.messageId))
1331
+ : (ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId));
1332
+ const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
1333
+ const lastRouteThreadId =
1334
+ isGroup && (isTopicSession || configReplyInThread || threadReply)
1335
+ ? replyTargetMessageId
1336
+ : undefined;
1337
+ const pinnedMainDmOwner = !isGroup
1338
+ ? resolvePinnedMainDmOwnerFromAllowlist({
1339
+ dmScope: cfg.session?.dmScope,
1340
+ allowFrom: configAllowFrom,
1341
+ normalizeEntry: normalizeFeishuAllowEntry,
1342
+ })
1343
+ : null;
1344
+ const pinnedMainDmSenderRecipient = pinnedMainDmOwner
1345
+ ? [ctx.senderOpenId, senderUserId]
1346
+ .map((id) => (id ? normalizeFeishuAllowEntry(id) : ""))
1347
+ .find((recipient) => recipient === pinnedMainDmOwner)
1348
+ : undefined;
1349
+ const buildFeishuInboundLastRouteUpdate = (params: {
1350
+ accountId: string;
1351
+ sessionKey: string;
1352
+ }) => {
1353
+ const inboundLastRouteSessionKey =
1354
+ params.sessionKey === route.sessionKey
1355
+ ? resolveInboundLastRouteSessionKey({
1356
+ route,
1357
+ sessionKey: params.sessionKey,
1358
+ })
1359
+ : params.sessionKey;
1360
+ return {
1361
+ sessionKey: inboundLastRouteSessionKey,
1362
+ channel: "feishu" as const,
1363
+ to: feishuTo,
1364
+ accountId: params.accountId,
1365
+ ...(lastRouteThreadId ? { threadId: lastRouteThreadId } : {}),
1366
+ mainDmOwnerPin:
1367
+ !isGroup && inboundLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner
1368
+ ? {
1369
+ ownerRecipient: pinnedMainDmOwner,
1370
+ senderRecipient: pinnedMainDmSenderRecipient ?? feishuTo,
1371
+ onSkip: (skipParams: { ownerRecipient: string; senderRecipient: string }) => {
1372
+ log(
1373
+ `feishu[${account.accountId}]: skip main-session last route for ${skipParams.senderRecipient} (pinned owner ${skipParams.ownerRecipient})`,
1374
+ );
1375
+ },
1376
+ }
1377
+ : undefined,
1378
+ };
1379
+ };
1380
+
1381
+ if (broadcastAgents) {
1382
+ // Cross-account dedup: in multi-account setups, Feishu delivers the same
1383
+ // event to every bot account in the group. Only one account should handle
1384
+ // broadcast dispatch to avoid duplicate agent sessions and race conditions.
1385
+ // Uses a shared "broadcast" namespace (not per-account) so the first handler
1386
+ // to reach this point claims the message; subsequent accounts skip.
1387
+ if (
1388
+ !(await tryRecordMessagePersistent(messageDedupeKey ?? ctx.messageId, "broadcast", log))
1389
+ ) {
1390
+ log(
1391
+ `feishu[${account.accountId}]: broadcast already claimed by another account for message ${ctx.messageId}; skipping`,
1392
+ );
1393
+ return;
1394
+ }
1395
+
1396
+ // --- Broadcast dispatch: send message to all configured agents ---
1397
+ const rawStrategy = (
1398
+ (cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined
1399
+ )?.strategy;
1400
+ const strategy = rawStrategy === "sequential" ? "sequential" : "parallel";
1401
+ const activeAgentId =
1402
+ ctx.mentionedBot || !requireMention ? normalizeAgentId(route.agentId) : null;
1403
+ const agentIds = (cfg.agents?.list ?? []).map((a: { id: string }) => normalizeAgentId(a.id));
1404
+ const hasKnownAgents = agentIds.length > 0;
1405
+
1406
+ log(
1407
+ `feishu[${account.accountId}]: broadcasting to ${broadcastAgents.length} agents (strategy=${strategy}, active=${activeAgentId ?? "none"})`,
1408
+ );
1409
+
1410
+ const dispatchForAgent = async (agentId: string) => {
1411
+ if (hasKnownAgents && !agentIds.includes(normalizeAgentId(agentId))) {
1412
+ log(
1413
+ `feishu[${account.accountId}]: broadcast agent ${agentId} not found in agents.list; skipping`,
1414
+ );
1415
+ return;
1416
+ }
1417
+
1418
+ const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
1419
+ const agentStorePath = core.channel.session.resolveStorePath(cfg.session?.store, {
1420
+ agentId,
1421
+ });
1422
+ const agentRecord = {
1423
+ updateLastRoute: buildFeishuInboundLastRouteUpdate({
1424
+ sessionKey: agentSessionKey,
1425
+ accountId: route.accountId,
1426
+ }),
1427
+ onRecordError: (err: unknown) => {
1428
+ log(
1429
+ `feishu[${account.accountId}]: failed to record broadcast inbound session ${agentSessionKey}: ${String(err)}`,
1430
+ );
1431
+ },
1432
+ };
1433
+ const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
1434
+ cfg,
1435
+ agentId,
1436
+ storePath: agentStorePath,
1437
+ sessionKey: agentSessionKey,
1438
+ });
1439
+ const agentCtx = await buildCtxPayloadForAgent(
1440
+ agentId,
1441
+ agentSessionKey,
1442
+ route.accountId,
1443
+ ctx.mentionedBot && agentId === activeAgentId,
1444
+ );
1445
+
1446
+ if (agentId === activeAgentId) {
1447
+ // Active agent: real Feishu dispatcher (responds on Feishu)
1448
+ const identity = resolveAgentOutboundIdentity(cfg, agentId);
1449
+ const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1450
+ cfg,
1451
+ agentId,
1452
+ runtime: runtime as RuntimeEnv,
1453
+ chatId: ctx.chatId,
1454
+ allowReasoningPreview,
1455
+ replyToMessageId: replyTargetMessageId,
1456
+ skipReplyToInMessages: !isGroup,
1457
+ replyInThread,
1458
+ rootId: ctx.rootId,
1459
+ threadReply,
1460
+ accountId: account.accountId,
1461
+ identity,
1462
+ messageCreateTimeMs,
1463
+ });
1464
+
1465
+ log(
1466
+ `feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
1467
+ );
1468
+ await core.channel.turn.run({
1469
+ channel: "feishu",
1470
+ accountId: route.accountId,
1471
+ raw: ctx,
1472
+ adapter: {
1473
+ ingest: () => ({
1474
+ id: ctx.messageId,
1475
+ timestamp: messageCreateTimeMs,
1476
+ rawText: ctx.content,
1477
+ textForAgent: agentCtx.BodyForAgent,
1478
+ textForCommands: agentCtx.CommandBody,
1479
+ raw: ctx,
1480
+ }),
1481
+ resolveTurn: () => ({
1482
+ channel: "feishu",
1483
+ accountId: route.accountId,
1484
+ routeSessionKey: agentSessionKey,
1485
+ storePath: agentStorePath,
1486
+ ctxPayload: agentCtx,
1487
+ recordInboundSession: core.channel.session.recordInboundSession,
1488
+ record: agentRecord,
1489
+ onPreDispatchFailure: () =>
1490
+ core.channel.reply.settleReplyDispatcher({
1491
+ dispatcher,
1492
+ onSettled: () => markDispatchIdle(),
1493
+ }),
1494
+ runDispatch: () =>
1495
+ core.channel.reply.withReplyDispatcher({
1496
+ dispatcher,
1497
+ onSettled: () => markDispatchIdle(),
1498
+ run: () =>
1499
+ core.channel.reply.dispatchReplyFromConfig({
1500
+ ctx: agentCtx,
1501
+ cfg,
1502
+ dispatcher,
1503
+ replyOptions,
1504
+ }),
1505
+ }),
1506
+ }),
1507
+ },
1508
+ });
1509
+ } else {
1510
+ // Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
1511
+ // Strip CommandAuthorized so slash commands (e.g. /reset) don't silently
1512
+ // mutate observer sessions — only the active agent should execute commands.
1513
+ delete (agentCtx as Record<string, unknown>).CommandAuthorized;
1514
+ const noopDispatcher = {
1515
+ sendToolResult: () => false,
1516
+ sendBlockReply: () => false,
1517
+ sendFinalReply: () => false,
1518
+ waitForIdle: async () => {},
1519
+ getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
1520
+ getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }),
1521
+ markComplete: () => {},
1522
+ };
1523
+
1524
+ log(
1525
+ `feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
1526
+ );
1527
+ await core.channel.turn.run({
1528
+ channel: "feishu",
1529
+ accountId: route.accountId,
1530
+ raw: ctx,
1531
+ adapter: {
1532
+ ingest: () => ({
1533
+ id: ctx.messageId,
1534
+ timestamp: messageCreateTimeMs,
1535
+ rawText: ctx.content,
1536
+ textForAgent: agentCtx.BodyForAgent,
1537
+ textForCommands: agentCtx.CommandBody,
1538
+ raw: ctx,
1539
+ }),
1540
+ resolveTurn: () => ({
1541
+ channel: "feishu",
1542
+ accountId: route.accountId,
1543
+ routeSessionKey: agentSessionKey,
1544
+ storePath: agentStorePath,
1545
+ ctxPayload: agentCtx,
1546
+ recordInboundSession: core.channel.session.recordInboundSession,
1547
+ record: agentRecord,
1548
+ runDispatch: () =>
1549
+ core.channel.reply.withReplyDispatcher({
1550
+ dispatcher: noopDispatcher,
1551
+ run: () =>
1552
+ core.channel.reply.dispatchReplyFromConfig({
1553
+ ctx: agentCtx,
1554
+ cfg,
1555
+ dispatcher: noopDispatcher,
1556
+ }),
1557
+ }),
1558
+ }),
1559
+ },
1560
+ });
1561
+ }
1562
+ };
1563
+
1564
+ if (strategy === "sequential") {
1565
+ for (const agentId of broadcastAgents) {
1566
+ try {
1567
+ await dispatchForAgent(agentId);
1568
+ } catch (err) {
1569
+ log(
1570
+ `feishu[${account.accountId}]: broadcast dispatch failed for agent=${agentId}: ${String(err)}`,
1571
+ );
1572
+ }
1573
+ }
1574
+ } else {
1575
+ const results = await Promise.allSettled(broadcastAgents.map(dispatchForAgent));
1576
+ for (let i = 0; i < results.length; i++) {
1577
+ if (results[i].status === "rejected") {
1578
+ log(
1579
+ `feishu[${account.accountId}]: broadcast dispatch failed for agent=${broadcastAgents[i]}: ${String((results[i] as PromiseRejectedResult).reason)}`,
1580
+ );
1581
+ }
1582
+ }
1583
+ }
1584
+
1585
+ if (isGroup && historyKey && chatHistories) {
1586
+ createChannelHistoryWindow({ historyMap: chatHistories }).clear({
1587
+ historyKey,
1588
+ limit: historyLimit,
1589
+ });
1590
+ }
1591
+
1592
+ log(
1593
+ `feishu[${account.accountId}]: broadcast dispatch complete for ${broadcastAgents.length} agents`,
1594
+ );
1595
+ } else {
1596
+ // --- Single-agent dispatch (existing behavior) ---
1597
+ const ctxPayload = await buildCtxPayloadForAgent(
1598
+ route.agentId,
1599
+ route.sessionKey,
1600
+ route.accountId,
1601
+ ctx.mentionedBot,
1602
+ );
1603
+
1604
+ const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
1605
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
1606
+ agentId: route.agentId,
1607
+ });
1608
+ const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
1609
+ cfg,
1610
+ agentId: route.agentId,
1611
+ storePath,
1612
+ sessionKey: route.sessionKey,
1613
+ });
1614
+ const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1615
+ cfg,
1616
+ agentId: route.agentId,
1617
+ runtime: runtime as RuntimeEnv,
1618
+ chatId: ctx.chatId,
1619
+ allowReasoningPreview,
1620
+ replyToMessageId: replyTargetMessageId,
1621
+ skipReplyToInMessages: !isGroup,
1622
+ replyInThread,
1623
+ rootId: ctx.rootId,
1624
+ threadReply,
1625
+ accountId: account.accountId,
1626
+ identity,
1627
+ messageCreateTimeMs,
1628
+ });
1629
+
1630
+ log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
1631
+ const turnResult = await core.channel.turn.run({
1632
+ channel: "feishu",
1633
+ accountId: route.accountId,
1634
+ raw: ctx,
1635
+ adapter: {
1636
+ ingest: () => ({
1637
+ id: ctx.messageId,
1638
+ timestamp: messageCreateTimeMs,
1639
+ rawText: ctx.content,
1640
+ textForAgent: ctxPayload.BodyForAgent,
1641
+ textForCommands: ctxPayload.CommandBody,
1642
+ raw: ctx,
1643
+ }),
1644
+ resolveTurn: () => ({
1645
+ channel: "feishu",
1646
+ accountId: route.accountId,
1647
+ routeSessionKey: route.sessionKey,
1648
+ storePath,
1649
+ ctxPayload,
1650
+ recordInboundSession: core.channel.session.recordInboundSession,
1651
+ record: {
1652
+ updateLastRoute: buildFeishuInboundLastRouteUpdate({
1653
+ sessionKey: route.sessionKey,
1654
+ accountId: route.accountId,
1655
+ }),
1656
+ onRecordError: (err) => {
1657
+ log(
1658
+ `feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
1659
+ );
1660
+ },
1661
+ },
1662
+ history: {
1663
+ isGroup,
1664
+ historyKey,
1665
+ historyMap: chatHistories,
1666
+ limit: historyLimit,
1667
+ },
1668
+ onPreDispatchFailure: () =>
1669
+ core.channel.reply.settleReplyDispatcher({
1670
+ dispatcher,
1671
+ onSettled: () => markDispatchIdle(),
1672
+ }),
1673
+ runDispatch: () =>
1674
+ core.channel.reply.withReplyDispatcher({
1675
+ dispatcher,
1676
+ onSettled: () => {
1677
+ markDispatchIdle();
1678
+ },
1679
+ run: () =>
1680
+ core.channel.reply.dispatchReplyFromConfig({
1681
+ ctx: ctxPayload,
1682
+ cfg,
1683
+ dispatcher,
1684
+ replyOptions,
1685
+ }),
1686
+ }),
1687
+ }),
1688
+ },
1689
+ });
1690
+ if (!turnResult.dispatched) {
1691
+ return;
1692
+ }
1693
+ const { dispatchResult } = turnResult;
1694
+ const { queuedFinal, counts } = dispatchResult;
1695
+
1696
+ log(
1697
+ `feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
1698
+ );
1699
+ }
1700
+ } catch (err) {
1701
+ error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
1702
+ }
1703
+ }