@m1heng-clawd/feishu 0.1.14 → 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
 
@@ -254,9 +255,7 @@ async function resolveFeishuSenderName(params: {
254
255
 
255
256
  const name: string | undefined =
256
257
  res?.data?.user?.name ||
257
- res?.data?.user?.display_name ||
258
- res?.data?.user?.nickname ||
259
- res?.data?.user?.en_name;
258
+ res?.data?.user?.nickname;
260
259
 
261
260
  if (name && typeof name === "string") {
262
261
  senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
@@ -281,6 +280,67 @@ async function resolveFeishuSenderName(params: {
281
280
  // Cache group bot counts for command mention bypass policy checks.
282
281
  const GROUP_BOT_COUNT_TTL_MS = 10 * 60 * 1000;
283
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
+ }
284
344
 
285
345
  async function resolveFeishuGroupBotCount(params: {
286
346
  account: ResolvedFeishuAccount;
@@ -327,7 +387,8 @@ export type FeishuMessageEvent = {
327
387
  root_id?: string;
328
388
  parent_id?: string;
329
389
  chat_id: string;
330
- chat_type: "p2p" | "group";
390
+ chat_type: "p2p" | "group" | "private";
391
+ create_time?: string;
331
392
  message_type: string;
332
393
  content: string;
333
394
  mentions?: Array<{
@@ -354,24 +415,64 @@ export type FeishuBotAddedEvent = {
354
415
  operator_tenant_key?: string;
355
416
  };
356
417
 
357
- 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 {
358
429
  try {
359
430
  const parsed = JSON.parse(content);
360
431
  const normalizedMessageType = normalizeFeishuInboundMessageType(messageType);
361
432
  if (normalizedMessageType === "text") {
362
- return parsed.text || "";
433
+ return { text: parsed.text || "", rawPayload: content };
363
434
  }
364
435
  if (normalizedMessageType === "post") {
365
436
  // Extract text content from rich text post
366
437
  const { textContent } = parsePostContent(content);
367
- return textContent;
438
+ return { text: textContent, rawPayload: content };
368
439
  }
369
440
  if (["image", "file", "audio", "video", "sticker"].includes(normalizedMessageType)) {
370
- return inferPlaceholder(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 };
450
+ }
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 };
371
472
  }
372
- return content;
473
+ return { text: content, rawPayload: content };
373
474
  } catch {
374
- return content;
475
+ return { text: content, rawPayload: content };
375
476
  }
376
477
  }
377
478
 
@@ -502,12 +603,33 @@ function parsePostContent(content: string): {
502
603
  }
503
604
 
504
605
  return {
505
- textContent: textContent.trim() || "[富文本消息]",
606
+ textContent: textContent.trim() || "[Rich text message]",
506
607
  imageKeys,
507
608
  mentionIds,
508
609
  };
509
610
  } catch {
510
- 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;
511
633
  }
512
634
  }
513
635
 
@@ -578,8 +700,12 @@ async function resolveFeishuMediaList(params: {
578
700
  });
579
701
 
580
702
  let contentType = result.contentType;
581
- if (!contentType) {
582
- 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");
583
709
  }
584
710
 
585
711
  const saved = await core.channel.media.saveMediaBuffer(
@@ -634,9 +760,17 @@ async function resolveFeishuMediaList(params: {
634
760
  contentType = result.contentType;
635
761
  fileName = result.fileName || mediaKeys.fileName;
636
762
 
637
- // Detect mime type if not provided
638
- if (!contentType) {
639
- 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;
640
774
  }
641
775
 
642
776
  // Save to disk using core's saveMediaBuffer
@@ -689,17 +823,44 @@ function buildFeishuMediaPayload(
689
823
  };
690
824
  }
691
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
+
692
853
  export function parseFeishuMessageEvent(
693
854
  event: FeishuMessageEvent,
694
855
  botOpenId?: string,
695
856
  ): FeishuMessageContext {
696
857
  const parsedPost =
697
858
  event.message.message_type === "post" ? parsePostContent(event.message.content) : undefined;
698
- const rawContent = parseMessageContent(event.message.content, event.message.message_type);
859
+ const parsedContent = parseMessageContent(event.message.content, event.message.message_type);
699
860
  const mentionedBot = checkBotMentioned(event, botOpenId, parsedPost?.mentionIds ?? []);
700
861
  const hasAnyMention =
701
862
  (event.message.mentions?.length ?? 0) > 0 || (parsedPost?.mentionIds.length ?? 0) > 0;
702
- const content = stripBotMention(rawContent, event.message.mentions);
863
+ const content = stripBotMention(parsedContent.text, event.message.mentions);
703
864
 
704
865
  const ctx: FeishuMessageContext = {
705
866
  chatId: event.message.chat_id,
@@ -712,6 +873,8 @@ export function parseFeishuMessageEvent(
712
873
  parentId: event.message.parent_id || undefined,
713
874
  content,
714
875
  contentType: event.message.message_type,
876
+ placeholder: parsedContent.placeholder,
877
+ rawPayload: parsedContent.rawPayload,
715
878
  hasAnyMention,
716
879
  };
717
880
 
@@ -736,8 +899,9 @@ export async function handleFeishuMessage(params: {
736
899
  runtime?: RuntimeEnv;
737
900
  chatHistories?: Map<string, HistoryEntry[]>;
738
901
  accountId?: string;
902
+ replyToMessageIdOverride?: string;
739
903
  }): Promise<void> {
740
- const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
904
+ const { cfg, event, botOpenId, runtime, chatHistories, accountId, replyToMessageIdOverride } = params;
741
905
 
742
906
  // Resolve account with merged config
743
907
  const account = resolveFeishuAccount({ cfg, accountId });
@@ -755,6 +919,144 @@ export async function handleFeishuMessage(params: {
755
919
  }
756
920
 
757
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
+
758
1060
  const isGroup = ctx.chatType === "group";
759
1061
 
760
1062
  // Resolve sender display name (best-effort) so the agent can attribute messages correctly.
@@ -835,7 +1137,7 @@ export async function handleFeishuMessage(params: {
835
1137
  try {
836
1138
  await sendMessageFeishu({
837
1139
  cfg,
838
- to: `user:${ctx.senderOpenId}`,
1140
+ to: `chat:${ctx.chatId}`,
839
1141
  text: core.channel.pairing.buildPairingReply({
840
1142
  channel: "feishu",
841
1143
  idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
@@ -914,7 +1216,7 @@ export async function handleFeishuMessage(params: {
914
1216
  }
915
1217
  }
916
1218
 
917
- const { requireMention } = resolveFeishuReplyPolicy({
1219
+ const { requireMention, allowMentionlessInMultiBotGroup } = resolveFeishuReplyPolicy({
918
1220
  isDirectMessage: false,
919
1221
  globalConfig: feishuCfg,
920
1222
  groupConfig,
@@ -954,22 +1256,49 @@ export async function handleFeishuMessage(params: {
954
1256
  effectiveWasMentioned = mentionGate.effectiveWasMentioned;
955
1257
 
956
1258
  if (mentionGate.shouldSkip) {
957
- log(
958
- `feishu[${account.accountId}]: message in group ${ctx.chatId} skipped (mention required)`,
959
- );
960
- if (chatHistories) {
961
- recordPendingHistoryEntryIfEnabled({
962
- historyMap: chatHistories,
963
- historyKey: ctx.chatId,
964
- limit: historyLimit,
965
- entry: {
966
- sender: ctx.senderOpenId,
967
- body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
968
- timestamp: Date.now(),
969
- messageId: ctx.messageId,
970
- },
971
- });
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
+ );
972
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
+ });
973
1302
  return;
974
1303
  }
975
1304
  }
@@ -1066,7 +1395,7 @@ export async function handleFeishuMessage(params: {
1066
1395
  const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId, accountId: account.accountId });
1067
1396
  if (quotedMsg) {
1068
1397
  quotedContent = quotedMsg.content;
1069
- log(`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`);
1398
+ log(`feishu[${account.accountId}]: fetched quoted message ${ctx.parentId}`);
1070
1399
  }
1071
1400
  } catch (err) {
1072
1401
  log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
@@ -1075,12 +1404,23 @@ export async function handleFeishuMessage(params: {
1075
1404
 
1076
1405
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
1077
1406
 
1078
- // Build message body with quoted content if available
1079
- 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;
1080
1410
  if (quotedContent) {
1081
- 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
+ }
1082
1420
  }
1083
1421
 
1422
+ let messageBody = rawBody;
1423
+
1084
1424
  // Include a readable speaker label so the model can attribute instructions.
1085
1425
  // (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
1086
1426
  const speaker = ctx.senderName ?? ctx.senderOpenId;
@@ -1093,6 +1433,7 @@ export async function handleFeishuMessage(params: {
1093
1433
  }
1094
1434
 
1095
1435
  const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
1436
+ const replyToMessageId = replyToMessageIdOverride ?? ctx.rootId ?? ctx.messageId;
1096
1437
 
1097
1438
  // If there's a permission error, dispatch a separate notification first
1098
1439
  if (permissionErrorForAgent) {
@@ -1135,8 +1476,9 @@ export async function handleFeishuMessage(params: {
1135
1476
  agentId: route.agentId,
1136
1477
  runtime: runtime as RuntimeEnv,
1137
1478
  chatId: ctx.chatId,
1138
- replyToMessageId: ctx.messageId,
1479
+ replyToMessageId,
1139
1480
  accountId: account.accountId,
1481
+ messageCreateTimeMs,
1140
1482
  });
1141
1483
 
1142
1484
  log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
@@ -1191,8 +1533,8 @@ export async function handleFeishuMessage(params: {
1191
1533
 
1192
1534
  const ctxPayload = core.channel.reply.finalizeInboundContext({
1193
1535
  Body: combinedBody,
1194
- RawBody: ctx.content,
1195
- CommandBody: ctx.content,
1536
+ RawBody: rawBody,
1537
+ CommandBody: rawBody,
1196
1538
  From: feishuFrom,
1197
1539
  To: feishuTo,
1198
1540
  SessionKey: route.sessionKey,
@@ -1209,6 +1551,7 @@ export async function handleFeishuMessage(params: {
1209
1551
  CommandAuthorized: commandAuthorized,
1210
1552
  OriginatingChannel: "feishu" as const,
1211
1553
  OriginatingTo: feishuTo,
1554
+ GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
1212
1555
  ReplyToBody: quotedContent,
1213
1556
  ...mediaPayload,
1214
1557
  });
@@ -1218,9 +1561,10 @@ export async function handleFeishuMessage(params: {
1218
1561
  agentId: route.agentId,
1219
1562
  runtime: runtime as RuntimeEnv,
1220
1563
  chatId: ctx.chatId,
1221
- replyToMessageId: ctx.messageId,
1564
+ replyToMessageId,
1222
1565
  mentionTargets: ctx.mentionTargets,
1223
1566
  accountId: account.accountId,
1567
+ messageCreateTimeMs,
1224
1568
  });
1225
1569
 
1226
1570
  log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
package/src/channel.ts CHANGED
@@ -91,6 +91,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
91
91
  groupAllowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
92
92
  requireMention: { type: "boolean" },
93
93
  groupCommandMentionBypass: { type: "string", enum: ["never", "single_bot", "always"] },
94
+ allowMentionlessInMultiBotGroup: { type: "boolean" },
94
95
  topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
95
96
  historyLimit: { type: "integer", minimum: 0 },
96
97
  dmHistoryLimit: { type: "integer", minimum: 0 },
@@ -114,6 +115,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
114
115
  connectionMode: { type: "string", enum: ["websocket", "webhook"] },
115
116
  mediaLocalRoots: { type: "array", items: { type: "string" } },
116
117
  groupCommandMentionBypass: { type: "string", enum: ["never", "single_bot", "always"] },
118
+ allowMentionlessInMultiBotGroup: { type: "boolean" },
117
119
  },
118
120
  },
119
121
  },