refactor: unify channel config matching and gating
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
This commit is contained in:
@@ -268,6 +268,7 @@ describe("discord mention gating", () => {
|
||||
scope: "thread",
|
||||
});
|
||||
expect(channelConfig?.matchSource).toBe("parent");
|
||||
expect(channelConfig?.matchKey).toBe("parent-1");
|
||||
expect(
|
||||
resolveDiscordShouldRequireMention({
|
||||
isGuildMessage: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Guild, User } from "@buape/carbon";
|
||||
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
resolveChannelEntryMatch,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
} from "../../channels/channel-config.js";
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
|
||||
@@ -178,40 +178,47 @@ type DiscordChannelLookup = {
|
||||
slug?: string;
|
||||
};
|
||||
type DiscordChannelScope = "channel" | "thread";
|
||||
type DiscordChannelMatch = {
|
||||
entry: DiscordChannelEntry;
|
||||
key: string;
|
||||
};
|
||||
|
||||
function resolveDiscordChannelEntry(
|
||||
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
|
||||
function buildDiscordChannelKeys(
|
||||
params: DiscordChannelLookup & { allowNameMatch?: boolean },
|
||||
): DiscordChannelMatch | null {
|
||||
): string[] {
|
||||
const allowNameMatch = params.allowNameMatch !== false;
|
||||
const keys = buildChannelKeyCandidates(
|
||||
return buildChannelKeyCandidates(
|
||||
params.id,
|
||||
allowNameMatch ? params.slug : undefined,
|
||||
allowNameMatch ? params.name : undefined,
|
||||
);
|
||||
const { entry, key } = resolveChannelEntryMatch({ entries: channels, keys });
|
||||
if (!entry || !key) return null;
|
||||
return { entry, key };
|
||||
}
|
||||
|
||||
function resolveDiscordChannelEntryMatch(
|
||||
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
|
||||
params: DiscordChannelLookup & { allowNameMatch?: boolean },
|
||||
parentParams?: DiscordChannelLookup,
|
||||
) {
|
||||
const keys = buildDiscordChannelKeys(params);
|
||||
const parentKeys = parentParams ? buildDiscordChannelKeys(parentParams) : undefined;
|
||||
return resolveChannelEntryMatchWithFallback({
|
||||
entries: channels,
|
||||
keys,
|
||||
parentKeys,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveDiscordChannelConfigEntry(
|
||||
match: DiscordChannelMatch,
|
||||
entry: DiscordChannelEntry,
|
||||
matchKey: string | undefined,
|
||||
matchSource: "direct" | "parent",
|
||||
): DiscordChannelConfigResolved {
|
||||
const resolved: DiscordChannelConfigResolved = {
|
||||
allowed: match.entry.allow !== false,
|
||||
requireMention: match.entry.requireMention,
|
||||
skills: match.entry.skills,
|
||||
enabled: match.entry.enabled,
|
||||
users: match.entry.users,
|
||||
systemPrompt: match.entry.systemPrompt,
|
||||
autoThread: match.entry.autoThread,
|
||||
allowed: entry.allow !== false,
|
||||
requireMention: entry.requireMention,
|
||||
skills: entry.skills,
|
||||
enabled: entry.enabled,
|
||||
users: entry.users,
|
||||
systemPrompt: entry.systemPrompt,
|
||||
autoThread: entry.autoThread,
|
||||
};
|
||||
if (match.key) resolved.matchKey = match.key;
|
||||
if (matchKey) resolved.matchKey = matchKey;
|
||||
resolved.matchSource = matchSource;
|
||||
return resolved;
|
||||
}
|
||||
@@ -225,13 +232,13 @@ export function resolveDiscordChannelConfig(params: {
|
||||
const { guildInfo, channelId, channelName, channelSlug } = params;
|
||||
const channels = guildInfo?.channels;
|
||||
if (!channels) return null;
|
||||
const entry = resolveDiscordChannelEntry(channels, {
|
||||
const match = resolveDiscordChannelEntryMatch(channels, {
|
||||
id: channelId,
|
||||
name: channelName,
|
||||
slug: channelSlug,
|
||||
});
|
||||
if (!entry) return { allowed: false };
|
||||
return resolveDiscordChannelConfigEntry(entry, "direct");
|
||||
if (!match.entry || !match.matchKey) return { allowed: false };
|
||||
return resolveDiscordChannelConfigEntry(match.entry, match.matchKey, "direct");
|
||||
}
|
||||
|
||||
export function resolveDiscordChannelConfigWithFallback(params: {
|
||||
@@ -256,21 +263,29 @@ export function resolveDiscordChannelConfigWithFallback(params: {
|
||||
} = params;
|
||||
const channels = guildInfo?.channels;
|
||||
if (!channels) return null;
|
||||
const entry = resolveDiscordChannelEntry(channels, {
|
||||
id: channelId,
|
||||
name: channelName,
|
||||
slug: channelSlug,
|
||||
allowNameMatch: scope !== "thread",
|
||||
});
|
||||
if (entry) return resolveDiscordChannelConfigEntry(entry, "direct");
|
||||
if (parentId || parentName || parentSlug) {
|
||||
const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
|
||||
const parentEntry = resolveDiscordChannelEntry(channels, {
|
||||
id: parentId ?? "",
|
||||
name: parentName,
|
||||
slug: resolvedParentSlug,
|
||||
});
|
||||
if (parentEntry) return resolveDiscordChannelConfigEntry(parentEntry, "parent");
|
||||
const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
|
||||
const match = resolveDiscordChannelEntryMatch(
|
||||
channels,
|
||||
{
|
||||
id: channelId,
|
||||
name: channelName,
|
||||
slug: channelSlug,
|
||||
allowNameMatch: scope !== "thread",
|
||||
},
|
||||
parentId || parentName || parentSlug
|
||||
? {
|
||||
id: parentId ?? "",
|
||||
name: parentName,
|
||||
slug: resolvedParentSlug,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (match.entry && match.matchKey && match.matchSource) {
|
||||
return resolveDiscordChannelConfigEntry(
|
||||
match.entry,
|
||||
match.matchKey,
|
||||
match.matchSource === "parent" ? "parent" : "direct",
|
||||
);
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { resolveMentionGating } from "../../channels/mention-gating.js";
|
||||
import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js";
|
||||
import { sendMessageDiscord } from "../send.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||
import { resolveControlCommandGate } from "../../channels/command-gating.js";
|
||||
import {
|
||||
allowListMatches,
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
@@ -347,6 +347,7 @@ export async function preflightDiscordMessage(
|
||||
cfg: params.cfg,
|
||||
surface: "discord",
|
||||
});
|
||||
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
|
||||
|
||||
if (!isDirectMessage) {
|
||||
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
|
||||
@@ -368,36 +369,35 @@ export async function preflightDiscordMessage(
|
||||
})
|
||||
: false;
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: ownerAllowList != null, allowed: ownerOk },
|
||||
{ configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk },
|
||||
],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
allowTextCommands,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
commandAuthorized = commandGate.commandAuthorized;
|
||||
|
||||
if (allowTextCommands && hasControlCommand(baseText, params.cfg) && !commandAuthorized) {
|
||||
if (commandGate.shouldBlock) {
|
||||
logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldBypassMention =
|
||||
allowTextCommands &&
|
||||
isGuildMessage &&
|
||||
shouldRequireMention &&
|
||||
!wasMentioned &&
|
||||
!hasAnyMention &&
|
||||
commandAuthorized &&
|
||||
hasControlCommand(baseText, params.cfg);
|
||||
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
|
||||
const mentionGate = resolveMentionGating({
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup: isGuildMessage,
|
||||
requireMention: Boolean(shouldRequireMention),
|
||||
canDetectMention,
|
||||
wasMentioned,
|
||||
implicitMention,
|
||||
shouldBypassMention,
|
||||
hasAnyMention,
|
||||
allowTextCommands,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
commandAuthorized,
|
||||
});
|
||||
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||
if (isGuildMessage && shouldRequireMention) {
|
||||
@@ -504,7 +504,7 @@ export async function preflightDiscordMessage(
|
||||
shouldRequireMention,
|
||||
hasAnyMention,
|
||||
allowTextCommands,
|
||||
shouldBypassMention,
|
||||
shouldBypassMention: mentionGate.shouldBypassMention,
|
||||
effectiveWasMentioned,
|
||||
canDetectMention,
|
||||
historyEntry,
|
||||
|
||||
Reference in New Issue
Block a user