@openclaw/feishu 2026.3.12 → 2026.5.1-beta.1

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 (188) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1653 -4
  7. package/package.json +32 -7
  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.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +115 -22
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +798 -786
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1252 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +84 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +365 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +77 -25
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +76 -35
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +119 -20
  72. package/src/directory.ts +61 -91
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +413 -87
  91. package/src/media.ts +488 -154
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +220 -313
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +194 -92
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +24 -36
  115. package/src/monitor.startup.ts +26 -16
  116. package/src/monitor.state.ts +20 -5
  117. package/src/monitor.synthetic-error.ts +18 -0
  118. package/src/monitor.test-mocks.ts +2 -2
  119. package/src/monitor.transport.ts +297 -39
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +272 -0
  122. package/src/monitor.webhook-security.test.ts +125 -91
  123. package/src/monitor.webhook.test-helpers.ts +116 -0
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +627 -53
  126. package/src/outbound.ts +623 -81
  127. package/src/perm-schema.ts +1 -1
  128. package/src/perm.ts +1 -7
  129. package/src/pins.ts +108 -0
  130. package/src/policy.test.ts +297 -117
  131. package/src/policy.ts +142 -29
  132. package/src/post.ts +7 -6
  133. package/src/probe.test.ts +122 -118
  134. package/src/probe.ts +26 -16
  135. package/src/processing-claims.ts +59 -0
  136. package/src/qr-terminal.ts +1 -0
  137. package/src/reactions.ts +23 -60
  138. package/src/reasoning-preview.test.ts +59 -0
  139. package/src/reasoning-preview.ts +20 -0
  140. package/src/reply-dispatcher-runtime-api.ts +7 -0
  141. package/src/reply-dispatcher.test.ts +721 -168
  142. package/src/reply-dispatcher.ts +422 -172
  143. package/src/runtime.ts +6 -3
  144. package/src/secret-contract.ts +145 -0
  145. package/src/secret-input.ts +1 -13
  146. package/src/security-audit-shared.ts +69 -0
  147. package/src/security-audit.test.ts +61 -0
  148. package/src/security-audit.ts +1 -0
  149. package/src/send-result.ts +1 -1
  150. package/src/send-target.test.ts +9 -3
  151. package/src/send-target.ts +10 -4
  152. package/src/send.reply-fallback.test.ts +127 -42
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +486 -164
  155. package/src/sequential-key.test.ts +72 -0
  156. package/src/sequential-key.ts +28 -0
  157. package/src/sequential-queue.test.ts +92 -0
  158. package/src/sequential-queue.ts +16 -0
  159. package/src/session-conversation.ts +42 -0
  160. package/src/session-route.ts +48 -0
  161. package/src/setup-core.ts +51 -0
  162. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  163. package/src/setup-surface.ts +581 -0
  164. package/src/streaming-card.test.ts +138 -2
  165. package/src/streaming-card.ts +134 -18
  166. package/src/subagent-hooks.test.ts +603 -0
  167. package/src/subagent-hooks.ts +397 -0
  168. package/src/targets.ts +3 -13
  169. package/src/test-support/lifecycle-test-support.ts +479 -0
  170. package/src/thread-bindings.test.ts +143 -0
  171. package/src/thread-bindings.ts +330 -0
  172. package/src/tool-account-routing.test.ts +66 -8
  173. package/src/tool-account.test.ts +44 -0
  174. package/src/tool-account.ts +40 -17
  175. package/src/tool-factory-test-harness.ts +11 -8
  176. package/src/tool-result.ts +3 -1
  177. package/src/tools-config.ts +1 -1
  178. package/src/types.ts +16 -15
  179. package/src/typing.ts +10 -6
  180. package/src/wiki-schema.ts +1 -1
  181. package/src/wiki.ts +1 -7
  182. package/subagent-hooks-api.ts +31 -0
  183. package/tsconfig.json +16 -0
  184. package/src/feishu-command-handler.ts +0 -59
  185. package/src/onboarding.status.test.ts +0 -25
  186. package/src/onboarding.ts +0 -489
  187. package/src/send-message.ts +0 -71
  188. package/src/targets.test.ts +0 -70
package/src/bot.ts CHANGED
@@ -1,742 +1,187 @@
1
- import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
1
+ import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
2
+ import {
3
+ ensureConfiguredBindingRouteReady,
4
+ resolveConfiguredBindingRoute,
5
+ resolveRuntimeConversationBindingRoute,
6
+ } from "openclaw/plugin-sdk/conversation-runtime";
7
+ import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime";
2
8
  import {
3
- buildAgentMediaPayload,
4
9
  buildPendingHistoryContextFromMap,
5
10
  clearHistoryEntriesIfEnabled,
6
- createScopedPairingAccess,
7
11
  DEFAULT_GROUP_HISTORY_LIMIT,
8
- type HistoryEntry,
9
- issuePairingChallenge,
10
- normalizeAgentId,
11
12
  recordPendingHistoryEntryIfEnabled,
12
- resolveOpenProviderRuntimeGroupPolicy,
13
+ type HistoryEntry,
14
+ } from "openclaw/plugin-sdk/reply-history";
15
+ import {
13
16
  resolveDefaultGroupPolicy,
17
+ resolveOpenProviderRuntimeGroupPolicy,
14
18
  warnMissingProviderGroupPolicyFallbackOnce,
15
- } from "openclaw/plugin-sdk/feishu";
16
- import { resolveFeishuAccount } from "./accounts.js";
19
+ } from "openclaw/plugin-sdk/runtime-group-policy";
20
+ import { resolveOpenDmAllowlistAccess } from "openclaw/plugin-sdk/security-runtime";
21
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
22
+ import { resolveFeishuRuntimeAccount } from "./accounts.js";
23
+ import {
24
+ checkBotMentioned,
25
+ normalizeFeishuCommandProbeBody,
26
+ normalizeMentions,
27
+ parseMergeForwardContent,
28
+ parseMessageContent,
29
+ resolveFeishuGroupSession,
30
+ resolveFeishuMediaList,
31
+ toMessageResourceType,
32
+ } from "./bot-content.js";
33
+ import {
34
+ buildAgentMediaPayload,
35
+ evaluateSupplementalContextVisibility,
36
+ filterSupplementalContextItems,
37
+ normalizeAgentId,
38
+ resolveChannelContextVisibilityMode,
39
+ } from "./bot-runtime-api.js";
40
+ import type { ClawdbotConfig, RuntimeEnv } from "./bot-runtime-api.js";
41
+ import { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js";
42
+ import { getChatInfo } from "./chat.js";
17
43
  import { createFeishuClient } from "./client.js";
18
- import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
44
+ import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
19
45
  import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
20
- import { normalizeFeishuExternalKey } from "./external-keys.js";
21
- import { downloadMessageResourceFeishu } from "./media.js";
22
46
  import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
23
47
  import {
48
+ hasExplicitFeishuGroupConfig,
49
+ isFeishuGroupAllowed,
50
+ resolveFeishuAllowlistMatch,
24
51
  resolveFeishuGroupConfig,
25
52
  resolveFeishuReplyPolicy,
26
- resolveFeishuAllowlistMatch,
27
- isFeishuGroupAllowed,
28
53
  } from "./policy.js";
29
- import { parsePostContent } from "./post.js";
54
+ import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js";
30
55
  import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
31
56
  import { getFeishuRuntime } from "./runtime.js";
32
- import { getMessageFeishu, sendMessageFeishu } from "./send.js";
33
- import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
57
+ import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
58
+ export type { FeishuBotAddedEvent, FeishuMessageEvent } from "./event-types.js";
59
+ import type { FeishuMessageEvent } from "./event-types.js";
60
+ import {
61
+ isFeishuGroupChatType,
62
+ type FeishuMessageContext,
63
+ type FeishuMediaInfo,
64
+ type FeishuMessageInfo,
65
+ type ResolvedFeishuAccount,
66
+ } from "./types.js";
34
67
  import type { DynamicAgentCreationConfig } from "./types.js";
35
68
 
36
- // --- Permission error extraction ---
37
- // Extract permission grant URL from Feishu API error response.
38
- type PermissionError = {
39
- code: number;
40
- message: string;
41
- grantUrl?: string;
42
- };
43
-
44
- const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
45
-
46
- // Feishu API sometimes returns incorrect scope names in permission error
47
- // responses (e.g. "contact:contact.base:readonly" instead of the valid
48
- // "contact:user.base:readonly"). This map corrects known mismatches.
49
- const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
50
- "contact:contact.base:readonly": "contact:user.base:readonly",
51
- };
52
-
53
- function correctFeishuScopeInUrl(url: string): string {
54
- let corrected = url;
55
- for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
56
- corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
57
- corrected = corrected.replaceAll(wrong, right);
58
- }
59
- return corrected;
60
- }
61
-
62
- function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
63
- const message = permissionError.message.toLowerCase();
64
- return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
65
- }
66
-
67
- function extractPermissionError(err: unknown): PermissionError | null {
68
- if (!err || typeof err !== "object") return null;
69
-
70
- // Axios error structure: err.response.data contains the Feishu error
71
- const axiosErr = err as { response?: { data?: unknown } };
72
- const data = axiosErr.response?.data;
73
- if (!data || typeof data !== "object") return null;
74
-
75
- const feishuErr = data as {
76
- code?: number;
77
- msg?: string;
78
- error?: { permission_violations?: Array<{ uri?: string }> };
79
- };
80
-
81
- // Feishu permission error code: 99991672
82
- if (feishuErr.code !== 99991672) return null;
83
-
84
- // Extract the grant URL from the error message (contains the direct link)
85
- const msg = feishuErr.msg ?? "";
86
- const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
87
- const grantUrl = urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined;
88
-
89
- return {
90
- code: feishuErr.code,
91
- message: msg,
92
- grantUrl,
93
- };
94
- }
95
-
96
- // --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
97
- // Cache display names by sender id (open_id/user_id) to avoid an API call on every message.
98
- const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
99
- const senderNameCache = new Map<string, { name: string; expireAt: number }>();
69
+ export { toMessageResourceType } from "./bot-content.js";
100
70
 
