refactor: centralize inbound mention policy

This commit is contained in:
Peter Steinberger
2026-04-07 07:50:09 +01:00
parent c8b7058058
commit 625fd5b3e3
31 changed files with 857 additions and 225 deletions

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { resolveMentionGating, resolveMentionGatingWithBypass } from "./mention-gating.js";
import {
implicitMentionKindWhen,
resolveInboundMentionDecision,
resolveMentionGating,
resolveMentionGatingWithBypass,
} from "./mention-gating.js";
describe("resolveMentionGating", () => {
it("combines explicit, implicit, and bypass mentions", () => {
@@ -65,3 +70,192 @@ describe("resolveMentionGatingWithBypass", () => {
expect(res.shouldSkip).toBe(shouldSkip);
});
});
describe("resolveInboundMentionDecision", () => {
it("allows matching implicit mention kinds by default", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
implicitMentionKinds: ["reply_to_bot"],
},
policy: {
isGroup: true,
requireMention: true,
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
},
});
expect(res.implicitMention).toBe(true);
expect(res.matchedImplicitMentionKinds).toEqual(["reply_to_bot"]);
expect(res.effectiveWasMentioned).toBe(true);
expect(res.shouldSkip).toBe(false);
});
it("filters implicit mention kinds through the allowlist", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
implicitMentionKinds: ["reply_to_bot", "bot_thread_participant"],
},
policy: {
isGroup: true,
requireMention: true,
allowedImplicitMentionKinds: ["reply_to_bot"],
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
},
});
expect(res.implicitMention).toBe(true);
expect(res.matchedImplicitMentionKinds).toEqual(["reply_to_bot"]);
expect(res.shouldSkip).toBe(false);
});
it("blocks implicit mention kinds excluded by policy", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
implicitMentionKinds: ["reply_to_bot"],
},
policy: {
isGroup: true,
requireMention: true,
allowedImplicitMentionKinds: [],
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
},
});
expect(res.implicitMention).toBe(false);
expect(res.matchedImplicitMentionKinds).toEqual([]);
expect(res.effectiveWasMentioned).toBe(false);
expect(res.shouldSkip).toBe(true);
});
it("dedupes repeated implicit mention kinds", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
implicitMentionKinds: ["reply_to_bot", "reply_to_bot", "native"],
},
policy: {
isGroup: true,
requireMention: true,
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
},
});
expect(res.matchedImplicitMentionKinds).toEqual(["reply_to_bot", "native"]);
});
it("keeps command bypass behavior unchanged", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
hasAnyMention: false,
implicitMentionKinds: [],
},
policy: {
isGroup: true,
requireMention: true,
allowTextCommands: true,
hasControlCommand: true,
commandAuthorized: true,
},
});
expect(res.shouldBypassMention).toBe(true);
expect(res.effectiveWasMentioned).toBe(true);
expect(res.shouldSkip).toBe(false);
});
it("does not allow command bypass when some other mention is present", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
hasAnyMention: true,
implicitMentionKinds: [],
},
policy: {
isGroup: true,
requireMention: true,
allowTextCommands: true,
hasControlCommand: true,
commandAuthorized: true,
},
});
expect(res.shouldBypassMention).toBe(false);
expect(res.effectiveWasMentioned).toBe(false);
expect(res.shouldSkip).toBe(true);
});
it("does not allow command bypass outside groups", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
hasAnyMention: false,
implicitMentionKinds: [],
},
policy: {
isGroup: false,
requireMention: true,
allowTextCommands: true,
hasControlCommand: true,
commandAuthorized: true,
},
});
expect(res.shouldBypassMention).toBe(false);
expect(res.effectiveWasMentioned).toBe(false);
expect(res.shouldSkip).toBe(true);
});
it("does not skip when mention detection is unavailable", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: false,
wasMentioned: false,
implicitMentionKinds: [],
},
policy: {
isGroup: true,
requireMention: true,
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
},
});
expect(res.shouldSkip).toBe(false);
});
it("keeps the flat call shape for compatibility", () => {
const res = resolveInboundMentionDecision({
isGroup: true,
requireMention: true,
canDetectMention: true,
wasMentioned: false,
implicitMentionKinds: ["reply_to_bot"],
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
});
expect(res.effectiveWasMentioned).toBe(true);
});
});
describe("implicitMentionKindWhen", () => {
it("returns a one-item list when enabled", () => {
expect(implicitMentionKindWhen("reply_to_bot", true)).toEqual(["reply_to_bot"]);
});
it("returns an empty list when disabled", () => {
expect(implicitMentionKindWhen("reply_to_bot", false)).toEqual([]);
});
});

