refactor: centralize ack reaction gating

This commit is contained in:
Peter Steinberger
2026-01-23 22:17:14 +00:00
parent 99d4820b39
commit 02bd6e4a24
11 changed files with 253 additions and 65 deletions

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { shouldAckReaction } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import { import {
handleBlueBubblesWebhookRequest, handleBlueBubblesWebhookRequest,
@@ -135,6 +136,9 @@ function createMockRuntime(): PluginRuntime {
buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"], buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"], matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
}, },
reactions: {
shouldAckReaction,
},
groups: { groups: {
resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],

View File

@@ -1521,19 +1521,20 @@ async function processMessage(
core, core,
runtime, runtime,
}); });
const shouldAckReaction = () => { const shouldAckReaction = () =>
if (!ackReactionValue) return false; Boolean(
if (ackReactionScope === "all") return true; ackReactionValue &&
if (ackReactionScope === "direct") return !isGroup; core.channel.reactions.shouldAckReaction({
if (ackReactionScope === "group-all") return isGroup; scope: ackReactionScope,
if (ackReactionScope === "group-mentions") { isDirect: !isGroup,
if (!isGroup) return false; isGroup,
if (!requireMention) return false; isMentionableGroup: isGroup,
if (!canDetectMention) return false; requireMention: Boolean(requireMention),
return effectiveWasMentioned; canDetectMention,
} effectiveWasMentioned,
return false; shouldBypassMention,
}; }),
);
const ackMessageId = message.messageId?.trim() || ""; const ackMessageId = message.messageId?.trim() || "";
const ackReactionPromise = const ackReactionPromise =
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue

View File

