refactor: unify channel config matching and gating

Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-18 01:21:27 +00:00
parent 05f49d2846
commit f73dbdbaea
24 changed files with 430 additions and 120 deletions

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "./channel-config.js";
import {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback,
} from "./channel-config.js";
describe("buildChannelKeyCandidates", () => {
it("dedupes and trims keys", () => {
@@ -22,3 +26,44 @@ describe("resolveChannelEntryMatch", () => {
expect(match.wildcardKey).toBe("*");
});
});
describe("resolveChannelEntryMatchWithFallback", () => {
it("prefers direct matches over parent and wildcard", () => {
const entries = { a: { allow: true }, parent: { allow: false }, "*": { allow: false } };
const match = resolveChannelEntryMatchWithFallback({
entries,
keys: ["a"],
parentKeys: ["parent"],
wildcardKey: "*",
});
expect(match.entry).toBe(entries.a);
expect(match.matchSource).toBe("direct");
expect(match.matchKey).toBe("a");
});
it("falls back to parent when direct misses", () => {
const entries = { parent: { allow: false }, "*": { allow: true } };
const match = resolveChannelEntryMatchWithFallback({
entries,
keys: ["missing"],
parentKeys: ["parent"],
wildcardKey: "*",
});
expect(match.entry).toBe(entries.parent);
expect(match.matchSource).toBe("parent");
expect(match.matchKey).toBe("parent");
});
it("falls back to wildcard when no direct or parent match", () => {
const entries = { "*": { allow: true } };
const match = resolveChannelEntryMatchWithFallback({
entries,
keys: ["missing"],
parentKeys: ["still-missing"],
wildcardKey: "*",
});
expect(match.entry).toBe(entries["*"]);
expect(match.matchSource).toBe("wildcard");
expect(match.matchKey).toBe("*");
});
});

View File

@@ -1,11 +1,22 @@
export type ChannelMatchSource = "direct" | "parent" | "wildcard";
export function buildChannelKeyCandidates(
...keys: Array<string | undefined | null>
): string[] {
export type ChannelEntryMatch<T> = {
entry?: T;
key?: string;
wildcardEntry?: T;
wildcardKey?: string;
parentEntry?: T;
parentKey?: string;
matchKey?: string;
matchSource?: ChannelMatchSource;
};
export function buildChannelKeyCandidates(...keys: Array<string | undefined | null>): string[] {
export function buildChannelKeyCandidates(
...keys: Array<string | undefined | null>
): string[] {
const seen = new Set<string>();
const candidates: string[] = [];
for (const key of keys) {
@@ -37,3 +48,48 @@ export function resolveChannelEntryMatch<T>(params: {
}
return match;
}
export function resolveChannelEntryMatchWithFallback<T>(params: {
entries?: Record<string, T>;
keys: string[];
parentKeys?: string[];
wildcardKey?: string;
}): ChannelEntryMatch<T> {
const direct = resolveChannelEntryMatch({
entries: params.entries,
keys: params.keys,
wildcardKey: params.wildcardKey,
});
if (direct.entry && direct.key) {
return { ...direct, matchKey: direct.key, matchSource: "direct" };
}
const parentKeys = params.parentKeys ?? [];
if (parentKeys.length > 0) {
const parent = resolveChannelEntryMatch({ entries: params.entries, keys: parentKeys });
if (parent.entry && parent.key) {
return {
...direct,
entry: parent.entry,
key: parent.key,
parentEntry: parent.entry,
parentKey: parent.key,
matchKey: parent.key,
matchSource: "parent",
};
}
}
if (direct.wildcardEntry && direct.wildcardKey) {
return {
...direct,
entry: direct.wildcardEntry,
key: direct.wildcardKey,
matchKey: direct.wildcardKey,
matchSource: "wildcard",
};
}
return direct;
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveCommandAuthorizedFromAuthorizers } from "./command-gating.js";
import { resolveCommandAuthorizedFromAuthorizers, resolveControlCommandGate } from "./command-gating.js";
describe("resolveCommandAuthorizedFromAuthorizers", () => {
it("denies when useAccessGroups is enabled and no authorizer is configured", () => {
@@ -70,3 +70,26 @@ describe("resolveCommandAuthorizedFromAuthorizers", () => {
).toBe(true);
});
});
describe("resolveControlCommandGate", () => {
it("blocks control commands when unauthorized", () => {
const result = resolveControlCommandGate({
useAccessGroups: true,
authorizers: [{ configured: true, allowed: false }],
allowTextCommands: true,
hasControlCommand: true,
});
expect(result.commandAuthorized).toBe(false);
expect(result.shouldBlock).toBe(true);
});
it("does not block when control commands are disabled", () => {
const result = resolveControlCommandGate({
useAccessGroups: true,
authorizers: [{ configured: true, allowed: false }],
allowTextCommands: false,
hasControlCommand: true,
});
expect(result.shouldBlock).toBe(false);
});
});

View File

@@ -21,3 +21,19 @@ export function resolveCommandAuthorizedFromAuthorizers(params: {
}
return authorizers.some((entry) => entry.configured && entry.allowed);
}
export function resolveControlCommandGate(params: {
useAccessGroups: boolean;
authorizers: CommandAuthorizer[];
allowTextCommands: boolean;
hasControlCommand: boolean;
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): { commandAuthorized: boolean; shouldBlock: boolean } {
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: params.useAccessGroups,
authorizers: params.authorizers,
modeWhenAccessGroupsOff: params.modeWhenAccessGroupsOff,
});
const shouldBlock = params.allowTextCommands && params.hasControlCommand && !commandAuthorized;
return { commandAuthorized, shouldBlock };
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveMentionGating } from "./mention-gating.js";
import { resolveMentionGating, resolveMentionGatingWithBypass } from "./mention-gating.js";
describe("resolveMentionGating", () => {
it("combines explicit, implicit, and bypass mentions", () => {
@@ -36,3 +36,35 @@ describe("resolveMentionGating", () => {
expect(res.shouldSkip).toBe(false);
});
});
describe("resolveMentionGatingWithBypass", () => {
it("enables bypass when control commands are authorized", () => {
const res = resolveMentionGatingWithBypass({
isGroup: true,
requireMention: true,
canDetectMention: true,
wasMentioned: false,
hasAnyMention: false,
allowTextCommands: true,
hasControlCommand: true,
commandAuthorized: true,
});
expect(res.shouldBypassMention).toBe(true);
expect(res.shouldSkip).toBe(false);
});
it("does not bypass when control commands are not authorized", () => {
const res = resolveMentionGatingWithBypass({
isGroup: true,
requireMention: true,
canDetectMention: true,
wasMentioned: false,
hasAnyMention: false,
allowTextCommands: true,
hasControlCommand: true,
commandAuthorized: false,
});
expect(res.shouldBypassMention).toBe(false);
expect(res.shouldSkip).toBe(true);
});
});

View File

@@ -11,6 +11,22 @@ export type MentionGateResult = {
shouldSkip: boolean;
};
export type MentionGateWithBypassParams = {
isGroup: boolean;
requireMention: boolean;
canDetectMention: boolean;
wasMentioned: boolean;
implicitMention?: boolean;
hasAnyMention?: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
};
export type MentionGateWithBypassResult = MentionGateResult & {
shouldBypassMention: boolean;
};
export function resolveMentionGating(params: MentionGateParams): MentionGateResult {
const implicit = params.implicitMention === true;
const bypass = params.shouldBypassMention === true;
@@ -18,3 +34,26 @@ export function resolveMentionGating(params: MentionGateParams): MentionGateResu
const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
return { effectiveWasMentioned, shouldSkip };
}
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,
canDetectMention: params.canDetectMention,
wasMentioned: params.wasMentioned,
implicitMention: params.implicitMention,
shouldBypassMention,
}),
shouldBypassMention,
};
}

View File

@@ -1,2 +1,6 @@
export type { ChannelEntryMatch } from "../channel-config.js";
export { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../channel-config.js";
export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js";
export {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback,
} from "../channel-config.js";

View File

@@ -87,6 +87,8 @@ export {
export {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback,
type ChannelEntryMatch,
type ChannelMatchSource,
} from "./channel-config.js";
export type { ChannelId, ChannelPlugin } from "./types.js";

View File

@@ -1,5 +1,5 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
import { asString, isRecord } from "./shared.js";
import { appendMatchMetadata, asString, isRecord } from "./shared.js";
type DiscordIntentSummary = {
messageContent?: "enabled" | "limited" | "disabled";
@@ -128,15 +128,15 @@ export function collectDiscordStatusIssues(
if (channel.ok === true) continue;
const missing = channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : "";
const error = channel.error ? `: ${channel.error}` : "";
const matchMeta =
channel.matchKey || channel.matchSource
? ` (matchKey=${channel.matchKey ?? "none"} matchSource=${channel.matchSource ?? "none"})`
: "";
const baseMessage = `Channel ${channel.channelId} permission check failed.${missing}${error}`;
issues.push({
channel: "discord",
accountId,
kind: "permissions",
message: `Channel ${channel.channelId} permission check failed.${missing}${error}${matchMeta}`,
message: appendMatchMetadata(baseMessage, {
matchKey: channel.matchKey,
matchSource: channel.matchSource,
}),
fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).",
});
}

View File

@@ -5,3 +5,27 @@ export function asString(value: unknown): string | undefined {
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function formatMatchMetadata(params: {
matchKey?: unknown;
matchSource?: unknown;
}): string | undefined {
const matchKey =
typeof params.matchKey === "string"
? params.matchKey
: typeof params.matchKey === "number"
? String(params.matchKey)
: undefined;
const matchSource = asString(params.matchSource);
const parts = [matchKey ? `matchKey=${matchKey}` : null, matchSource ? `matchSource=${matchSource}` : null]
.filter((entry): entry is string => Boolean(entry));
return parts.length > 0 ? parts.join(" ") : undefined;
}
export function appendMatchMetadata(
message: string,
params: { matchKey?: unknown; matchSource?: unknown },
): string {
const meta = formatMatchMetadata(params);
return meta ? `${message} (${meta})` : message;
}

View File

@@ -1,5 +1,5 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
import { asString, isRecord } from "./shared.js";
import { appendMatchMetadata, asString, isRecord } from "./shared.js";
type TelegramAccountStatus = {
accountId?: unknown;
@@ -111,15 +111,15 @@ export function collectTelegramStatusIssues(
if (group.ok === true) continue;
const status = group.status ? ` status=${group.status}` : "";
const err = group.error ? `: ${group.error}` : "";
const matchMeta =
group.matchKey || group.matchSource
? ` (matchKey=${group.matchKey ?? "none"} matchSource=${group.matchSource ?? "none"})`
: "";
const baseMessage = `Group ${group.chatId} not reachable by bot.${status}${err}`;
issues.push({
channel: "telegram",
accountId,
kind: "runtime",
message: `Group ${group.chatId} not reachable by bot.${status}${err}${matchMeta}`,
message: appendMatchMetadata(baseMessage, {
matchKey: group.matchKey,
matchSource: group.matchSource,
}),
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
});
}