@openclaw/feishu 2026.2.25 → 2026.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +161 -0
  5. package/src/accounts.ts +76 -8
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +56 -1
  10. package/src/bot.test.ts +1271 -56
  11. package/src/bot.ts +499 -215
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +26 -4
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +121 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +101 -1
  20. package/src/config-schema.ts +66 -11
  21. package/src/dedup.ts +47 -1
  22. package/src/doc-schema.ts +135 -0
  23. package/src/docx-batch-insert.ts +190 -0
  24. package/src/docx-color-text.ts +149 -0
  25. package/src/docx-table-ops.ts +298 -0
  26. package/src/docx.account-selection.test.ts +70 -0
  27. package/src/docx.test.ts +331 -9
  28. package/src/docx.ts +996 -72
  29. package/src/drive.ts +38 -33
  30. package/src/media.test.ts +227 -7
  31. package/src/media.ts +52 -11
  32. package/src/mention.ts +1 -1
  33. package/src/monitor.account.ts +534 -0
  34. package/src/monitor.reaction.test.ts +578 -0
  35. package/src/monitor.startup.test.ts +203 -0
  36. package/src/monitor.startup.ts +51 -0
  37. package/src/monitor.state.defaults.test.ts +46 -0
  38. package/src/monitor.state.ts +152 -0
  39. package/src/monitor.test-mocks.ts +12 -0
  40. package/src/monitor.transport.ts +163 -0
  41. package/src/monitor.ts +44 -346
  42. package/src/monitor.webhook-security.test.ts +53 -10
  43. package/src/onboarding.status.test.ts +25 -0
  44. package/src/onboarding.ts +144 -52
  45. package/src/outbound.test.ts +181 -0
  46. package/src/outbound.ts +94 -7
  47. package/src/perm.ts +37 -30
  48. package/src/policy.test.ts +56 -1
  49. package/src/policy.ts +5 -1
  50. package/src/post.test.ts +105 -0
  51. package/src/post.ts +274 -0
  52. package/src/probe.test.ts +271 -0
  53. package/src/probe.ts +131 -19
  54. package/src/reply-dispatcher.test.ts +300 -0
  55. package/src/reply-dispatcher.ts +159 -46
  56. package/src/secret-input.ts +19 -0
  57. package/src/send-target.test.ts +74 -0
  58. package/src/send-target.ts +6 -2
  59. package/src/send.reply-fallback.test.ts +105 -0
  60. package/src/send.test.ts +168 -0
  61. package/src/send.ts +143 -18
  62. package/src/streaming-card.ts +131 -43
  63. package/src/targets.test.ts +55 -1
  64. package/src/targets.ts +32 -7
  65. package/src/tool-account-routing.test.ts +129 -0
  66. package/src/tool-account.ts +70 -0
  67. package/src/tool-factory-test-harness.ts +76 -0
  68. package/src/tools-config.test.ts +21 -0
  69. package/src/tools-config.ts +2 -1
  70. package/src/types.ts +10 -1
  71. package/src/typing.test.ts +144 -0
  72. package/src/typing.ts +140 -10
  73. package/src/wiki.ts +55 -50
package/src/bot.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  buildAgentMediaPayload,
4
4
  buildPendingHistoryContextFromMap,
5
5
  clearHistoryEntriesIfEnabled,
6
+ createScopedPairingAccess,
6
7
  DEFAULT_GROUP_HISTORY_LIMIT,
7
8
  type HistoryEntry,
8
9
  recordPendingHistoryEntryIfEnabled,
@@ -12,7 +13,7 @@ import {
12
13
  } from "openclaw/plugin-sdk";
13
14
  import { resolveFeishuAccount } from "./accounts.js";
14
15
  import { createFeishuClient } from "./client.js";
15
- import { tryRecordMessagePersistent } from "./dedup.js";
16
+ import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
16
17
  import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
17
18
  import { normalizeFeishuExternalKey } from "./external-keys.js";
18
19
  import { downloadMessageResourceFeishu } from "./media.js";
@@ -28,6 +29,7 @@ import {
28
29
  resolveFeishuAllowlistMatch,
29
30
  isFeishuGroupAllowed,
30
31
  } from "./policy.js";
32
+ import { parsePostContent } from "./post.js";
31
33
  import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
32
34
  import { getFeishuRuntime } from "./runtime.js";
33
35
  import { getMessageFeishu, sendMessageFeishu } from "./send.js";
@@ -42,6 +44,13 @@ type PermissionError = {
42
44
  grantUrl?: string;
43
45
  };
44
46
 
47
+ const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
48
+
49
+ function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
50
+ const message = permissionError.message.toLowerCase();
51
+ return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
52
+ }
53
+
45
54
  function extractPermissionError(err: unknown): PermissionError | null {
46
55
  if (!err || typeof err !== "object") return null;
47
56
 
@@ -72,7 +81,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
72
81
  }
73
82
 
74
83
  // --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
75
- // Cache display names by open_id to avoid an API call on every message.
84
+ // Cache display names by sender id (open_id/user_id) to avoid an API call on every message.
76
85
  const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
77
86
  const senderNameCache = new Map<string, { name: string; expireAt: number }>();
78
87
 
@@ -86,26 +95,40 @@ type SenderNameResult = {
86
95
  permissionError?: PermissionError;
87
96
  };
88
97
 
