@openclaw/feishu 2026.2.24 → 2026.3.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 (64) 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 +90 -0
  5. package/src/accounts.ts +11 -2
  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 +55 -0
  10. package/src/bot.test.ts +863 -9
  11. package/src/bot.ts +414 -200
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +6 -0
  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 +107 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +82 -1
  20. package/src/config-schema.ts +54 -3
  21. package/src/doc-schema.ts +141 -0
  22. package/src/docx-batch-insert.ts +190 -0
  23. package/src/docx-color-text.ts +149 -0
  24. package/src/docx-table-ops.ts +298 -0
  25. package/src/docx.account-selection.test.ts +76 -0
  26. package/src/docx.test.ts +470 -0
  27. package/src/docx.ts +996 -72
  28. package/src/drive.ts +38 -33
  29. package/src/media.test.ts +123 -6
  30. package/src/media.ts +31 -10
  31. package/src/monitor.account.ts +286 -0
  32. package/src/monitor.reaction.test.ts +235 -0
  33. package/src/monitor.startup.test.ts +187 -0
  34. package/src/monitor.startup.ts +51 -0
  35. package/src/monitor.state.ts +76 -0
  36. package/src/monitor.transport.ts +163 -0
  37. package/src/monitor.ts +44 -346
  38. package/src/monitor.webhook-security.test.ts +27 -1
  39. package/src/outbound.test.ts +181 -0
  40. package/src/outbound.ts +94 -7
  41. package/src/perm.ts +37 -30
  42. package/src/policy.test.ts +56 -1
  43. package/src/policy.ts +5 -1
  44. package/src/post.test.ts +105 -0
  45. package/src/post.ts +274 -0
  46. package/src/probe.test.ts +253 -0
  47. package/src/probe.ts +99 -7
  48. package/src/reply-dispatcher.test.ts +259 -0
  49. package/src/reply-dispatcher.ts +139 -45
  50. package/src/send.reply-fallback.test.ts +105 -0
  51. package/src/send.test.ts +168 -0
  52. package/src/send.ts +143 -18
  53. package/src/streaming-card.ts +131 -43
  54. package/src/targets.test.ts +26 -1
  55. package/src/targets.ts +11 -6
  56. package/src/tool-account-routing.test.ts +129 -0
  57. package/src/tool-account.ts +70 -0
  58. package/src/tool-factory-test-harness.ts +76 -0
  59. package/src/tools-config.test.ts +21 -0
  60. package/src/tools-config.ts +2 -1
  61. package/src/types.ts +1 -0
  62. package/src/typing.test.ts +144 -0
  63. package/src/typing.ts +140 -10
  64. 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";
@@ -72,7 +74,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
72
74
  }
73
75
 
74
76
  // --- 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.
77
+ // Cache display names by sender id (open_id/user_id) to avoid an API call on every message.
76
78
  const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
77
79
  const senderNameCache = new Map<string, { name: string; expireAt: number }>();
78
80
 
@@ -86,26 +88,40 @@ type SenderNameResult = {
86
88
  permissionError?: PermissionError;
87
89
  };
88
90
 
