From aef53551021dd5bf4470d6ed6292a68a8d520318 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:47:12 -0600 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + extensions/feishu/src/config-schema.ts | 3 ++ .../feishu/src/monitor.reaction.test.ts | 51 +++++++++++++++++++ extensions/feishu/src/monitor.ts | 12 +++-- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54647ae6a26..cf5591eb3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 1d3b80e0944..2e85cc93fea 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -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, diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index d4d6914621b..900c8520e40 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -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({ diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts index 1400d6728e7..c5217e65f39 100644 --- a/extensions/feishu/src/monitor.ts +++ b/extensions/feishu/src/monitor.ts @@ -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"})`,