98
+ function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
99
+ const trimmed = senderId.trim();
100
+ if (trimmed.startsWith("ou_")) {
101
+ return "open_id";
102
+ }
103
+ if (trimmed.startsWith("on_")) {
104
+ return "union_id";
105
+ }
106
+ return "user_id";
107
+ }
108
+
89
109
  async function resolveFeishuSenderName(params: {
90
110
  account: ResolvedFeishuAccount;
91
- senderOpenId: string;
111
+ senderId: string;
92
112
  log: (...args: any[]) => void;
93
113
  }): Promise<SenderNameResult> {
94
- const { account, senderOpenId, log } = params;
114
+ const { account, senderId, log } = params;
95
115
  if (!account.configured) return {};
96
- if (!senderOpenId) return {};
97
116
 
98
- const cached = senderNameCache.get(senderOpenId);
117
+ const normalizedSenderId = senderId.trim();
118
+ if (!normalizedSenderId) return {};
119
+
120
+ const cached = senderNameCache.get(normalizedSenderId);
99
121
  const now = Date.now();
100
122
  if (cached && cached.expireAt > now) return { name: cached.name };
101
123
 
102
124
  try {
103
125
  const client = createFeishuClient(account);
126
+ const userIdType = resolveSenderLookupIdType(normalizedSenderId);
104
127
 
105
- // contact/v3/users/:user_id?user_id_type=open_id
128
+ // contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
106
129
  const res: any = await client.contact.user.get({
107
- path: { user_id: senderOpenId },
108
- params: { user_id_type: "open_id" },
130
+ path: { user_id: normalizedSenderId },
131
+ params: { user_id_type: userIdType },
109
132
  });
110
133
 
111
134
  const name: string | undefined =
@@ -115,7 +138,7 @@ async function resolveFeishuSenderName(params: {
115
138
  res?.data?.user?.en_name;
116
139
 
117
140
  if (name && typeof name === "string") {
118
- senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
141
+ senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
119
142
  return { name };
120
143
  }
121
144
 
@@ -124,12 +147,16 @@ async function resolveFeishuSenderName(params: {
124
147
  // Check if this is a permission error
125
148
  const permErr = extractPermissionError(err);
126
149
  if (permErr) {
150
+ if (shouldSuppressPermissionErrorNotice(permErr)) {
151
+ log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
152
+ return {};
153
+ }
127
154
  log(`feishu: permission error resolving sender name: code=${permErr.code}`);
128
155
  return { permissionError: permErr };
129
156
  }
130
157
 
131
158
  // Best-effort. Don't fail message handling if name lookup fails.
132
- log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
159
+ log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
133
160
  return {};
134
161
  }
135
162
  }
@@ -148,10 +175,12 @@ export type FeishuMessageEvent = {
148
175
  message_id: string;
149
176
  root_id?: string;
150
177
  parent_id?: string;
178
+ thread_id?: string;
151
179
  chat_id: string;
152
- chat_type: "p2p" | "group";
180
+ chat_type: "p2p" | "group" | "private";
153
181
  message_type: string;
154
182
  content: string;
183
+ create_time?: string;
155
184
  mentions?: Array<{
156
185
  key: string;
157
186
  id: {
@@ -176,16 +205,129 @@ export type FeishuBotAddedEvent = {
176
205
  operator_tenant_key?: string;
177
206
  };
178
207
 
208
+ type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
209
+
210
+ type ResolvedFeishuGroupSession = {
211
+ peerId: string;
212
+ parentPeer: { kind: "group"; id: string } | null;
213
+ groupSessionScope: GroupSessionScope;
214
+ replyInThread: boolean;
215
+ threadReply: boolean;
216
+ };
217
+
218
+ function resolveFeishuGroupSession(params: {
219
+ chatId: string;
220
+ senderOpenId: string;
221
+ messageId: string;
222
+ rootId?: string;
223
+ threadId?: string;
224
+ groupConfig?: {
225
+ groupSessionScope?: GroupSessionScope;
226
+ topicSessionMode?: "enabled" | "disabled";
227
+ replyInThread?: "enabled" | "disabled";
228
+ };
229
+ feishuCfg?: {
230
+ groupSessionScope?: GroupSessionScope;
231
+ topicSessionMode?: "enabled" | "disabled";
232
+ replyInThread?: "enabled" | "disabled";
233
+ };
234
+ }): ResolvedFeishuGroupSession {
235
+ const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
236
+
237
+ const normalizedThreadId = threadId?.trim();
238
+ const normalizedRootId = rootId?.trim();
239
+ const threadReply = Boolean(normalizedThreadId || normalizedRootId);
240
+ const replyInThread =
241
+ (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
242
+ threadReply;
243
+
244
+ const legacyTopicSessionMode =
245
+ groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
246
+ const groupSessionScope: GroupSessionScope =
247
+ groupConfig?.groupSessionScope ??
248
+ feishuCfg?.groupSessionScope ??
249
+ (legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
250
+
251
+ // Keep topic session keys stable across the "first turn creates thread" flow:
252
+ // first turn may only have message_id, while the next turn carries root_id/thread_id.
253
+ // Prefer root_id first so both turns stay on the same peer key.
254
+ const topicScope =
255
+ groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
256
+ ? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
257
+ : null;
258
+
259
+ let peerId = chatId;
260
+ switch (groupSessionScope) {
261
+ case "group_sender":
262
+ peerId = `${chatId}:sender:${senderOpenId}`;
263
+ break;
264
+ case "group_topic":
265
+ peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
266
+ break;
267
+ case "group_topic_sender":
268
+ peerId = topicScope
269
+ ? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
270
+ : `${chatId}:sender:${senderOpenId}`;
271
+ break;
272
+ case "group":
273
+ default:
274
+ peerId = chatId;
275
+ break;
276
+ }
277
+
278
+ const parentPeer =
279
+ topicScope &&
280
+ (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
281
+ ? {
282
+ kind: "group" as const,
283
+ id: chatId,
284
+ }
285
+ : null;
286
+
287
+ return {
288
+ peerId,
289
+ parentPeer,
290
+ groupSessionScope,
291
+ replyInThread,
292
+ threadReply,
293
+ };
294
+ }
295
+
179
296
  function parseMessageContent(content: string, messageType: string): string {
297
+ if (messageType === "post") {
298
+ // Extract text content from rich text post
299
+ const { textContent } = parsePostContent(content);
300
+ return textContent;
301
+ }
302
+
180
303
  try {
181
304
  const parsed = JSON.parse(content);
182
305
  if (messageType === "text") {
183
306
  return parsed.text || "";
184
307
  }
185
- if (messageType === "post") {
186
- // Extract text content from rich text post
187
- const { textContent } = parsePostContent(content);
188
- return textContent;
308
+ if (messageType === "share_chat") {
309
+ // Preserve available summary text for merged/forwarded chat messages.
310
+ if (parsed && typeof parsed === "object") {
311
+ const share = parsed as {
312
+ body?: unknown;
313
+ summary?: unknown;
314
+ share_chat_id?: unknown;
315
+ };
316
+ if (typeof share.body === "string" && share.body.trim().length > 0) {
317
+ return share.body.trim();
318
+ }
319
+ if (typeof share.summary === "string" && share.summary.trim().length > 0) {
320
+ return share.summary.trim();
321
+ }
322
+ if (typeof share.share_chat_id === "string" && share.share_chat_id.trim().length > 0) {
323
+ return `[Forwarded message: ${share.share_chat_id.trim()}]`;
324
+ }
325
+ }
326
+ return "[Forwarded message]";
327
+ }
328
+ if (messageType === "merge_forward") {
329
+ // Return placeholder; actual content fetched asynchronously in handleFeishuMessage
330
+ return "[Merged and Forwarded Message - loading...]";
189
331
  }
190
332
  return content;
191
333
  } catch {
@@ -193,6 +335,109 @@ function parseMessageContent(content: string, messageType: string): string {
193
335
  }
194
336
  }
195
337
 
338
+ /**
339
+ * Parse merge_forward message content and fetch sub-messages.
340
+ * Returns formatted text content of all sub-messages.
341
+ */
342
+ function parseMergeForwardContent(params: {
343
+ content: string;
344
+ log?: (...args: any[]) => void;
345
+ }): string {
346
+ const { content, log } = params;
347
+ const maxMessages = 50;
348
+
349
+ // For merge_forward, the API returns all sub-messages in items array
350
+ // with upper_message_id pointing to the merge_forward message.
351
+ // The 'content' parameter here is actually the full API response items array as JSON.
352
+ log?.(`feishu: parsing merge_forward sub-messages from API response`);
353
+
354
+ let items: Array<{
355
+ message_id?: string;
356
+ msg_type?: string;
357
+ body?: { content?: string };
358
+ sender?: { id?: string };
359
+ upper_message_id?: string;
360
+ create_time?: string;
361
+ }>;
362
+
363
+ try {
364
+ items = JSON.parse(content);
365
+ } catch {
366
+ log?.(`feishu: merge_forward items parse failed`);
367
+ return "[Merged and Forwarded Message - parse error]";
368
+ }
369
+
370
+ if (!Array.isArray(items) || items.length === 0) {
371
+ return "[Merged and Forwarded Message - no sub-messages]";
372
+ }
373
+
374
+ // Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
375
+ const subMessages = items.filter((item) => item.upper_message_id);
376
+
377
+ if (subMessages.length === 0) {
378
+ return "[Merged and Forwarded Message - no sub-messages found]";
379
+ }
380
+
381
+ log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
382
+
383
+ // Sort by create_time
384
+ subMessages.sort((a, b) => {
385
+ const timeA = parseInt(a.create_time || "0", 10);
386
+ const timeB = parseInt(b.create_time || "0", 10);
387
+ return timeA - timeB;
388
+ });
389
+
390
+ // Format output
391
+ const lines: string[] = ["[Merged and Forwarded Messages]"];
392
+ const limitedMessages = subMessages.slice(0, maxMessages);
393
+
394
+ for (const item of limitedMessages) {
395
+ const msgContent = item.body?.content || "";
396
+ const msgType = item.msg_type || "text";
397
+ const formatted = formatSubMessageContent(msgContent, msgType);
398
+ lines.push(`- ${formatted}`);
399
+ }
400
+
401
+ if (subMessages.length > maxMessages) {
402
+ lines.push(`... and ${subMessages.length - maxMessages} more messages`);
403
+ }
404
+
405
+ return lines.join("\n");
406
+ }
407
+
408
+ /**
409
+ * Format sub-message content based on message type.
410
+ */
411
+ function formatSubMessageContent(content: string, contentType: string): string {
412
+ try {
413
+ const parsed = JSON.parse(content);
414
+ switch (contentType) {
415
+ case "text":
416
+ return parsed.text || content;
417
+ case "post": {
418
+ const { textContent } = parsePostContent(content);
419
+ return textContent;
420
+ }
421
+ case "image":
422
+ return "[Image]";
423
+ case "file":
424
+ return `[File: ${parsed.file_name || "unknown"}]`;
425
+ case "audio":
426
+ return "[Audio]";
427
+ case "video":
428
+ return "[Video]";
429
+ case "sticker":
430
+ return "[Sticker]";
431
+ case "merge_forward":
432
+ return "[Nested Merged Forward]";
433
+ default:
434
+ return `[${contentType}]`;
435
+ }
436
+ } catch {
437
+ return content;
438
+ }
439
+ }
440
+
196
441
  function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
197
442
  if (!botOpenId) return false;
198
443
  const mentions = event.message.mentions ?? [];
@@ -243,7 +488,8 @@ function parseMediaKeys(
243
488
  case "audio":
244
489
  return { fileKey };
245
490
  case "video":
246
- // Video has both file_key (video) and image_key (thumbnail)
491
+ case "media":
492
+ // Video/media has both file_key (video) and image_key (thumbnail)
247
493
  return { fileKey, imageKey };
248
494
  case "sticker":
249
495
  return { fileKey };
@@ -256,56 +502,11 @@ function parseMediaKeys(
256
502
  }
257
503
 
258
504
  /**
259
- * Parse post (rich text) content and extract embedded image keys.
260
- * Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
505
+ * Map Feishu message type to messageResource.get resource type.
506
+ * Feishu messageResource API supports only: image | file.
261
507
  */
262
- function parsePostContent(content: string): {
263
- textContent: string;
264
- imageKeys: string[];
265
- mentionedOpenIds: string[];
266
- } {
267
- try {
268
- const parsed = JSON.parse(content);
269
- const title = parsed.title || "";
270
- const contentBlocks = parsed.content || [];
271
- let textContent = title ? `${title}\n\n` : "";
272
- const imageKeys: string[] = [];
273
- const mentionedOpenIds: string[] = [];
274
-
275
- for (const paragraph of contentBlocks) {
276
- if (Array.isArray(paragraph)) {
277
- for (const element of paragraph) {
278
- if (element.tag === "text") {
279
- textContent += element.text || "";
280
- } else if (element.tag === "a") {
281
- // Link: show text or href
282
- textContent += element.text || element.href || "";
283
- } else if (element.tag === "at") {
284
- // Mention: @username
285
- textContent += `@${element.user_name || element.user_id || ""}`;
286
- if (element.user_id) {
287
- mentionedOpenIds.push(element.user_id);
288
- }
289
- } else if (element.tag === "img" && element.image_key) {
290
- // Embedded image
291
- const imageKey = normalizeFeishuExternalKey(element.image_key);
292
- if (imageKey) {
293
- imageKeys.push(imageKey);
294
- }
295
- }
296
- }
297
- textContent += "\n";
298
- }
299
- }
300
-
301
- return {
302
- textContent: textContent.trim() || "[Rich text message]",
303
- imageKeys,
304
- mentionedOpenIds,
305
- };
306
- } catch {
307
- return { textContent: "[Rich text message]", imageKeys: [], mentionedOpenIds: [] };
308
- }
508
+ export function toMessageResourceType(messageType: string): "image" | "file" {
509
+ return messageType === "image" ? "image" : "file";
309
510
  }
310
511
 
311
512
  /**
@@ -320,6 +521,7 @@ function inferPlaceholder(messageType: string): string {
320
521
  case "audio":
321
522
  return "<media:audio>";
322
523
  case "video":
524
+ case "media":
323
525
  return "<media:video>";
324
526
  case "sticker":
325
527
  return "<media:sticker>";
@@ -344,7 +546,7 @@ async function resolveFeishuMediaList(params: {
344
546
  const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
345
547
 
346
548
  // Only process media message types (including post for embedded images)
347
- const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
549
+ const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
348
550
  if (!mediaTypes.includes(messageType)) {
349
551
  return [];
350
552
  }
@@ -352,14 +554,19 @@ async function resolveFeishuMediaList(params: {
352
554
  const out: FeishuMediaInfo[] = [];
353
555
  const core = getFeishuRuntime();
354
556
 
355
- // Handle post (rich text) messages with embedded images
557
+ // Handle post (rich text) messages with embedded images/media.
356
558
  if (messageType === "post") {
357
- const { imageKeys } = parsePostContent(content);
358
- if (imageKeys.length === 0) {
559
+ const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
560
+ if (imageKeys.length === 0 && postMediaKeys.length === 0) {
359
561
  return [];
360
562
  }
361
563
 
362
- log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
564
+ if (imageKeys.length > 0) {
565
+ log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
566
+ }
567
+ if (postMediaKeys.length > 0) {
568
+ log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(s)`);
569
+ }
363
570
 
364
571
  for (const imageKey of imageKeys) {
365
572
  try {
@@ -396,6 +603,40 @@ async function resolveFeishuMediaList(params: {
396
603
  }
397
604
  }
398
605
 
606
+ for (const media of postMediaKeys) {
607
+ try {
608
+ const result = await downloadMessageResourceFeishu({
609
+ cfg,
610
+ messageId,
611
+ fileKey: media.fileKey,
612
+ type: "file",
613
+ accountId,
614
+ });
615
+
616
+ let contentType = result.contentType;
617
+ if (!contentType) {
618
+ contentType = await core.media.detectMime({ buffer: result.buffer });
619
+ }
620
+
621
+ const saved = await core.channel.media.saveMediaBuffer(
622
+ result.buffer,
623
+ contentType,
624
+ "inbound",
625
+ maxBytes,
626
+ );
627
+
628
+ out.push({
629
+ path: saved.path,
630
+ contentType: saved.contentType,
631
+ placeholder: "<media:video>",
632
+ });
633
+
634
+ log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
635
+ } catch (err) {
636
+ log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
637
+ }
638
+ }
639
+
399
640
  return out;
400
641
  }
401
642
 
@@ -417,7 +658,7 @@ async function resolveFeishuMediaList(params: {
417
658
  return [];
418
659
  }
419
660
 
420
- const resourceType = messageType === "image" ? "image" : "file";
661
+ const resourceType = toMessageResourceType(messageType);
421
662
  const result = await downloadMessageResourceFeishu({
422
663
  cfg,
423
664
  messageId,
@@ -468,16 +709,22 @@ export function parseFeishuMessageEvent(
468
709
  const rawContent = parseMessageContent(event.message.content, event.message.message_type);
469
710
  const mentionedBot = checkBotMentioned(event, botOpenId);
470
711
  const content = stripBotMention(rawContent, event.message.mentions);
712
+ const senderOpenId = event.sender.sender_id.open_id?.trim();
713
+ const senderUserId = event.sender.sender_id.user_id?.trim();
714
+ const senderFallbackId = senderOpenId || senderUserId || "";
471
715
 
472
716
  const ctx: FeishuMessageContext = {
473
717
  chatId: event.message.chat_id,
474
718
  messageId: event.message.message_id,
475
- senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "",
476
- senderOpenId: event.sender.sender_id.open_id || "",
719
+ senderId: senderUserId || senderOpenId || "",
720
+ // Keep the historical field name, but fall back to user_id when open_id is unavailable
721
+ // (common in some mobile app deliveries).
722
+ senderOpenId: senderFallbackId,
477
723
  chatType: event.message.chat_type,
478
724
  mentionedBot,
479
725
  rootId: event.message.root_id || undefined,
480
726
  parentId: event.message.parent_id || undefined,
727
+ threadId: event.message.thread_id || undefined,
481
728
  content,
482
729
  contentType: event.message.message_type,
483
730
  };
@@ -496,6 +743,40 @@ export function parseFeishuMessageEvent(
496
743
  return ctx;
497
744
  }
498
745
 
746
+ export function buildFeishuAgentBody(params: {
747
+ ctx: Pick<
748
+ FeishuMessageContext,
749
+ "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
750
+ >;
751
+ quotedContent?: string;
752
+ permissionErrorForAgent?: PermissionError;
753
+ }): string {
754
+ const { ctx, quotedContent, permissionErrorForAgent } = params;
755
+ let messageBody = ctx.content;
756
+ if (quotedContent) {
757
+ messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
758
+ }
759
+
760
+ // DMs already have per-sender sessions, but this label still improves attribution.
761
+ const speaker = ctx.senderName ?? ctx.senderOpenId;
762
+ messageBody = `${speaker}: ${messageBody}`;
763
+
764
+ if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
765
+ const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
766
+ messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
767
+ }
768
+
769
+ // Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
770
+ messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
771
+
772
+ if (permissionErrorForAgent) {
773
+ const grantUrl = permissionErrorForAgent.grantUrl ?? "";
774
+ messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
775
+ }
776
+
777
+ return messageBody;
778
+ }
779
+
499
780
  export async function handleFeishuMessage(params: {
500
781
  cfg: ClawdbotConfig;
501
782
  event: FeishuMessageEvent;
@@ -513,8 +794,15 @@ export async function handleFeishuMessage(params: {
513
794
  const log = runtime?.log ?? console.log;
514
795
  const error = runtime?.error ?? console.error;
515
796
 
516
- // Dedup check: skip if this message was already processed (memory + disk).
797
+ // Dedup: synchronous memory guard prevents concurrent duplicate dispatch
798
+ // before the async persistent check completes.
517
799
  const messageId = event.message.message_id;
800
+ const memoryDedupeKey = `${account.accountId}:${messageId}`;
801
+ if (!tryRecordMessage(memoryDedupeKey)) {
802
+ log(`feishu: skipping duplicate message ${messageId} (memory dedup)`);
803
+ return;
804
+ }
805
+ // Persistent dedup survives restarts and reconnects.
518
806
  if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
519
807
  log(`feishu: skipping duplicate message ${messageId}`);
520
808
  return;
@@ -522,26 +810,62 @@ export async function handleFeishuMessage(params: {
522
810
 
523
811
  let ctx = parseFeishuMessageEvent(event, botOpenId);
524
812
  const isGroup = ctx.chatType === "group";
813
+ const isDirect = !isGroup;
525
814
  const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
526
815
 
527
- // Resolve sender display name (best-effort) so the agent can attribute messages correctly.
528
- const senderResult = await resolveFeishuSenderName({
529
- account,
530
- senderOpenId: ctx.senderOpenId,
531
- log,
532
- });
533
- if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
816
+ // Handle merge_forward messages: fetch full message via API then expand sub-messages
817
+ if (event.message.message_type === "merge_forward") {
818
+ log(
819
+ `feishu[${account.accountId}]: processing merge_forward message, fetching full content via API`,
820
+ );
821
+ try {
822
+ // Websocket event doesn't include sub-messages, need to fetch via API
823
+ // The API returns all sub-messages in the items array
824
+ const client = createFeishuClient(account);
825
+ const response = (await client.im.message.get({
826
+ path: { message_id: event.message.message_id },
827
+ })) as { code?: number; data?: { items?: unknown[] } };
828
+
829
+ if (response.code === 0 && response.data?.items && response.data.items.length > 0) {
830
+ log(
831
+ `feishu[${account.accountId}]: merge_forward API returned ${response.data.items.length} items`,
832
+ );
833
+ const expandedContent = parseMergeForwardContent({
834
+ content: JSON.stringify(response.data.items),
835
+ log,
836
+ });
837
+ ctx = { ...ctx, content: expandedContent };
838
+ } else {
839
+ log(`feishu[${account.accountId}]: merge_forward API returned no items`);
840
+ ctx = { ...ctx, content: "[Merged and Forwarded Message - could not fetch]" };
841
+ }
842
+ } catch (err) {
843
+ log(`feishu[${account.accountId}]: merge_forward fetch failed: ${String(err)}`);
844
+ ctx = { ...ctx, content: "[Merged and Forwarded Message - fetch error]" };
845
+ }
846
+ }
534
847
 
535
- // Track permission error to inform agent later (with cooldown to avoid repetition)
848
+ // Resolve sender display name (best-effort) so the agent can attribute messages correctly.
849
+ // Optimization: skip if disabled to save API quota (Feishu free tier limit).
536
850
  let permissionErrorForAgent: PermissionError | undefined;
537
- if (senderResult.permissionError) {
538
- const appKey = account.appId ?? "default";
539
- const now = Date.now();
540
- const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
541
-
542
- if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
543
- permissionErrorNotifiedAt.set(appKey, now);
544
- permissionErrorForAgent = senderResult.permissionError;
851
+ if (feishuCfg?.resolveSenderNames ?? true) {
852
+ const senderResult = await resolveFeishuSenderName({
853
+ account,
854
+ senderId: ctx.senderOpenId,
855
+ log,
856
+ });
857
+ if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
858
+
859
+ // Track permission error to inform agent later (with cooldown to avoid repetition)
860
+ if (senderResult.permissionError) {
861
+ const appKey = account.appId ?? "default";
862
+ const now = Date.now();
863
+ const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
864
+
865
+ if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
866
+ permissionErrorNotifiedAt.set(appKey, now);
867
+ permissionErrorForAgent = senderResult.permissionError;
868
+ }
545
869
  }
546
870
  }
547
871
 
@@ -562,11 +886,27 @@ export async function handleFeishuMessage(params: {
562
886
  const groupConfig = isGroup
563
887
  ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
564
888
  : undefined;
889
+ const groupSession = isGroup
890
+ ? resolveFeishuGroupSession({
891
+ chatId: ctx.chatId,
892
+ senderOpenId: ctx.senderOpenId,
893
+ messageId: ctx.messageId,
894
+ rootId: ctx.rootId,
895
+ threadId: ctx.threadId,
896
+ groupConfig,
897
+ feishuCfg,
898
+ })
899
+ : null;
900
+ const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined;
565
901
  const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
566
902
  const configAllowFrom = feishuCfg?.allowFrom ?? [];
567
903
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
568
904
 
569
905
  if (isGroup) {
906
+ if (groupConfig?.enabled === false) {
907
+ log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
908
+ return;
909
+ }
570
910
  const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
571
911
  const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
572
912
  providerConfigPresent: cfg.channels?.feishu !== undefined,
@@ -591,16 +931,21 @@ export async function handleFeishuMessage(params: {
591
931
  });
592
932
 
593
933
  if (!groupAllowed) {
594
- log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in group allowlist`);
934
+ log(
935
+ `feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
936
+ );
595
937
  return;
596
938
  }
597
939
 
598
- // Additional sender-level allowlist check if group has specific allowFrom config
599
- const senderAllowFrom = groupConfig?.allowFrom ?? [];
600
- if (senderAllowFrom.length > 0) {
940
+ // Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
941
+ const perGroupSenderAllowFrom = groupConfig?.allowFrom ?? [];
942
+ const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
943
+ const effectiveSenderAllowFrom =
944
+ perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
945
+ if (effectiveSenderAllowFrom.length > 0) {
601
946
  const senderAllowed = isFeishuGroupAllowed({
602
947
  groupPolicy: "allowlist",
603
- allowFrom: senderAllowFrom,
948
+ allowFrom: effectiveSenderAllowFrom,
604
949
  senderId: ctx.senderOpenId,
605
950
  senderIds: [senderUserId],
606
951
  senderName: ctx.senderName,
@@ -621,10 +966,10 @@ export async function handleFeishuMessage(params: {
621
966
  log(
622
967
  `feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
623
968
  );
624
- if (chatHistories) {
969
+ if (chatHistories && groupHistoryKey) {
625
970
  recordPendingHistoryEntryIfEnabled({
626
971
  historyMap: chatHistories,
627
- historyKey: ctx.chatId,
972
+ historyKey: groupHistoryKey,
628
973
  limit: historyLimit,
629
974
  entry: {
630
975
  sender: ctx.senderOpenId,
@@ -641,6 +986,11 @@ export async function handleFeishuMessage(params: {
641
986
 
642
987
  try {
643
988
  const core = getFeishuRuntime();
989
+ const pairing = createScopedPairingAccess({
990
+ core,
991
+ channel: "feishu",
992
+ accountId: account.accountId,
993
+ });
644
994
  const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
645
995
  ctx.content,
646
996
  cfg,
@@ -649,7 +999,7 @@ export async function handleFeishuMessage(params: {
649
999
  !isGroup &&
650
1000
  dmPolicy !== "allowlist" &&
651
1001
  (dmPolicy !== "open" || shouldComputeCommandAuthorized)
652
- ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
1002
+ ? await pairing.readAllowFromStore().catch(() => [])
653
1003
  : [];
654
1004
  const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
655
1005
  const dmAllowed = resolveFeishuAllowlistMatch({
@@ -659,10 +1009,9 @@ export async function handleFeishuMessage(params: {
659
1009
  senderName: ctx.senderName,
660
1010
  }).allowed;
661
1011
 
662
- if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
1012
+ if (isDirect && dmPolicy !== "open" && !dmAllowed) {
663
1013
  if (dmPolicy === "pairing") {
664
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
665
- channel: "feishu",
1014
+ const { code, created } = await pairing.upsertPairingRequest({
666
1015
  id: ctx.senderOpenId,
667
1016
  meta: { name: ctx.senderName },
668
1017
  });
@@ -671,7 +1020,7 @@ export async function handleFeishuMessage(params: {
671
1020
  try {
672
1021
  await sendMessageFeishu({
673
1022
  cfg,
674
- to: `user:${ctx.senderOpenId}`,
1023
+ to: `chat:${ctx.chatId}`,
675
1024
  text: core.channel.pairing.buildPairingReply({
676
1025
  channel: "feishu",
677
1026
  idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
@@ -715,20 +1064,14 @@ export async function handleFeishuMessage(params: {
715
1064
  // Using a group-scoped From causes the agent to treat different users as the same person.
716
1065
  const feishuFrom = `feishu:${ctx.senderOpenId}`;
717
1066
  const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
1067
+ const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
1068
+ const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
1069
+ const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
718
1070
 
719
- // Resolve peer ID for session routing
720
- // When topicSessionMode is enabled, messages within a topic (identified by root_id)
721
- // get a separate session from the main group chat.
722
- let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
723
- let topicSessionMode: "enabled" | "disabled" = "disabled";
724
- if (isGroup && ctx.rootId) {
725
- const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
726
- topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
727
- if (topicSessionMode === "enabled") {
728
- // Use chatId:topic:rootId as peer ID for topic-scoped sessions
729
- peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
730
- log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
731
- }
1071
+ if (isGroup && groupSession) {
1072
+ log(
1073
+ `feishu[${account.accountId}]: group session scope=${groupSession.groupSessionScope}, peer=${peerId}`,
1074
+ );
732
1075
  }
733
1076
 
734
1077
  let route = core.channel.routing.resolveAgentRoute({
@@ -739,14 +1082,7 @@ export async function handleFeishuMessage(params: {
739
1082
  kind: isGroup ? "group" : "direct",
740
1083
  id: peerId,
741
1084
  },
742
- // Add parentPeer for binding inheritance in topic mode
743
- parentPeer:
744
- isGroup && ctx.rootId && topicSessionMode === "enabled"
745
- ? {
746
- kind: "group",
747
- id: ctx.chatId,
748
- }
749
- : null,
1085
+ parentPeer,
750
1086
  });
751
1087
 
752
1088
  // Dynamic agent creation for DM users
@@ -784,10 +1120,10 @@ export async function handleFeishuMessage(params: {
784
1120
  ? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
785
1121
  : `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
786
1122
 
787
- core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
788
- sessionKey: route.sessionKey,
789
- contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
790
- });
1123
+ // Do not enqueue inbound user previews as system events.
1124
+ // System events are prepended to future prompts and can be misread as
1125
+ // authoritative transcript turns.
1126
+ log(`feishu[${account.accountId}]: ${inboundLabel}: ${preview}`);
791
1127
 
792
1128
  // Resolve media from message
793
1129
  const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
@@ -823,85 +1159,15 @@ export async function handleFeishuMessage(params: {
823
1159
  }
824
1160
 
825
1161
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
826
-
827
- // Build message body with quoted content if available
828
- let messageBody = ctx.content;
829
- if (quotedContent) {
830
- messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
831
- }
832
-
833
- // Include a readable speaker label so the model can attribute instructions.
834
- // (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
835
- const speaker = ctx.senderName ?? ctx.senderOpenId;
836
- messageBody = `${speaker}: ${messageBody}`;
837
-
838
- // If there are mention targets, inform the agent that replies will auto-mention them
839
- if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
840
- const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
841
- messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
842
- }
843
-
1162
+ const messageBody = buildFeishuAgentBody({
1163
+ ctx,
1164
+ quotedContent,
1165
+ permissionErrorForAgent,
1166
+ });
844
1167
  const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
845
-
846
- // If there's a permission error, dispatch a separate notification first
847
1168
  if (permissionErrorForAgent) {
848
- const grantUrl = permissionErrorForAgent.grantUrl ?? "";
849
- const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
850
-
851
- const permissionBody = core.channel.reply.formatAgentEnvelope({
852
- channel: "Feishu",
853
- from: envelopeFrom,
854
- timestamp: new Date(),
855
- envelope: envelopeOptions,
856
- body: permissionNotifyBody,
857
- });
858
-
859
- const permissionCtx = core.channel.reply.finalizeInboundContext({
860
- Body: permissionBody,
861
- BodyForAgent: permissionNotifyBody,
862
- RawBody: permissionNotifyBody,
863
- CommandBody: permissionNotifyBody,
864
- From: feishuFrom,
865
- To: feishuTo,
866
- SessionKey: route.sessionKey,
867
- AccountId: route.accountId,
868
- ChatType: isGroup ? "group" : "direct",
869
- GroupSubject: isGroup ? ctx.chatId : undefined,
870
- SenderName: "system",
871
- SenderId: "system",
872
- Provider: "feishu" as const,
873
- Surface: "feishu" as const,
874
- MessageSid: `${ctx.messageId}:permission-error`,
875
- Timestamp: Date.now(),
876
- WasMentioned: false,
877
- CommandAuthorized: commandAuthorized,
878
- OriginatingChannel: "feishu" as const,
879
- OriginatingTo: feishuTo,
880
- });
881
-
882
- const {
883
- dispatcher: permDispatcher,
884
- replyOptions: permReplyOptions,
885
- markDispatchIdle: markPermIdle,
886
- } = createFeishuReplyDispatcher({
887
- cfg,
888
- agentId: route.agentId,
889
- runtime: runtime as RuntimeEnv,
890
- chatId: ctx.chatId,
891
- replyToMessageId: ctx.messageId,
892
- accountId: account.accountId,
893
- });
894
-
895
- log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
896
-
897
- await core.channel.reply.dispatchReplyFromConfig({
898
- ctx: permissionCtx,
899
- cfg,
900
- dispatcher: permDispatcher,
901
- replyOptions: permReplyOptions,
902
- });
903
-
904
- markPermIdle();
1169
+ // Keep the notice in a single dispatch to avoid duplicate replies (#27372).
1170
+ log(`feishu[${account.accountId}]: appending permission error notice to message body`);
905
1171
  }
906
1172
 
907
1173
  const body = core.channel.reply.formatAgentEnvelope({
@@ -913,7 +1179,7 @@ export async function handleFeishuMessage(params: {
913
1179
  });
914
1180
 
915
1181
  let combinedBody = body;
916
- const historyKey = isGroup ? ctx.chatId : undefined;
1182
+ const historyKey = groupHistoryKey;
917
1183
 
918
1184
  if (isGroup && historyKey && chatHistories) {
919
1185
  combinedBody = buildPendingHistoryContextFromMap({
@@ -944,8 +1210,12 @@ export async function handleFeishuMessage(params: {
944
1210
 
945
1211
  const ctxPayload = core.channel.reply.finalizeInboundContext({
946
1212
  Body: combinedBody,
947
- BodyForAgent: ctx.content,
1213
+ BodyForAgent: messageBody,
948
1214
  InboundHistory: inboundHistory,
1215
+ // Quote/reply message support: use standard ReplyToId for parent,
1216
+ // and pass root_id for thread reconstruction.
1217
+ ReplyToId: ctx.parentId,
1218
+ RootMessageId: ctx.rootId,
949
1219
  RawBody: ctx.content,
950
1220
  CommandBody: ctx.content,
951
1221
  From: feishuFrom,
@@ -968,27 +1238,41 @@ export async function handleFeishuMessage(params: {
968
1238
  ...mediaPayload,
969
1239
  });
970
1240
 
1241
+ // Parse message create_time (Feishu uses millisecond epoch string).
1242
+ const messageCreateTimeMs = event.message.create_time
1243
+ ? parseInt(event.message.create_time, 10)
1244
+ : undefined;
1245
+ const replyTargetMessageId = ctx.rootId ?? ctx.messageId;
971
1246
  const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
972
1247
  cfg,
973
1248
  agentId: route.agentId,
974
1249
  runtime: runtime as RuntimeEnv,
975
1250
  chatId: ctx.chatId,
976
- replyToMessageId: ctx.messageId,
1251
+ replyToMessageId: replyTargetMessageId,
1252
+ skipReplyToInMessages: !isGroup,
1253
+ replyInThread,
1254
+ rootId: ctx.rootId,
1255
+ threadReply: isGroup ? (groupSession?.threadReply ?? false) : false,
977
1256
  mentionTargets: ctx.mentionTargets,
978
1257
  accountId: account.accountId,
1258
+ messageCreateTimeMs,
979
1259
  });
980
1260
 
981
1261
  log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
982
-
983
- const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
984
- ctx: ctxPayload,
985
- cfg,
1262
+ const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
986
1263
  dispatcher,
987
- replyOptions,
1264
+ onSettled: () => {
1265
+ markDispatchIdle();
1266
+ },
1267
+ run: () =>
1268
+ core.channel.reply.dispatchReplyFromConfig({
1269
+ ctx: ctxPayload,
1270
+ cfg,
1271
+ dispatcher,
1272
+ replyOptions,
1273
+ }),
988
1274
  });
989
1275
 
990
- markDispatchIdle();
991
-
992
1276
  if (isGroup && historyKey && chatHistories) {
993
1277
  clearHistoryEntriesIfEnabled({
994
1278
  historyMap: chatHistories,