@openclaw/feishu 2026.3.2 → 2026.3.8-beta.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.
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/accounts.test.ts +199 -13
- package/src/accounts.ts +45 -17
- package/src/bitable.ts +40 -28
- package/src/bot.checkBotMentioned.test.ts +8 -0
- package/src/bot.stripBotMention.test.ts +118 -22
- package/src/bot.test.ts +516 -9
- package/src/bot.ts +366 -109
- package/src/card-action.ts +1 -1
- package/src/channel.test.ts +1 -1
- package/src/channel.ts +52 -64
- package/src/chat.test.ts +2 -2
- package/src/chat.ts +1 -1
- package/src/client.test.ts +207 -4
- package/src/client.ts +70 -5
- package/src/config-schema.test.ts +14 -6
- package/src/config-schema.ts +5 -1
- package/src/dedup.ts +1 -1
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +29 -50
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +8 -11
- package/src/docx.account-selection.test.ts +3 -3
- package/src/docx.ts +1 -1
- package/src/drive.ts +13 -17
- package/src/dynamic-agent.ts +1 -1
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +60 -13
- package/src/media.ts +23 -9
- package/src/monitor.account.ts +19 -8
- package/src/monitor.reaction.test.ts +111 -105
- package/src/monitor.startup.test.ts +11 -10
- package/src/monitor.startup.ts +20 -7
- package/src/monitor.state.ts +4 -1
- package/src/monitor.test-mocks.ts +42 -9
- package/src/monitor.transport.ts +4 -1
- package/src/monitor.ts +4 -4
- package/src/monitor.webhook-security.test.ts +8 -23
- package/src/onboarding.status.test.ts +1 -1
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +86 -71
- package/src/outbound.test.ts +178 -0
- package/src/outbound.ts +39 -6
- package/src/perm.ts +11 -15
- package/src/policy.test.ts +40 -0
- package/src/policy.ts +9 -10
- package/src/probe.test.ts +18 -18
- package/src/reactions.ts +1 -1
- package/src/reply-dispatcher.test.ts +175 -0
- package/src/reply-dispatcher.ts +69 -21
- package/src/runtime.ts +5 -13
- package/src/secret-input.ts +8 -14
- package/src/send-message.ts +71 -0
- package/src/send-target.test.ts +1 -1
- package/src/send-target.ts +1 -1
- package/src/send.reply-fallback.test.ts +74 -0
- package/src/send.test.ts +1 -1
- package/src/send.ts +88 -49
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +96 -28
- package/src/targets.ts +5 -1
- package/src/tool-account-routing.test.ts +3 -3
- package/src/tool-account.ts +1 -1
- package/src/tool-factory-test-harness.ts +1 -1
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/types.ts +2 -3
- package/src/typing.ts +1 -1
- package/src/wiki.ts +15 -19
package/src/bot.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
|
|
2
2
|
import {
|
|
3
3
|
buildAgentMediaPayload,
|
|
4
4
|
buildPendingHistoryContextFromMap,
|
|
@@ -6,23 +6,20 @@ import {
|
|
|
6
6
|
createScopedPairingAccess,
|
|
7
7
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
8
8
|
type HistoryEntry,
|
|
9
|
+
issuePairingChallenge,
|
|
10
|
+
normalizeAgentId,
|
|
9
11
|
recordPendingHistoryEntryIfEnabled,
|
|
10
12
|
resolveOpenProviderRuntimeGroupPolicy,
|
|
11
13
|
resolveDefaultGroupPolicy,
|
|
12
14
|
warnMissingProviderGroupPolicyFallbackOnce,
|
|
13
|
-
} from "openclaw/plugin-sdk";
|
|
15
|
+
} from "openclaw/plugin-sdk/feishu";
|
|
14
16
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
15
17
|
import { createFeishuClient } from "./client.js";
|
|
16
18
|
import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
|
|
17
19
|
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
18
20
|
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
19
21
|
import { downloadMessageResourceFeishu } from "./media.js";
|
|
20
|
-
import {
|
|
21
|
-
escapeRegExp,
|
|
22
|
-
extractMentionTargets,
|
|
23
|
-
extractMessageBody,
|
|
24
|
-
isMentionForwardRequest,
|
|
25
|
-
} from "./mention.js";
|
|
22
|
+
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
|
|
26
23
|
import {
|
|
27
24
|
resolveFeishuGroupConfig,
|
|
28
25
|
resolveFeishuReplyPolicy,
|
|
@@ -46,6 +43,22 @@ type PermissionError = {
|
|
|
46
43
|
|
|
47
44
|
const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
|
|
48
45
|
|
|
46
|
+
// Feishu API sometimes returns incorrect scope names in permission error
|
|
47
|
+
// responses (e.g. "contact:contact.base:readonly" instead of the valid
|
|
48
|
+
// "contact:user.base:readonly"). This map corrects known mismatches.
|
|
49
|
+
const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
|
|
50
|
+
"contact:contact.base:readonly": "contact:user.base:readonly",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function correctFeishuScopeInUrl(url: string): string {
|
|
54
|
+
let corrected = url;
|
|
55
|
+
for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
|
|
56
|
+
corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
|
|
57
|
+
corrected = corrected.replaceAll(wrong, right);
|
|
58
|
+
}
|
|
59
|
+
return corrected;
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
|
|
50
63
|
const message = permissionError.message.toLowerCase();
|
|
51
64
|
return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
|
|
@@ -71,7 +84,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
|
|
|
71
84
|
// Extract the grant URL from the error message (contains the direct link)
|
|
72
85
|
const msg = feishuErr.msg ?? "";
|
|
73
86
|
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
|
|
74
|
-
const grantUrl = urlMatch?.[0];
|
|
87
|
+
const grantUrl = urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined;
|
|
75
88
|
|
|
76
89
|
return {
|
|
77
90
|
code: feishuErr.code,
|
|
@@ -440,8 +453,12 @@ function formatSubMessageContent(content: string, contentType: string): string {
|
|
|
440
453
|
|
|
441
454
|
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
|
442
455
|
if (!botOpenId) return false;
|
|
456
|
+
// Check for @all (@_all in Feishu) — treat as mentioning every bot
|
|
457
|
+
const rawContent = event.message.content ?? "";
|
|
458
|
+
if (rawContent.includes("@_all")) return true;
|
|
443
459
|
const mentions = event.message.mentions ?? [];
|
|
444
460
|
if (mentions.length > 0) {
|
|
461
|
+
// Rely on Feishu mention IDs; display names can vary by alias/context.
|
|
445
462
|
return mentions.some((m) => m.id.open_id === botOpenId);
|
|
446
463
|
}
|
|
447
464
|
// Post (rich text) messages may have empty message.mentions when they contain docs/paste
|
|
@@ -452,17 +469,41 @@ function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boole
|
|
|
452
469
|
return false;
|
|
453
470
|
}
|
|
454
471
|
|
|
455
|
-
|
|
472
|
+
function normalizeMentions(
|
|
456
473
|
text: string,
|
|
457
474
|
mentions?: FeishuMessageEvent["message"]["mentions"],
|
|
475
|
+
botStripId?: string,
|
|
458
476
|
): string {
|
|
459
477
|
if (!mentions || mentions.length === 0) return text;
|
|
478
|
+
|
|
479
|
+
const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
480
|
+
const escapeName = (value: string) => value.replace(/</g, "<").replace(/>/g, ">");
|
|
460
481
|
let result = text;
|
|
482
|
+
|
|
461
483
|
for (const mention of mentions) {
|
|
462
|
-
|
|
463
|
-
|
|
484
|
+
const mentionId = mention.id.open_id;
|
|
485
|
+
const replacement =
|
|
486
|
+
botStripId && mentionId === botStripId
|
|
487
|
+
? ""
|
|
488
|
+
: mentionId
|
|
489
|
+
? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
|
|
490
|
+
: `@${mention.name}`;
|
|
491
|
+
|
|
492
|
+
result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
|
|
464
493
|
}
|
|
465
|
-
|
|
494
|
+
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function normalizeFeishuCommandProbeBody(text: string): string {
|
|
499
|
+
if (!text) {
|
|
500
|
+
return "";
|
|
501
|
+
}
|
|
502
|
+
return text
|
|
503
|
+
.replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
|
|
504
|
+
.replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
|
|
505
|
+
.replace(/\s+/g, " ")
|
|
506
|
+
.trim();
|
|
466
507
|
}
|
|
467
508
|
|
|
468
509
|
/**
|
|
@@ -698,6 +739,31 @@ async function resolveFeishuMediaList(params: {
|
|
|
698
739
|
return out;
|
|
699
740
|
}
|
|
700
741
|
|
|
742
|
+
// --- Broadcast support ---
|
|
743
|
+
// Resolve broadcast agent list for a given peer (group) ID.
|
|
744
|
+
// Returns null if no broadcast config exists or the peer is not in the broadcast list.
|
|
745
|
+
export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null {
|
|
746
|
+
const broadcast = (cfg as Record<string, unknown>).broadcast;
|
|
747
|
+
if (!broadcast || typeof broadcast !== "object") return null;
|
|
748
|
+
const agents = (broadcast as Record<string, unknown>)[peerId];
|
|
749
|
+
if (!Array.isArray(agents) || agents.length === 0) return null;
|
|
750
|
+
return agents as string[];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Build a session key for a broadcast target agent by replacing the agent ID prefix.
|
|
754
|
+
// Session keys follow the format: agent:<agentId>:<channel>:<peerKind>:<peerId>
|
|
755
|
+
export function buildBroadcastSessionKey(
|
|
756
|
+
baseSessionKey: string,
|
|
757
|
+
originalAgentId: string,
|
|
758
|
+
targetAgentId: string,
|
|
759
|
+
): string {
|
|
760
|
+
const prefix = `agent:${originalAgentId}:`;
|
|
761
|
+
if (baseSessionKey.startsWith(prefix)) {
|
|
762
|
+
return `agent:${targetAgentId}:${baseSessionKey.slice(prefix.length)}`;
|
|
763
|
+
}
|
|
764
|
+
return baseSessionKey;
|
|
765
|
+
}
|
|
766
|
+
|
|
701
767
|
/**
|
|
702
768
|
* Build media payload for inbound context.
|
|
703
769
|
* Similar to Discord's buildDiscordMediaPayload().
|
|
@@ -705,10 +771,17 @@ async function resolveFeishuMediaList(params: {
|
|
|
705
771
|
export function parseFeishuMessageEvent(
|
|
706
772
|
event: FeishuMessageEvent,
|
|
707
773
|
botOpenId?: string,
|
|
774
|
+
_botName?: string,
|
|
708
775
|
): FeishuMessageContext {
|
|
709
776
|
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
|
710
777
|
const mentionedBot = checkBotMentioned(event, botOpenId);
|
|
711
|
-
const
|
|
778
|
+
const hasAnyMention = (event.message.mentions?.length ?? 0) > 0;
|
|
779
|
+
// Strip the bot's own mention so slash commands like @Bot /help retain
|
|
780
|
+
// the leading /. This applies in both p2p *and* group contexts — the
|
|
781
|
+
// mentionedBot flag already captures whether the bot was addressed, so
|
|
782
|
+
// keeping the mention tag in content only breaks command detection (#35994).
|
|
783
|
+
// Non-bot mentions (e.g. mention-forward targets) are still normalized to <at> tags.
|
|
784
|
+
const content = normalizeMentions(rawContent, event.message.mentions, botOpenId);
|
|
712
785
|
const senderOpenId = event.sender.sender_id.open_id?.trim();
|
|
713
786
|
const senderUserId = event.sender.sender_id.user_id?.trim();
|
|
714
787
|
const senderFallbackId = senderOpenId || senderUserId || "";
|
|
@@ -722,6 +795,7 @@ export function parseFeishuMessageEvent(
|
|
|
722
795
|
senderOpenId: senderFallbackId,
|
|
723
796
|
chatType: event.message.chat_type,
|
|
724
797
|
mentionedBot,
|
|
798
|
+
hasAnyMention,
|
|
725
799
|
rootId: event.message.root_id || undefined,
|
|
726
800
|
parentId: event.message.parent_id || undefined,
|
|
727
801
|
threadId: event.message.thread_id || undefined,
|
|
@@ -734,9 +808,6 @@ export function parseFeishuMessageEvent(
|
|
|
734
808
|
const mentionTargets = extractMentionTargets(event, botOpenId);
|
|
735
809
|
if (mentionTargets.length > 0) {
|
|
736
810
|
ctx.mentionTargets = mentionTargets;
|
|
737
|
-
// Extract message body (remove all @ placeholders)
|
|
738
|
-
const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
|
|
739
|
-
ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
|
|
740
811
|
}
|
|
741
812
|
}
|
|
742
813
|
|
|
@@ -746,12 +817,13 @@ export function parseFeishuMessageEvent(
|
|
|
746
817
|
export function buildFeishuAgentBody(params: {
|
|
747
818
|
ctx: Pick<
|
|
748
819
|
FeishuMessageContext,
|
|
749
|
-
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
|
|
820
|
+
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
|
|
750
821
|
>;
|
|
751
822
|
quotedContent?: string;
|
|
752
823
|
permissionErrorForAgent?: PermissionError;
|
|
824
|
+
botOpenId?: string;
|
|
753
825
|
}): string {
|
|
754
|
-
const { ctx, quotedContent, permissionErrorForAgent } = params;
|
|
826
|
+
const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
|
|
755
827
|
let messageBody = ctx.content;
|
|
756
828
|
if (quotedContent) {
|
|
757
829
|
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
|
@@ -761,6 +833,16 @@ export function buildFeishuAgentBody(params: {
|
|
|
761
833
|
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
|
762
834
|
messageBody = `${speaker}: ${messageBody}`;
|
|
763
835
|
|
|
836
|
+
if (ctx.hasAnyMention) {
|
|
837
|
+
const botIdHint = botOpenId?.trim();
|
|
838
|
+
messageBody +=
|
|
839
|
+
`\n\n[System: The content may include mention tags in the form <at user_id="...">name</at>. ` +
|
|
840
|
+
`Treat these as real mentions of Feishu entities (users or bots).]`;
|
|
841
|
+
if (botIdHint) {
|
|
842
|
+
messageBody += `\n[System: If user_id is "${botIdHint}", that mention refers to you.]`;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
764
846
|
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
|
765
847
|
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
|
766
848
|
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
|
@@ -781,11 +863,12 @@ export async function handleFeishuMessage(params: {
|
|
|
781
863
|
cfg: ClawdbotConfig;
|
|
782
864
|
event: FeishuMessageEvent;
|
|
783
865
|
botOpenId?: string;
|
|
866
|
+
botName?: string;
|
|
784
867
|
runtime?: RuntimeEnv;
|
|
785
868
|
chatHistories?: Map<string, HistoryEntry[]>;
|
|
786
869
|
accountId?: string;
|
|
787
870
|
}): Promise<void> {
|
|
788
|
-
const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
|
|
871
|
+
const { cfg, event, botOpenId, botName, runtime, chatHistories, accountId } = params;
|
|
789
872
|
|
|
790
873
|
// Resolve account with merged config
|
|
791
874
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
@@ -808,7 +891,7 @@ export async function handleFeishuMessage(params: {
|
|
|
808
891
|
return;
|
|
809
892
|
}
|
|
810
893
|
|
|
811
|
-
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
|
894
|
+
let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
|
|
812
895
|
const isGroup = ctx.chatType === "group";
|
|
813
896
|
const isDirect = !isGroup;
|
|
814
897
|
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
|
|
@@ -901,7 +984,12 @@ export async function handleFeishuMessage(params: {
|
|
|
901
984
|
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
|
902
985
|
const configAllowFrom = feishuCfg?.allowFrom ?? [];
|
|
903
986
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
987
|
+
const rawBroadcastAgents = isGroup ? resolveBroadcastAgents(cfg, ctx.chatId) : null;
|
|
988
|
+
const broadcastAgents = rawBroadcastAgents
|
|
989
|
+
? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
|
|
990
|
+
: null;
|
|
904
991
|
|
|
992
|
+
let requireMention = false; // DMs never require mention; groups may override below
|
|
905
993
|
if (isGroup) {
|
|
906
994
|
if (groupConfig?.enabled === false) {
|
|
907
995
|
log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
|
|
@@ -956,17 +1044,19 @@ export async function handleFeishuMessage(params: {
|
|
|
956
1044
|
}
|
|
957
1045
|
}
|
|
958
1046
|
|
|
959
|
-
|
|
1047
|
+
({ requireMention } = resolveFeishuReplyPolicy({
|
|
960
1048
|
isDirectMessage: false,
|
|
961
1049
|
globalConfig: feishuCfg,
|
|
962
1050
|
groupConfig,
|
|
963
|
-
});
|
|
1051
|
+
}));
|
|
964
1052
|
|
|
965
1053
|
if (requireMention && !ctx.mentionedBot) {
|
|
966
|
-
log(
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1054
|
+
log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot`);
|
|
1055
|
+
// Record to pending history for non-broadcast groups only. For broadcast groups,
|
|
1056
|
+
// the mentioned handler's broadcast dispatch writes the turn directly into all
|
|
1057
|
+
// agent sessions — buffering here would cause duplicate replay when this account
|
|
1058
|
+
// later becomes active via buildPendingHistoryContextFromMap.
|
|
1059
|
+
if (!broadcastAgents && chatHistories && groupHistoryKey) {
|
|
970
1060
|
recordPendingHistoryEntryIfEnabled({
|
|
971
1061
|
historyMap: chatHistories,
|
|
972
1062
|
historyKey: groupHistoryKey,
|
|
@@ -991,8 +1081,9 @@ export async function handleFeishuMessage(params: {
|
|
|
991
1081
|
channel: "feishu",
|
|
992
1082
|
accountId: account.accountId,
|
|
993
1083
|
});
|
|
1084
|
+
const commandProbeBody = isGroup ? normalizeFeishuCommandProbeBody(ctx.content) : ctx.content;
|
|
994
1085
|
const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
|
|
995
|
-
|
|
1086
|
+
commandProbeBody,
|
|
996
1087
|
cfg,
|
|
997
1088
|
);
|
|
998
1089
|
const storeAllowFrom =
|
|
@@ -1011,29 +1102,29 @@ export async function handleFeishuMessage(params: {
|
|
|
1011
1102
|
|
|
1012
1103
|
if (isDirect && dmPolicy !== "open" && !dmAllowed) {
|
|
1013
1104
|
if (dmPolicy === "pairing") {
|
|
1014
|
-
|
|
1015
|
-
|
|
1105
|
+
await issuePairingChallenge({
|
|
1106
|
+
channel: "feishu",
|
|
1107
|
+
senderId: ctx.senderOpenId,
|
|
1108
|
+
senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
|
1016
1109
|
meta: { name: ctx.senderName },
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1110
|
+
upsertPairingRequest: pairing.upsertPairingRequest,
|
|
1111
|
+
onCreated: () => {
|
|
1112
|
+
log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
|
|
1113
|
+
},
|
|
1114
|
+
sendPairingReply: async (text) => {
|
|
1021
1115
|
await sendMessageFeishu({
|
|
1022
1116
|
cfg,
|
|
1023
1117
|
to: `chat:${ctx.chatId}`,
|
|
1024
|
-
text
|
|
1025
|
-
channel: "feishu",
|
|
1026
|
-
idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
|
1027
|
-
code,
|
|
1028
|
-
}),
|
|
1118
|
+
text,
|
|
1029
1119
|
accountId: account.accountId,
|
|
1030
1120
|
});
|
|
1031
|
-
}
|
|
1121
|
+
},
|
|
1122
|
+
onReplyError: (err) => {
|
|
1032
1123
|
log(
|
|
1033
1124
|
`feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
|
|
1034
1125
|
);
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1126
|
+
},
|
|
1127
|
+
});
|
|
1037
1128
|
} else {
|
|
1038
1129
|
log(
|
|
1039
1130
|
`feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
|
|
@@ -1163,6 +1254,7 @@ export async function handleFeishuMessage(params: {
|
|
|
1163
1254
|
ctx,
|
|
1164
1255
|
quotedContent,
|
|
1165
1256
|
permissionErrorForAgent,
|
|
1257
|
+
botOpenId,
|
|
1166
1258
|
});
|
|
1167
1259
|
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
|
1168
1260
|
if (permissionErrorForAgent) {
|
|
@@ -1208,82 +1300,247 @@ export async function handleFeishuMessage(params: {
|
|
|
1208
1300
|
}))
|
|
1209
1301
|
: undefined;
|
|
1210
1302
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1303
|
+
// --- Shared context builder for dispatch ---
|
|
1304
|
+
const buildCtxPayloadForAgent = (
|
|
1305
|
+
agentSessionKey: string,
|
|
1306
|
+
agentAccountId: string,
|
|
1307
|
+
wasMentioned: boolean,
|
|
1308
|
+
) =>
|
|
1309
|
+
core.channel.reply.finalizeInboundContext({
|
|
1310
|
+
Body: combinedBody,
|
|
1311
|
+
BodyForAgent: messageBody,
|
|
1312
|
+
InboundHistory: inboundHistory,
|
|
1313
|
+
ReplyToId: ctx.parentId,
|
|
1314
|
+
RootMessageId: ctx.rootId,
|
|
1315
|
+
RawBody: ctx.content,
|
|
1316
|
+
CommandBody: ctx.content,
|
|
1317
|
+
From: feishuFrom,
|
|
1318
|
+
To: feishuTo,
|
|
1319
|
+
SessionKey: agentSessionKey,
|
|
1320
|
+
AccountId: agentAccountId,
|
|
1321
|
+
ChatType: isGroup ? "group" : "direct",
|
|
1322
|
+
GroupSubject: isGroup ? ctx.chatId : undefined,
|
|
1323
|
+
SenderName: ctx.senderName ?? ctx.senderOpenId,
|
|
1324
|
+
SenderId: ctx.senderOpenId,
|
|
1325
|
+
Provider: "feishu" as const,
|
|
1326
|
+
Surface: "feishu" as const,
|
|
1327
|
+
MessageSid: ctx.messageId,
|
|
1328
|
+
ReplyToBody: quotedContent ?? undefined,
|
|
1329
|
+
Timestamp: Date.now(),
|
|
1330
|
+
WasMentioned: wasMentioned,
|
|
1331
|
+
CommandAuthorized: commandAuthorized,
|
|
1332
|
+
OriginatingChannel: "feishu" as const,
|
|
1333
|
+
OriginatingTo: feishuTo,
|
|
1334
|
+
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
|
|
1335
|
+
...mediaPayload,
|
|
1336
|
+
});
|
|
1240
1337
|
|
|
1241
1338
|
// Parse message create_time (Feishu uses millisecond epoch string).
|
|
1242
1339
|
const messageCreateTimeMs = event.message.create_time
|
|
1243
1340
|
? parseInt(event.message.create_time, 10)
|
|
1244
1341
|
: undefined;
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1342
|
+
// Determine reply target based on group session mode:
|
|
1343
|
+
// - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
|
|
1344
|
+
// root so the bot stays in the same thread.
|
|
1345
|
+
// - Groups with explicit replyInThread config: reply to the root so the bot
|
|
1346
|
+
// stays in the thread the user expects.
|
|
1347
|
+
// - Normal groups (auto-detected threadReply from root_id): reply to the
|
|
1348
|
+
// triggering message itself. Using rootId here would silently push the
|
|
1349
|
+
// reply into a topic thread invisible in the main chat view (#32980).
|
|
1350
|
+
const isTopicSession =
|
|
1351
|
+
isGroup &&
|
|
1352
|
+
(groupSession?.groupSessionScope === "group_topic" ||
|
|
1353
|
+
groupSession?.groupSessionScope === "group_topic_sender");
|
|
1354
|
+
const configReplyInThread =
|
|
1355
|
+
isGroup &&
|
|
1356
|
+
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
|
|
1357
|
+
const replyTargetMessageId =
|
|
1358
|
+
isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId;
|
|
1359
|
+
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
|
|
1360
|
+
|
|
1361
|
+
if (broadcastAgents) {
|
|
1362
|
+
// Cross-account dedup: in multi-account setups, Feishu delivers the same
|
|
1363
|
+
// event to every bot account in the group. Only one account should handle
|
|
1364
|
+
// broadcast dispatch to avoid duplicate agent sessions and race conditions.
|
|
1365
|
+
// Uses a shared "broadcast" namespace (not per-account) so the first handler
|
|
1366
|
+
// to reach this point claims the message; subsequent accounts skip.
|
|
1367
|
+
if (!(await tryRecordMessagePersistent(ctx.messageId, "broadcast", log))) {
|
|
1368
|
+
log(
|
|
1369
|
+
`feishu[${account.accountId}]: broadcast already claimed by another account for message ${ctx.messageId}; skipping`,
|
|
1370
|
+
);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1260
1373
|
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
ctx: ctxPayload,
|
|
1270
|
-
cfg,
|
|
1271
|
-
dispatcher,
|
|
1272
|
-
replyOptions,
|
|
1273
|
-
}),
|
|
1274
|
-
});
|
|
1374
|
+
// --- Broadcast dispatch: send message to all configured agents ---
|
|
1375
|
+
const strategy =
|
|
1376
|
+
((cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined)
|
|
1377
|
+
?.strategy || "parallel";
|
|
1378
|
+
const activeAgentId =
|
|
1379
|
+
ctx.mentionedBot || !requireMention ? normalizeAgentId(route.agentId) : null;
|
|
1380
|
+
const agentIds = (cfg.agents?.list ?? []).map((a: { id: string }) => normalizeAgentId(a.id));
|
|
1381
|
+
const hasKnownAgents = agentIds.length > 0;
|
|
1275
1382
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1383
|
+
log(
|
|
1384
|
+
`feishu[${account.accountId}]: broadcasting to ${broadcastAgents.length} agents (strategy=${strategy}, active=${activeAgentId ?? "none"})`,
|
|
1385
|
+
);
|
|
1386
|
+
|
|
1387
|
+
const dispatchForAgent = async (agentId: string) => {
|
|
1388
|
+
if (hasKnownAgents && !agentIds.includes(normalizeAgentId(agentId))) {
|
|
1389
|
+
log(
|
|
1390
|
+
`feishu[${account.accountId}]: broadcast agent ${agentId} not found in agents.list; skipping`,
|
|
1391
|
+
);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
|
1396
|
+
const agentCtx = buildCtxPayloadForAgent(
|
|
1397
|
+
agentSessionKey,
|
|
1398
|
+
route.accountId,
|
|
1399
|
+
ctx.mentionedBot && agentId === activeAgentId,
|
|
1400
|
+
);
|
|
1401
|
+
|
|
1402
|
+
if (agentId === activeAgentId) {
|
|
1403
|
+
// Active agent: real Feishu dispatcher (responds on Feishu)
|
|
1404
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
1405
|
+
cfg,
|
|
1406
|
+
agentId,
|
|
1407
|
+
runtime: runtime as RuntimeEnv,
|
|
1408
|
+
chatId: ctx.chatId,
|
|
1409
|
+
replyToMessageId: replyTargetMessageId,
|
|
1410
|
+
skipReplyToInMessages: !isGroup,
|
|
1411
|
+
replyInThread,
|
|
1412
|
+
rootId: ctx.rootId,
|
|
1413
|
+
threadReply,
|
|
1414
|
+
mentionTargets: ctx.mentionTargets,
|
|
1415
|
+
accountId: account.accountId,
|
|
1416
|
+
messageCreateTimeMs,
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
log(
|
|
1420
|
+
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
|
|
1421
|
+
);
|
|
1422
|
+
await core.channel.reply.withReplyDispatcher({
|
|
1423
|
+
dispatcher,
|
|
1424
|
+
onSettled: () => markDispatchIdle(),
|
|
1425
|
+
run: () =>
|
|
1426
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1427
|
+
ctx: agentCtx,
|
|
1428
|
+
cfg,
|
|
1429
|
+
dispatcher,
|
|
1430
|
+
replyOptions,
|
|
1431
|
+
}),
|
|
1432
|
+
});
|
|
1433
|
+
} else {
|
|
1434
|
+
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
|
|
1435
|
+
// Strip CommandAuthorized so slash commands (e.g. /reset) don't silently
|
|
1436
|
+
// mutate observer sessions — only the active agent should execute commands.
|
|
1437
|
+
delete (agentCtx as Record<string, unknown>).CommandAuthorized;
|
|
1438
|
+
const noopDispatcher = {
|
|
1439
|
+
sendToolResult: () => false,
|
|
1440
|
+
sendBlockReply: () => false,
|
|
1441
|
+
sendFinalReply: () => false,
|
|
1442
|
+
waitForIdle: async () => {},
|
|
1443
|
+
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
1444
|
+
markComplete: () => {},
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
log(
|
|
1448
|
+
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
|
|
1449
|
+
);
|
|
1450
|
+
await core.channel.reply.withReplyDispatcher({
|
|
1451
|
+
dispatcher: noopDispatcher,
|
|
1452
|
+
run: () =>
|
|
1453
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1454
|
+
ctx: agentCtx,
|
|
1455
|
+
cfg,
|
|
1456
|
+
dispatcher: noopDispatcher,
|
|
1457
|
+
}),
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
};
|
|
1461
|
+
|
|
1462
|
+
if (strategy === "sequential") {
|
|
1463
|
+
for (const agentId of broadcastAgents) {
|
|
1464
|
+
try {
|
|
1465
|
+
await dispatchForAgent(agentId);
|
|
1466
|
+
} catch (err) {
|
|
1467
|
+
log(
|
|
1468
|
+
`feishu[${account.accountId}]: broadcast dispatch failed for agent=${agentId}: ${String(err)}`,
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
} else {
|
|
1473
|
+
const results = await Promise.allSettled(broadcastAgents.map(dispatchForAgent));
|
|
1474
|
+
for (let i = 0; i < results.length; i++) {
|
|
1475
|
+
if (results[i].status === "rejected") {
|
|
1476
|
+
log(
|
|
1477
|
+
`feishu[${account.accountId}]: broadcast dispatch failed for agent=${broadcastAgents[i]}: ${String((results[i] as PromiseRejectedResult).reason)}`,
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
if (isGroup && historyKey && chatHistories) {
|
|
1484
|
+
clearHistoryEntriesIfEnabled({
|
|
1485
|
+
historyMap: chatHistories,
|
|
1486
|
+
historyKey,
|
|
1487
|
+
limit: historyLimit,
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
log(
|
|
1492
|
+
`feishu[${account.accountId}]: broadcast dispatch complete for ${broadcastAgents.length} agents`,
|
|
1493
|
+
);
|
|
1494
|
+
} else {
|
|
1495
|
+
// --- Single-agent dispatch (existing behavior) ---
|
|
1496
|
+
const ctxPayload = buildCtxPayloadForAgent(
|
|
1497
|
+
route.sessionKey,
|
|
1498
|
+
route.accountId,
|
|
1499
|
+
ctx.mentionedBot,
|
|
1500
|
+
);
|
|
1501
|
+
|
|
1502
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
1503
|
+
cfg,
|
|
1504
|
+
agentId: route.agentId,
|
|
1505
|
+
runtime: runtime as RuntimeEnv,
|
|
1506
|
+
chatId: ctx.chatId,
|
|
1507
|
+
replyToMessageId: replyTargetMessageId,
|
|
1508
|
+
skipReplyToInMessages: !isGroup,
|
|
1509
|
+
replyInThread,
|
|
1510
|
+
rootId: ctx.rootId,
|
|
1511
|
+
threadReply,
|
|
1512
|
+
mentionTargets: ctx.mentionTargets,
|
|
1513
|
+
accountId: account.accountId,
|
|
1514
|
+
messageCreateTimeMs,
|
|
1281
1515
|
});
|
|
1282
|
-
}
|
|
1283
1516
|
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1517
|
+
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
|
1518
|
+
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
|
1519
|
+
dispatcher,
|
|
1520
|
+
onSettled: () => {
|
|
1521
|
+
markDispatchIdle();
|
|
1522
|
+
},
|
|
1523
|
+
run: () =>
|
|
1524
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1525
|
+
ctx: ctxPayload,
|
|
1526
|
+
cfg,
|
|
1527
|
+
dispatcher,
|
|
1528
|
+
replyOptions,
|
|
1529
|
+
}),
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
if (isGroup && historyKey && chatHistories) {
|
|
1533
|
+
clearHistoryEntriesIfEnabled({
|
|
1534
|
+
historyMap: chatHistories,
|
|
1535
|
+
historyKey,
|
|
1536
|
+
limit: historyLimit,
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
log(
|
|
1541
|
+
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1287
1544
|
} catch (err) {
|
|
1288
1545
|
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
|
|
1289
1546
|
}
|
package/src/card-action.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
|
|
2
2
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
3
3
|
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
|
|
4
4
|
|
package/src/channel.test.ts
CHANGED