fix(feishu): add reactionNotifications mode gating (openclaw#29388) thanks @Takhoffman

Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Tak Hoffman 2026-02-27 21:47:12 -06:00 committed by GitHub
parent 0e4c24ebe2
commit aef5355102
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 63 additions and 4 deletions

View File

@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959)
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529)
- Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798)
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325)
- Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (`429`, `99991400`, `99991403`) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494)

View File

@ -110,6 +110,7 @@ const GroupSessionScopeSchema = z
* - "enabled": Messages in different topics get separate sessions
*/
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional();
/**
* Reply-in-thread mode for group chats.
@ -159,6 +160,7 @@ const FeishuSharedConfigShape = {
streaming: StreamingModeSchema,
tools: FeishuToolsConfigSchema,
replyInThread: ReplyInThreadSchema,
reactionNotifications: ReactionNotificationModeSchema,
};
/**
@ -195,6 +197,7 @@ export const FeishuConfigSchema = z
webhookPath: z.string().optional().default("/feishu/events"),
...FeishuSharedConfigShape,
dmPolicy: DmPolicySchema.optional().default("pairing"),
reactionNotifications: ReactionNotificationModeSchema.optional().default("own"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
requireMention: z.boolean().optional().default(true),
groupSessionScope: GroupSessionScopeSchema,

View File

@ -49,6 +49,31 @@ describe("resolveReactionSyntheticEvent", () => {
expect(result).toBeNull();
});
it("drops reactions when reactionNotifications is off", async () => {
const event = makeReactionEvent();
const result = await resolveReactionSyntheticEvent({
cfg: {
channels: {
feishu: {
reactionNotifications: "off",
},
},
} as ClawdbotConfig,
accountId: "default",
event,
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group",
senderOpenId: "ou_bot",
senderType: "app",
content: "hello",
contentType: "text",
}),
});
expect(result).toBeNull();
});
it("filters reactions on non-bot messages", async () => {
const event = makeReactionEvent();
const result = await resolveReactionSyntheticEvent({
@ -68,6 +93,32 @@ describe("resolveReactionSyntheticEvent", () => {
expect(result).toBeNull();
});
it("allows non-bot reactions when reactionNotifications is all", async () => {
const event = makeReactionEvent();
const result = await resolveReactionSyntheticEvent({
cfg: {
channels: {
feishu: {
reactionNotifications: "all",
},
},
} as ClawdbotConfig,
accountId: "default",
event,
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group",
senderOpenId: "ou_other",
senderType: "user",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
});
expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid");
});
it("drops unverified reactions when sender verification times out", async () => {
const event = makeReactionEvent();
const result = await resolveReactionSyntheticEvent({

View File

@ -177,6 +177,12 @@ export async function resolveReactionSyntheticEvent(
return null;
}
const account = resolveFeishuAccount({ cfg, accountId });
const reactionNotifications = account.config.reactionNotifications ?? "own";
if (reactionNotifications === "off") {
return null;
}
// Skip bot self-reactions
if (event.operator_type === "app" || senderId === botOpenId) {
return null;
@ -187,9 +193,7 @@ export async function resolveReactionSyntheticEvent(
return null;
}
// Fail closed if bot identity cannot be resolved; otherwise reactions on any
// message can leak into the agent.
if (!botOpenId) {
if (reactionNotifications === "own" && !botOpenId) {
logger?.(
`feishu[${accountId}]: bot open_id unavailable, skipping reaction ${emoji} on ${messageId}`,
);
@ -201,7 +205,7 @@ export async function resolveReactionSyntheticEvent(
verificationTimeoutMs,
).catch(() => null);
const isBotMessage = reactedMsg?.senderType === "app" || reactedMsg?.senderOpenId === botOpenId;
if (!reactedMsg || !isBotMessage) {
if (!reactedMsg || (reactionNotifications === "own" && !isBotMessage)) {
logger?.(
`feishu[${accountId}]: ignoring reaction on non-bot/unverified message ${messageId} ` +
`(sender: ${reactedMsg?.senderOpenId ?? "unknown"})`,