refactor(channels): share channel config matching
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
41
src/channels/channel-config.ts
Normal file
41
src/channels/channel-config.ts
Normal 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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordCommandAuthorized,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordShouldRequireMention,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -553,6 +553,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
parentId: threadParentId,
|
||||
parentName: threadParentName,
|
||||
parentSlug: threadParentSlug,
|
||||
scope: isThreadChannel ? "thread" : "channel",
|
||||
})
|
||||
: null;
|
||||
if (channelConfig?.enabled === false) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user