@@ -410,6 +410,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
!hasExplicitMention && !hasExplicitMention &&
commandAuthorized && commandAuthorized &&
core.channel.text.hasControlCommand(bodyText); core.channel.text.hasControlCommand(bodyText);
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message"); logger.info({ roomId, reason: "no-mention" }, "skipping room message");
return; return;
@@ -515,18 +516,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const shouldAckReaction = () => { const shouldAckReaction = () =>
if (!ackReaction) return false; Boolean(
if (ackScope === "all") return true; ackReaction &&
if (ackScope === "direct") return isDirectMessage; core.channel.reactions.shouldAckReaction({
if (ackScope === "group-all") return isRoom; scope: ackScope,
if (ackScope === "group-mentions") { isDirect: isDirectMessage,
if (!isRoom) return false; isGroup: isRoom,
if (!shouldRequireMention) return false; isMentionableGroup: isRoom,
return wasMentioned || shouldBypassMention; requireMention: Boolean(shouldRequireMention),
} canDetectMention,
return false; effectiveWasMentioned: wasMentioned || shouldBypassMention,
}; shouldBypassMention,
}),
);
if (shouldAckReaction() && messageId) { if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { shouldAckReaction } from "./ack-reactions.js";
describe("shouldAckReaction", () => {
it("honors direct and group-all scopes", () => {
expect(
shouldAckReaction({
scope: "direct",
isDirect: true,
isGroup: false,
isMentionableGroup: false,
requireMention: false,
canDetectMention: false,
effectiveWasMentioned: false,
}),
).toBe(true);
expect(
shouldAckReaction({
scope: "group-all",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: false,
canDetectMention: false,
effectiveWasMentioned: false,
}),
).toBe(true);
});
it("skips when scope is off or none", () => {
expect(
shouldAckReaction({
scope: "off",
isDirect: true,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "none",
isDirect: true,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
});
it("defaults to group-mentions gating", () => {
expect(
shouldAckReaction({
scope: undefined,
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(true);
});
it("requires mention gating for group-mentions", () => {
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: false,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: false,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: false,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(true);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: false,
shouldBypassMention: true,
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions" | "off" | "none";
export type AckReactionGateParams = {
scope: AckReactionScope | undefined;
isDirect: boolean;
isGroup: boolean;
isMentionableGroup: boolean;
requireMention: boolean;
canDetectMention: boolean;
effectiveWasMentioned: boolean;
shouldBypassMention?: boolean;
};
export function shouldAckReaction(params: AckReactionGateParams): boolean {
const scope = params.scope ?? "group-mentions";
if (scope === "off" || scope === "none") return false;
if (scope === "all") return true;
if (scope === "direct") return params.isDirect;
if (scope === "group-all") return params.isGroup;
if (scope === "group-mentions") {
if (!params.isMentionableGroup) return false;
if (!params.requireMention) return false;
if (!params.canDetectMention) return false;
return params.effectiveWasMentioned || params.shouldBypassMention === true;
}
return false;
}

View File

@@ -8,6 +8,7 @@ import {
extractShortModelName, extractShortModelName,
type ResponsePrefixContext, type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js"; } from "../../auto-reply/reply/response-prefix-template.js";
import { shouldAckReaction as shouldAckReactionGate } from "../../channels/ack-reactions.js";
import { import {
formatInboundEnvelope, formatInboundEnvelope,
formatThreadStarterEnvelope, formatThreadStarterEnvelope,
@@ -73,6 +74,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
shouldRequireMention, shouldRequireMention,
canDetectMention, canDetectMention,
effectiveWasMentioned, effectiveWasMentioned,
shouldBypassMention,
threadChannel, threadChannel,
threadParentId, threadParentId,
threadParentName, threadParentName,
@@ -95,20 +97,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
} }
const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () => { const shouldAckReaction = () =>
if (!ackReaction) return false; Boolean(
if (ackReactionScope === "all") return true; ackReaction &&
if (ackReactionScope === "direct") return isDirectMessage; shouldAckReactionGate({
const isGroupChat = isGuildMessage || isGroupDm; scope: ackReactionScope,
if (ackReactionScope === "group-all") return isGroupChat; isDirect: isDirectMessage,
if (ackReactionScope === "group-mentions") { isGroup: isGuildMessage || isGroupDm,
if (!isGuildMessage) return false; isMentionableGroup: isGuildMessage,
if (!shouldRequireMention) return false; requireMention: Boolean(shouldRequireMention),
if (!canDetectMention) return false; canDetectMention,
return effectiveWasMentioned; effectiveWasMentioned,
} shouldBypassMention,
return false; }),
}; );
const ackReactionPromise = shouldAckReaction() const ackReactionPromise = shouldAckReaction()
? reactMessageDiscord(message.channelId, message.id, ackReaction, { ? reactMessageDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest, rest: client.rest,

View File

@@ -117,6 +117,8 @@ export {
resolveMentionGating, resolveMentionGating,
resolveMentionGatingWithBypass, resolveMentionGatingWithBypass,
} from "../channels/mention-gating.js"; } from "../channels/mention-gating.js";
export type { AckReactionGateParams, AckReactionScope } from "../channels/ack-reactions.js";
export { shouldAckReaction } from "../channels/ack-reactions.js";
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
export type { NormalizedLocation } from "../channels/location.js"; export type { NormalizedLocation } from "../channels/location.js";
export { formatLocationText, toLocationContext } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js";

View File

@@ -25,6 +25,7 @@ import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../a
import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js"; import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js"; import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js"; import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js";
import { shouldAckReaction } from "../../channels/ack-reactions.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js"; import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js"; import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
@@ -198,6 +199,9 @@ export function createPluginRuntime(): PluginRuntime {
buildMentionRegexes, buildMentionRegexes,
matchesMentionPatterns, matchesMentionPatterns,
}, },
reactions: {
shouldAckReaction,
},
groups: { groups: {
resolveGroupPolicy: resolveChannelGroupPolicy, resolveGroupPolicy: resolveChannelGroupPolicy,
resolveRequireMention: resolveChannelGroupRequireMention, resolveRequireMention: resolveChannelGroupRequireMention,

View File

@@ -19,6 +19,7 @@ type SaveMediaBuffer = typeof import("../../media/store.js").saveMediaBuffer;
type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes; type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes;
type MatchesMentionPatterns = type MatchesMentionPatterns =
typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns; typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns;
type ShouldAckReaction = typeof import("../../channels/ack-reactions.js").shouldAckReaction;
type ResolveChannelGroupPolicy = type ResolveChannelGroupPolicy =
typeof import("../../config/group-policy.js").resolveChannelGroupPolicy; typeof import("../../config/group-policy.js").resolveChannelGroupPolicy;
type ResolveChannelGroupRequireMention = type ResolveChannelGroupRequireMention =
@@ -211,6 +212,9 @@ export type PluginRuntime = {
buildMentionRegexes: BuildMentionRegexes; buildMentionRegexes: BuildMentionRegexes;
matchesMentionPatterns: MatchesMentionPatterns; matchesMentionPatterns: MatchesMentionPatterns;
}; };
reactions: {
shouldAckReaction: ShouldAckReaction;
};
groups: { groups: {
resolveGroupPolicy: ResolveChannelGroupPolicy; resolveGroupPolicy: ResolveChannelGroupPolicy;
resolveRequireMention: ResolveChannelGroupRequireMention; resolveRequireMention: ResolveChannelGroupRequireMention;

View File

@@ -19,6 +19,10 @@ import { buildPairingReply } from "../../../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js"; import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
import {
shouldAckReaction as shouldAckReactionGate,
type AckReactionScope,
} from "../../../channels/ack-reactions.js";
import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js"; import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js";
import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import { resolveConversationLabel } from "../../../channels/conversation-label.js";
import { resolveControlCommandGate } from "../../../channels/command-gating.js"; import { resolveControlCommandGate } from "../../../channels/command-gating.js";
@@ -324,19 +328,20 @@ export async function prepareSlackMessage(params: {
const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReaction = resolveAckReaction(cfg, route.agentId);
const ackReactionValue = ackReaction ?? ""; const ackReactionValue = ackReaction ?? "";
const shouldAckReaction = () => { const shouldAckReaction = () =>
if (!ackReaction) return false; Boolean(
if (ctx.ackReactionScope === "all") return true; ackReaction &&
if (ctx.ackReactionScope === "direct") return isDirectMessage; shouldAckReactionGate({
if (ctx.ackReactionScope === "group-all") return isRoomish; scope: ctx.ackReactionScope as AckReactionScope | undefined,
if (ctx.ackReactionScope === "group-mentions") { isDirect: isDirectMessage,
if (!isRoom) return false; isGroup: isRoomish,
if (!shouldRequireMention) return false; isMentionableGroup: isRoom,
if (!canDetectMention) return false; requireMention: Boolean(shouldRequireMention),
return effectiveWasMentioned; canDetectMention,
} effectiveWasMentioned,
return false; shouldBypassMention: mentionGate.shouldBypassMention,
}; }),
);
const ackReactionMessageTs = message.ts; const ackReactionMessageTs = message.ts;
const ackReactionPromise = const ackReactionPromise =

View File

@@ -24,6 +24,7 @@ import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../conf
import { logVerbose, shouldLogVerbose } from "../globals.js"; import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js"; import { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveAgentRoute } from "../routing/resolve-route.js";
import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js";
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
import { resolveControlCommandGate } from "../channels/command-gating.js"; import { resolveControlCommandGate } from "../channels/command-gating.js";
import { import {
@@ -369,19 +370,20 @@ export const buildTelegramMessageContext = async ({
// ACK reactions // ACK reactions
const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () => { const shouldAckReaction = () =>
if (!ackReaction) return false; Boolean(
if (ackReactionScope === "all") return true; ackReaction &&
if (ackReactionScope === "direct") return !isGroup; shouldAckReactionGate({
if (ackReactionScope === "group-all") return isGroup; scope: ackReactionScope,
if (ackReactionScope === "group-mentions") { isDirect: !isGroup,
if (!isGroup) return false; isGroup,
if (!requireMention) return false; isMentionableGroup: isGroup,
if (!canDetectMention) return false; requireMention: Boolean(requireMention),
return effectiveWasMentioned; canDetectMention,
} effectiveWasMentioned,
return false; shouldBypassMention: mentionGate.shouldBypassMention,
}; }),
);
const api = bot.api as unknown as { const api = bot.api as unknown as {
setMessageReaction?: ( setMessageReaction?: (
chatId: number | string, chatId: number | string,