@m1heng-clawd/feishu 0.1.13 → 0.1.15

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.
package/src/bot.ts CHANGED
@@ -53,6 +53,7 @@ function extractFirstUrl(raw: string): string | undefined {
53
53
  return urlMatch?.[0];
54
54
  }
55
55
 
56
+
56
57
  function extractPermissionError(err: unknown): PermissionError | null {
57
58
  if (!err || typeof err !== "object") return null;
58
59
 
@@ -99,6 +100,137 @@ type SenderNameResult = {
99
100
  permissionError?: PermissionError;
100
101
  };
101
102
 
103
+ type FeishuPairingApiMode = "legacy" | "scoped";
104
+ let detectedFeishuPairingApiMode: FeishuPairingApiMode | null = null;
105
+
106
+ async function resolveFeishuPairingApiMode(params: {
107
+ readAllowFromStore: (...args: any[]) => Promise<Array<string | number>>;
108
+ }): Promise<FeishuPairingApiMode> {
109
+ if (detectedFeishuPairingApiMode) return detectedFeishuPairingApiMode;
110
+
111
+ let channelObserved = false;
112
+ const probe = new Proxy<Record<PropertyKey, unknown>>(
113
+ {},
114
+ {
115
+ get(_target, prop) {
116
+ if (prop === "channel") {
117
+ channelObserved = true;
118
+ return "__feishu_pairing_probe__";
119
+ }
120
+ if (prop === "accountId") return "__feishu_pairing_probe__";
121
+ if (prop === "env") return undefined;
122
+ if (prop === Symbol.toPrimitive) return () => "__feishu_pairing_probe__";
123
+ if (prop === "toString") return () => "__feishu_pairing_probe__";
124
+ return undefined;
125
+ },
126
+ has(_target, prop) {
127
+ if (prop === "channel") {
128
+ channelObserved = true;
129
+ return true;
130
+ }
131
+ return false;
132
+ },
133
+ },
134
+ );
135
+
136
+ try {
137
+ // Probe once to detect whether runtime reads object-style params (scoped API)
138
+ // or positional params (legacy API).
139
+ await params.readAllowFromStore(probe as any, undefined, "__feishu_pairing_probe__");
140
+ } catch {
141
+ // Ignore probe errors; fallback below still keeps behavior safe.
142
+ }
143
+
144
+ detectedFeishuPairingApiMode = channelObserved ? "scoped" : "legacy";
145
+ return detectedFeishuPairingApiMode;
146
+ }
147
+
148
+ async function readFeishuPairingAllowFrom(params: {
149
+ core: ReturnType<typeof getFeishuRuntime>;
150
+ accountId: string;
151
+ }): Promise<Array<string | number>> {
152
+ const pairing = params.core.channel.pairing as {
153
+ readAllowFromStore: (...args: any[]) => Promise<Array<string | number>>;
154
+ };
155
+ const pairingMode = await resolveFeishuPairingApiMode({
156
+ readAllowFromStore: pairing.readAllowFromStore,
157
+ });
158
+
159
+ if (pairingMode === "scoped") {
160
+ try {
161
+ return await pairing.readAllowFromStore({
162
+ channel: "feishu",
163
+ accountId: params.accountId,
164
+ });
165
+ } catch {
166
+ // Compatibility fallback for older OpenClaw implementations.
167
+ return await pairing.readAllowFromStore("feishu", undefined, params.accountId);
168
+ }
169
+ }
170
+
171
+ try {
172
+ // OpenClaw <= 2026.2.19 signature: readAllowFromStore(channel, env?, accountId?)
173
+ return await pairing.readAllowFromStore("feishu", undefined, params.accountId);
174
+ } catch {
175
+ // Compatibility fallback for newer OpenClaw implementations.
176
+ return await pairing.readAllowFromStore({
177
+ channel: "feishu",
178
+ accountId: params.accountId,
179
+ });
180
+ }
181
+ }
182
+
183
+ async function upsertFeishuPairingRequest(params: {
184
+ core: ReturnType<typeof getFeishuRuntime>;
185
+ accountId: string;
186
+ senderId: string;
187
+ senderName?: string;
188
+ }): Promise<{ code: string; created: boolean }> {
189
+ const pairing = params.core.channel.pairing as {
190
+ upsertPairingRequest: (...args: any[]) => Promise<{ code: string; created: boolean }>;
191
+ };
192
+ const meta = params.senderName ? { name: params.senderName } : undefined;
193
+ const pairingMode = await resolveFeishuPairingApiMode({
194
+ readAllowFromStore: params.core.channel.pairing.readAllowFromStore as (...args: any[]) => Promise<
195
+ Array<string | number>
196
+ >,
197
+ });
198
+
199
+ if (pairingMode === "scoped") {
200
+ try {
201
+ return await pairing.upsertPairingRequest({
202
+ channel: "feishu",
203
+ id: params.senderId,
204
+ accountId: params.accountId,
205
+ meta,
206
+ });
207
+ } catch {
208
+ // Compatibility fallback for older OpenClaw implementations.
209
+ return await pairing.upsertPairingRequest({
210
+ channel: "feishu",
211
+ id: params.senderId,
212
+ meta,
213
+ });
214
+ }
215
+ }
216
+
217
+ try {
218
+ return await pairing.upsertPairingRequest({
219
+ channel: "feishu",
220
+ id: params.senderId,
221
+ meta,
222
+ });
223
+ } catch {
224
+ // Compatibility fallback for newer OpenClaw implementations.
225
+ return await pairing.upsertPairingRequest({
226
+ channel: "feishu",
227
+ id: params.senderId,
228
+ accountId: params.accountId,
229
+ meta,
230
+ });
231
+ }
232
+ }
233
+
102
234
  async function resolveFeishuSenderName(params: {
103
235
  account: ResolvedFeishuAccount;
104
236
  senderOpenId: string;
@@ -123,9 +255,7 @@ async function resolveFeishuSenderName(params: {
123
255
 
124
256
  const name: string | undefined =
125
257
  res?.data?.user?.name ||
126
- res?.data?.user?.display_name ||
127
- res?.data?.user?.nickname ||
128
- res?.data?.user?.en_name;
258
+ res?.data?.user?.nickname;
129
259
 
130
260
  if (name && typeof name === "string") {
131
261
  senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
@@ -150,6 +280,67 @@ async function resolveFeishuSenderName(params: {
150
280
  // Cache group bot counts for command mention bypass policy checks.
151
281
  const GROUP_BOT_COUNT_TTL_MS = 10 * 60 * 1000;
152
282
  const groupBotCountCache = new Map<string, { count: number; expireAt: number }>();
283
+ // Fallback flush only for standalone forwarded messages.
284
+ // Quote-reply merging should rely on parent_id relation instead of time windows.
285
+ const FORWARDED_COALESCE_WINDOW_MS = 1500;
286
+ const FORWARDED_COMPANION_TTL_MS = 20 * 1000;
287
+ type PendingForwardedDispatchEntry = {
288
+ token: string;
289
+ timer: ReturnType<typeof setTimeout>;
290
+ content: string;
291
+ messageKeys: Set<string>;
292
+ };
293
+ const pendingForwardedDispatch = new Map<string, PendingForwardedDispatchEntry>();
294
+ const pendingForwardedDispatchByMessageId = new Map<string, string>();
295
+ const recentForwardedCompanionReplies = new Map<string, number>();
296
+
297
+ function getForwardedKey(params: {
298
+ accountId: string;
299
+ chatId: string;
300
+ forwardedMessageId: string;
301
+ }): string {
302
+ return `${params.accountId}:${params.chatId}:${params.forwardedMessageId}`;
303
+ }
304
+
305
+ function clearPendingForwardedDispatchEntry(dispatchKey: string, entry: PendingForwardedDispatchEntry): void {
306
+ clearTimeout(entry.timer);
307
+ pendingForwardedDispatch.delete(dispatchKey);
308
+ for (const key of entry.messageKeys) {
309
+ pendingForwardedDispatchByMessageId.delete(key);
310
+ }
311
+ }
312
+
313
+ function markForwardedCompanionReply(params: {
314
+ accountId: string;
315
+ chatId: string;
316
+ forwardedMessageId: string;
317
+ }): void {
318
+ const now = Date.now();
319
+ for (const [key, expireAt] of recentForwardedCompanionReplies) {
320
+ if (expireAt <= now) recentForwardedCompanionReplies.delete(key);
321
+ }
322
+ recentForwardedCompanionReplies.set(
323
+ getForwardedKey(params),
324
+ now + FORWARDED_COMPANION_TTL_MS,
325
+ );
326
+ }
327
+
328
+ function consumeForwardedCompanionReply(params: {
329
+ accountId: string;
330
+ chatId: string;
331
+ forwardedMessageId: string;
332
+ }): boolean {
333
+ const key = getForwardedKey(params);
334
+ const now = Date.now();
335
+ const expireAt = recentForwardedCompanionReplies.get(key);
336
+ if (!expireAt) return false;
337
+ if (expireAt <= now) {
338
+ recentForwardedCompanionReplies.delete(key);
339
+ return false;
340
+ }
341
+ recentForwardedCompanionReplies.delete(key);
342
+ return true;
343
+ }
153
344
 
154
345
  async function resolveFeishuGroupBotCount(params: {
155
346
  account: ResolvedFeishuAccount;
@@ -196,7 +387,8 @@ export type FeishuMessageEvent = {
196
387
  root_id?: string;
197
388
  parent_id?: string;
198
389
  chat_id: string;
199
- chat_type: "p2p" | "group";
390
+ chat_type: "p2p" | "group" | "private";
391
+ create_time?: string;
200
392
  message_type: string;
201
393
  content: string;
202
394
  mentions?: Array<{
@@ -223,21 +415,89 @@ export type FeishuBotAddedEvent = {
223
415
  operator_tenant_key?: string;
224
416
  };
225
417
 
226
- function parseMessageContent(content: string, messageType: string): string {
418
+ function isForwardedMessageType(messageType: string): boolean {
419
+ return messageType === "forwarded" || messageType === "merged_forwarded" || messageType === "merge_forward";
420
+ }
421
+
422
+ type ParsedFeishuMessageContent = {
423
+ text: string;
424
+ placeholder?: string;
425
+ rawPayload?: string;
426
+ };
427
+
428
+ function parseMessageContent(content: string, messageType: string): ParsedFeishuMessageContent {
227
429
  try {
228
430
  const parsed = JSON.parse(content);
229
- if (messageType === "text") {
230
- return parsed.text || "";
431
+ const normalizedMessageType = normalizeFeishuInboundMessageType(messageType);
432
+ if (normalizedMessageType === "text") {
433
+ return { text: parsed.text || "", rawPayload: content };
231
434
  }
232
- if (messageType === "post") {
435
+ if (normalizedMessageType === "post") {
233
436
  // Extract text content from rich text post
234
437
  const { textContent } = parsePostContent(content);
235
- return textContent;
438
+ return { text: textContent, rawPayload: content };
439
+ }
440
+ if (["image", "file", "audio", "video", "sticker"].includes(normalizedMessageType)) {
441
+ const ph = inferPlaceholder(normalizedMessageType);
442
+ return { text: ph, placeholder: ph, rawPayload: content };
443
+ }
444
+ // Handle forwarded messages (single message forwarded)
445
+ if (messageType === "forwarded") {
446
+ const senderName = parsed.sender_name || parsed.sender?.name || "unknown";
447
+ const text = parsed.text || parsed.title || "";
448
+ const forwarded = text ? `[Forwarded from ${senderName}]: ${text.substring(0, 100)}` : `[Forwarded from ${senderName}]`;
449
+ return { text: forwarded, rawPayload: content };
236
450
  }
237
- return content;
451
+ // Handle merged_forwarded messages (multiple messages merged)
452
+ // Note: Feishu uses "merge_forward" for merged forwarded messages
453
+ if (messageType === "merged_forwarded" || messageType === "merge_forward") {
454
+ const messageCount = parsed.message_count || parsed.messages?.length || 0;
455
+ const senderNames = parsed.messages
456
+ ?.map((m: any) => m.sender_name || m.sender?.name)
457
+ .filter(Boolean)
458
+ .join(", ") || "unknown";
459
+ // Extract preview of first few messages
460
+ let preview = "";
461
+ if (parsed.messages && parsed.messages.length > 0) {
462
+ const previews = parsed.messages
463
+ .slice(0, 3)
464
+ .map((m: any) => m.text || m.title || "")
465
+ .filter(Boolean)
466
+ .map((t: string) => t.substring(0, 50));
467
+ if (previews.length > 0) {
468
+ preview = `\n└ ${previews.join("\n└ ")}`;
469
+ }
470
+ }
471
+ return { text: `[Merged forward (${messageCount} messages), from: ${senderNames}]${preview}`, rawPayload: content };
472
+ }
473
+ return { text: content, rawPayload: content };
238
474
  } catch {
239
- return content;
475
+ return { text: content, rawPayload: content };
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Feishu may use "media" for inbound video messages.
481
+ * Normalize to "video" so downstream media handling is consistent.
482
+ */
483
+ export function normalizeFeishuInboundMessageType(messageType: string): string {
484
+ return messageType === "media" ? "video" : messageType;
485
+ }
486
+
487
+ /**
488
+ * Select the correct resource key for messageResource download.
489
+ * - image message: prefer image_key
490
+ * - other media (video/audio/file/sticker): prefer file_key
491
+ */
492
+ export function selectFeishuMessageResourceKey(
493
+ messageType: string,
494
+ keys: { imageKey?: string; fileKey?: string },
495
+ ): string | undefined {
496
+ const normalized = normalizeFeishuInboundMessageType(messageType);
497
+ if (normalized === "image") {
498
+ return keys.imageKey ?? keys.fileKey;
240
499
  }
500
+ return keys.fileKey ?? keys.imageKey;
241
501
  }
242
502
 
243
503
  function checkBotMentioned(
@@ -288,6 +548,7 @@ function parseMediaKeys(
288
548
  case "audio":
289
549
  return { fileKey: parsed.file_key };
290
550
  case "video":
551
+ case "media":
291
552
  // Video has both file_key (video) and image_key (thumbnail)
292
553
  return { fileKey: parsed.file_key, imageKey: parsed.image_key };
293
554
  case "sticker":
@@ -342,12 +603,33 @@ function parsePostContent(content: string): {
342
603
  }
343
604
 
344
605
  return {
345
- textContent: textContent.trim() || "[富文本消息]",
606
+ textContent: textContent.trim() || "[Rich text message]",
346
607
  imageKeys,
347
608
  mentionIds,
348
609
  };
349
610
  } catch {
350
- return { textContent: "[富文本消息]", imageKeys: [], mentionIds: [] };
611
+ return { textContent: "[Rich text message]", imageKeys: [], mentionIds: [] };
612
+ }
613
+ }
614
+
615
+ function isGenericMimeType(mime?: string): boolean {
616
+ if (!mime) return true;
617
+ const normalized = mime.split(";")[0]?.trim().toLowerCase();
618
+ return !normalized || normalized === "application/octet-stream";
619
+ }
620
+
621
+ function resolveFallbackMimeType(messageType: string): string | undefined {
622
+ switch (messageType) {
623
+ case "audio":
624
+ // Feishu voice messages are typically opus in ogg container.
625
+ return "audio/ogg; codecs=opus";
626
+ case "video":
627
+ return "video/mp4";
628
+ case "image":
629
+ case "post":
630
+ return "image/jpeg";
631
+ default:
632
+ return undefined;
351
633
  }
352
634
  }
353
635
 
@@ -363,6 +645,7 @@ function inferPlaceholder(messageType: string): string {
363
645
  case "audio":
364
646
  return "<media:audio>";
365
647
  case "video":
648
+ case "media":
366
649
  return "<media:video>";
367
650
  case "sticker":
368
651
  return "<media:sticker>";
@@ -385,10 +668,11 @@ async function resolveFeishuMediaList(params: {
385
668
  accountId?: string;
386
669
  }): Promise<FeishuMediaInfo[]> {
387
670
  const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
671
+ const normalizedMessageType = normalizeFeishuInboundMessageType(messageType);
388
672
 
389
673
  // Only process media message types (including post for embedded images)
390
674
  const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
391
- if (!mediaTypes.includes(messageType)) {
675
+ if (!mediaTypes.includes(normalizedMessageType)) {
392
676
  return [];
393
677
  }
394
678
 
@@ -396,7 +680,7 @@ async function resolveFeishuMediaList(params: {
396
680
  const core = getFeishuRuntime();
397
681
 
398
682
  // Handle post (rich text) messages with embedded images
399
- if (messageType === "post") {
683
+ if (normalizedMessageType === "post") {
400
684
  const { imageKeys } = parsePostContent(content);
401
685
  if (imageKeys.length === 0) {
402
686
  return [];
@@ -416,8 +700,12 @@ async function resolveFeishuMediaList(params: {
416
700
  });
417
701
 
418
702
  let contentType = result.contentType;
419
- if (!contentType) {
420
- contentType = await core.media.detectMime({ buffer: result.buffer });
703
+ if (!contentType || isGenericMimeType(contentType)) {
704
+ contentType =
705
+ (await core.media.detectMime({
706
+ buffer: result.buffer,
707
+ headerMime: contentType,
708
+ })) ?? resolveFallbackMimeType("image");
421
709
  }
422
710
 
423
711
  const saved = await core.channel.media.saveMediaBuffer(
@@ -443,7 +731,7 @@ async function resolveFeishuMediaList(params: {
443
731
  }
444
732
 
445
733
  // Handle other media types
446
- const mediaKeys = parseMediaKeys(content, messageType);
734
+ const mediaKeys = parseMediaKeys(content, normalizedMessageType);
447
735
  if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
448
736
  return [];
449
737
  }
@@ -455,12 +743,12 @@ async function resolveFeishuMediaList(params: {
455
743
 
456
744
  // For message media, always use messageResource API
457
745
  // The image.get API is only for images uploaded via im/v1/images, not for message attachments
458
- const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
746
+ const fileKey = selectFeishuMessageResourceKey(normalizedMessageType, mediaKeys);
459
747
  if (!fileKey) {
460
748
  return [];
461
749
  }
462
750
 
463
- const resourceType = messageType === "image" ? "image" : "file";
751
+ const resourceType = normalizedMessageType === "image" ? "image" : "file";
464
752
  const result = await downloadMessageResourceFeishu({
465
753
  cfg,
466
754
  messageId,
@@ -472,9 +760,17 @@ async function resolveFeishuMediaList(params: {
472
760
  contentType = result.contentType;
473
761
  fileName = result.fileName || mediaKeys.fileName;
474
762
 
475
- // Detect mime type if not provided
476
- if (!contentType) {
477
- contentType = await core.media.detectMime({ buffer });
763
+ // Detect MIME type and apply message-type fallback when needed.
764
+ if (!contentType || isGenericMimeType(contentType)) {
765
+ contentType =
766
+ (await core.media.detectMime({
767
+ buffer,
768
+ headerMime: contentType,
769
+ filePath: fileName,
770
+ })) ?? resolveFallbackMimeType(normalizedMessageType);
771
+ }
772
+ if (isGenericMimeType(contentType)) {
773
+ contentType = resolveFallbackMimeType(normalizedMessageType) ?? contentType;
478
774
  }
479
775
 
480
776
  // Save to disk using core's saveMediaBuffer
@@ -489,12 +785,12 @@ async function resolveFeishuMediaList(params: {
489
785
  out.push({
490
786
  path: saved.path,
491
787
  contentType: saved.contentType,
492
- placeholder: inferPlaceholder(messageType),
788
+ placeholder: inferPlaceholder(normalizedMessageType),
493
789
  });
494
790
 
495
- log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
791
+ log?.(`feishu: downloaded ${normalizedMessageType} media, saved to ${saved.path}`);
496
792
  } catch (err) {
497
- log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
793
+ log?.(`feishu: failed to download ${normalizedMessageType} media: ${String(err)}`);
498
794
  }
499
795
 
500
796
  return out;
@@ -527,17 +823,44 @@ function buildFeishuMediaPayload(
527
823
  };
528
824
  }
529
825
 
826
+ function skipGroupMessageForMentionGate(params: {
827
+ accountId: string;
828
+ chatId: string;
829
+ senderOpenId: string;
830
+ senderName?: string;
831
+ content: string;
832
+ messageId: string;
833
+ historyLimit: number;
834
+ chatHistories?: Map<string, HistoryEntry[]>;
835
+ log: (...args: any[]) => void;
836
+ }): void {
837
+ params.log(`feishu[${params.accountId}]: message in group ${params.chatId} skipped (mention required)`);
838
+ if (params.chatHistories) {
839
+ recordPendingHistoryEntryIfEnabled({
840
+ historyMap: params.chatHistories,
841
+ historyKey: params.chatId,
842
+ limit: params.historyLimit,
843
+ entry: {
844
+ sender: params.senderOpenId,
845
+ body: `${params.senderName ?? params.senderOpenId}: ${params.content}`,
846
+ timestamp: Date.now(),
847
+ messageId: params.messageId,
848
+ },
849
+ });
850
+ }
851
+ }
852
+
530
853
  export function parseFeishuMessageEvent(
531
854
  event: FeishuMessageEvent,
532
855
  botOpenId?: string,
533
856
  ): FeishuMessageContext {
534
857
  const parsedPost =
535
858
  event.message.message_type === "post" ? parsePostContent(event.message.content) : undefined;
536
- const rawContent = parseMessageContent(event.message.content, event.message.message_type);
859
+ const parsedContent = parseMessageContent(event.message.content, event.message.message_type);
537
860
  const mentionedBot = checkBotMentioned(event, botOpenId, parsedPost?.mentionIds ?? []);
538
861
  const hasAnyMention =
539
862
  (event.message.mentions?.length ?? 0) > 0 || (parsedPost?.mentionIds.length ?? 0) > 0;
540
- const content = stripBotMention(rawContent, event.message.mentions);
863
+ const content = stripBotMention(parsedContent.text, event.message.mentions);
541
864
 
542
865
  const ctx: FeishuMessageContext = {
543
866
  chatId: event.message.chat_id,
@@ -550,6 +873,8 @@ export function parseFeishuMessageEvent(
550
873
  parentId: event.message.parent_id || undefined,
551
874
  content,
552
875
  contentType: event.message.message_type,
876
+ placeholder: parsedContent.placeholder,
877
+ rawPayload: parsedContent.rawPayload,
553
878
  hasAnyMention,
554
879
  };
555
880
 
@@ -574,8 +899,9 @@ export async function handleFeishuMessage(params: {
574
899
  runtime?: RuntimeEnv;
575
900
  chatHistories?: Map<string, HistoryEntry[]>;
576
901
  accountId?: string;
902
+ replyToMessageIdOverride?: string;
577
903
  }): Promise<void> {
578
- const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
904
+ const { cfg, event, botOpenId, runtime, chatHistories, accountId, replyToMessageIdOverride } = params;
579
905
 
580
906
  // Resolve account with merged config
581
907
  const account = resolveFeishuAccount({ cfg, accountId });
@@ -593,6 +919,144 @@ export async function handleFeishuMessage(params: {
593
919
  }
594
920
 
595
921
  let ctx = parseFeishuMessageEvent(event, botOpenId);
922
+
923
+ // Parse message create_time (Feishu uses millisecond epoch string).
924
+ const messageCreateTimeMs = event.message.create_time
925
+ ? parseInt(event.message.create_time, 10)
926
+ : undefined;
927
+
928
+ const messageType = event.message.message_type;
929
+ const isForwardedInbound = isForwardedMessageType(messageType);
930
+ const forwardedDispatchKey = getForwardedKey({
931
+ accountId: account.accountId,
932
+ chatId: ctx.chatId,
933
+ forwardedMessageId: ctx.messageId,
934
+ });
935
+ if (!isForwardedInbound && ctx.parentId) {
936
+ markForwardedCompanionReply({
937
+ accountId: account.accountId,
938
+ chatId: ctx.chatId,
939
+ forwardedMessageId: ctx.parentId,
940
+ });
941
+ }
942
+
943
+ // For forwarded/merged_forwarded messages, fetch full message details via API
944
+ // Note: Feishu uses "merge_forward" for merged forwarded messages
945
+ if (isForwardedInbound) {
946
+ if (
947
+ consumeForwardedCompanionReply({
948
+ accountId: account.accountId,
949
+ chatId: ctx.chatId,
950
+ forwardedMessageId: ctx.messageId,
951
+ })
952
+ ) {
953
+ log(
954
+ `feishu[${account.accountId}]: skipping companion forwarded message ${ctx.messageId} (covered by parent reply)`,
955
+ );
956
+ return;
957
+ }
958
+ try {
959
+ const fullMessage = await getMessageFeishu({
960
+ cfg,
961
+ messageId,
962
+ accountId: account.accountId,
963
+ });
964
+ if (fullMessage?.content) {
965
+ // Use the already-parsed content from getMessageFeishu (includes all child messages for merge_forward)
966
+ ctx = { ...ctx, content: fullMessage.content };
967
+ }
968
+ } catch (err) {
969
+ log(`feishu: failed to fetch forwarded message details: ${err}`);
970
+ }
971
+ // Handle race: parent-reply may arrive and be processed while we're awaiting getMessageFeishu above.
972
+ // In that case, do not enqueue deferred forwarded flush again.
973
+ if (
974
+ consumeForwardedCompanionReply({
975
+ accountId: account.accountId,
976
+ chatId: ctx.chatId,
977
+ forwardedMessageId: ctx.messageId,
978
+ })
979
+ ) {
980
+ log(
981
+ `feishu[${account.accountId}]: skipping companion forwarded message ${ctx.messageId} (covered during fetch)`,
982
+ );
983
+ return;
984
+ }
985
+ const existing = pendingForwardedDispatch.get(forwardedDispatchKey);
986
+ if (existing) clearPendingForwardedDispatchEntry(forwardedDispatchKey, existing);
987
+ const mergedMessageKeys = new Set<string>([forwardedDispatchKey]);
988
+ const token = `${ctx.messageId}:${Date.now()}`;
989
+ const deferredMessageIdBase = ctx.messageId;
990
+ const timer = setTimeout(() => {
991
+ const current = pendingForwardedDispatch.get(forwardedDispatchKey);
992
+ if (!current || current.token !== token) return;
993
+ clearPendingForwardedDispatchEntry(forwardedDispatchKey, current);
994
+
995
+ const syntheticEvent: FeishuMessageEvent = {
996
+ ...event,
997
+ message: {
998
+ ...event.message,
999
+ message_id: `${deferredMessageIdBase}:forwarded-flush:${Date.now()}`,
1000
+ message_type: "text",
1001
+ content: JSON.stringify({ text: current.content }),
1002
+ },
1003
+ };
1004
+
1005
+ void handleFeishuMessage({
1006
+ cfg,
1007
+ event: syntheticEvent,
1008
+ botOpenId,
1009
+ runtime,
1010
+ chatHistories,
1011
+ accountId: account.accountId,
1012
+ replyToMessageIdOverride: deferredMessageIdBase,
1013
+ }).catch((err) => {
1014
+ error(`feishu[${account.accountId}]: failed deferred forwarded dispatch: ${String(err)}`);
1015
+ });
1016
+ }, FORWARDED_COALESCE_WINDOW_MS);
1017
+
1018
+ pendingForwardedDispatch.set(forwardedDispatchKey, {
1019
+ token,
1020
+ timer,
1021
+ content: ctx.content,
1022
+ messageKeys: mergedMessageKeys,
1023
+ });
1024
+ for (const messageKey of mergedMessageKeys) {
1025
+ pendingForwardedDispatchByMessageId.set(messageKey, forwardedDispatchKey);
1026
+ }
1027
+ log(
1028
+ `feishu[${account.accountId}]: queued forwarded message ${ctx.messageId} for deferred dispatch`,
1029
+ );
1030
+ return;
1031
+ } else {
1032
+ let pending: PendingForwardedDispatchEntry | undefined;
1033
+ let pendingDispatchKey: string | undefined;
1034
+ if (ctx.parentId) {
1035
+ const parentMessageKey = getForwardedKey({
1036
+ accountId: account.accountId,
1037
+ chatId: ctx.chatId,
1038
+ forwardedMessageId: ctx.parentId,
1039
+ });
1040
+ const parentDispatchKey = pendingForwardedDispatchByMessageId.get(parentMessageKey);
1041
+ if (parentDispatchKey) {
1042
+ pendingDispatchKey = parentDispatchKey;
1043
+ pending = pendingForwardedDispatch.get(parentDispatchKey);
1044
+ } else {
1045
+ pendingForwardedDispatchByMessageId.delete(parentMessageKey);
1046
+ }
1047
+ }
1048
+ if (pending && pendingDispatchKey) {
1049
+ clearPendingForwardedDispatchEntry(pendingDispatchKey, pending);
1050
+ ctx = {
1051
+ ...ctx,
1052
+ content: `${pending.content}\n\n---\n\n${ctx.content}`,
1053
+ };
1054
+ log(
1055
+ `feishu[${account.accountId}]: merged pending forwarded context into follow-up ${ctx.messageId}`,
1056
+ );
1057
+ }
1058
+ }
1059
+
596
1060
  const isGroup = ctx.chatType === "group";
597
1061
 
598
1062
  // Resolve sender display name (best-effort) so the agent can attribute messages correctly.
@@ -648,7 +1112,10 @@ export async function handleFeishuMessage(params: {
648
1112
  const hasControlCommand = core.channel.text.hasControlCommand(ctx.content, cfg);
649
1113
  const storeAllowFrom =
650
1114
  !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
651
- ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
1115
+ ? await readFeishuPairingAllowFrom({
1116
+ core,
1117
+ accountId: account.accountId,
1118
+ }).catch(() => [])
652
1119
  : [];
653
1120
  const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
654
1121
  const dmAllowed = resolveFeishuAllowlistMatch({
@@ -659,17 +1126,18 @@ export async function handleFeishuMessage(params: {
659
1126
 
660
1127
  if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
661
1128
  if (dmPolicy === "pairing") {
662
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
663
- channel: "feishu",
664
- id: ctx.senderOpenId,
665
- meta: { name: ctx.senderName },
1129
+ const { code, created } = await upsertFeishuPairingRequest({
1130
+ core,
1131
+ accountId: account.accountId,
1132
+ senderId: ctx.senderOpenId,
1133
+ senderName: ctx.senderName,
666
1134
  });
667
1135
  if (created) {
668
1136
  log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
669
1137
  try {
670
1138
  await sendMessageFeishu({
671
1139
  cfg,
672
- to: `user:${ctx.senderOpenId}`,
1140
+ to: `chat:${ctx.chatId}`,
673
1141
  text: core.channel.pairing.buildPairingReply({
674
1142
  channel: "feishu",
675
1143
  idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
@@ -748,7 +1216,7 @@ export async function handleFeishuMessage(params: {
748
1216
  }
749
1217
  }
750
1218
 
751
- const { requireMention } = resolveFeishuReplyPolicy({
1219
+ const { requireMention, allowMentionlessInMultiBotGroup } = resolveFeishuReplyPolicy({
752
1220
  isDirectMessage: false,
753
1221
  globalConfig: feishuCfg,
754
1222
  groupConfig,
@@ -788,22 +1256,49 @@ export async function handleFeishuMessage(params: {
788
1256
  effectiveWasMentioned = mentionGate.effectiveWasMentioned;
789
1257
 
790
1258
  if (mentionGate.shouldSkip) {
791
- log(
792
- `feishu[${account.accountId}]: message in group ${ctx.chatId} skipped (mention required)`,
793
- );
794
- if (chatHistories) {
795
- recordPendingHistoryEntryIfEnabled({
796
- historyMap: chatHistories,
797
- historyKey: ctx.chatId,
798
- limit: historyLimit,
799
- entry: {
800
- sender: ctx.senderOpenId,
801
- body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
802
- timestamp: Date.now(),
803
- messageId: ctx.messageId,
804
- },
805
- });
1259
+ skipGroupMessageForMentionGate({
1260
+ accountId: account.accountId,
1261
+ chatId: ctx.chatId,
1262
+ senderOpenId: ctx.senderOpenId,
1263
+ senderName: ctx.senderName,
1264
+ content: ctx.content,
1265
+ messageId: ctx.messageId,
1266
+ historyLimit,
1267
+ chatHistories,
1268
+ log,
1269
+ });
1270
+ return;
1271
+ }
1272
+ } else if (!ctx.mentionedBot && !allowMentionlessInMultiBotGroup) {
1273
+ // Safety default for mention-free mode: only allow non-@ traffic when the group has <= 1 bot.
1274
+ // In multi-bot groups, explicit @ is required unless allowMentionlessInMultiBotGroup=true.
1275
+ const botCount = await resolveFeishuGroupBotCount({
1276
+ account,
1277
+ chatId: ctx.chatId,
1278
+ log,
1279
+ });
1280
+ const singleBotGroup = botCount !== undefined && botCount <= 1;
1281
+ if (!singleBotGroup) {
1282
+ if (botCount === undefined) {
1283
+ log(
1284
+ `feishu[${account.accountId}]: unable to resolve bot_count for ${ctx.chatId}, mention-free mode disabled for safety`,
1285
+ );
1286
+ } else {
1287
+ log(
1288
+ `feishu[${account.accountId}]: group ${ctx.chatId} has ${botCount} bots, explicit @ required unless allowMentionlessInMultiBotGroup=true`,
1289
+ );
806
1290
  }
1291
+ skipGroupMessageForMentionGate({
1292
+ accountId: account.accountId,
1293
+ chatId: ctx.chatId,
1294
+ senderOpenId: ctx.senderOpenId,
1295
+ senderName: ctx.senderName,
1296
+ content: ctx.content,
1297
+ messageId: ctx.messageId,
1298
+ historyLimit,
1299
+ chatHistories,
1300
+ log,
1301
+ });
807
1302
  return;
808
1303
  }
809
1304
  }
@@ -900,7 +1395,7 @@ export async function handleFeishuMessage(params: {
900
1395
  const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId, accountId: account.accountId });
901
1396
  if (quotedMsg) {
902
1397
  quotedContent = quotedMsg.content;
903
- log(`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`);
1398
+ log(`feishu[${account.accountId}]: fetched quoted message ${ctx.parentId}`);
904
1399
  }
905
1400
  } catch (err) {
906
1401
  log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
@@ -909,12 +1404,23 @@ export async function handleFeishuMessage(params: {
909
1404
 
910
1405
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
911
1406
 
912
- // Build message body with quoted content if available
913
- let messageBody = ctx.content;
1407
+ // Build core message content with quoted content if available.
1408
+ // Keep this separate from speaker/system prefixes so RawBody preserves quote context.
1409
+ let rawBody = ctx.content;
914
1410
  if (quotedContent) {
915
- messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
1411
+ const normalizedQuoted = quotedContent.replace(/\s+/g, " ").trim();
1412
+ const normalizedBody = ctx.content.replace(/\s+/g, " ").trim();
1413
+ const quoteAlreadyInBody =
1414
+ normalizedQuoted.length > 16 && normalizedBody.length > 0 && normalizedBody.includes(normalizedQuoted);
1415
+ if (quoteAlreadyInBody) {
1416
+ log(`feishu[${account.accountId}]: skip duplicate quote merge for message ${ctx.messageId}`);
1417
+ } else {
1418
+ rawBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
1419
+ }
916
1420
  }
917
1421
 
1422
+ let messageBody = rawBody;
1423
+
918
1424
  // Include a readable speaker label so the model can attribute instructions.
919
1425
  // (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
920
1426
  const speaker = ctx.senderName ?? ctx.senderOpenId;
@@ -927,6 +1433,7 @@ export async function handleFeishuMessage(params: {
927
1433
  }
928
1434
 
929
1435
  const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
1436
+ const replyToMessageId = replyToMessageIdOverride ?? ctx.rootId ?? ctx.messageId;
930
1437
 
931
1438
  // If there's a permission error, dispatch a separate notification first
932
1439
  if (permissionErrorForAgent) {
@@ -969,8 +1476,9 @@ export async function handleFeishuMessage(params: {
969
1476
  agentId: route.agentId,
970
1477
  runtime: runtime as RuntimeEnv,
971
1478
  chatId: ctx.chatId,
972
- replyToMessageId: ctx.messageId,
1479
+ replyToMessageId,
973
1480
  accountId: account.accountId,
1481
+ messageCreateTimeMs,
974
1482
  });
975
1483
 
976
1484
  log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
@@ -1025,8 +1533,8 @@ export async function handleFeishuMessage(params: {
1025
1533
 
1026
1534
  const ctxPayload = core.channel.reply.finalizeInboundContext({
1027
1535
  Body: combinedBody,
1028
- RawBody: ctx.content,
1029
- CommandBody: ctx.content,
1536
+ RawBody: rawBody,
1537
+ CommandBody: rawBody,
1030
1538
  From: feishuFrom,
1031
1539
  To: feishuTo,
1032
1540
  SessionKey: route.sessionKey,
@@ -1043,6 +1551,7 @@ export async function handleFeishuMessage(params: {
1043
1551
  CommandAuthorized: commandAuthorized,
1044
1552
  OriginatingChannel: "feishu" as const,
1045
1553
  OriginatingTo: feishuTo,
1554
+ GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
1046
1555
  ReplyToBody: quotedContent,
1047
1556
  ...mediaPayload,
1048
1557
  });
@@ -1052,9 +1561,10 @@ export async function handleFeishuMessage(params: {
1052
1561
  agentId: route.agentId,
1053
1562
  runtime: runtime as RuntimeEnv,
1054
1563
  chatId: ctx.chatId,
1055
- replyToMessageId: ctx.messageId,
1564
+ replyToMessageId,
1056
1565
  mentionTargets: ctx.mentionTargets,
1057
1566
  accountId: account.accountId,
1567
+ messageCreateTimeMs,
1058
1568
  });
1059
1569
 
1060
1570
  log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);