@openclaw/feishu 2026.3.13 → 2026.5.2-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 (187) 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 +1827 -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 +95 -7
  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 +778 -775
  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 +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -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 +406 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  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 +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  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 +403 -26
  91. package/src/media.ts +509 -132
  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 +218 -312
  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 +108 -48
  113. package/src/monitor.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. 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
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
86
  }
290
87
 
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
- }
315
-
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");
101
+ function setCacheEntry(key: string, value: { name: string; expiresAt: number }): void {
102
+ groupNameCache.delete(key);
103
+ groupNameCache.set(key, value);
419
104
  }
420
105
 
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
- }
106
+ export function clearGroupNameCache(): void {
107
+ groupNameCache.clear();
452
108
  }
453
109
 
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;
470
- }
471
-
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;
@@ -881,7 +402,7 @@ export async function handleFeishuMessage(params: {
881
402
  } = params;
882
403
 
883
404
  // Resolve account with merged config
884
- const account = resolveFeishuAccount({ cfg, accountId });
405
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
885
406
  const feishuCfg = account.config;
886
407
 
887
408
  const log = runtime?.log ?? console.log;
@@ -901,9 +422,9 @@ export async function handleFeishuMessage(params: {
901
422
  }
902
423
 
903
424
  let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
904
- const isGroup = ctx.chatType === "group";
425
+ const isGroup = isFeishuGroupChatType(ctx.chatType);
905
426
  const isDirect = !isGroup;
906
- const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
427
+ const senderUserId = normalizeOptionalString(event.sender.sender_id.user_id);
907
428
 
908
429
  // Handle merge_forward messages: fetch full message via API then expand sub-messages
909
430
  if (event.message.message_type === "merge_forward") {
@@ -939,14 +460,16 @@ export async function handleFeishuMessage(params: {
939
460
 
940
461
  // Resolve sender display name (best-effort) so the agent can attribute messages correctly.
941
462
  // Optimization: skip if disabled to save API quota (Feishu free tier limit).
942
- let permissionErrorForAgent: PermissionError | undefined;
463
+ let permissionErrorForAgent: FeishuPermissionError | undefined;
943
464
  if (feishuCfg?.resolveSenderNames ?? true) {
944
465
  const senderResult = await resolveFeishuSenderName({
945
466
  account,
946
467
  senderId: ctx.senderOpenId,
947
468
  log,
948
469
  });
949
- if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
470
+ if (senderResult.name) {
471
+ ctx = { ...ctx, senderName: senderResult.name };
472
+ }
950
473
 
951
474
  // Track permission error to inform agent later (with cooldown to avoid repetition)
952
475
  if (senderResult.permissionError) {
@@ -978,6 +501,11 @@ export async function handleFeishuMessage(params: {
978
501
  const groupConfig = isGroup
979
502
  ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
980
503
  : undefined;
504
+ const effectiveGroupSenderAllowFrom = isGroup
505
+ ? (groupConfig?.allowFrom?.length ?? 0) > 0
506
+ ? (groupConfig?.allowFrom ?? [])
507
+ : (feishuCfg?.groupSenderAllowFrom ?? [])
508
+ : [];
981
509
  const groupSession = isGroup
982
510
  ? resolveFeishuGroupSession({
983
511
  chatId: ctx.chatId,
@@ -985,6 +513,7 @@ export async function handleFeishuMessage(params: {
985
513
  messageId: ctx.messageId,
986
514
  rootId: ctx.rootId,
987
515
  threadId: ctx.threadId,
516
+ chatType: ctx.chatType,
988
517
  groupConfig,
989
518
  feishuCfg,
990
519
  })
@@ -998,6 +527,14 @@ export async function handleFeishuMessage(params: {
998
527
  ? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
999
528
  : null;
1000
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
+
1001
538
  let requireMention = false; // DMs never require mention; groups may override below
1002
539
  if (isGroup) {
1003
540
  if (groupConfig?.enabled === false) {
@@ -1019,14 +556,26 @@ export async function handleFeishuMessage(params: {
1019
556
  const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
1020
557
  // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
1021
558
 
1022
- // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
1023
- const groupAllowed = isFeishuGroupAllowed({
1024
- groupPolicy,
1025
- allowFrom: groupAllowFrom,
1026
- senderId: ctx.chatId, // Check group ID, not sender ID
1027
- 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,
1028
566
  });
1029
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
+
1030
579
  if (!groupAllowed) {
1031
580
  log(
1032
581
  `feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
@@ -1035,14 +584,10 @@ export async function handleFeishuMessage(params: {
1035
584
  }
1036
585
 
1037
586
  // Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
1038
- const perGroupSenderAllowFrom = groupConfig?.allowFrom ?? [];
1039
- const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
1040
- const effectiveSenderAllowFrom =
1041
- perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
1042
- if (effectiveSenderAllowFrom.length > 0) {
587
+ if (effectiveGroupSenderAllowFrom.length > 0) {
1043
588
  const senderAllowed = isFeishuGroupAllowed({
1044
589
  groupPolicy: "allowlist",
1045
- allowFrom: effectiveSenderAllowFrom,
590
+ allowFrom: effectiveGroupSenderAllowFrom,
1046
591
  senderId: ctx.senderOpenId,
1047
592
  senderIds: [senderUserId],
1048
593
  senderName: ctx.senderName,
@@ -1055,8 +600,10 @@ export async function handleFeishuMessage(params: {
1055
600
 
1056
601
  ({ requireMention } = resolveFeishuReplyPolicy({
1057
602
  isDirectMessage: false,
1058
- globalConfig: feishuCfg,
1059
- groupConfig,
603
+ cfg,
604
+ accountId: account.accountId,
605
+ groupId: ctx.chatId,
606
+ groupPolicy,
1060
607
  }));
1061
608
 
1062
609
  if (requireMention && !ctx.mentionedBot) {
@@ -1073,19 +620,18 @@ export async function handleFeishuMessage(params: {
1073
620
  entry: {
1074
621
  sender: ctx.senderOpenId,
1075
622
  body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
1076
- timestamp: Date.now(),
623
+ timestamp: messageCreateTimeMs,
1077
624
  messageId: ctx.messageId,
1078
625
  },
1079
626
  });
1080
627
  }
1081
628
  return;
1082
629
  }
1083
- } else {
1084
630
  }
1085
631
 
1086
632
  try {
1087
633
  const core = getFeishuRuntime();
1088
- const pairing = createScopedPairingAccess({
634
+ const pairing = createChannelPairingController({
1089
635
  core,
1090
636
  channel: "feishu",
1091
637
  accountId: account.accountId,
@@ -1096,9 +642,7 @@ export async function handleFeishuMessage(params: {
1096
642
  cfg,
1097
643
  );
1098
644
  const storeAllowFrom =
1099
- !isGroup &&
1100
- dmPolicy !== "allowlist" &&
1101
- (dmPolicy !== "open" || shouldComputeCommandAuthorized)
645
+ !isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open"
1102
646
  ? await pairing.readAllowFromStore().catch(() => [])
1103
647
  : [];
1104
648
  const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
@@ -1109,14 +653,26 @@ export async function handleFeishuMessage(params: {
1109
653
  senderName: ctx.senderName,
1110
654
  }).allowed;
1111
655
 
1112
- 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) {
1113
671
  if (dmPolicy === "pairing") {
1114
- await issuePairingChallenge({
1115
- channel: "feishu",
672
+ await pairing.issueChallenge({
1116
673
  senderId: ctx.senderOpenId,
1117
674
  senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
1118
675
  meta: { name: ctx.senderName },
1119
- upsertPairingRequest: pairing.upsertPairingRequest,
1120
676
  onCreated: () => {
1121
677
  log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
1122
678
  },
@@ -1151,14 +707,6 @@ export async function handleFeishuMessage(params: {
1151
707
  senderIds: [senderUserId],
1152
708
  senderName: ctx.senderName,
1153
709
  }).allowed;
1154
- const commandAuthorized = shouldComputeCommandAuthorized
1155
- ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
1156
- useAccessGroups,
1157
- authorizers: [
1158
- { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
1159
- ],
1160
- })
1161
- : undefined;
1162
710
 
1163
711
  // In group chats, the session is scoped to the group, but the *speaker* is the sender.
1164
712
  // Using a group-scoped From causes the agent to treat different users as the same person.
@@ -1167,6 +715,10 @@ export async function handleFeishuMessage(params: {
1167
715
  const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
1168
716
  const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
1169
717
  const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
718
+ const feishuAcpConversationSupported =
719
+ !isGroup ||
720
+ groupSession?.groupSessionScope === "group_topic" ||
721
+ groupSession?.groupSessionScope === "group_topic_sender";
1170
722
 
1171
723
  if (isGroup && groupSession) {
1172
724
  log(
@@ -1215,10 +767,81 @@ export async function handleFeishuMessage(params: {
1215
767
  }
1216
768
  }
1217
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
+
1218
836
  const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
1219
837
  const inboundLabel = isGroup
1220
838
  ? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
1221
839
  : `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
840
+ const contextVisibilityMode = resolveChannelContextVisibilityMode({
841
+ cfg: effectiveCfg,
842
+ channel: "feishu",
843
+ accountId: account.accountId,
844
+ });
1222
845
 
1223
846
  // Do not enqueue inbound user previews as system events.
1224
847
  // System events are prepended to future prompts and can be misread as
@@ -1236,31 +859,101 @@ export async function handleFeishuMessage(params: {
1236
859
  log,
1237
860
  accountId: account.accountId,
1238
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
+
1239
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;
1240
913
 
1241
914
  // Fetch quoted/replied message content if parentId exists
915
+ let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
1242
916
  let quotedContent: string | undefined;
1243
917
  if (ctx.parentId) {
1244
918
  try {
1245
- const quotedMsg = await getMessageFeishu({
919
+ quotedMessageInfo = await getMessageFeishu({
1246
920
  cfg,
1247
921
  messageId: ctx.parentId,
1248
922
  accountId: account.accountId,
1249
923
  });
1250
- if (quotedMsg) {
1251
- 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;
1252
936
  log(
1253
937
  `feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
1254
938
  );
939
+ } else if (quotedMessageInfo) {
940
+ log(
941
+ `feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
942
+ );
1255
943
  }
1256
944
  } catch (err) {
1257
945
  log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
1258
946
  }
1259
947
  }
1260
948
 
949
+ const isTopicSessionForThread =
950
+ isGroup &&
951
+ (groupSession?.groupSessionScope === "group_topic" ||
952
+ groupSession?.groupSessionScope === "group_topic_sender");
953
+
1261
954
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
1262
955
  const messageBody = buildFeishuAgentBody({
1263
- ctx,
956
+ ctx: agentFacingCtx,
1264
957
  quotedContent,
1265
958
  permissionErrorForAgent,
1266
959
  botOpenId,
@@ -1309,45 +1002,226 @@ export async function handleFeishuMessage(params: {
1309
1002
  }))
1310
1003
  : undefined;
1311
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
+
1312
1177
  // --- Shared context builder for dispatch ---
1313
- const buildCtxPayloadForAgent = (
1178
+ const buildCtxPayloadForAgent = async (
1179
+ agentId: string,
1314
1180
  agentSessionKey: string,
1315
1181
  agentAccountId: string,
1316
1182
  wasMentioned: boolean,
1317
- ) =>
1318
- core.channel.reply.finalizeInboundContext({
1183
+ ) => {
1184
+ const groupName = await resolveGroupNameForLabel();
1185
+ const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName);
1186
+ return core.channel.reply.finalizeInboundContext({
1319
1187
  Body: combinedBody,
1320
1188
  BodyForAgent: messageBody,
1321
1189
  InboundHistory: inboundHistory,
1322
1190
  ReplyToId: ctx.parentId,
1323
1191
  RootMessageId: ctx.rootId,
1324
- RawBody: ctx.content,
1325
- CommandBody: ctx.content,
1192
+ RawBody: agentFacingContent,
1193
+ CommandBody: agentFacingContent,
1194
+ Transcript: audioTranscript,
1326
1195
  From: feishuFrom,
1327
1196
  To: feishuTo,
1328
1197
  SessionKey: agentSessionKey,
1329
1198
  AccountId: agentAccountId,
1330
1199
  ChatType: isGroup ? "group" : "direct",
1331
- GroupSubject: isGroup ? ctx.chatId : undefined,
1200
+ GroupSubject: isGroup ? groupName || ctx.chatId : undefined,
1201
+ ConversationLabel: isGroup && groupName && !isTopicSessionForThread ? groupName : undefined,
1332
1202
  SenderName: ctx.senderName ?? ctx.senderOpenId,
1333
1203
  SenderId: ctx.senderOpenId,
1334
1204
  Provider: "feishu" as const,
1335
1205
  Surface: "feishu" as const,
1336
1206
  MessageSid: ctx.messageId,
1337
1207
  ReplyToBody: quotedContent ?? undefined,
1338
- 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,
1339
1215
  WasMentioned: wasMentioned,
1340
1216
  CommandAuthorized: commandAuthorized,
1341
1217
  OriginatingChannel: "feishu" as const,
1342
1218
  OriginatingTo: feishuTo,
1343
- GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
1219
+ GroupSystemPrompt: isGroup ? normalizeOptionalString(groupConfig?.systemPrompt) : undefined,
1344
1220
  ...mediaPayload,
1221
+ ...(preflightAudioIndex >= 0 ? { MediaTranscribedIndexes: [preflightAudioIndex] } : {}),
1345
1222
  });
1223
+ };
1346
1224
 
1347
- // Parse message create_time (Feishu uses millisecond epoch string).
1348
- const messageCreateTimeMs = event.message.create_time
1349
- ? parseInt(event.message.create_time, 10)
1350
- : undefined;
1351
1225
  // Determine reply target based on group session mode:
1352
1226
  // - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
1353
1227
  // root so the bot stays in the same thread.
@@ -1364,7 +1238,11 @@ export async function handleFeishuMessage(params: {
1364
1238
  isGroup &&
1365
1239
  (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
1366
1240
  const replyTargetMessageId =
1367
- 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));
1368
1246
  const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
1369
1247
 
1370
1248
  if (broadcastAgents) {
@@ -1381,9 +1259,10 @@ export async function handleFeishuMessage(params: {
1381
1259
  }
1382
1260
 
1383
1261
  // --- Broadcast dispatch: send message to all configured agents ---
1384
- const strategy =
1385
- ((cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined)
1386
- ?.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";
1387
1266
  const activeAgentId =
1388
1267
  ctx.mentionedBot || !requireMention ? normalizeAgentId(route.agentId) : null;
1389
1268
  const agentIds = (cfg.agents?.list ?? []).map((a: { id: string }) => normalizeAgentId(a.id));
@@ -1402,7 +1281,22 @@ export async function handleFeishuMessage(params: {
1402
1281
  }
1403
1282
 
1404
1283
  const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
1405
- 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,
1406
1300
  agentSessionKey,
1407
1301
  route.accountId,
1408
1302
  ctx.mentionedBot && agentId === activeAgentId,
@@ -1410,11 +1304,13 @@ export async function handleFeishuMessage(params: {
1410
1304
 
1411
1305
  if (agentId === activeAgentId) {
1412
1306
  // Active agent: real Feishu dispatcher (responds on Feishu)
1307
+ const identity = resolveAgentOutboundIdentity(cfg, agentId);
1413
1308
  const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1414
1309
  cfg,
1415
1310
  agentId,
1416
1311
  runtime: runtime as RuntimeEnv,
1417
1312
  chatId: ctx.chatId,
1313
+ allowReasoningPreview,
1418
1314
  replyToMessageId: replyTargetMessageId,
1419
1315
  skipReplyToInMessages: !isGroup,
1420
1316
  replyInThread,
@@ -1422,22 +1318,53 @@ export async function handleFeishuMessage(params: {
1422
1318
  threadReply,
1423
1319
  mentionTargets: ctx.mentionTargets,
1424
1320
  accountId: account.accountId,
1321
+ identity,
1425
1322
  messageCreateTimeMs,
1426
1323
  });
1427
1324
 
1428
1325
  log(
1429
1326
  `feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
1430
1327
  );
1431
- await core.channel.reply.withReplyDispatcher({
1432
- dispatcher,
1433
- onSettled: () => markDispatchIdle(),
1434
- run: () =>
1435
- core.channel.reply.dispatchReplyFromConfig({
1436
- ctx: agentCtx,
1437
- cfg,
1438
- dispatcher,
1439
- 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,
1440
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
+ },
1441
1368
  });
1442
1369
  } else {
1443
1370
  // Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
@@ -1450,20 +1377,46 @@ export async function handleFeishuMessage(params: {
1450
1377
  sendFinalReply: () => false,
1451
1378
  waitForIdle: async () => {},
1452
1379
  getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
1380
+ getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }),
1453
1381
  markComplete: () => {},
1454
1382
  };
1455
1383
 
1456
1384
  log(
1457
1385
  `feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
1458
1386
  );
1459
- await core.channel.reply.withReplyDispatcher({
1460
- dispatcher: noopDispatcher,
1461
- run: () =>
1462
- core.channel.reply.dispatchReplyFromConfig({
1463
- ctx: agentCtx,
1464
- cfg,
1465
- 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,
1466
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
+ },
1467
1420
  });
1468
1421
  }
1469
1422
  };
@@ -1502,17 +1455,27 @@ export async function handleFeishuMessage(params: {
1502
1455
  );
1503
1456
  } else {
1504
1457
  // --- Single-agent dispatch (existing behavior) ---
1505
- const ctxPayload = buildCtxPayloadForAgent(
1458
+ const ctxPayload = await buildCtxPayloadForAgent(
1459
+ route.agentId,
1506
1460
  route.sessionKey,
1507
1461
  route.accountId,
1508
1462
  ctx.mentionedBot,
1509
1463
  );
1510
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
+ });
1511
1473
  const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1512
1474
  cfg,
1513
1475
  agentId: route.agentId,
1514
1476
  runtime: runtime as RuntimeEnv,
1515
1477
  chatId: ctx.chatId,
1478
+ allowReasoningPreview,
1516
1479
  replyToMessageId: replyTargetMessageId,
1517
1480
  skipReplyToInMessages: !isGroup,
1518
1481
  replyInThread,
@@ -1520,31 +1483,71 @@ export async function handleFeishuMessage(params: {
1520
1483
  threadReply,
1521
1484
  mentionTargets: ctx.mentionTargets,
1522
1485
  accountId: account.accountId,
1486
+ identity,
1523
1487
  messageCreateTimeMs,
1524
1488
  });
1525
1489
 
1526
1490
  log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
1527
- const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
1528
- dispatcher,
1529
- onSettled: () => {
1530
- markDispatchIdle();
1531
- },
1532
- run: () =>
1533
- core.channel.reply.dispatchReplyFromConfig({
1534
- ctx: ctxPayload,
1535
- cfg,
1536
- dispatcher,
1537
- 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,
1538
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
+ },
1539
1545
  });
1540
-
1541
- if (isGroup && historyKey && chatHistories) {
1542
- clearHistoryEntriesIfEnabled({
1543
- historyMap: chatHistories,
1544
- historyKey,
1545
- limit: historyLimit,
1546
- });
1546
+ if (!turnResult.dispatched) {
1547
+ return;
1547
1548
  }
1549
+ const { dispatchResult } = turnResult;
1550
+ const { queuedFinal, counts } = dispatchResult;
1548
1551
 
1549
1552
  log(
1550
1553
  `feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,