101
71
  // Cache permission errors to avoid spamming the user with repeated notifications.
102
72
  // Key: appId or "default", Value: timestamp of last notification
103
73
  const permissionErrorNotifiedAt = new Map<string, number>();
104
74
  const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
105
75
 
106
- type SenderNameResult = {
107
- name?: string;
108
- permissionError?: PermissionError;
109
- };
110
-
111
- function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
112
- const trimmed = senderId.trim();
113
- if (trimmed.startsWith("ou_")) {
114
- return "open_id";
115
- }
116
- if (trimmed.startsWith("on_")) {
117
- return "union_id";
118
- }
119
- return "user_id";
120
- }
121
-
122
- async function resolveFeishuSenderName(params: {
123
- account: ResolvedFeishuAccount;
124
- senderId: string;
125
- log: (...args: any[]) => void;
126
- }): Promise<SenderNameResult> {
127
- const { account, senderId, log } = params;
128
- if (!account.configured) return {};
129
-
130
- const normalizedSenderId = senderId.trim();
131
- if (!normalizedSenderId) return {};
76
+ const groupNameCache = new Map<string, { name: string; expiresAt: number }>();
77
+ const GROUP_NAME_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
78
+ const GROUP_NAME_CACHE_MAX_SIZE = 500; // hard cap
132
79
 
133
- const cached = senderNameCache.get(normalizedSenderId);
80
+ function evictGroupNameCache(): void {
134
81
  const now = Date.now();
135
- if (cached && cached.expireAt > now) return { name: cached.name };
136
-
137
- try {
138
- const client = createFeishuClient(account);
139
- const userIdType = resolveSenderLookupIdType(normalizedSenderId);
140
-
141
- // contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
142
- const res: any = await client.contact.user.get({
143
- path: { user_id: normalizedSenderId },
144
- params: { user_id_type: userIdType },
145
- });
146
-
147
- const name: string | undefined =
148
- res?.data?.user?.name ||
149
- res?.data?.user?.display_name ||
150
- res?.data?.user?.nickname ||
151
- res?.data?.user?.en_name;
152
-
153
- if (name && typeof name === "string") {
154
- senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
155
- return { name };
156
- }
157
-
158
- return {};
159
- } catch (err) {
160
- // Check if this is a permission error
161
- const permErr = extractPermissionError(err);
162
- if (permErr) {
163
- if (shouldSuppressPermissionErrorNotice(permErr)) {
164
- log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
165
- return {};
166
- }
167
- log(`feishu: permission error resolving sender name: code=${permErr.code}`);
168
- return { permissionError: permErr };
82
+ for (const [key, val] of groupNameCache) {
83
+ if (val.expiresAt <= now) {
84
+ groupNameCache.delete(key);
169
85
  }
170
-
171
- // Best-effort. Don't fail message handling if name lookup fails.
172
- log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
173
- return {};
174
- }
175
- }
176
-
177
- export type FeishuMessageEvent = {
178
- sender: {
179
- sender_id: {
180
- open_id?: string;
181
- user_id?: string;
182
- union_id?: string;
183
- };
184
- sender_type?: string;
185
- tenant_key?: string;
186
- };
187
- message: {
188
- message_id: string;
189
- root_id?: string;
190
- parent_id?: string;
191
- thread_id?: string;
192
- chat_id: string;
193
- chat_type: "p2p" | "group" | "private";
194
- message_type: string;
195
- content: string;
196
- create_time?: string;
197
- mentions?: Array<{
198
- key: string;
199
- id: {
200
- open_id?: string;
201
- user_id?: string;
202
- union_id?: string;
203
- };
204
- name: string;
205
- tenant_key?: string;
206
- }>;
207
- };
208
- };
209
-
210
- export type FeishuBotAddedEvent = {
211
- chat_id: string;
212
- operator_id: {
213
- open_id?: string;
214
- user_id?: string;
215
- union_id?: string;
216
- };
217
- external: boolean;
218
- operator_tenant_key?: string;
219
- };
220
-
221
- type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
222
-
223
- type ResolvedFeishuGroupSession = {
224
- peerId: string;
225
- parentPeer: { kind: "group"; id: string } | null;
226
- groupSessionScope: GroupSessionScope;
227
- replyInThread: boolean;
228
- threadReply: boolean;
229
- };
230
-
231
- function resolveFeishuGroupSession(params: {
232
- chatId: string;
233
- senderOpenId: string;
234
- messageId: string;
235
- rootId?: string;
236
- threadId?: string;
237
- groupConfig?: {
238
- groupSessionScope?: GroupSessionScope;
239
- topicSessionMode?: "enabled" | "disabled";
240
- replyInThread?: "enabled" | "disabled";
241
- };
242
- feishuCfg?: {
243
- groupSessionScope?: GroupSessionScope;
244
- topicSessionMode?: "enabled" | "disabled";
245
- replyInThread?: "enabled" | "disabled";
246
- };
247
- }): ResolvedFeishuGroupSession {
248
- const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
249
-
250
- const normalizedThreadId = threadId?.trim();
251
- const normalizedRootId = rootId?.trim();
252
- const threadReply = Boolean(normalizedThreadId || normalizedRootId);
253
- const replyInThread =
254
- (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
255
- threadReply;
256
-
257
- const legacyTopicSessionMode =
258
- groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
259
- const groupSessionScope: GroupSessionScope =
260
- groupConfig?.groupSessionScope ??
261
- feishuCfg?.groupSessionScope ??
262
- (legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
263
-
264
- // Keep topic session keys stable across the "first turn creates thread" flow:
265
- // first turn may only have message_id, while the next turn carries root_id/thread_id.
266
- // Prefer root_id first so both turns stay on the same peer key.
267
- const topicScope =
268
- groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
269
- ? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
270
- : null;
271
-
272
- let peerId = chatId;
273
- switch (groupSessionScope) {
274
- case "group_sender":
275
- peerId = `${chatId}:sender:${senderOpenId}`;
276
- break;
277
- case "group_topic":
278
- peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
279
- break;
280
- case "group_topic_sender":
281
- peerId = topicScope
282
- ? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
283
- : `${chatId}:sender:${senderOpenId}`;
284
- break;
285
- case "group":
286
- default:
287
- peerId = chatId;
288
- break;
289
- }
290
-
291
- const parentPeer =
292
- topicScope &&
293
- (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
294
- ? {
295
- kind: "group" as const,
296
- id: chatId,
297
- }
298
- : null;
299
-
300
- return {
301
- peerId,
302
- parentPeer,
303
- groupSessionScope,
304
- replyInThread,
305
- threadReply,
306
- };
307
- }
308
-
309
- function parseMessageContent(content: string, messageType: string): string {
310
- if (messageType === "post") {
311
- // Extract text content from rich text post
312
- const { textContent } = parsePostContent(content);
313
- return textContent;
314
86
  }
315
87
 
316
- try {
317
- const parsed = JSON.parse(content);
318
- if (messageType === "text") {
319
- return parsed.text || "";
320
- }
321
- if (messageType === "share_chat") {
322
- // Preserve available summary text for merged/forwarded chat messages.
323
- if (parsed && typeof parsed === "object") {
324
- const share = parsed as {
325
- body?: unknown;
326
- summary?: unknown;
327
- share_chat_id?: unknown;
328
- };
329
- if (typeof share.body === "string" && share.body.trim().length > 0) {
330
- return share.body.trim();
331
- }
332
- if (typeof share.summary === "string" && share.summary.trim().length > 0) {
333
- return share.summary.trim();
334
- }
335
- if (typeof share.share_chat_id === "string" && share.share_chat_id.trim().length > 0) {
336
- return `[Forwarded message: ${share.share_chat_id.trim()}]`;
337
- }
88
+ if (groupNameCache.size > GROUP_NAME_CACHE_MAX_SIZE) {
89
+ const excess = groupNameCache.size - GROUP_NAME_CACHE_MAX_SIZE;
90
+ let removed = 0;
91
+ for (const key of groupNameCache.keys()) {
92
+ if (removed >= excess) {
93
+ break;
338
94
  }
339
- return "[Forwarded message]";
340
- }
341
- if (messageType === "merge_forward") {
342
- // Return placeholder; actual content fetched asynchronously in handleFeishuMessage
343
- return "[Merged and Forwarded Message - loading...]";
95
+ groupNameCache.delete(key);
96
+ removed++;
344
97
  }
345
- return content;
346
- } catch {
347
- return content;
348
98
  }
349
99
  }
350
100
 
351
- /**
352
- * Parse merge_forward message content and fetch sub-messages.
353
- * Returns formatted text content of all sub-messages.
354
- */
355
- function parseMergeForwardContent(params: {
356
- content: string;
357
- log?: (...args: any[]) => void;
358
- }): string {
359
- const { content, log } = params;
360
- const maxMessages = 50;
361
-
362
- // For merge_forward, the API returns all sub-messages in items array
363
- // with upper_message_id pointing to the merge_forward message.
364
- // The 'content' parameter here is actually the full API response items array as JSON.
365
- log?.(`feishu: parsing merge_forward sub-messages from API response`);
366
-
367
- let items: Array<{
368
- message_id?: string;
369
- msg_type?: string;
370
- body?: { content?: string };
371
- sender?: { id?: string };
372
- upper_message_id?: string;
373
- create_time?: string;
374
- }>;
375
-
376
- try {
377
- items = JSON.parse(content);
378
- } catch {
379
- log?.(`feishu: merge_forward items parse failed`);
380
- return "[Merged and Forwarded Message - parse error]";
381
- }
382
-
383
- if (!Array.isArray(items) || items.length === 0) {
384
- return "[Merged and Forwarded Message - no sub-messages]";
385
- }
386
-
387
- // Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
388
- const subMessages = items.filter((item) => item.upper_message_id);
389
-
390
- if (subMessages.length === 0) {
391
- return "[Merged and Forwarded Message - no sub-messages found]";
392
- }
393
-
394
- log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
395
-
396
- // Sort by create_time
397
- subMessages.sort((a, b) => {
398
- const timeA = parseInt(a.create_time || "0", 10);
399
- const timeB = parseInt(b.create_time || "0", 10);
400
- return timeA - timeB;
401
- });
402
-
403
- // Format output
404
- const lines: string[] = ["[Merged and Forwarded Messages]"];
405
- const limitedMessages = subMessages.slice(0, maxMessages);
406
-
407
- for (const item of limitedMessages) {
408
- const msgContent = item.body?.content || "";
409
- const msgType = item.msg_type || "text";
410
- const formatted = formatSubMessageContent(msgContent, msgType);
411
- lines.push(`- ${formatted}`);
412
- }
413
-
414
- if (subMessages.length > maxMessages) {
415
- lines.push(`... and ${subMessages.length - maxMessages} more messages`);
416
- }
417
-
418
- return lines.join("\n");
419
- }
420
-
421
- /**
422
- * Format sub-message content based on message type.
423
- */
424
- function formatSubMessageContent(content: string, contentType: string): string {
425
- try {
426
- const parsed = JSON.parse(content);
427
- switch (contentType) {
428
- case "text":
429
- return parsed.text || content;
430
- case "post": {
431
- const { textContent } = parsePostContent(content);
432
- return textContent;
433
- }
434
- case "image":
435
- return "[Image]";
436
- case "file":
437
- return `[File: ${parsed.file_name || "unknown"}]`;
438
- case "audio":
439
- return "[Audio]";
440
- case "video":
441
- return "[Video]";
442
- case "sticker":
443
- return "[Sticker]";
444
- case "merge_forward":
445
- return "[Nested Merged Forward]";
446
- default:
447
- return `[${contentType}]`;
448
- }
449
- } catch {
450
- return content;
451
- }
101
+ function setCacheEntry(key: string, value: { name: string; expiresAt: number }): void {
102
+ groupNameCache.delete(key);
103
+ groupNameCache.set(key, value);
452
104
  }
453
105
 
454
- function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
455
- if (!botOpenId) return false;
456
- // Check for @all (@_all in Feishu) — treat as mentioning every bot
457
- const rawContent = event.message.content ?? "";
458
- if (rawContent.includes("@_all")) return true;
459
- const mentions = event.message.mentions ?? [];
460
- if (mentions.length > 0) {
461
- // Rely on Feishu mention IDs; display names can vary by alias/context.
462
- return mentions.some((m) => m.id.open_id === botOpenId);
463
- }
464
- // Post (rich text) messages may have empty message.mentions when they contain docs/paste
465
- if (event.message.message_type === "post") {
466
- const { mentionedOpenIds } = parsePostContent(event.message.content);
467
- return mentionedOpenIds.some((id) => id === botOpenId);
468
- }
469
- return false;
106
+ export function clearGroupNameCache(): void {
107
+ groupNameCache.clear();
470
108
  }
471
109
 
472
- function normalizeMentions(
473
- text: string,
474
- mentions?: FeishuMessageEvent["message"]["mentions"],
475
- botStripId?: string,
476
- ): string {
477
- if (!mentions || mentions.length === 0) return text;
478
-
479
- const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
480
- const escapeName = (value: string) => value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
481
- let result = text;
482
-
483
- for (const mention of mentions) {
484
- const mentionId = mention.id.open_id;
485
- const replacement =
486
- botStripId && mentionId === botStripId
487
- ? ""
488
- : mentionId
489
- ? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
490
- : `@${mention.name}`;
491
-
492
- result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
110
+ export async function resolveGroupName(params: {
111
+ account: ResolvedFeishuAccount;
112
+ chatId: string;
113
+ log: (...args: unknown[]) => void;
114
+ }): Promise<string | undefined> {
115
+ const { account, chatId, log } = params;
116
+ if (!account.configured) {
117
+ return undefined;
493
118
  }
494
119
 
495
- return result;
496
- }
120
+ const cacheKey = `${account.accountId}:${chatId}`;
497
121
 
498
- function normalizeFeishuCommandProbeBody(text: string): string {
499
- if (!text) {
500
- return "";
122
+ const cached = groupNameCache.get(cacheKey);
123
+ if (cached && cached.expiresAt > Date.now()) {
124
+ return cached.name || undefined;
501
125
  }
502
- return text
503
- .replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
504
- .replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
505
- .replace(/\s+/g, " ")
506
- .trim();
507
- }
508
126
 
509
- /**
510
- * Parse media keys from message content based on message type.
511
- */
512
- function parseMediaKeys(
513
- content: string,
514
- messageType: string,
515
- ): {
516
- imageKey?: string;
517
- fileKey?: string;
518
- fileName?: string;
519
- } {
520
127
  try {
521
- const parsed = JSON.parse(content);
522
- const imageKey = normalizeFeishuExternalKey(parsed.image_key);
523
- const fileKey = normalizeFeishuExternalKey(parsed.file_key);
524
- switch (messageType) {
525
- case "image":
526
- return { imageKey };
527
- case "file":
528
- return { fileKey, fileName: parsed.file_name };
529
- case "audio":
530
- return { fileKey };
531
- case "video":
532
- case "media":
533
- // Video/media has both file_key (video) and image_key (thumbnail)
534
- return { fileKey, imageKey };
535
- case "sticker":
536
- return { fileKey };
537
- default:
538
- return {};
128
+ const client = createFeishuClient(account);
129
+ const chatInfo = await getChatInfo(client, chatId);
130
+ const name = chatInfo?.name?.trim();
131
+ if (name) {
132
+ setCacheEntry(cacheKey, {
133
+ name,
134
+ expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
135
+ });
136
+ } else {
137
+ setCacheEntry(cacheKey, {
138
+ name: "",
139
+ expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
140
+ });
539
141
  }
540
- } catch {
541
- return {};
142
+ } catch (err) {
143
+ log(`feishu[${account.accountId}]: getChatInfo failed for ${chatId}: ${String(err)}`);
144
+ setCacheEntry(cacheKey, {
145
+ name: "",
146
+ expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
147
+ });
542
148
  }
543
- }
544
149
 
545
- /**
546
- * Map Feishu message type to messageResource.get resource type.
547
- * Feishu messageResource API supports only: image | file.
548
- */
549
- export function toMessageResourceType(messageType: string): "image" | "file" {
550
- return messageType === "image" ? "image" : "file";
551
- }
150
+ const result = groupNameCache.get(cacheKey)?.name || undefined;
151
+ evictGroupNameCache();
552
152
 
553
- /**
554
- * Infer placeholder text based on message type.
555
- */
556
- function inferPlaceholder(messageType: string): string {
557
- switch (messageType) {
558
- case "image":
559
- return "<media:image>";
560
- case "file":
561
- return "<media:document>";
562
- case "audio":
563
- return "<media:audio>";
564
- case "video":
565
- case "media":
566
- return "<media:video>";
567
- case "sticker":
568
- return "<media:sticker>";
569
- default:
570
- return "<media:document>";
571
- }
153
+ return result;
572
154
  }
573
155
 
574
- /**
575
- * Resolve media from a Feishu message, downloading and saving to disk.
576
- * Similar to Discord's resolveMediaList().
577
- */
578
- async function resolveFeishuMediaList(params: {
156
+ async function resolveFeishuAudioPreflightTranscript(params: {
579
157
  cfg: ClawdbotConfig;
580
- messageId: string;
581
- messageType: string;
158
+ mediaList: FeishuMediaInfo[];
582
159
  content: string;
583
- maxBytes: number;
584
- log?: (msg: string) => void;
585
- accountId?: string;
586
- }): Promise<FeishuMediaInfo[]> {
587
- const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
588
-
589
- // Only process media message types (including post for embedded images)
590
- const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
591
- if (!mediaTypes.includes(messageType)) {
592
- return [];
160
+ chatType: "direct" | "group";
161
+ log: (msg: string) => void;
162
+ }): Promise<string | undefined> {
163
+ if (params.content.trim() !== "<media:audio>") {
164
+ return undefined;
593
165
  }
594
-
595
- const out: FeishuMediaInfo[] = [];
596
- const core = getFeishuRuntime();
597
-
598
- // Handle post (rich text) messages with embedded images/media.
599
- if (messageType === "post") {
600
- const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
601
- if (imageKeys.length === 0 && postMediaKeys.length === 0) {
602
- return [];
603
- }
604
-
605
- if (imageKeys.length > 0) {
606
- log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
607
- }
608
- if (postMediaKeys.length > 0) {
609
- log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(s)`);
610
- }
611
-
612
- for (const imageKey of imageKeys) {
613
- try {
614
- // Embedded images in post use messageResource API with image_key as file_key
615
- const result = await downloadMessageResourceFeishu({
616
- cfg,
617
- messageId,
618
- fileKey: imageKey,
619
- type: "image",
620
- accountId,
621
- });
622
-
623
- let contentType = result.contentType;
624
- if (!contentType) {
625
- contentType = await core.media.detectMime({ buffer: result.buffer });
626
- }
627
-
628
- const saved = await core.channel.media.saveMediaBuffer(
629
- result.buffer,
630
- contentType,
631
- "inbound",
632
- maxBytes,
633
- );
634
-
635
- out.push({
636
- path: saved.path,
637
- contentType: saved.contentType,
638
- placeholder: "<media:image>",
639
- });
640
-
641
- log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
642
- } catch (err) {
643
- log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
644
- }
645
- }
646
-
647
- for (const media of postMediaKeys) {
648
- try {
649
- const result = await downloadMessageResourceFeishu({
650
- cfg,
651
- messageId,
652
- fileKey: media.fileKey,
653
- type: "file",
654
- accountId,
655
- });
656
-
657
- let contentType = result.contentType;
658
- if (!contentType) {
659
- contentType = await core.media.detectMime({ buffer: result.buffer });
660
- }
661
-
662
- const saved = await core.channel.media.saveMediaBuffer(
663
- result.buffer,
664
- contentType,
665
- "inbound",
666
- maxBytes,
667
- );
668
-
669
- out.push({
670
- path: saved.path,
671
- contentType: saved.contentType,
672
- placeholder: "<media:video>",
673
- });
674
-
675
- log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
676
- } catch (err) {
677
- log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
678
- }
679
- }
680
-
681
- return out;
682
- }
683
-
684
- // Handle other media types
685
- const mediaKeys = parseMediaKeys(content, messageType);
686
- if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
687
- return [];
166
+ const audioMedia = params.mediaList.filter((media) => media.contentType?.startsWith("audio/"));
167
+ if (audioMedia.length === 0) {
168
+ return undefined;
688
169
  }
689
170
 
690
171
  try {
691
- let buffer: Buffer;
692
- let contentType: string | undefined;
693
- let fileName: string | undefined;
694
-
695
- // For message media, always use messageResource API
696
- // The image.get API is only for images uploaded via im/v1/images, not for message attachments
697
- const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
698
- if (!fileKey) {
699
- return [];
700
- }
701
-
702
- const resourceType = toMessageResourceType(messageType);
703
- const result = await downloadMessageResourceFeishu({
704
- cfg,
705
- messageId,
706
- fileKey,
707
- type: resourceType,
708
- accountId,
709
- });
710
- buffer = result.buffer;
711
- contentType = result.contentType;
712
- fileName = result.fileName || mediaKeys.fileName;
713
-
714
- // Detect mime type if not provided
715
- if (!contentType) {
716
- contentType = await core.media.detectMime({ buffer });
717
- }
718
-
719
- // Save to disk using core's saveMediaBuffer
720
- const saved = await core.channel.media.saveMediaBuffer(
721
- buffer,
722
- contentType,
723
- "inbound",
724
- maxBytes,
725
- fileName,
726
- );
727
-
728
- out.push({
729
- path: saved.path,
730
- contentType: saved.contentType,
731
- placeholder: inferPlaceholder(messageType),
172
+ const { transcribeFirstAudio } = await import("./audio-preflight.runtime.js");
173
+ return await transcribeFirstAudio({
174
+ ctx: {
175
+ MediaPaths: audioMedia.map((media) => media.path),
176
+ MediaTypes: audioMedia.map((media) => media.contentType).filter(Boolean) as string[],
177
+ ChatType: params.chatType,
178
+ },
179
+ cfg: params.cfg,
732
180
  });
733
-
734
- log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
735
181
  } catch (err) {
736
- log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
182
+ params.log(`feishu: audio preflight transcription failed: ${String(err)}`);
183
+ return undefined;
737
184
  }
738
-
739
- return out;
740
185
  }
741
186
 
742
187
  // --- Broadcast support ---
@@ -744,9 +189,13 @@ async function resolveFeishuMediaList(params: {
744
189
  // Returns null if no broadcast config exists or the peer is not in the broadcast list.
745
190
  export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null {
746
191
  const broadcast = (cfg as Record<string, unknown>).broadcast;
747
- if (!broadcast || typeof broadcast !== "object") return null;
192
+ if (!broadcast || typeof broadcast !== "object") {
193
+ return null;
194
+ }
748
195
  const agents = (broadcast as Record<string, unknown>)[peerId];
749
- if (!Array.isArray(agents) || agents.length === 0) return null;
196
+ if (!Array.isArray(agents) || agents.length === 0) {
197
+ return null;
198
+ }
750
199
  return agents as string[];
751
200
  }
752
201
 
@@ -789,6 +238,8 @@ export function parseFeishuMessageEvent(
789
238
  const ctx: FeishuMessageContext = {
790
239
  chatId: event.message.chat_id,
791
240
  messageId: event.message.message_id,
241
+ replyTargetMessageId: event.message.reply_target_message_id?.trim() || undefined,
242
+ suppressReplyTarget: event.message.suppress_reply_target === true,
792
243
  senderId: senderUserId || senderOpenId || "",
793
244
  // Keep the historical field name, but fall back to user_id when open_id is unavailable
794
245
  // (common in some mobile app deliveries).
@@ -820,7 +271,7 @@ export function buildFeishuAgentBody(params: {
820
271
  "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
821
272
  >;
822
273
  quotedContent?: string;
823
- permissionErrorForAgent?: PermissionError;
274
+ permissionErrorForAgent?: FeishuPermissionError;
824
275
  botOpenId?: string;
825
276
  }): string {
826
277
  const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
@@ -859,6 +310,76 @@ export function buildFeishuAgentBody(params: {
859
310
  return messageBody;
860
311
  }
861
312
 
313
+ function isFetchedGroupContextSenderAllowed(params: {
314
+ isGroup: boolean;
315
+ allowFrom: Array<string | number>;
316
+ senderId?: string;
317
+ senderType?: string;
318
+ }): boolean {
319
+ if (!params.isGroup || params.allowFrom.length === 0) {
320
+ return true;
321
+ }
322
+ if (params.senderType === "app") {
323
+ return true;
324
+ }
325
+ const senderId = params.senderId?.trim();
326
+ const senderAllowed =
327
+ !!senderId &&
328
+ isFeishuGroupAllowed({
329
+ groupPolicy: "allowlist",
330
+ allowFrom: params.allowFrom,
331
+ senderId,
332
+ senderName: undefined,
333
+ });
334
+ return senderAllowed;
335
+ }
336
+
337
+ function shouldIncludeFetchedGroupContextMessage(params: {
338
+ isGroup: boolean;
339
+ allowFrom: Array<string | number>;
340
+ mode: "all" | "allowlist" | "allowlist_quote";
341
+ kind: "quote" | "thread" | "history";
342
+ senderId?: string;
343
+ senderType?: string;
344
+ }): boolean {
345
+ const senderAllowed = isFetchedGroupContextSenderAllowed({
346
+ isGroup: params.isGroup,
347
+ allowFrom: params.allowFrom,
348
+ senderId: params.senderId,
349
+ senderType: params.senderType,
350
+ });
351
+ return evaluateSupplementalContextVisibility({
352
+ mode: params.mode,
353
+ kind: params.kind,
354
+ senderAllowed,
355
+ }).include;
356
+ }
357
+
358
+ function filterFetchedGroupContextMessages<
359
+ T extends Pick<FeishuMessageInfo, "senderId" | "senderType">,
360
+ >(
361
+ messages: readonly T[],
362
+ params: {
363
+ isGroup: boolean;
364
+ allowFrom: Array<string | number>;
365
+ mode: "all" | "allowlist" | "allowlist_quote";
366
+ kind: "quote" | "thread" | "history";
367
+ },
368
+ ): T[] {
369
+ return filterSupplementalContextItems({
370
+ items: messages,
371
+ mode: params.mode,
372
+ kind: params.kind,
373
+ isSenderAllowed: (message) =>
374
+ isFetchedGroupContextSenderAllowed({
375
+ isGroup: params.isGroup,
376
+ allowFrom: params.allowFrom,
377
+ senderId: message.senderId,
378
+ senderType: message.senderType,
379
+ }),
380
+ }).items;
381
+ }
382
+
862
383
  export async function handleFeishuMessage(params: {
863
384
  cfg: ClawdbotConfig;
864
385
  event: FeishuMessageEvent;
@@ -867,34 +388,43 @@ export async function handleFeishuMessage(params: {
867
388
  runtime?: RuntimeEnv;
868
389
  chatHistories?: Map<string, HistoryEntry[]>;
869
390
  accountId?: string;
391
+ processingClaimHeld?: boolean;
870
392
  }): Promise<void> {
871
- const { cfg, event, botOpenId, botName, runtime, chatHistories, accountId } = params;
393
+ const {
394
+ cfg,
395
+ event,
396
+ botOpenId,
397
+ botName,
398
+ runtime,
399
+ chatHistories,
400
+ accountId,
401
+ processingClaimHeld = false,
402
+ } = params;
872
403
 
873
404
  // Resolve account with merged config
874
- const account = resolveFeishuAccount({ cfg, accountId });
405
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
875
406
  const feishuCfg = account.config;
876
407
 
877
408
  const log = runtime?.log ?? console.log;
878
409
  const error = runtime?.error ?? console.error;
879
410
 
880
- // Dedup: synchronous memory guard prevents concurrent duplicate dispatch
881
- // before the async persistent check completes.
882
411
  const messageId = event.message.message_id;
883
- const memoryDedupeKey = `${account.accountId}:${messageId}`;
884
- if (!tryRecordMessage(memoryDedupeKey)) {
885
- log(`feishu: skipping duplicate message ${messageId} (memory dedup)`);
886
- return;
887
- }
888
- // Persistent dedup survives restarts and reconnects.
889
- if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
412
+ if (
413
+ !(await finalizeFeishuMessageProcessing({
414
+ messageId,
415
+ namespace: account.accountId,
416
+ log,
417
+ claimHeld: processingClaimHeld,
418
+ }))
419
+ ) {
890
420
  log(`feishu: skipping duplicate message ${messageId}`);
891
421
  return;
892
422
  }
893
423
 
894
424
  let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
895
- const isGroup = ctx.chatType === "group";
425
+ const isGroup = isFeishuGroupChatType(ctx.chatType);
896
426
  const isDirect = !isGroup;
897
- const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
427
+ const senderUserId = normalizeOptionalString(event.sender.sender_id.user_id);
898
428
 
899
429
  // Handle merge_forward messages: fetch full message via API then expand sub-messages
900
430
  if (event.message.message_type === "merge_forward") {
@@ -930,14 +460,16 @@ export async function handleFeishuMessage(params: {
930
460
 
931
461
  // Resolve sender display name (best-effort) so the agent can attribute messages correctly.
932
462
  // Optimization: skip if disabled to save API quota (Feishu free tier limit).
933
- let permissionErrorForAgent: PermissionError | undefined;
463
+ let permissionErrorForAgent: FeishuPermissionError | undefined;
934
464
  if (feishuCfg?.resolveSenderNames ?? true) {
935
465
  const senderResult = await resolveFeishuSenderName({
936
466
  account,
937
467
  senderId: ctx.senderOpenId,
938
468
  log,
939
469
  });
940
- if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
470
+ if (senderResult.name) {
471
+ ctx = { ...ctx, senderName: senderResult.name };
472
+ }
941
473
 
942
474
  // Track permission error to inform agent later (with cooldown to avoid repetition)
943
475
  if (senderResult.permissionError) {
@@ -969,6 +501,11 @@ export async function handleFeishuMessage(params: {
969
501
  const groupConfig = isGroup
970
502
  ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
971
503
  : undefined;
504
+ const effectiveGroupSenderAllowFrom = isGroup
505
+ ? (groupConfig?.allowFrom?.length ?? 0) > 0
506
+ ? (groupConfig?.allowFrom ?? [])
507
+ : (feishuCfg?.groupSenderAllowFrom ?? [])
508
+ : [];
972
509
  const groupSession = isGroup
973
510
  ? resolveFeishuGroupSession({
974
511
  chatId: ctx.chatId,
@@ -976,6 +513,7 @@ export async function handleFeishuMessage(params: {
976
513
  messageId: ctx.messageId,
977
514
  rootId: ctx.rootId,
978
515
  threadId: ctx.threadId,
516
+ chatType: ctx.chatType,
979
517
  groupConfig,
980
518
  feishuCfg,
981
519
  })
@@ -989,6 +527,14 @@ export async function handleFeishuMessage(params: {
989
527
  ? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
990
528
  : null;
991
529
 
530
+ // Parse message create_time early so every downstream consumer (pending
531
+ // history, inbound payload, etc.) uses the original authoring timestamp
532
+ // instead of the delivery/processing time. Feishu uses a millisecond
533
+ // epoch string; fall back to Date.now() only when the field is absent.
534
+ const messageCreateTimeMs = event.message.create_time
535
+ ? Number.parseInt(event.message.create_time, 10)
536
+ : Date.now();
537
+
992
538
  let requireMention = false; // DMs never require mention; groups may override below
993
539
  if (isGroup) {
994
540
  if (groupConfig?.enabled === false) {
@@ -1010,14 +556,26 @@ export async function handleFeishuMessage(params: {
1010
556
  const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
1011
557
  // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
1012
558
 
1013
- // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
1014
- const groupAllowed = isFeishuGroupAllowed({
1015
- groupPolicy,
1016
- allowFrom: groupAllowFrom,
1017
- senderId: ctx.chatId, // Check group ID, not sender ID
1018
- senderName: undefined,
559
+ // A group explicitly configured under `channels.feishu.groups.<chat_id>` is
560
+ // treated as admitted in allowlist mode even when `groupAllowFrom` is empty.
561
+ // Wildcard defaults still configure matching groups, but they are not an
562
+ // admission signal by themselves.
563
+ const groupExplicitlyConfigured = hasExplicitFeishuGroupConfig({
564
+ cfg: feishuCfg,
565
+ groupId: ctx.chatId,
1019
566
  });
1020
567
 
568
+ // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
569
+ const groupAllowed =
570
+ groupPolicy !== "disabled" &&
571
+ (groupExplicitlyConfigured ||
572
+ isFeishuGroupAllowed({
573
+ groupPolicy,
574
+ allowFrom: groupAllowFrom,
575
+ senderId: ctx.chatId, // Check group ID, not sender ID
576
+ senderName: undefined,
577
+ }));
578
+
1021
579
  if (!groupAllowed) {
1022
580
  log(
1023
581
  `feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
@@ -1026,14 +584,10 @@ export async function handleFeishuMessage(params: {
1026
584
  }
1027
585
 
1028
586
  // Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
1029
- const perGroupSenderAllowFrom = groupConfig?.allowFrom ?? [];
1030
- const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
1031
- const effectiveSenderAllowFrom =
1032
- perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
1033
- if (effectiveSenderAllowFrom.length > 0) {
587
+ if (effectiveGroupSenderAllowFrom.length > 0) {
1034
588
  const senderAllowed = isFeishuGroupAllowed({
1035
589
  groupPolicy: "allowlist",
1036
- allowFrom: effectiveSenderAllowFrom,
590
+ allowFrom: effectiveGroupSenderAllowFrom,
1037
591
  senderId: ctx.senderOpenId,
1038
592
  senderIds: [senderUserId],
1039
593
  senderName: ctx.senderName,
@@ -1046,8 +600,10 @@ export async function handleFeishuMessage(params: {
1046
600
 
1047
601
  ({ requireMention } = resolveFeishuReplyPolicy({
1048
602
  isDirectMessage: false,
1049
- globalConfig: feishuCfg,
1050
- groupConfig,
603
+ cfg,
604
+ accountId: account.accountId,
605
+ groupId: ctx.chatId,
606
+ groupPolicy,
1051
607
  }));
1052
608
 
1053
609
  if (requireMention && !ctx.mentionedBot) {
@@ -1064,19 +620,18 @@ export async function handleFeishuMessage(params: {
1064
620
  entry: {
1065
621
  sender: ctx.senderOpenId,
1066
622
  body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
1067
- timestamp: Date.now(),
623
+ timestamp: messageCreateTimeMs,
1068
624
  messageId: ctx.messageId,
1069
625
  },
1070
626
  });
1071
627
  }
1072
628
  return;
1073
629
  }
1074
- } else {
1075
630
  }
1076
631
 
1077
632
  try {
1078
633
  const core = getFeishuRuntime();
1079
- const pairing = createScopedPairingAccess({
634
+ const pairing = createChannelPairingController({
1080
635
  core,
1081
636
  channel: "feishu",
1082
637
  accountId: account.accountId,
@@ -1087,9 +642,7 @@ export async function handleFeishuMessage(params: {
1087
642
  cfg,
1088
643
  );
1089
644
  const storeAllowFrom =
1090
- !isGroup &&
1091
- dmPolicy !== "allowlist" &&
1092
- (dmPolicy !== "open" || shouldComputeCommandAuthorized)
645
+ !isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open"
1093
646
  ? await pairing.readAllowFromStore().catch(() => [])
1094
647
  : [];
1095
648
  const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
@@ -1100,14 +653,26 @@ export async function handleFeishuMessage(params: {
1100
653
  senderName: ctx.senderName,
1101
654
  }).allowed;
1102
655
 
1103
- if (isDirect && dmPolicy !== "open" && !dmAllowed) {
656
+ const dmAccessAllowed =
657
+ dmPolicy === "open"
658
+ ? resolveOpenDmAllowlistAccess({
659
+ effectiveAllowFrom: effectiveDmAllowFrom,
660
+ isSenderAllowed: (allowFrom) =>
661
+ resolveFeishuAllowlistMatch({
662
+ allowFrom,
663
+ senderId: ctx.senderOpenId,
664
+ senderIds: [senderUserId],
665
+ senderName: ctx.senderName,
666
+ }).allowed,
667
+ }).decision === "allow"
668
+ : dmAllowed;
669
+
670
+ if (isDirect && !dmAccessAllowed) {
1104
671
  if (dmPolicy === "pairing") {
1105
- await issuePairingChallenge({
1106
- channel: "feishu",
672
+ await pairing.issueChallenge({
1107
673
  senderId: ctx.senderOpenId,
1108
674
  senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
1109
675
  meta: { name: ctx.senderName },
1110
- upsertPairingRequest: pairing.upsertPairingRequest,
1111
676
  onCreated: () => {
1112
677
  log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
1113
678
  },
@@ -1142,14 +707,6 @@ export async function handleFeishuMessage(params: {
1142
707
  senderIds: [senderUserId],
1143
708
  senderName: ctx.senderName,
1144
709
  }).allowed;
1145
- const commandAuthorized = shouldComputeCommandAuthorized
1146
- ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
1147
- useAccessGroups,
1148
- authorizers: [
1149
- { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
1150
- ],
1151
- })
1152
- : undefined;
1153
710
 
1154
711
  // In group chats, the session is scoped to the group, but the *speaker* is the sender.
1155
712
  // Using a group-scoped From causes the agent to treat different users as the same person.
@@ -1158,6 +715,10 @@ export async function handleFeishuMessage(params: {
1158
715
  const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
1159
716
  const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
1160
717
  const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
718
+ const feishuAcpConversationSupported =
719
+ !isGroup ||
720
+ groupSession?.groupSessionScope === "group_topic" ||
721
+ groupSession?.groupSessionScope === "group_topic_sender";
1161
722
 
1162
723
  if (isGroup && groupSession) {
1163
724
  log(
@@ -1206,10 +767,81 @@ export async function handleFeishuMessage(params: {
1206
767
  }
1207
768
  }
1208
769
 
770
+ const currentConversationId = peerId;
771
+ const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
772
+ let configuredBinding = null;
773
+ if (feishuAcpConversationSupported) {
774
+ const configuredRoute = resolveConfiguredBindingRoute({
775
+ cfg: effectiveCfg,
776
+ route,
777
+ conversation: {
778
+ channel: "feishu",
779
+ accountId: account.accountId,
780
+ conversationId: currentConversationId,
781
+ parentConversationId,
782
+ },
783
+ });
784
+ configuredBinding = configuredRoute.bindingResolution;
785
+ route = configuredRoute.route;
786
+
787
+ // Bound Feishu conversations intentionally require an exact live conversation-id match.
788
+ // Sender-scoped topic sessions therefore bind on `chat:topic:root:sender:user`, while
789
+ // configured ACP bindings may still inherit the shared `chat:topic:root` topic session.
790
+ const runtimeRoute = resolveRuntimeConversationBindingRoute({
791
+ route,
792
+ conversation: {
793
+ channel: "feishu",
794
+ accountId: account.accountId,
795
+ conversationId: currentConversationId,
796
+ ...(parentConversationId ? { parentConversationId } : {}),
797
+ },
798
+ });
799
+ route = runtimeRoute.route;
800
+ if (runtimeRoute.bindingRecord) {
801
+ configuredBinding = null;
802
+ log(
803
+ runtimeRoute.boundSessionKey
804
+ ? `feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${runtimeRoute.boundSessionKey}`
805
+ : `feishu[${account.accountId}]: plugin-bound conversation ${currentConversationId}`,
806
+ );
807
+ }
808
+ }
809
+
810
+ if (configuredBinding) {
811
+ const ensured = await ensureConfiguredBindingRouteReady({
812
+ cfg: effectiveCfg,
813
+ bindingResolution: configuredBinding,
814
+ });
815
+ if (!ensured.ok) {
816
+ const replyTargetMessageId =
817
+ isGroup &&
818
+ (groupSession?.groupSessionScope === "group_topic" ||
819
+ groupSession?.groupSessionScope === "group_topic_sender")
820
+ ? (ctx.rootId ?? ctx.messageId)
821
+ : ctx.messageId;
822
+ await sendMessageFeishu({
823
+ cfg: effectiveCfg,
824
+ to: `chat:${ctx.chatId}`,
825
+ text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
826
+ replyToMessageId: replyTargetMessageId,
827
+ replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
828
+ accountId: account.accountId,
829
+ }).catch((err) => {
830
+ log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
831
+ });
832
+ return;
833
+ }
834
+ }
835
+
1209
836
  const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
1210
837
  const inboundLabel = isGroup
1211
838
  ? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
1212
839
  : `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
840
+ const contextVisibilityMode = resolveChannelContextVisibilityMode({
841
+ cfg: effectiveCfg,
842
+ channel: "feishu",
843
+ accountId: account.accountId,
844
+ });
1213
845
 
1214
846
  // Do not enqueue inbound user previews as system events.
1215
847
  // System events are prepended to future prompts and can be misread as
@@ -1227,31 +859,101 @@ export async function handleFeishuMessage(params: {
1227
859
  log,
1228
860
  accountId: account.accountId,
1229
861
  });
862
+ // Skip messages with no text content and no media attachments. Feishu can
863
+ // deliver empty-text events (e.g. `{"text":""}`) when a user sends a blank
864
+ // message or when media parsing produces an empty string. Writing a blank
865
+ // user turn to the session causes downstream LLM providers (e.g. MiniMax)
866
+ // to reject the request with "messages must not be empty" errors. Logging
867
+ // the skip avoids silent loss without polluting the agent session.
868
+ if (!ctx.content.trim() && mediaList.length === 0) {
869
+ log(
870
+ `feishu[${account.accountId}]: skipping empty message (no text, no media) from ${ctx.senderOpenId}`,
871
+ );
872
+ return;
873
+ }
874
+
1230
875
  const mediaPayload = buildAgentMediaPayload(mediaList);
876
+ const audioTranscript = await resolveFeishuAudioPreflightTranscript({
877
+ cfg: effectiveCfg,
878
+ mediaList,
879
+ content: ctx.content,
880
+ chatType: isGroup ? "group" : "direct",
881
+ log,
882
+ });
883
+ const preflightAudioIndex =
884
+ audioTranscript === undefined
885
+ ? -1
886
+ : mediaList.findIndex((media) => media.contentType?.startsWith("audio/"));
887
+ const agentFacingContent = audioTranscript ?? ctx.content;
888
+ const agentFacingCtx =
889
+ audioTranscript === undefined
890
+ ? ctx
891
+ : {
892
+ ...ctx,
893
+ content: audioTranscript,
894
+ };
895
+ const effectiveCommandProbeBody =
896
+ audioTranscript === undefined
897
+ ? commandProbeBody
898
+ : isGroup
899
+ ? normalizeFeishuCommandProbeBody(audioTranscript)
900
+ : audioTranscript;
901
+ const shouldComputeEffectiveCommandAuthorized =
902
+ audioTranscript === undefined
903
+ ? shouldComputeCommandAuthorized
904
+ : core.channel.commands.shouldComputeCommandAuthorized(effectiveCommandProbeBody, cfg);
905
+ const commandAuthorized = shouldComputeEffectiveCommandAuthorized
906
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
907
+ useAccessGroups,
908
+ authorizers: [
909
+ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
910
+ ],
911
+ })
912
+ : undefined;
1231
913
 
1232
914
  // Fetch quoted/replied message content if parentId exists
915
+ let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
1233
916
  let quotedContent: string | undefined;
1234
917
  if (ctx.parentId) {
1235
918
  try {
1236
- const quotedMsg = await getMessageFeishu({
919
+ quotedMessageInfo = await getMessageFeishu({
1237
920
  cfg,
1238
921
  messageId: ctx.parentId,
1239
922
  accountId: account.accountId,
1240
923
  });
1241
- if (quotedMsg) {
1242
- quotedContent = quotedMsg.content;
924
+ if (
925
+ quotedMessageInfo &&
926
+ shouldIncludeFetchedGroupContextMessage({
927
+ isGroup,
928
+ allowFrom: effectiveGroupSenderAllowFrom,
929
+ mode: contextVisibilityMode,
930
+ kind: "quote",
931
+ senderId: quotedMessageInfo.senderId,
932
+ senderType: quotedMessageInfo.senderType,
933
+ })
934
+ ) {
935
+ quotedContent = quotedMessageInfo.content;
1243
936
  log(
1244
937
  `feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
1245
938
  );
939
+ } else if (quotedMessageInfo) {
940
+ log(
941
+ `feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
942
+ );
1246
943
  }
1247
944
  } catch (err) {
1248
945
  log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
1249
946
  }
1250
947
  }
1251
948
 
949
+ const isTopicSessionForThread =
950
+ isGroup &&
951
+ (groupSession?.groupSessionScope === "group_topic" ||
952
+ groupSession?.groupSessionScope === "group_topic_sender");
953
+
1252
954
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
1253
955
  const messageBody = buildFeishuAgentBody({
1254
- ctx,
956
+ ctx: agentFacingCtx,
1255
957
  quotedContent,
1256
958
  permissionErrorForAgent,
1257
959
  botOpenId,
@@ -1300,45 +1002,226 @@ export async function handleFeishuMessage(params: {
1300
1002
  }))
1301
1003
  : undefined;
1302
1004
 
1005
+ const threadContextBySessionKey = new Map<
1006
+ string,
1007
+ {
1008
+ threadStarterBody?: string;
1009
+ threadHistoryBody?: string;
1010
+ threadLabel?: string;
1011
+ }
1012
+ >();
1013
+ let rootMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> | undefined;
1014
+ let rootMessageThreadId: string | undefined;
1015
+ let rootMessageFetched = false;
1016
+ const getRootMessageInfo = async () => {
1017
+ if (!ctx.rootId) {
1018
+ return null;
1019
+ }
1020
+ if (!rootMessageFetched) {
1021
+ rootMessageFetched = true;
1022
+ if (ctx.rootId === ctx.parentId && quotedMessageInfo) {
1023
+ rootMessageInfo = quotedMessageInfo;
1024
+ } else {
1025
+ try {
1026
+ rootMessageInfo = await getMessageFeishu({
1027
+ cfg,
1028
+ messageId: ctx.rootId,
1029
+ accountId: account.accountId,
1030
+ });
1031
+ } catch (err) {
1032
+ log(`feishu[${account.accountId}]: failed to fetch root message: ${String(err)}`);
1033
+ rootMessageInfo = null;
1034
+ }
1035
+ }
1036
+ rootMessageThreadId = rootMessageInfo?.threadId;
1037
+ if (
1038
+ rootMessageInfo &&
1039
+ !shouldIncludeFetchedGroupContextMessage({
1040
+ isGroup,
1041
+ allowFrom: effectiveGroupSenderAllowFrom,
1042
+ mode: contextVisibilityMode,
1043
+ kind: "thread",
1044
+ senderId: rootMessageInfo.senderId,
1045
+ senderType: rootMessageInfo.senderType,
1046
+ })
1047
+ ) {
1048
+ log(
1049
+ `feishu[${account.accountId}]: skipped thread starter from sender ${rootMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
1050
+ );
1051
+ rootMessageInfo = null;
1052
+ }
1053
+ }
1054
+ return rootMessageInfo ?? null;
1055
+ };
1056
+ let groupNamePromise: Promise<string | undefined> | undefined;
1057
+ const resolveGroupNameForLabel = (): Promise<string | undefined> => {
1058
+ if (!isGroup) {
1059
+ return Promise.resolve(undefined);
1060
+ }
1061
+ groupNamePromise ??= resolveGroupName({ account, chatId: ctx.chatId, log });
1062
+ return groupNamePromise;
1063
+ };
1064
+
1065
+ const resolveThreadContextForAgent = async (
1066
+ agentId: string,
1067
+ agentSessionKey: string,
1068
+ groupName: string | undefined,
1069
+ ) => {
1070
+ const cached = threadContextBySessionKey.get(agentSessionKey);
1071
+ if (cached) {
1072
+ return cached;
1073
+ }
1074
+
1075
+ const threadContext: {
1076
+ threadStarterBody?: string;
1077
+ threadHistoryBody?: string;
1078
+ threadLabel?: string;
1079
+ } = {
1080
+ threadLabel:
1081
+ (ctx.rootId || ctx.threadId) && isTopicSessionForThread
1082
+ ? `Feishu thread in ${groupName ?? ctx.chatId}`
1083
+ : undefined,
1084
+ };
1085
+
1086
+ if (!(ctx.rootId || ctx.threadId) || !isTopicSessionForThread) {
1087
+ threadContextBySessionKey.set(agentSessionKey, threadContext);
1088
+ return threadContext;
1089
+ }
1090
+
1091
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
1092
+ const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
1093
+ storePath,
1094
+ sessionKey: agentSessionKey,
1095
+ });
1096
+ if (previousThreadSessionTimestamp) {
1097
+ log(
1098
+ `feishu[${account.accountId}]: skipping thread bootstrap for existing session ${agentSessionKey}`,
1099
+ );
1100
+ threadContextBySessionKey.set(agentSessionKey, threadContext);
1101
+ return threadContext;
1102
+ }
1103
+
1104
+ const rootMsg = await getRootMessageInfo();
1105
+ let feishuThreadId = ctx.threadId ?? rootMessageThreadId ?? rootMsg?.threadId;
1106
+ if (feishuThreadId) {
1107
+ log(`feishu[${account.accountId}]: resolved thread ID: ${feishuThreadId}`);
1108
+ }
1109
+ if (!feishuThreadId) {
1110
+ log(
1111
+ `feishu[${account.accountId}]: no threadId found for root message ${ctx.rootId ?? "none"}, skipping thread history`,
1112
+ );
1113
+ threadContextBySessionKey.set(agentSessionKey, threadContext);
1114
+ return threadContext;
1115
+ }
1116
+
1117
+ try {
1118
+ const threadMessages = await listFeishuThreadMessages({
1119
+ cfg,
1120
+ threadId: feishuThreadId,
1121
+ currentMessageId: ctx.messageId,
1122
+ rootMessageId: ctx.rootId,
1123
+ limit: 20,
1124
+ accountId: account.accountId,
1125
+ });
1126
+ const senderScoped = groupSession?.groupSessionScope === "group_topic_sender";
1127
+ const senderIds = new Set(
1128
+ [ctx.senderOpenId, senderUserId]
1129
+ .map((id) => id?.trim())
1130
+ .filter((id): id is string => id !== undefined && id.length > 0),
1131
+ );
1132
+ const allowlistedMessages = filterFetchedGroupContextMessages(threadMessages, {
1133
+ isGroup,
1134
+ allowFrom: effectiveGroupSenderAllowFrom,
1135
+ mode: contextVisibilityMode,
1136
+ kind: "history",
1137
+ });
1138
+ const relevantMessages =
1139
+ (senderScoped
1140
+ ? allowlistedMessages.filter(
1141
+ (msg) =>
1142
+ msg.senderType === "app" ||
1143
+ (msg.senderId !== undefined && senderIds.has(msg.senderId.trim())),
1144
+ )
1145
+ : allowlistedMessages) ?? [];
1146
+
1147
+ const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content;
1148
+ const includeStarterInHistory = Boolean(rootMsg?.content || ctx.rootId);
1149
+ const historyMessages = includeStarterInHistory
1150
+ ? relevantMessages
1151
+ : relevantMessages.slice(1);
1152
+ const historyParts = historyMessages.map((msg) => {
1153
+ const role = msg.senderType === "app" ? "assistant" : "user";
1154
+ return core.channel.reply.formatAgentEnvelope({
1155
+ channel: "Feishu",
1156
+ from: `${msg.senderId ?? "Unknown"} (${role})`,
1157
+ timestamp: msg.createTime,
1158
+ body: msg.content,
1159
+ envelope: envelopeOptions,
1160
+ });
1161
+ });
1162
+
1163
+ threadContext.threadStarterBody = threadStarterBody;
1164
+ threadContext.threadHistoryBody =
1165
+ historyParts.length > 0 ? historyParts.join("\n\n") : undefined;
1166
+ log(
1167
+ `feishu[${account.accountId}]: populated thread bootstrap with starter=${threadStarterBody ? "yes" : "no"} history=${historyMessages.length}`,
1168
+ );
1169
+ } catch (err) {
1170
+ log(`feishu[${account.accountId}]: failed to fetch thread history: ${String(err)}`);
1171
+ }
1172
+
1173
+ threadContextBySessionKey.set(agentSessionKey, threadContext);
1174
+ return threadContext;
1175
+ };
1176
+
1303
1177
  // --- Shared context builder for dispatch ---
1304
- const buildCtxPayloadForAgent = (
1178
+ const buildCtxPayloadForAgent = async (
1179
+ agentId: string,
1305
1180
  agentSessionKey: string,
1306
1181
  agentAccountId: string,
1307
1182
  wasMentioned: boolean,
1308
- ) =>
1309
- core.channel.reply.finalizeInboundContext({
1183
+ ) => {
1184
+ const groupName = await resolveGroupNameForLabel();
1185
+ const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName);
1186
+ return core.channel.reply.finalizeInboundContext({
1310
1187
  Body: combinedBody,
1311
1188
  BodyForAgent: messageBody,
1312
1189
  InboundHistory: inboundHistory,
1313
1190
  ReplyToId: ctx.parentId,
1314
1191
  RootMessageId: ctx.rootId,
1315
- RawBody: ctx.content,
1316
- CommandBody: ctx.content,
1192
+ RawBody: agentFacingContent,
1193
+ CommandBody: agentFacingContent,
1194
+ Transcript: audioTranscript,
1317
1195
  From: feishuFrom,
1318
1196
  To: feishuTo,
1319
1197
  SessionKey: agentSessionKey,
1320
1198
  AccountId: agentAccountId,
1321
1199
  ChatType: isGroup ? "group" : "direct",
1322
- GroupSubject: isGroup ? ctx.chatId : undefined,
1200
+ GroupSubject: isGroup ? groupName || ctx.chatId : undefined,
1201
+ ConversationLabel: isGroup && groupName && !isTopicSessionForThread ? groupName : undefined,
1323
1202
  SenderName: ctx.senderName ?? ctx.senderOpenId,
1324
1203
  SenderId: ctx.senderOpenId,
1325
1204
  Provider: "feishu" as const,
1326
1205
  Surface: "feishu" as const,
1327
1206
  MessageSid: ctx.messageId,
1328
1207
  ReplyToBody: quotedContent ?? undefined,
1329
- Timestamp: Date.now(),
1208
+ ThreadStarterBody: threadContext.threadStarterBody,
1209
+ ThreadHistoryBody: threadContext.threadHistoryBody,
1210
+ ThreadLabel: threadContext.threadLabel,
1211
+ // Only use rootId (om_* message anchor) — threadId (omt_*) is a container
1212
+ // ID and would produce invalid reply targets downstream.
1213
+ MessageThreadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
1214
+ Timestamp: messageCreateTimeMs,
1330
1215
  WasMentioned: wasMentioned,
1331
1216
  CommandAuthorized: commandAuthorized,
1332
1217
  OriginatingChannel: "feishu" as const,
1333
1218
  OriginatingTo: feishuTo,
1334
- GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
1219
+ GroupSystemPrompt: isGroup ? normalizeOptionalString(groupConfig?.systemPrompt) : undefined,
1335
1220
  ...mediaPayload,
1221
+ ...(preflightAudioIndex >= 0 ? { MediaTranscribedIndexes: [preflightAudioIndex] } : {}),
1336
1222
  });
1223
+ };
1337
1224
 
1338
- // Parse message create_time (Feishu uses millisecond epoch string).
1339
- const messageCreateTimeMs = event.message.create_time
1340
- ? parseInt(event.message.create_time, 10)
1341
- : undefined;
1342
1225
  // Determine reply target based on group session mode:
1343
1226
  // - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
1344
1227
  // root so the bot stays in the same thread.
@@ -1355,7 +1238,11 @@ export async function handleFeishuMessage(params: {
1355
1238
  isGroup &&
1356
1239
  (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
1357
1240
  const replyTargetMessageId =
1358
- isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId;
1241
+ isTopicSession || configReplyInThread
1242
+ ? (ctx.rootId ??
1243
+ ctx.replyTargetMessageId ??
1244
+ (ctx.suppressReplyTarget ? undefined : ctx.messageId))
1245
+ : (ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId));
1359
1246
  const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
1360
1247
 
1361
1248
  if (broadcastAgents) {
@@ -1372,9 +1259,10 @@ export async function handleFeishuMessage(params: {
1372
1259
  }
1373
1260
 
1374
1261
  // --- Broadcast dispatch: send message to all configured agents ---
1375
- const strategy =
1376
- ((cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined)
1377
- ?.strategy || "parallel";
1262
+ const rawStrategy = (
1263
+ (cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined
1264
+ )?.strategy;
1265
+ const strategy = rawStrategy === "sequential" ? "sequential" : "parallel";
1378
1266
  const activeAgentId =
1379
1267
  ctx.mentionedBot || !requireMention ? normalizeAgentId(route.agentId) : null;
1380
1268
  const agentIds = (cfg.agents?.list ?? []).map((a: { id: string }) => normalizeAgentId(a.id));
@@ -1393,7 +1281,22 @@ export async function handleFeishuMessage(params: {
1393
1281
  }
1394
1282
 
1395
1283
  const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
1396
- const agentCtx = buildCtxPayloadForAgent(
1284
+ const agentStorePath = core.channel.session.resolveStorePath(cfg.session?.store, {
1285
+ agentId,
1286
+ });
1287
+ const agentRecord = {
1288
+ onRecordError: (err: unknown) => {
1289
+ log(
1290
+ `feishu[${account.accountId}]: failed to record broadcast inbound session ${agentSessionKey}: ${String(err)}`,
1291
+ );
1292
+ },
1293
+ };
1294
+ const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
1295
+ storePath: agentStorePath,
1296
+ sessionKey: agentSessionKey,
1297
+ });
1298
+ const agentCtx = await buildCtxPayloadForAgent(
1299
+ agentId,
1397
1300
  agentSessionKey,
1398
1301
  route.accountId,
1399
1302
  ctx.mentionedBot && agentId === activeAgentId,
@@ -1401,11 +1304,13 @@ export async function handleFeishuMessage(params: {
1401
1304
 
1402
1305
  if (agentId === activeAgentId) {
1403
1306
  // Active agent: real Feishu dispatcher (responds on Feishu)
1307
+ const identity = resolveAgentOutboundIdentity(cfg, agentId);
1404
1308
  const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1405
1309
  cfg,
1406
1310
  agentId,
1407
1311
  runtime: runtime as RuntimeEnv,
1408
1312
  chatId: ctx.chatId,
1313
+ allowReasoningPreview,
1409
1314
  replyToMessageId: replyTargetMessageId,
1410
1315
  skipReplyToInMessages: !isGroup,
1411
1316
  replyInThread,
@@ -1413,22 +1318,53 @@ export async function handleFeishuMessage(params: {
1413
1318
  threadReply,
1414
1319
  mentionTargets: ctx.mentionTargets,
1415
1320
  accountId: account.accountId,
1321
+ identity,
1416
1322
  messageCreateTimeMs,
1417
1323
  });
1418
1324
 
1419
1325
  log(
1420
1326
  `feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
1421
1327
  );
1422
- await core.channel.reply.withReplyDispatcher({
1423
- dispatcher,
1424
- onSettled: () => markDispatchIdle(),
1425
- run: () =>
1426
- core.channel.reply.dispatchReplyFromConfig({
1427
- ctx: agentCtx,
1428
- cfg,
1429
- dispatcher,
1430
- replyOptions,
1328
+ await core.channel.turn.run({
1329
+ channel: "feishu",
1330
+ accountId: route.accountId,
1331
+ raw: ctx,
1332
+ adapter: {
1333
+ ingest: () => ({
1334
+ id: ctx.messageId,
1335
+ timestamp: messageCreateTimeMs,
1336
+ rawText: ctx.content,
1337
+ textForAgent: agentCtx.BodyForAgent,
1338
+ textForCommands: agentCtx.CommandBody,
1339
+ raw: ctx,
1431
1340
  }),
1341
+ resolveTurn: () => ({
1342
+ channel: "feishu",
1343
+ accountId: route.accountId,
1344
+ routeSessionKey: agentSessionKey,
1345
+ storePath: agentStorePath,
1346
+ ctxPayload: agentCtx,
1347
+ recordInboundSession: core.channel.session.recordInboundSession,
1348
+ record: agentRecord,
1349
+ onPreDispatchFailure: () =>
1350
+ core.channel.reply.settleReplyDispatcher({
1351
+ dispatcher,
1352
+ onSettled: () => markDispatchIdle(),
1353
+ }),
1354
+ runDispatch: () =>
1355
+ core.channel.reply.withReplyDispatcher({
1356
+ dispatcher,
1357
+ onSettled: () => markDispatchIdle(),
1358
+ run: () =>
1359
+ core.channel.reply.dispatchReplyFromConfig({
1360
+ ctx: agentCtx,
1361
+ cfg,
1362
+ dispatcher,
1363
+ replyOptions,
1364
+ }),
1365
+ }),
1366
+ }),
1367
+ },
1432
1368
  });
1433
1369
  } else {
1434
1370
  // Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
@@ -1441,20 +1377,46 @@ export async function handleFeishuMessage(params: {
1441
1377
  sendFinalReply: () => false,
1442
1378
  waitForIdle: async () => {},
1443
1379
  getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
1380
+ getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }),
1444
1381
  markComplete: () => {},
1445
1382
  };
1446
1383
 
1447
1384
  log(
1448
1385
  `feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
1449
1386
  );
1450
- await core.channel.reply.withReplyDispatcher({
1451
- dispatcher: noopDispatcher,
1452
- run: () =>
1453
- core.channel.reply.dispatchReplyFromConfig({
1454
- ctx: agentCtx,
1455
- cfg,
1456
- dispatcher: noopDispatcher,
1387
+ await core.channel.turn.run({
1388
+ channel: "feishu",
1389
+ accountId: route.accountId,
1390
+ raw: ctx,
1391
+ adapter: {
1392
+ ingest: () => ({
1393
+ id: ctx.messageId,
1394
+ timestamp: messageCreateTimeMs,
1395
+ rawText: ctx.content,
1396
+ textForAgent: agentCtx.BodyForAgent,
1397
+ textForCommands: agentCtx.CommandBody,
1398
+ raw: ctx,
1457
1399
  }),
1400
+ resolveTurn: () => ({
1401
+ channel: "feishu",
1402
+ accountId: route.accountId,
1403
+ routeSessionKey: agentSessionKey,
1404
+ storePath: agentStorePath,
1405
+ ctxPayload: agentCtx,
1406
+ recordInboundSession: core.channel.session.recordInboundSession,
1407
+ record: agentRecord,
1408
+ runDispatch: () =>
1409
+ core.channel.reply.withReplyDispatcher({
1410
+ dispatcher: noopDispatcher,
1411
+ run: () =>
1412
+ core.channel.reply.dispatchReplyFromConfig({
1413
+ ctx: agentCtx,
1414
+ cfg,
1415
+ dispatcher: noopDispatcher,
1416
+ }),
1417
+ }),
1418
+ }),
1419
+ },
1458
1420
  });
1459
1421
  }
1460
1422
  };
@@ -1493,17 +1455,27 @@ export async function handleFeishuMessage(params: {
1493
1455
  );
1494
1456
  } else {
1495
1457
  // --- Single-agent dispatch (existing behavior) ---
1496
- const ctxPayload = buildCtxPayloadForAgent(
1458
+ const ctxPayload = await buildCtxPayloadForAgent(
1459
+ route.agentId,
1497
1460
  route.sessionKey,
1498
1461
  route.accountId,
1499
1462
  ctx.mentionedBot,
1500
1463
  );
1501
1464
 
1465
+ const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
1466
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
1467
+ agentId: route.agentId,
1468
+ });
1469
+ const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
1470
+ storePath,
1471
+ sessionKey: route.sessionKey,
1472
+ });
1502
1473
  const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1503
1474
  cfg,
1504
1475
  agentId: route.agentId,
1505
1476
  runtime: runtime as RuntimeEnv,
1506
1477
  chatId: ctx.chatId,
1478
+ allowReasoningPreview,
1507
1479
  replyToMessageId: replyTargetMessageId,
1508
1480
  skipReplyToInMessages: !isGroup,
1509
1481
  replyInThread,
@@ -1511,31 +1483,71 @@ export async function handleFeishuMessage(params: {
1511
1483
  threadReply,
1512
1484
  mentionTargets: ctx.mentionTargets,
1513
1485
  accountId: account.accountId,
1486
+ identity,
1514
1487
  messageCreateTimeMs,
1515
1488
  });
1516
1489
 
1517
1490
  log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
1518
- const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
1519
- dispatcher,
1520
- onSettled: () => {
1521
- markDispatchIdle();
1522
- },
1523
- run: () =>
1524
- core.channel.reply.dispatchReplyFromConfig({
1525
- ctx: ctxPayload,
1526
- cfg,
1527
- dispatcher,
1528
- replyOptions,
1491
+ const turnResult = await core.channel.turn.run({
1492
+ channel: "feishu",
1493
+ accountId: route.accountId,
1494
+ raw: ctx,
1495
+ adapter: {
1496
+ ingest: () => ({
1497
+ id: ctx.messageId,
1498
+ timestamp: messageCreateTimeMs,
1499
+ rawText: ctx.content,
1500
+ textForAgent: ctxPayload.BodyForAgent,
1501
+ textForCommands: ctxPayload.CommandBody,
1502
+ raw: ctx,
1529
1503
  }),
1504
+ resolveTurn: () => ({
1505
+ channel: "feishu",
1506
+ accountId: route.accountId,
1507
+ routeSessionKey: route.sessionKey,
1508
+ storePath,
1509
+ ctxPayload,
1510
+ recordInboundSession: core.channel.session.recordInboundSession,
1511
+ record: {
1512
+ onRecordError: (err) => {
1513
+ log(
1514
+ `feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
1515
+ );
1516
+ },
1517
+ },
1518
+ history: {
1519
+ isGroup,
1520
+ historyKey,
1521
+ historyMap: chatHistories,
1522
+ limit: historyLimit,
1523
+ },
1524
+ onPreDispatchFailure: () =>
1525
+ core.channel.reply.settleReplyDispatcher({
1526
+ dispatcher,
1527
+ onSettled: () => markDispatchIdle(),
1528
+ }),
1529
+ runDispatch: () =>
1530
+ core.channel.reply.withReplyDispatcher({
1531
+ dispatcher,
1532
+ onSettled: () => {
1533
+ markDispatchIdle();
1534
+ },
1535
+ run: () =>
1536
+ core.channel.reply.dispatchReplyFromConfig({
1537
+ ctx: ctxPayload,
1538
+ cfg,
1539
+ dispatcher,
1540
+ replyOptions,
1541
+ }),
1542
+ }),
1543
+ }),
1544
+ },
1530
1545
  });
1531
-
1532
- if (isGroup && historyKey && chatHistories) {
1533
- clearHistoryEntriesIfEnabled({
1534
- historyMap: chatHistories,
1535
- historyKey,
1536
- limit: historyLimit,
1537
- });
1546
+ if (!turnResult.dispatched) {
1547
+ return;
1538
1548
  }
1549
+ const { dispatchResult } = turnResult;
1550
+ const { queuedFinal, counts } = dispatchResult;
1539
1551
 
1540
1552
  log(
1541
1553
  `feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,