@actagent/feishu 2026.6.2

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