refactor(channels): share channel config matching

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Peter Steinberger
2026-01-17 22:30:37 +00:00
parent 277e43e32c
commit e63e483c38
7 changed files with 137 additions and 39 deletions

View File

@@ -0,0 +1,41 @@
export type ChannelEntryMatch<T> = {
entry?: T;
key?: string;
wildcardEntry?: T;
wildcardKey?: string;
};
export function buildChannelKeyCandidates(
...keys: Array<string | undefined | null>
): string[] {
const seen = new Set<string>();
const candidates: string[] = [];
for (const key of keys) {
if (typeof key !== "string") continue;
const trimmed = key.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
candidates.push(trimmed);
}
return candidates;
}
export function resolveChannelEntryMatch<T>(params: {
entries?: Record<string, T>;
keys: string[];
wildcardKey?: string;
}): ChannelEntryMatch<T> {
const entries = params.entries ?? {};
const match: ChannelEntryMatch<T> = {};
for (const key of params.keys) {
if (!Object.prototype.hasOwnProperty.call(entries, key)) continue;
match.entry = entries[key];
match.key = key;
break;
}
if (params.wildcardKey && Object.prototype.hasOwnProperty.call(entries, params.wildcardKey)) {
match.wildcardEntry = entries[params.wildcardKey];
match.wildcardKey = params.wildcardKey;
}
return match;
}

View File

@@ -9,6 +9,7 @@ import {
normalizeDiscordSlug,
registerDiscordListener,
resolveDiscordChannelConfig,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordReplyTarget,
resolveDiscordShouldRequireMention,
@@ -160,6 +161,46 @@ describe("discord guild/channel resolution", () => {
});
expect(channel?.allowed).toBe(false);
});
it("inherits parent config for thread channels", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true },
random: { allow: false },
},
};
const thread = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: "thread-123",
channelName: "topic",
channelSlug: "topic",
parentId: "999",
parentName: "random",
parentSlug: "random",
scope: "thread",
});
expect(thread?.allowed).toBe(false);
});
it("does not match thread name/slug when resolving allowlists", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true },
random: { allow: false },
},
};
const thread = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: "thread-999",
channelName: "general",
channelSlug: "general",
parentId: "999",
parentName: "random",
parentSlug: "random",
scope: "thread",
});
expect(thread?.allowed).toBe(false);
});
});
describe("discord mention gating", () => {

View File

@@ -9,6 +9,7 @@ export {
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordChannelConfig,
resolveDiscordChannelConfigWithFallback,
resolveDiscordCommandAuthorized,
resolveDiscordGuildEntry,
resolveDiscordShouldRequireMention,

View File

@@ -1,5 +1,6 @@
import type { Guild, User } from "@buape/carbon";
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../../channels/channel-config.js";
import { formatDiscordUserTag } from "./format.js";
export type DiscordAllowList = {
@@ -138,17 +139,25 @@ export function resolveDiscordGuildEntry(params: {
}
type DiscordChannelEntry = NonNullable<DiscordGuildEntryResolved["channels"]>[string];
type DiscordChannelLookup = {
id: string;
name?: string;
slug?: string;
};
type DiscordChannelScope = "channel" | "thread";
function resolveDiscordChannelEntry(
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
channelId: string,
channelName?: string,
channelSlug?: string,
params: DiscordChannelLookup & { allowNameMatch?: boolean },
): DiscordChannelEntry | null {
if (channelId && channels[channelId]) return channels[channelId];
if (channelSlug && channels[channelSlug]) return channels[channelSlug];
if (channelName && channels[channelName]) return channels[channelName];
return null;
const allowNameMatch = params.allowNameMatch !== false;
const keys = buildChannelKeyCandidates(
params.id,
allowNameMatch ? params.slug : undefined,
allowNameMatch ? params.name : undefined,
);
const { entry } = resolveChannelEntryMatch({ entries: channels, keys });
return entry ?? null;
}
function resolveDiscordChannelConfigEntry(
@@ -174,7 +183,11 @@ export function resolveDiscordChannelConfig(params: {
const { guildInfo, channelId, channelName, channelSlug } = params;
const channels = guildInfo?.channels;
if (!channels) return null;
const entry = resolveDiscordChannelEntry(channels, channelId, channelName, channelSlug);
const entry = resolveDiscordChannelEntry(channels, {
id: channelId,
name: channelName,
slug: channelSlug,
});
if (!entry) return { allowed: false };
return resolveDiscordChannelConfigEntry(entry);
}
@@ -187,21 +200,34 @@ export function resolveDiscordChannelConfigWithFallback(params: {
parentId?: string;
parentName?: string;
parentSlug?: string;
scope?: DiscordChannelScope;
}): DiscordChannelConfigResolved | null {
const { guildInfo, channelId, channelName, channelSlug, parentId, parentName, parentSlug } =
params;
const {
guildInfo,
channelId,
channelName,
channelSlug,
parentId,
parentName,
parentSlug,
scope,
} = params;
const channels = guildInfo?.channels;
if (!channels) return null;
const entry = resolveDiscordChannelEntry(channels, channelId, channelName, channelSlug);
const entry = resolveDiscordChannelEntry(channels, {
id: channelId,
name: channelName,
slug: channelSlug,
allowNameMatch: scope !== "thread",
});
if (entry) return resolveDiscordChannelConfigEntry(entry);
if (parentId || parentName || parentSlug) {
const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
const parentEntry = resolveDiscordChannelEntry(
channels,
parentId ?? "",
parentName,
resolvedParentSlug,
);
const parentEntry = resolveDiscordChannelEntry(channels, {
id: parentId ?? "",
name: parentName,
slug: resolvedParentSlug,
});
if (parentEntry) return resolveDiscordChannelConfigEntry(parentEntry);
}
return { allowed: false };

View File

@@ -249,6 +249,7 @@ export async function preflightDiscordMessage(
parentId: threadParentId ?? undefined,
parentName: threadParentName ?? undefined,
parentSlug: threadParentSlug,
scope: threadChannel ? "thread" : "channel",
})
: null;
if (isGuildMessage && channelConfig?.enabled === false) {

View File

@@ -553,6 +553,7 @@ async function dispatchDiscordCommandInteraction(params: {
parentId: threadParentId,
parentName: threadParentName,
parentSlug: threadParentSlug,
scope: isThreadChannel ? "thread" : "channel",
})
: null;
if (channelConfig?.enabled === false) {

View File

@@ -1,5 +1,6 @@
import type { SlackReactionNotificationMode } from "../../config/config.js";
import type { SlackMessageEvent } from "../types.js";
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../../channels/channel-config.js";
import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js";
export type SlackChannelConfigResolved = {
@@ -77,31 +78,17 @@ export function resolveSlackChannelConfig(params: {
const keys = Object.keys(entries);
const normalizedName = channelName ? normalizeSlackSlug(channelName) : "";
const directName = channelName ? channelName.trim() : "";
const candidates = [
const candidates = buildChannelKeyCandidates(
channelId,
channelName ? `#${directName}` : "",
channelName ? `#${directName}` : undefined,
directName,
normalizedName,
].filter(Boolean);
let matched:
| {
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
allowBots?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
}
| undefined;
for (const candidate of candidates) {
if (candidate && entries[candidate]) {
matched = entries[candidate];
break;
}
}
const fallback = entries["*"];
);
const { entry: matched, wildcardEntry: fallback } = resolveChannelEntryMatch({
entries,
keys: candidates,
wildcardKey: "*",
});
const requireMentionDefault = defaultRequireMention ?? true;
if (keys.length === 0) {