View File

@@ -1,3 +1,4 @@
/** @deprecated Prefer `resolveInboundMentionDecision({ facts, policy })`. */
export type MentionGateParams = {
requireMention: boolean;
canDetectMention: boolean;
@@ -6,11 +7,13 @@ export type MentionGateParams = {
shouldBypassMention?: boolean;
};
/** @deprecated Prefer `InboundMentionDecision`. */
export type MentionGateResult = {
effectiveWasMentioned: boolean;
shouldSkip: boolean;
};
/** @deprecated Prefer `resolveInboundMentionDecision({ facts, policy })`. */
export type MentionGateWithBypassParams = {
isGroup: boolean;
requireMention: boolean;
@@ -23,37 +26,207 @@ export type MentionGateWithBypassParams = {
commandAuthorized: boolean;
};
/** @deprecated Prefer `InboundMentionDecision`. */
export type MentionGateWithBypassResult = MentionGateResult & {
shouldBypassMention: boolean;
};
export function resolveMentionGating(params: MentionGateParams): MentionGateResult {
const implicit = params.implicitMention === true;
const bypass = params.shouldBypassMention === true;
const effectiveWasMentioned = params.wasMentioned || implicit || bypass;
const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
return { effectiveWasMentioned, shouldSkip };
export type InboundImplicitMentionKind =
| "reply_to_bot"
| "quoted_bot"
| "bot_thread_participant"
| "native";
export type InboundMentionFacts = {
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention?: boolean;
implicitMentionKinds?: readonly InboundImplicitMentionKind[];
};
export type InboundMentionPolicy = {
isGroup: boolean;
requireMention: boolean;
allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[];
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
};
/** @deprecated Prefer the nested `{ facts, policy }` call shape for new code. */
export type ResolveInboundMentionDecisionFlatParams = InboundMentionFacts & InboundMentionPolicy;
export type ResolveInboundMentionDecisionNestedParams = {
facts: InboundMentionFacts;
policy: InboundMentionPolicy;
};
export type ResolveInboundMentionDecisionParams =
| ResolveInboundMentionDecisionFlatParams
| ResolveInboundMentionDecisionNestedParams;
export type InboundMentionDecision = MentionGateResult & {
implicitMention: boolean;
matchedImplicitMentionKinds: InboundImplicitMentionKind[];
shouldBypassMention: boolean;
};
export function implicitMentionKindWhen(
kind: InboundImplicitMentionKind,
enabled: boolean,
): InboundImplicitMentionKind[] {
return enabled ? [kind] : [];
}
function resolveMatchedImplicitMentionKinds(params: {
implicitMentionKinds?: readonly InboundImplicitMentionKind[];
allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[];
}): InboundImplicitMentionKind[] {
const inputKinds = params.implicitMentionKinds ?? [];
if (inputKinds.length === 0) {
return [];
}
const allowedKinds = params.allowedImplicitMentionKinds
? new Set(params.allowedImplicitMentionKinds)
: null;
const matched: InboundImplicitMentionKind[] = [];
for (const kind of inputKinds) {
if (allowedKinds && !allowedKinds.has(kind)) {
continue;
}
if (!matched.includes(kind)) {
matched.push(kind);
}
}
return matched;
}
function resolveMentionDecisionCore(params: {
requireMention: boolean;
canDetectMention: boolean;
wasMentioned: boolean;
implicitMentionKinds?: readonly InboundImplicitMentionKind[];
allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[];
shouldBypassMention: boolean;
}): InboundMentionDecision {
const matchedImplicitMentionKinds = resolveMatchedImplicitMentionKinds({
implicitMentionKinds: params.implicitMentionKinds,
allowedImplicitMentionKinds: params.allowedImplicitMentionKinds,
});
const implicitMention = matchedImplicitMentionKinds.length > 0;
const effectiveWasMentioned =
params.wasMentioned || implicitMention || params.shouldBypassMention;
const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
return {
implicitMention,
matchedImplicitMentionKinds,
effectiveWasMentioned,
shouldBypassMention: params.shouldBypassMention,
shouldSkip,
};
}
function hasNestedMentionDecisionParams(
params: ResolveInboundMentionDecisionParams,
): params is ResolveInboundMentionDecisionNestedParams {
return "facts" in params && "policy" in params;
}
function normalizeMentionDecisionParams(
params: ResolveInboundMentionDecisionParams,
): ResolveInboundMentionDecisionNestedParams {
if (hasNestedMentionDecisionParams(params)) {
return params;
}
const {
canDetectMention,
wasMentioned,
hasAnyMention,
implicitMentionKinds,
isGroup,
requireMention,
allowedImplicitMentionKinds,
allowTextCommands,
hasControlCommand,
commandAuthorized,
} = params;
return {
facts: {
canDetectMention,
wasMentioned,
hasAnyMention,
implicitMentionKinds,
},
policy: {
isGroup,
requireMention,
allowedImplicitMentionKinds,
allowTextCommands,
hasControlCommand,
commandAuthorized,
},
};
}
export function resolveInboundMentionDecision(
params: ResolveInboundMentionDecisionParams,
): InboundMentionDecision {
const { facts, policy } = normalizeMentionDecisionParams(params);
const shouldBypassMention =
policy.isGroup &&
policy.requireMention &&
!facts.wasMentioned &&
!(facts.hasAnyMention ?? false) &&
policy.allowTextCommands &&
policy.commandAuthorized &&
policy.hasControlCommand;
return resolveMentionDecisionCore({
requireMention: policy.requireMention,
canDetectMention: facts.canDetectMention,
wasMentioned: facts.wasMentioned,
implicitMentionKinds: facts.implicitMentionKinds,
allowedImplicitMentionKinds: policy.allowedImplicitMentionKinds,
shouldBypassMention,
});
}
/** @deprecated Prefer `resolveInboundMentionDecision({ facts, policy })`. */
export function resolveMentionGating(params: MentionGateParams): MentionGateResult {
const result = resolveMentionDecisionCore({
requireMention: params.requireMention,
canDetectMention: params.canDetectMention,
wasMentioned: params.wasMentioned,
implicitMentionKinds: implicitMentionKindWhen("native", params.implicitMention === true),
shouldBypassMention: params.shouldBypassMention === true,
});
return {
effectiveWasMentioned: result.effectiveWasMentioned,
shouldSkip: result.shouldSkip,
};
}
/** @deprecated Prefer `resolveInboundMentionDecision({ facts, policy })`. */
export function resolveMentionGatingWithBypass(
params: MentionGateWithBypassParams,
): MentionGateWithBypassResult {
const shouldBypassMention =
params.isGroup &&
params.requireMention &&
!params.wasMentioned &&
!(params.hasAnyMention ?? false) &&
params.allowTextCommands &&
params.commandAuthorized &&
params.hasControlCommand;
return {
...resolveMentionGating({
requireMention: params.requireMention,
const result = resolveInboundMentionDecision({
facts: {
canDetectMention: params.canDetectMention,
wasMentioned: params.wasMentioned,
implicitMention: params.implicitMention,
shouldBypassMention,
}),
shouldBypassMention,
hasAnyMention: params.hasAnyMention,
implicitMentionKinds: implicitMentionKindWhen("native", params.implicitMention === true),
},
policy: {
isGroup: params.isGroup,
requireMention: params.requireMention,
allowTextCommands: params.allowTextCommands,
hasControlCommand: params.hasControlCommand,
commandAuthorized: params.commandAuthorized,
},
});
return {
effectiveWasMentioned: result.effectiveWasMentioned,
shouldSkip: result.shouldSkip,
shouldBypassMention: result.shouldBypassMention,
};
}