@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/README.md +82 -2
- package/index.ts +7 -2
- package/package.json +5 -4
- package/skills/feishu-chat/SKILL.md +139 -0
- package/skills/feishu-urgent/SKILL.md +107 -0
- package/src/bot.ts +389 -45
- package/src/channel.ts +2 -0
- package/src/chat-tools/actions.ts +433 -0
- package/src/chat-tools/common.ts +13 -0
- package/src/chat-tools/index.ts +2 -0
- package/src/chat-tools/register.ts +72 -0
- package/src/chat-tools/schemas.ts +66 -0
- package/src/client.ts +13 -0
- package/src/config-schema.ts +6 -0
- package/src/drive-tools/actions.ts +57 -21
- package/src/drive-tools/schemas.ts +2 -2
- package/src/media-duration.ts +190 -0
- package/src/media.ts +60 -5
- package/src/mention.ts +1 -1
- package/src/monitor.ts +32 -8
- package/src/policy.ts +7 -3
- package/src/reply-dispatcher.ts +97 -13
- package/src/send.ts +360 -42
- package/src/streaming-card.ts +32 -4
- package/src/tools-config.ts +9 -2
- package/src/types.ts +8 -1
- package/src/typing.ts +115 -5
- package/src/urgent-tools/actions.ts +84 -0
- package/src/urgent-tools/index.ts +3 -0
- package/src/urgent-tools/register.ts +64 -0
- package/src/urgent-tools/schemas.ts +25 -0
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?.
|
|
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
|
|
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
|
-
|
|
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: "[
|
|
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 =
|
|
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
|
|
638
|
-
if (!contentType) {
|
|
639
|
-
contentType =
|
|
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
|
|
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(
|
|
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: `
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
|
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
|
|
1079
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
1195
|
-
CommandBody:
|
|
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
|
|
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
|
},
|