Skip to content

Commit 625fd5b

Browse files
committed
refactor: centralize inbound mention policy
1 parent c8b7058 commit 625fd5b

31 files changed

Lines changed: 856 additions & 224 deletions

extensions/discord/src/monitor/message-handler.preflight.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,45 @@ describe("preflightDiscordMessage", () => {
594594
expect(result).not.toBeNull();
595595
});
596596

597+
it("still drops bot control commands without a real mention when allowBots=mentions", async () => {
598+
const channelId = "channel-bot-command-no-mention";
599+
const guildId = "guild-bot-command-no-mention";
600+
const message = createDiscordMessage({
601+
id: "m-bot-command-no-mention",
602+
channelId,
603+
content: "/new incident room",
604+
author: {
605+
id: "relay-bot-1",
606+
bot: true,
607+
username: "Relay",
608+
},
609+
});
610+
611+
const result = await runMentionOnlyBotPreflight({ channelId, guildId, message });
612+
613+
expect(result).toBeNull();
614+
});
615+
616+
it("still allows bot control commands with an explicit mention when allowBots=mentions", async () => {
617+
const channelId = "channel-bot-command-with-mention";
618+
const guildId = "guild-bot-command-with-mention";
619+
const message = createDiscordMessage({
620+
id: "m-bot-command-with-mention",
621+
channelId,
622+
content: "<@openclaw-bot> /new incident room",
623+
mentionedUsers: [{ id: "openclaw-bot" }],
624+
author: {
625+
id: "relay-bot-1",
626+
bot: true,
627+
username: "Relay",
628+
},
629+
});
630+
631+
const result = await runMentionOnlyBotPreflight({ channelId, guildId, message });
632+
633+
expect(result).not.toBeNull();
634+
});
635+
597636
it("treats @everyone as a mention when requireMention is true", async () => {
598637
const channelId = "channel-everyone-mention";
599638
const guildId = "guild-everyone-mention";

extensions/discord/src/monitor/message-handler.preflight.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { Routes, type APIMessage } from "discord-api-types/v10";
33
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
44
import {
55
buildMentionRegexes,
6+
implicitMentionKindWhen,
67
logInboundDrop,
78
matchesMentionWithExplicit,
8-
resolveMentionGatingWithBypass,
9+
resolveInboundMentionDecision,
910
} from "openclaw/plugin-sdk/channel-inbound";
1011
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
1112
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
@@ -181,10 +182,10 @@ function resolveDiscordMentionState(params: {
181182
referencedAuthorId?: string;
182183
senderIsPluralKit: boolean;
183184
transcript?: string;
184-
}): { implicitMention: boolean; wasMentioned: boolean } {
185+
}) {
185186
if (params.isDirectMessage) {
186187
return {
187-
implicitMention: false,
188+
implicitMentionKinds: [],
188189
wasMentioned: false,
189190
};
190191
}
@@ -203,12 +204,15 @@ function resolveDiscordMentionState(params: {
203204
},
204205
transcript: params.transcript,
205206
});
206-
const implicitMention = Boolean(
207-
params.botId && params.referencedAuthorId && params.referencedAuthorId === params.botId,
207+
const implicitMentionKinds = implicitMentionKindWhen(
208+
"reply_to_bot",
209+
Boolean(params.botId) &&
210+
Boolean(params.referencedAuthorId) &&
211+
params.referencedAuthorId === params.botId,
208212
);
209213

210214
return {
211-
implicitMention,
215+
implicitMentionKinds,
212216
wasMentioned,
213217
};
214218
}
@@ -887,7 +891,7 @@ export async function preflightDiscordMessage(
887891
}
888892

889893
const mentionText = hasTypedText ? baseText : "";
890-
const { implicitMention, wasMentioned } = resolveDiscordMentionState({
894+
const { implicitMentionKinds, wasMentioned } = resolveDiscordMentionState({
891895
authorIsBot: Boolean(author.bot),
892896
botId,
893897
hasAnyMention,
@@ -946,23 +950,27 @@ export async function preflightDiscordMessage(
946950
}
947951

948952
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
949-
const mentionGate = resolveMentionGatingWithBypass({
950-
isGroup: isGuildMessage,
951-
requireMention: Boolean(shouldRequireMention),
952-
canDetectMention,
953-
wasMentioned,
954-
implicitMention,
955-
hasAnyMention,
956-
allowTextCommands,
957-
hasControlCommand: hasControlCommandInMessage,
958-
commandAuthorized,
953+
const mentionDecision = resolveInboundMentionDecision({
954+
facts: {
955+
canDetectMention,
956+
wasMentioned,
957+
hasAnyMention,
958+
implicitMentionKinds,
959+
},
960+
policy: {
961+
isGroup: isGuildMessage,
962+
requireMention: Boolean(shouldRequireMention),
963+
allowTextCommands,
964+
hasControlCommand: hasControlCommandInMessage,
965+
commandAuthorized,
966+
},
959967
});
960-
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
968+
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
961969
logDebug(
962-
`[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`,
970+
`[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionDecision.shouldSkip=${mentionDecision.shouldSkip} wasMentioned=${wasMentioned}`,
963971
);
964972
if (isGuildMessage && shouldRequireMention) {
965-
if (botId && mentionGate.shouldSkip) {
973+
if (botId && mentionDecision.shouldSkip) {
966974
logDebug(`[discord-preflight] drop: no-mention`);
967975
logVerbose(`discord: drop guild message (mention required, botId=${botId})`);
968976
logger.info(
@@ -983,7 +991,7 @@ export async function preflightDiscordMessage(
983991
}
984992

985993
if (author.bot && !sender.isPluralKit && allowBotsMode === "mentions") {
986-
const botMentioned = isDirectMessage || wasMentioned || implicitMention;
994+
const botMentioned = isDirectMessage || wasMentioned || mentionDecision.implicitMention;
987995
if (!botMentioned) {
988996
logDebug(`[discord-preflight] drop: bot message missing mention (allowBots=mentions)`);
989997
logVerbose("discord: drop bot message (allowBots=mentions, missing mention)");
@@ -998,7 +1006,7 @@ export async function preflightDiscordMessage(
9981006
ignoreOtherMentions &&
9991007
hasUserOrRoleMention &&
10001008
!wasMentioned &&
1001-
!implicitMention
1009+
!mentionDecision.implicitMention
10021010
) {
10031011
logDebug(`[discord-preflight] drop: other-mention`);
10041012
logVerbose(
@@ -1103,7 +1111,7 @@ export async function preflightDiscordMessage(
11031111
shouldRequireMention,
11041112
hasAnyMention,
11051113
allowTextCommands,
1106-
shouldBypassMention: mentionGate.shouldBypassMention,
1114+
shouldBypassMention: mentionDecision.shouldBypassMention,
11071115
effectiveWasMentioned,
11081116
canDetectMention,
11091117
historyEntry,

extensions/googlechat/runtime-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export {
4747
type GoogleChatConfig,
4848
} from "openclaw/plugin-sdk/googlechat-runtime-shared";
4949
export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
50-
export { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-inbound";
50+
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
5151
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
5252
export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-path";
5353
export {

extensions/googlechat/src/monitor-access.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
66
const resolveAllowlistProviderRuntimeGroupPolicy = vi.hoisted(() => vi.fn());
77
const resolveDefaultGroupPolicy = vi.hoisted(() => vi.fn());
88
const resolveDmGroupAccessWithLists = vi.hoisted(() => vi.fn());
9-
const resolveMentionGatingWithBypass = vi.hoisted(() => vi.fn());
9+
const resolveInboundMentionDecision = vi.hoisted(() => vi.fn());
1010
const resolveSenderScopedGroupPolicy = vi.hoisted(() => vi.fn());
1111
const warnMissingProviderGroupPolicyFallbackOnce = vi.hoisted(() => vi.fn());
1212
const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
1313

14+
vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({
15+
resolveInboundMentionDecision,
16+
}));
17+
1418
vi.mock("../runtime-api.js", () => ({
1519
GROUP_POLICY_BLOCKED_LABEL: { space: "space" },
1620
createChannelPairingController,
@@ -19,7 +23,6 @@ vi.mock("../runtime-api.js", () => ({
1923
resolveAllowlistProviderRuntimeGroupPolicy,
2024
resolveDefaultGroupPolicy,
2125
resolveDmGroupAccessWithLists,
22-
resolveMentionGatingWithBypass,
2326
resolveSenderScopedGroupPolicy,
2427
warnMissingProviderGroupPolicyFallbackOnce,
2528
}));
@@ -84,7 +87,7 @@ function allowInboundGroupTraffic(options?: {
8487
effectiveAllowFrom: [],
8588
effectiveGroupAllowFrom: options?.effectiveGroupAllowFrom ?? ["users/alice"],
8689
});
87-
resolveMentionGatingWithBypass.mockReturnValue({
90+
resolveInboundMentionDecision.mockReturnValue({
8891
shouldSkip: false,
8992
effectiveWasMentioned: options?.effectiveWasMentioned ?? true,
9093
});

extensions/googlechat/src/monitor-access.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
12
import {
23
GROUP_POLICY_BLOCKED_LABEL,
34
createChannelPairingController,
@@ -6,7 +7,6 @@ import {
67
resolveAllowlistProviderRuntimeGroupPolicy,
78
resolveDefaultGroupPolicy,
89
resolveDmGroupAccessWithLists,
9-
resolveMentionGatingWithBypass,
1010
resolveSenderScopedGroupPolicy,
1111
warnMissingProviderGroupPolicyFallbackOnce,
1212
type OpenClawConfig,
@@ -321,19 +321,23 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
321321
cfg: config,
322322
surface: "googlechat",
323323
});
324-
const mentionGate = resolveMentionGatingWithBypass({
325-
isGroup: true,
326-
requireMention,
327-
canDetectMention: true,
328-
wasMentioned: mentionInfo.wasMentioned,
329-
implicitMention: false,
330-
hasAnyMention: mentionInfo.hasAnyMention,
331-
allowTextCommands,
332-
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
333-
commandAuthorized: commandAuthorized === true,
324+
const mentionDecision = resolveInboundMentionDecision({
325+
facts: {
326+
canDetectMention: true,
327+
wasMentioned: mentionInfo.wasMentioned,
328+
hasAnyMention: mentionInfo.hasAnyMention,
329+
implicitMentionKinds: [],
330+
},
331+
policy: {
332+
isGroup: true,
333+
requireMention,
334+
allowTextCommands,
335+
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
336+
commandAuthorized: commandAuthorized === true,
337+
},
334338
});
335-
effectiveWasMentioned = mentionGate.effectiveWasMentioned;
336-
if (mentionGate.shouldSkip) {
339+
effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
340+
if (mentionDecision.shouldSkip) {
337341
logVerbose(`drop group message (mention required, space=${spaceId})`);
338342
return { ok: false };
339343
}

extensions/imessage/src/monitor/inbound-processing.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
logInboundDrop,
77
matchesMentionPatterns,
88
resolveEnvelopeFormatOptions,
9+
resolveInboundMentionDecision,
910
} from "openclaw/plugin-sdk/channel-inbound";
1011
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
1112
import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-auth";
@@ -468,10 +469,23 @@ export function resolveIMessageInboundDecision(params: {
468469
return { kind: "drop", reason: "control command (unauthorized)" };
469470
}
470471

471-
const shouldBypassMention =
472-
isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage;
473-
const effectiveWasMentioned = mentioned || shouldBypassMention;
474-
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
472+
const mentionDecision = resolveInboundMentionDecision({
473+
facts: {
474+
canDetectMention,
475+
wasMentioned: mentioned,
476+
hasAnyMention: false,
477+
implicitMentionKinds: [],
478+
},
479+
policy: {
480+
isGroup,
481+
requireMention,
482+
allowTextCommands: true,
483+
hasControlCommand: hasControlCommandInMessage,
484+
commandAuthorized,
485+
},
486+
});
487+
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
488+
if (isGroup && requireMention && canDetectMention && mentionDecision.shouldSkip) {
475489
params.logVerbose?.(`imessage: skipping group message (no mention)`);
476490
recordPendingHistoryEntryIfEnabled({
477491
historyMap: params.groupHistories,

extensions/line/src/bot-handlers.test.ts

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,76 @@ type PostbackEvent = webhook.PostbackEvent;
1111
vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({
1212
buildMentionRegexes: () => [],
1313
matchesMentionPatterns: () => false,
14-
resolveMentionGatingWithBypass: ({
15-
isGroup,
16-
requireMention,
17-
canDetectMention,
18-
wasMentioned,
19-
hasAnyMention,
20-
allowTextCommands,
21-
hasControlCommand,
22-
commandAuthorized,
23-
}: {
24-
isGroup: boolean;
25-
requireMention: boolean;
26-
canDetectMention: boolean;
27-
wasMentioned: boolean;
28-
hasAnyMention: boolean;
29-
allowTextCommands: boolean;
30-
hasControlCommand: boolean;
31-
commandAuthorized: boolean;
32-
}) => ({
33-
shouldSkip:
34-
isGroup &&
35-
requireMention &&
36-
canDetectMention &&
37-
!wasMentioned &&
38-
!(allowTextCommands && hasControlCommand && commandAuthorized && !hasAnyMention),
39-
}),
14+
resolveInboundMentionDecision: (params: {
15+
facts?: {
16+
canDetectMention: boolean;
17+
wasMentioned: boolean;
18+
hasAnyMention?: boolean;
19+
};
20+
policy?: {
21+
isGroup: boolean;
22+
requireMention: boolean;
23+
allowTextCommands: boolean;
24+
hasControlCommand: boolean;
25+
commandAuthorized: boolean;
26+
};
27+
isGroup?: boolean;
28+
requireMention?: boolean;
29+
canDetectMention?: boolean;
30+
wasMentioned?: boolean;
31+
hasAnyMention?: boolean;
32+
allowTextCommands?: boolean;
33+
hasControlCommand?: boolean;
34+
commandAuthorized?: boolean;
35+
}) => {
36+
const facts =
37+
"facts" in params && params.facts
38+
? params.facts
39+
: {
40+
canDetectMention: Boolean(params.canDetectMention),
41+
wasMentioned: Boolean(params.wasMentioned),
42+
hasAnyMention: params.hasAnyMention,
43+
};
44+
const policy =
45+
"policy" in params && params.policy
46+
? params.policy
47+
: {
48+
isGroup: Boolean(params.isGroup),
49+
requireMention: Boolean(params.requireMention),
50+
allowTextCommands: Boolean(params.allowTextCommands),
51+
hasControlCommand: Boolean(params.hasControlCommand),
52+
commandAuthorized: Boolean(params.commandAuthorized),
53+
};
54+
return {
55+
effectiveWasMentioned:
56+
facts.wasMentioned ||
57+
(policy.allowTextCommands &&
58+
policy.hasControlCommand &&
59+
policy.commandAuthorized &&
60+
!facts.hasAnyMention),
61+
shouldSkip:
62+
policy.isGroup &&
63+
policy.requireMention &&
64+
facts.canDetectMention &&
65+
!facts.wasMentioned &&
66+
!(
67+
policy.allowTextCommands &&
68+
policy.hasControlCommand &&
69+
policy.commandAuthorized &&
70+
!facts.hasAnyMention
71+
),
72+
shouldBypassMention:
73+
policy.isGroup &&
74+
policy.requireMention &&
75+
!facts.wasMentioned &&
76+
!facts.hasAnyMention &&
77+
policy.allowTextCommands &&
78+
policy.hasControlCommand &&
79+
policy.commandAuthorized,
80+
implicitMention: false,
81+
matchedImplicitMentionKinds: [],
82+
};
83+
},
4084
}));
4185
vi.mock("openclaw/plugin-sdk/channel-pairing", () => ({
4286
createChannelPairingChallengeIssuer:

0 commit comments

Comments
 (0)