91
+ function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
92
+ const trimmed = senderId.trim();
93
+ if (trimmed.startsWith("ou_")) {
94
+ return "open_id";
95
+ }
96
+ if (trimmed.startsWith("on_")) {
97
+ return "union_id";
98
+ }
99
+ return "user_id";
100
+ }
101
+
89
102
  async function resolveFeishuSenderName(params: {
90
103
  account: ResolvedFeishuAccount;
91
- senderOpenId: string;
104
+ senderId: string;
92
105
  log: (...args: any[]) => void;
93
106
  }): Promise<SenderNameResult> {
94
- const { account, senderOpenId, log } = params;
107
+ const { account, senderId, log } = params;
95
108
  if (!account.configured) return {};
96
- if (!senderOpenId) return {};
97
109
 
98
- const cached = senderNameCache.get(senderOpenId);
110
+ const normalizedSenderId = senderId.trim();
111
+ if (!normalizedSenderId) return {};
112
+
113
+ const cached = senderNameCache.get(normalizedSenderId);
99
114
  const now = Date.now();
100
115
  if (cached && cached.expireAt > now) return { name: cached.name };
101
116
 
102
117
  try {
103
118
  const client = createFeishuClient(account);
119
+ const userIdType = resolveSenderLookupIdType(normalizedSenderId);
104
120
 
105
- // contact/v3/users/:user_id?user_id_type=open_id
121
+ // contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
106
122
  const res: any = await client.contact.user.get({
107
- path: { user_id: senderOpenId },
108
- params: { user_id_type: "open_id" },
123
+ path: { user_id: normalizedSenderId },
124
+ params: { user_id_type: userIdType },
109
125
  });
110
126
 
111
127
  const name: string | undefined =
@@ -115,7 +131,7 @@ async function resolveFeishuSenderName(params: {
115
131
  res?.data?.user?.en_name;
116
132
 
117
133
  if (name && typeof name === "string") {
118
- senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
134
+ senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
119
135
  return { name };
120
136
  }
121
137
 
@@ -129,7 +145,7 @@ async function resolveFeishuSenderName(params: {
129
145
  }
130
146
 
131
147
  // Best-effort. Don't fail message handling if name lookup fails.
132
- log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
148
+ log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
133
149
  return {};
134
150
  }
135
151
  }
@@ -152,6 +168,7 @@ export type FeishuMessageEvent = {
152
168
  chat_type: "p2p" | "group";
153
169
  message_type: string;
154
170
  content: string;
171
+ create_time?: string;
155
172
  mentions?: Array<{
156
173
  key: string;
157
174
  id: {
@@ -177,15 +194,40 @@ export type FeishuBotAddedEvent = {
177
194
  };
178
195
 
179
196
  function parseMessageContent(content: string, messageType: string): string {
197
+ if (messageType === "post") {
198
+ // Extract text content from rich text post
199
+ const { textContent } = parsePostContent(content);
200
+ return textContent;
201
+ }
202
+
180
203
  try {
181
204
  const parsed = JSON.parse(content);
182
205
  if (messageType === "text") {
183
206
  return parsed.text || "";
184
207
  }
185
- if (messageType === "post") {
186
- // Extract text content from rich text post
187
- const { textContent } = parsePostContent(content);
188
- return textContent;
208
+ if (messageType === "share_chat") {
209
+ // Preserve available summary text for merged/forwarded chat messages.
210
+ if (parsed && typeof parsed === "object") {
211
+ const share = parsed as {
212
+ body?: unknown;
213
+ summary?: unknown;
214
+ share_chat_id?: unknown;
215
+ };
216
+ if (typeof share.body === "string" && share.body.trim().length > 0) {
217
+ return share.body.trim();
218
+ }
219
+ if (typeof share.summary === "string" && share.summary.trim().length > 0) {
220
+ return share.summary.trim();
221
+ }
222
+ if (typeof share.share_chat_id === "string" && share.share_chat_id.trim().length > 0) {
223
+ return `[Forwarded message: ${share.share_chat_id.trim()}]`;
224
+ }
225
+ }
226
+ return "[Forwarded message]";
227
+ }
228
+ if (messageType === "merge_forward") {
229
+ // Return placeholder; actual content fetched asynchronously in handleFeishuMessage
230
+ return "[Merged and Forwarded Message - loading...]";
189
231
  }
190
232
  return content;
191
233
  } catch {
@@ -193,6 +235,109 @@ function parseMessageContent(content: string, messageType: string): string {
193
235
  }
194
236
  }
195
237
 
238
+ /**
239
+ * Parse merge_forward message content and fetch sub-messages.
240
+ * Returns formatted text content of all sub-messages.
241
+ */
242
+ function parseMergeForwardContent(params: {
243
+ content: string;
244
+ log?: (...args: any[]) => void;
245
+ }): string {
246
+ const { content, log } = params;
247
+ const maxMessages = 50;
248
+
249
+ // For merge_forward, the API returns all sub-messages in items array
250
+ // with upper_message_id pointing to the merge_forward message.
251
+ // The 'content' parameter here is actually the full API response items array as JSON.
252
+ log?.(`feishu: parsing merge_forward sub-messages from API response`);
253
+
254
+ let items: Array<{
255
+ message_id?: string;
256
+ msg_type?: string;
257
+ body?: { content?: string };
258
+ sender?: { id?: string };
259
+ upper_message_id?: string;
260
+ create_time?: string;
261
+ }>;
262
+
263
+ try {
264
+ items = JSON.parse(content);
265
+ } catch {
266
+ log?.(`feishu: merge_forward items parse failed`);
267
+ return "[Merged and Forwarded Message - parse error]";
268
+ }
269
+
270
+ if (!Array.isArray(items) || items.length === 0) {
271
+ return "[Merged and Forwarded Message - no sub-messages]";
272
+ }
273
+
274
+ // Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
275
+ const subMessages = items.filter((item) => item.upper_message_id);
276
+
277
+ if (subMessages.length === 0) {
278
+ return "[Merged and Forwarded Message - no sub-messages found]";
279
+ }
280
+
281
+ log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
282
+
283
+ // Sort by create_time
284
+ subMessages.sort((a, b) => {
285
+ const timeA = parseInt(a.create_time || "0", 10);
286
+ const timeB = parseInt(b.create_time || "0", 10);
287
+ return timeA - timeB;
288
+ });
289
+
290
+ // Format output
291
+ const lines: string[] = ["[Merged and Forwarded Messages]"];
292
+ const limitedMessages = subMessages.slice(0, maxMessages);
293
+
294
+ for (const item of limitedMessages) {
295
+ const msgContent = item.body?.content || "";
296
+ const msgType = item.msg_type || "text";
297
+ const formatted = formatSubMessageContent(msgContent, msgType);
298
+ lines.push(`- ${formatted}`);
299
+ }
300
+
301
+ if (subMessages.length > maxMessages) {
302
+ lines.push(`... and ${subMessages.length - maxMessages} more messages`);
303
+ }
304
+
305
+ return lines.join("\n");
306
+ }
307
+
308
+ /**
309
+ * Format sub-message content based on message type.
310
+ */
311
+ function formatSubMessageContent(content: string, contentType: string): string {
312
+ try {
313
+ const parsed = JSON.parse(content);
314
+ switch (contentType) {
315
+ case "text":
316
+ return parsed.text || content;
317
+ case "post": {
318
+ const { textContent } = parsePostContent(content);
319
+ return textContent;
320
+ }
321
+ case "image":
322
+ return "[Image]";
323
+ case "file":
324
+ return `[File: ${parsed.file_name || "unknown"}]`;
325
+ case "audio":
326
+ return "[Audio]";
327
+ case "video":
328
+ return "[Video]";
329
+ case "sticker":
330
+ return "[Sticker]";
331
+ case "merge_forward":
332
+ return "[Nested Merged Forward]";
333
+ default:
334
+ return `[${contentType}]`;
335
+ }
336
+ } catch {
337
+ return content;
338
+ }
339
+ }
340
+
196
341
  function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
197
342
  if (!botOpenId) return false;
198
343
  const mentions = event.message.mentions ?? [];
@@ -243,7 +388,8 @@ function parseMediaKeys(
243
388
  case "audio":
244
389
  return { fileKey };
245
390
  case "video":
246
- // Video has both file_key (video) and image_key (thumbnail)
391
+ case "media":
392
+ // Video/media has both file_key (video) and image_key (thumbnail)
247
393
  return { fileKey, imageKey };
248
394
  case "sticker":
249
395
  return { fileKey };
@@ -256,56 +402,11 @@ function parseMediaKeys(
256
402
  }
257
403
 
258
404
  /**
259
- * Parse post (rich text) content and extract embedded image keys.
260
- * Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
405
+ * Map Feishu message type to messageResource.get resource type.
406
+ * Feishu messageResource API supports only: image | file.
261
407
  */
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
- }
408
+ export function toMessageResourceType(messageType: string): "image" | "file" {
409
+ return messageType === "image" ? "image" : "file";
309
410
  }
310
411
 
311
412
  /**
@@ -320,6 +421,7 @@ function inferPlaceholder(messageType: string): string {
320
421
  case "audio":
321
422
  return "<media:audio>";
322
423
  case "video":
424
+ case "media":
323
425
  return "<media:video>";
324
426
  case "sticker":
325
427
  return "<media:sticker>";
@@ -344,7 +446,7 @@ async function resolveFeishuMediaList(params: {
344
446
  const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
345
447
 
346
448
  // Only process media message types (including post for embedded images)
347
- const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
449
+ const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
348
450
  if (!mediaTypes.includes(messageType)) {
349
451
  return [];
350
452
  }
@@ -352,14 +454,19 @@ async function resolveFeishuMediaList(params: {
352
454
  const out: FeishuMediaInfo[] = [];
353
455
  const core = getFeishuRuntime();
354
456
 
355
- // Handle post (rich text) messages with embedded images
457
+ // Handle post (rich text) messages with embedded images/media.
356
458
  if (messageType === "post") {
357
- const { imageKeys } = parsePostContent(content);
358
- if (imageKeys.length === 0) {
459
+ const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
460
+ if (imageKeys.length === 0 && postMediaKeys.length === 0) {
359
461
  return [];
360
462
  }
361
463
 
362
- log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
464
+ if (imageKeys.length > 0) {
465
+ log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
466
+ }
467
+ if (postMediaKeys.length > 0) {
468
+ log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(s)`);
469
+ }
363
470
 
364
471
  for (const imageKey of imageKeys) {
365
472
  try {
@@ -396,6 +503,40 @@ async function resolveFeishuMediaList(params: {
396
503
  }
397
504
  }
398
505
 
506
+ for (const media of postMediaKeys) {
507
+ try {
508
+ const result = await downloadMessageResourceFeishu({
509
+ cfg,
510
+ messageId,
511
+ fileKey: media.fileKey,
512
+ type: "file",
513
+ accountId,
514
+ });
515
+
516
+ let contentType = result.contentType;
517
+ if (!contentType) {
518
+ contentType = await core.media.detectMime({ buffer: result.buffer });
519
+ }
520
+
521
+ const saved = await core.channel.media.saveMediaBuffer(
522
+ result.buffer,
523
+ contentType,
524
+ "inbound",
525
+ maxBytes,
526
+ );
527
+
528
+ out.push({
529
+ path: saved.path,
530
+ contentType: saved.contentType,
531
+ placeholder: "<media:video>",
532
+ });
533
+
534
+ log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
535
+ } catch (err) {
536
+ log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
537
+ }
538
+ }
539
+
399
540
  return out;
400
541
  }
401
542
 
@@ -417,7 +558,7 @@ async function resolveFeishuMediaList(params: {
417
558
  return [];
418
559
  }
419
560
 
420
- const resourceType = messageType === "image" ? "image" : "file";
561
+ const resourceType = toMessageResourceType(messageType);
421
562
  const result = await downloadMessageResourceFeishu({
422
563
  cfg,
423
564
  messageId,
@@ -468,12 +609,17 @@ export function parseFeishuMessageEvent(
468
609
  const rawContent = parseMessageContent(event.message.content, event.message.message_type);
469
610
  const mentionedBot = checkBotMentioned(event, botOpenId);
470
611
  const content = stripBotMention(rawContent, event.message.mentions);
612
+ const senderOpenId = event.sender.sender_id.open_id?.trim();
613
+ const senderUserId = event.sender.sender_id.user_id?.trim();
614
+ const senderFallbackId = senderOpenId || senderUserId || "";
471
615
 
472
616
  const ctx: FeishuMessageContext = {
473
617
  chatId: event.message.chat_id,
474
618
  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 || "",
619
+ senderId: senderUserId || senderOpenId || "",
620
+ // Keep the historical field name, but fall back to user_id when open_id is unavailable
621
+ // (common in some mobile app deliveries).
622
+ senderOpenId: senderFallbackId,
477
623
  chatType: event.message.chat_type,
478
624
  mentionedBot,
479
625
  rootId: event.message.root_id || undefined,
@@ -496,6 +642,40 @@ export function parseFeishuMessageEvent(
496
642
  return ctx;
497
643
  }
498
644
 
645
+ export function buildFeishuAgentBody(params: {
646
+ ctx: Pick<
647
+ FeishuMessageContext,
648
+ "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
649
+ >;
650
+ quotedContent?: string;
651
+ permissionErrorForAgent?: PermissionError;
652
+ }): string {
653
+ const { ctx, quotedContent, permissionErrorForAgent } = params;
654
+ let messageBody = ctx.content;
655
+ if (quotedContent) {
656
+ messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
657
+ }
658
+
659
+ // DMs already have per-sender sessions, but this label still improves attribution.
660
+ const speaker = ctx.senderName ?? ctx.senderOpenId;
661
+ messageBody = `${speaker}: ${messageBody}`;
662
+
663
+ if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
664
+ const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
665
+ messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
666
+ }
667
+
668
+ // Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
669
+ messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
670
+
671
+ if (permissionErrorForAgent) {
672
+ const grantUrl = permissionErrorForAgent.grantUrl ?? "";
673
+ 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}]`;
674
+ }
675
+
676
+ return messageBody;
677
+ }
678
+
499
679
  export async function handleFeishuMessage(params: {
500
680
  cfg: ClawdbotConfig;
501
681
  event: FeishuMessageEvent;
@@ -513,8 +693,15 @@ export async function handleFeishuMessage(params: {
513
693
  const log = runtime?.log ?? console.log;
514
694
  const error = runtime?.error ?? console.error;
515
695
 
516
- // Dedup check: skip if this message was already processed (memory + disk).
696
+ // Dedup: synchronous memory guard prevents concurrent duplicate dispatch
697
+ // before the async persistent check completes.
517
698
  const messageId = event.message.message_id;
699
+ const memoryDedupeKey = `${account.accountId}:${messageId}`;
700
+ if (!tryRecordMessage(memoryDedupeKey)) {
701
+ log(`feishu: skipping duplicate message ${messageId} (memory dedup)`);
702
+ return;
703
+ }
704
+ // Persistent dedup survives restarts and reconnects.
518
705
  if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
519
706
  log(`feishu: skipping duplicate message ${messageId}`);
520
707
  return;
@@ -524,24 +711,59 @@ export async function handleFeishuMessage(params: {
524
711
  const isGroup = ctx.chatType === "group";
525
712
  const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
526
713
 
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 };
714
+ // Handle merge_forward messages: fetch full message via API then expand sub-messages
715
+ if (event.message.message_type === "merge_forward") {
716
+ log(
717
+ `feishu[${account.accountId}]: processing merge_forward message, fetching full content via API`,
718
+ );
719
+ try {
720
+ // Websocket event doesn't include sub-messages, need to fetch via API
721
+ // The API returns all sub-messages in the items array
722
+ const client = createFeishuClient(account);
723
+ const response = (await client.im.message.get({
724
+ path: { message_id: event.message.message_id },
725
+ })) as { code?: number; data?: { items?: unknown[] } };
726
+
727
+ if (response.code === 0 && response.data?.items && response.data.items.length > 0) {
728
+ log(
729
+ `feishu[${account.accountId}]: merge_forward API returned ${response.data.items.length} items`,
730
+ );
731
+ const expandedContent = parseMergeForwardContent({
732
+ content: JSON.stringify(response.data.items),
733
+ log,
734
+ });
735
+ ctx = { ...ctx, content: expandedContent };
736
+ } else {
737
+ log(`feishu[${account.accountId}]: merge_forward API returned no items`);
738
+ ctx = { ...ctx, content: "[Merged and Forwarded Message - could not fetch]" };
739
+ }
740
+ } catch (err) {
741
+ log(`feishu[${account.accountId}]: merge_forward fetch failed: ${String(err)}`);
742
+ ctx = { ...ctx, content: "[Merged and Forwarded Message - fetch error]" };
743
+ }
744
+ }
534
745
 
535
- // Track permission error to inform agent later (with cooldown to avoid repetition)
746
+ // Resolve sender display name (best-effort) so the agent can attribute messages correctly.
747
+ // Optimization: skip if disabled to save API quota (Feishu free tier limit).
536
748
  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;
749
+ if (feishuCfg?.resolveSenderNames ?? true) {
750
+ const senderResult = await resolveFeishuSenderName({
751
+ account,
752
+ senderId: ctx.senderOpenId,
753
+ log,
754
+ });
755
+ if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
756
+
757
+ // Track permission error to inform agent later (with cooldown to avoid repetition)
758
+ if (senderResult.permissionError) {
759
+ const appKey = account.appId ?? "default";
760
+ const now = Date.now();
761
+ const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
762
+
763
+ if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
764
+ permissionErrorNotifiedAt.set(appKey, now);
765
+ permissionErrorForAgent = senderResult.permissionError;
766
+ }
545
767
  }
546
768
  }
547
769
 
@@ -567,6 +789,10 @@ export async function handleFeishuMessage(params: {
567
789
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
568
790
 
569
791
  if (isGroup) {
792
+ if (groupConfig?.enabled === false) {
793
+ log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
794
+ return;
795
+ }
570
796
  const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
571
797
  const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
572
798
  providerConfigPresent: cfg.channels?.feishu !== undefined,
@@ -591,16 +817,21 @@ export async function handleFeishuMessage(params: {
591
817
  });
592
818
 
593
819
  if (!groupAllowed) {
594
- log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in group allowlist`);
820
+ log(
821
+ `feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
822
+ );
595
823
  return;
596
824
  }
597
825
 
598
- // Additional sender-level allowlist check if group has specific allowFrom config
599
- const senderAllowFrom = groupConfig?.allowFrom ?? [];
600
- if (senderAllowFrom.length > 0) {
826
+ // Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
827
+ const perGroupSenderAllowFrom = groupConfig?.allowFrom ?? [];
828
+ const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
829
+ const effectiveSenderAllowFrom =
830
+ perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
831
+ if (effectiveSenderAllowFrom.length > 0) {
601
832
  const senderAllowed = isFeishuGroupAllowed({
602
833
  groupPolicy: "allowlist",
603
- allowFrom: senderAllowFrom,
834
+ allowFrom: effectiveSenderAllowFrom,
604
835
  senderId: ctx.senderOpenId,
605
836
  senderIds: [senderUserId],
606
837
  senderName: ctx.senderName,
@@ -641,6 +872,11 @@ export async function handleFeishuMessage(params: {
641
872
 
642
873
  try {
643
874
  const core = getFeishuRuntime();
875
+ const pairing = createScopedPairingAccess({
876
+ core,
877
+ channel: "feishu",
878
+ accountId: account.accountId,
879
+ });
644
880
  const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
645
881
  ctx.content,
646
882
  cfg,
@@ -649,7 +885,7 @@ export async function handleFeishuMessage(params: {
649
885
  !isGroup &&
650
886
  dmPolicy !== "allowlist" &&
651
887
  (dmPolicy !== "open" || shouldComputeCommandAuthorized)
652
- ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
888
+ ? await pairing.readAllowFromStore().catch(() => [])
653
889
  : [];
654
890
  const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
655
891
  const dmAllowed = resolveFeishuAllowlistMatch({
@@ -661,8 +897,7 @@ export async function handleFeishuMessage(params: {
661
897
 
662
898
  if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
663
899
  if (dmPolicy === "pairing") {
664
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
665
- channel: "feishu",
900
+ const { code, created } = await pairing.upsertPairingRequest({
666
901
  id: ctx.senderOpenId,
667
902
  meta: { name: ctx.senderName },
668
903
  });
@@ -716,19 +951,49 @@ export async function handleFeishuMessage(params: {
716
951
  const feishuFrom = `feishu:${ctx.senderOpenId}`;
717
952
  const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
718
953
 
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.
954
+ // Resolve peer ID for session routing.
955
+ // Default is one session per group chat; this can be customized with groupSessionScope.
722
956
  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}`);
957
+ let groupSessionScope: "group" | "group_sender" | "group_topic" | "group_topic_sender" =
958
+ "group";
959
+ let topicRootForSession: string | null = null;
960
+ const replyInThread =
961
+ isGroup &&
962
+ (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
963
+
964
+ if (isGroup) {
965
+ const legacyTopicSessionMode =
966
+ groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
967
+ groupSessionScope =
968
+ groupConfig?.groupSessionScope ??
969
+ feishuCfg?.groupSessionScope ??
970
+ (legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
971
+
972
+ // When topic-scoped sessions are enabled and replyInThread is on, the first
973
+ // bot reply creates the thread rooted at the current message ID.
974
+ if (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender") {
975
+ topicRootForSession = ctx.rootId ?? (replyInThread ? ctx.messageId : null);
731
976
  }
977
+
978
+ switch (groupSessionScope) {
979
+ case "group_sender":
980
+ peerId = `${ctx.chatId}:sender:${ctx.senderOpenId}`;
981
+ break;
982
+ case "group_topic":
983
+ peerId = topicRootForSession ? `${ctx.chatId}:topic:${topicRootForSession}` : ctx.chatId;
984
+ break;
985
+ case "group_topic_sender":
986
+ peerId = topicRootForSession
987
+ ? `${ctx.chatId}:topic:${topicRootForSession}:sender:${ctx.senderOpenId}`
988
+ : `${ctx.chatId}:sender:${ctx.senderOpenId}`;
989
+ break;
990
+ case "group":
991
+ default:
992
+ peerId = ctx.chatId;
993
+ break;
994
+ }
995
+
996
+ log(`feishu[${account.accountId}]: group session scope=${groupSessionScope}, peer=${peerId}`);
732
997
  }
733
998
 
734
999
  let route = core.channel.routing.resolveAgentRoute({
@@ -739,9 +1004,11 @@ export async function handleFeishuMessage(params: {
739
1004
  kind: isGroup ? "group" : "direct",
740
1005
  id: peerId,
741
1006
  },
742
- // Add parentPeer for binding inheritance in topic mode
1007
+ // Add parentPeer for binding inheritance in topic-scoped modes.
743
1008
  parentPeer:
744
- isGroup && ctx.rootId && topicSessionMode === "enabled"
1009
+ isGroup &&
1010
+ topicRootForSession &&
1011
+ (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
745
1012
  ? {
746
1013
  kind: "group",
747
1014
  id: ctx.chatId,
@@ -784,10 +1051,10 @@ export async function handleFeishuMessage(params: {
784
1051
  ? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
785
1052
  : `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
786
1053
 
787
- core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
788
- sessionKey: route.sessionKey,
789
- contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
790
- });
1054
+ // Do not enqueue inbound user previews as system events.
1055
+ // System events are prepended to future prompts and can be misread as
1056
+ // authoritative transcript turns.
1057
+ log(`feishu[${account.accountId}]: ${inboundLabel}: ${preview}`);
791
1058
 
792
1059
  // Resolve media from message
793
1060
  const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
@@ -823,85 +1090,15 @@ export async function handleFeishuMessage(params: {
823
1090
  }
824
1091
 
825
1092
  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
-
1093
+ const messageBody = buildFeishuAgentBody({
1094
+ ctx,
1095
+ quotedContent,
1096
+ permissionErrorForAgent,
1097
+ });
844
1098
  const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
845
-
846
- // If there's a permission error, dispatch a separate notification first
847
1099
  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();
1100
+ // Keep the notice in a single dispatch to avoid duplicate replies (#27372).
1101
+ log(`feishu[${account.accountId}]: appending permission error notice to message body`);
905
1102
  }
906
1103
 
907
1104
  const body = core.channel.reply.formatAgentEnvelope({
@@ -944,8 +1141,12 @@ export async function handleFeishuMessage(params: {
944
1141
 
945
1142
  const ctxPayload = core.channel.reply.finalizeInboundContext({
946
1143
  Body: combinedBody,
947
- BodyForAgent: ctx.content,
1144
+ BodyForAgent: messageBody,
948
1145
  InboundHistory: inboundHistory,
1146
+ // Quote/reply message support: use standard ReplyToId for parent,
1147
+ // and pass root_id for thread reconstruction.
1148
+ ReplyToId: ctx.parentId,
1149
+ RootMessageId: ctx.rootId,
949
1150
  RawBody: ctx.content,
950
1151
  CommandBody: ctx.content,
951
1152
  From: feishuFrom,
@@ -968,27 +1169,40 @@ export async function handleFeishuMessage(params: {
968
1169
  ...mediaPayload,
969
1170
  });
970
1171
 
1172
+ // Parse message create_time (Feishu uses millisecond epoch string).
1173
+ const messageCreateTimeMs = event.message.create_time
1174
+ ? parseInt(event.message.create_time, 10)
1175
+ : undefined;
1176
+
971
1177
  const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
972
1178
  cfg,
973
1179
  agentId: route.agentId,
974
1180
  runtime: runtime as RuntimeEnv,
975
1181
  chatId: ctx.chatId,
976
1182
  replyToMessageId: ctx.messageId,
1183
+ skipReplyToInMessages: !isGroup,
1184
+ replyInThread,
1185
+ rootId: ctx.rootId,
977
1186
  mentionTargets: ctx.mentionTargets,
978
1187
  accountId: account.accountId,
1188
+ messageCreateTimeMs,
979
1189
  });
980
1190
 
981
1191
  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,
1192
+ const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
986
1193
  dispatcher,
987
- replyOptions,
1194
+ onSettled: () => {
1195
+ markDispatchIdle();
1196
+ },
1197
+ run: () =>
1198
+ core.channel.reply.dispatchReplyFromConfig({
1199
+ ctx: ctxPayload,
1200
+ cfg,
1201
+ dispatcher,
1202
+ replyOptions,
1203
+ }),
988
1204
  });
989
1205
 
990
- markDispatchIdle();
991
-
992
1206
  if (isGroup && historyKey && chatHistories) {
993
1207
  clearHistoryEntriesIfEnabled({
994
1208
  historyMap: chatHistories,