fix(feishu): filter fetched group thread context (#58237)

* fix(feishu): filter fetched group thread context

* fix(feishu): preserve filtered thread bootstrap
This commit is contained in:
Vincent Koc 2026-03-31 19:43:54 +09:00 committed by GitHub
parent 2194587d70
commit f45e5a6569
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 215 additions and 11 deletions

View File

@ -129,6 +129,7 @@ Docs: https://docs.openclaw.ai
- Gateway/attachments: offload large inbound images without leaking `media://` markers into text-only runs, preserve mixed attachment order for model input/transcripts, and fail closed when model image capability cannot be resolved. (#55513) Thanks @Syysean.
- Agents/subagents: fix interim subagent runtime display so `/subagents list` and `/subagents info` stop inflating short runtimes and show second-level durations correctly. (#57739) Thanks @samzong.
- Diffs/config: preserve schema-shaped plugin config parsing from `diffsPluginConfigSchema.safeParse()`, so direct callers keep `defaults` and `security` sections instead of receiving flattened tool defaults. (#57904) Thanks @gumadeiras.
- Feishu/groups: keep quoted replies and topic bootstrap context aligned with group sender allowlists so only allowlisted thread messages seed agent context. Thanks @AntAISecurityLab and @vincentkoc.
- Diffs: fall back to plain text when `lang` hints are invalid during diff render and viewer hydration, so bad or stale language values no longer break the diff viewer. (#57902) Thanks @gumadeiras.
- Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled `enabledByDefault` plugins in the gateway startup set. (#57931) Thanks @dinakars777.
- Zalo/webhooks: scope replay dedupe to the authenticated target so one configured account can no longer cause same-id inbound events for another target to be dropped. Thanks @smaeljaish771 and @vincentkoc.

View File

@ -1148,6 +1148,57 @@ describe("handleFeishuMessage command authorization", () => {
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("drops quoted group context from senders outside the group sender allowlist", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValueOnce({
messageId: "om_parent_blocked",
chatId: "oc-group",
senderId: "ou-blocked",
senderType: "user",
content: "blocked quoted content",
contentType: "text",
});
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "open",
groupSenderAllowFrom: ["ou-allowed"],
groups: {
"oc-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-allowed",
},
},
message: {
message_id: "msg-group-quoted-filter",
parent_id: "om_parent_blocked",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ReplyToId: "om_parent_blocked",
ReplyToBody: undefined,
}),
);
});
it("dispatches group image message when groupPolicy is open (requireMention defaults to false)", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
@ -2458,6 +2509,82 @@ describe("handleFeishuMessage command authorization", () => {
);
});
it("filters topic bootstrap context to allowlisted group senders", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValue({
messageId: "om_topic_root",
chatId: "oc-group",
senderId: "ou-blocked",
senderType: "user",
content: "blocked root starter",
contentType: "text",
threadId: "omt_topic_1",
});
mockListFeishuThreadMessages.mockResolvedValue([
{
messageId: "om_blocked_reply",
senderId: "ou-blocked",
senderType: "user",
content: "blocked follow-up",
contentType: "text",
createTime: 1710000000000,
},
{
messageId: "om_bot_reply",
senderId: "app_1",
senderType: "app",
content: "assistant reply",
contentType: "text",
createTime: 1710000001000,
},
{
messageId: "om_allowed_reply",
senderId: "ou-allowed",
senderType: "user",
content: "allowed follow-up",
contentType: "text",
createTime: 1710000002000,
},
]);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "open",
groupSenderAllowFrom: ["ou-allowed"],
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-allowed" } },
message: {
message_id: "om_topic_followup_allowlisted",
root_id: "om_topic_root",
thread_id: "omt_topic_1",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: "assistant reply",
ThreadHistoryBody: "assistant reply\n\nallowed follow-up",
}),
);
});
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@ -45,7 +45,7 @@ import {
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext } from "./types.js";
import type { FeishuMessageContext, FeishuMessageInfo } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
export { toMessageResourceType } from "./bot-content.js";
@ -218,6 +218,49 @@ export function buildFeishuAgentBody(params: {
return messageBody;
}
function shouldIncludeFetchedGroupContextMessage(params: {
isGroup: boolean;
allowFrom: Array<string | number>;
senderId?: string;
senderType?: string;
}): boolean {
if (!params.isGroup || params.allowFrom.length === 0) {
return true;
}
if (params.senderType === "app") {
return true;
}
const senderId = params.senderId?.trim();
if (!senderId) {
return false;
}
return isFeishuGroupAllowed({
groupPolicy: "allowlist",
allowFrom: params.allowFrom,
senderId,
senderName: undefined,
});
}
function filterFetchedGroupContextMessages<
T extends Pick<FeishuMessageInfo, "senderId" | "senderType">,
>(
messages: readonly T[],
params: {
isGroup: boolean;
allowFrom: Array<string | number>;
},
): T[] {
return messages.filter((message) =>
shouldIncludeFetchedGroupContextMessage({
isGroup: params.isGroup,
allowFrom: params.allowFrom,
senderId: message.senderId,
senderType: message.senderType,
}),
);
}
export async function handleFeishuMessage(params: {
cfg: ClawdbotConfig;
event: FeishuMessageEvent;
@ -337,6 +380,11 @@ export async function handleFeishuMessage(params: {
const groupConfig = isGroup
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
: undefined;
const effectiveGroupSenderAllowFrom = isGroup
? (groupConfig?.allowFrom?.length ?? 0) > 0
? (groupConfig?.allowFrom ?? [])
: (feishuCfg?.groupSenderAllowFrom ?? [])
: [];
const groupSession = isGroup
? resolveFeishuGroupSession({
chatId: ctx.chatId,
@ -402,14 +450,10 @@ export async function handleFeishuMessage(params: {
}
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
const perGroupSenderAllowFrom = groupConfig?.allowFrom ?? [];
const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
const effectiveSenderAllowFrom =
perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
if (effectiveSenderAllowFrom.length > 0) {
if (effectiveGroupSenderAllowFrom.length > 0) {
const senderAllowed = isFeishuGroupAllowed({
groupPolicy: "allowlist",
allowFrom: effectiveSenderAllowFrom,
allowFrom: effectiveGroupSenderAllowFrom,
senderId: ctx.senderOpenId,
senderIds: [senderUserId],
senderName: ctx.senderName,
@ -691,11 +735,23 @@ export async function handleFeishuMessage(params: {
messageId: ctx.parentId,
accountId: account.accountId,
});
if (quotedMessageInfo) {
if (
quotedMessageInfo &&
shouldIncludeFetchedGroupContextMessage({
isGroup,
allowFrom: effectiveGroupSenderAllowFrom,
senderId: quotedMessageInfo.senderId,
senderType: quotedMessageInfo.senderType,
})
) {
quotedContent = quotedMessageInfo.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
} else if (quotedMessageInfo) {
log(
`feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} due to group sender allowlist`,
);
}
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
@ -767,6 +823,7 @@ export async function handleFeishuMessage(params: {
}
>();
let rootMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> | undefined;
let rootMessageThreadId: string | undefined;
let rootMessageFetched = false;
const getRootMessageInfo = async () => {
if (!ctx.rootId) {
@ -788,6 +845,21 @@ export async function handleFeishuMessage(params: {
rootMessageInfo = null;
}
}
rootMessageThreadId = rootMessageInfo?.threadId;
if (
rootMessageInfo &&
!shouldIncludeFetchedGroupContextMessage({
isGroup,
allowFrom: effectiveGroupSenderAllowFrom,
senderId: rootMessageInfo.senderId,
senderType: rootMessageInfo.senderType,
})
) {
log(
`feishu[${account.accountId}]: skipped thread starter from sender ${rootMessageInfo.senderId ?? "unknown"} due to group sender allowlist`,
);
rootMessageInfo = null;
}
}
return rootMessageInfo ?? null;
};
@ -827,7 +899,7 @@ export async function handleFeishuMessage(params: {
}
const rootMsg = await getRootMessageInfo();
let feishuThreadId = ctx.threadId ?? rootMsg?.threadId;
let feishuThreadId = ctx.threadId ?? rootMessageThreadId ?? rootMsg?.threadId;
if (feishuThreadId) {
log(`feishu[${account.accountId}]: resolved thread ID: ${feishuThreadId}`);
}
@ -854,14 +926,18 @@ export async function handleFeishuMessage(params: {
.map((id) => id?.trim())
.filter((id): id is string => id !== undefined && id.length > 0),
);
const allowlistedMessages = filterFetchedGroupContextMessages(threadMessages, {
isGroup,
allowFrom: effectiveGroupSenderAllowFrom,
});
const relevantMessages =
(senderScoped
? threadMessages.filter(
? allowlistedMessages.filter(
(msg) =>
msg.senderType === "app" ||
(msg.senderId !== undefined && senderIds.has(msg.senderId.trim())),
)
: threadMessages) ?? [];
: allowlistedMessages) ?? [];
const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content;
const includeStarterInHistory = Boolean(rootMsg?.content || ctx.rootId);