From e63e483c38d0a7e2bfc07288419d46fc82e6a212 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 22:30:37 +0000 Subject: [PATCH] refactor(channels): share channel config matching Co-authored-by: Codex --- src/channels/channel-config.ts | 41 +++++++++++++ src/discord/monitor.test.ts | 41 +++++++++++++ src/discord/monitor.ts | 1 + src/discord/monitor/allow-list.ts | 60 +++++++++++++------ .../monitor/message-handler.preflight.ts | 1 + src/discord/monitor/native-command.ts | 1 + src/slack/monitor/channel-config.ts | 31 +++------- 7 files changed, 137 insertions(+), 39 deletions(-) create mode 100644 src/channels/channel-config.ts diff --git a/src/channels/channel-config.ts b/src/channels/channel-config.ts new file mode 100644 index 000000000..d023da971 --- /dev/null +++ b/src/channels/channel-config.ts @@ -0,0 +1,41 @@ +export type ChannelEntryMatch = { + entry?: T; + key?: string; + wildcardEntry?: T; + wildcardKey?: string; +}; + +export function buildChannelKeyCandidates( + ...keys: Array +): string[] { + const seen = new Set(); + 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(params: { + entries?: Record; + keys: string[]; + wildcardKey?: string; +}): ChannelEntryMatch { + const entries = params.entries ?? {}; + const match: ChannelEntryMatch = {}; + 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; +} diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index adb19820b..2984c09ee 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -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", () => { diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index a6dd061d5..cc141cf6f 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -9,6 +9,7 @@ export { normalizeDiscordAllowList, normalizeDiscordSlug, resolveDiscordChannelConfig, + resolveDiscordChannelConfigWithFallback, resolveDiscordCommandAuthorized, resolveDiscordGuildEntry, resolveDiscordShouldRequireMention, diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index a47300a5a..3837ecb2d 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -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[string]; +type DiscordChannelLookup = { + id: string; + name?: string; + slug?: string; +}; +type DiscordChannelScope = "channel" | "thread"; function resolveDiscordChannelEntry( channels: NonNullable, - 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 }; diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 5568e6b60..725ea797f 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -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) { diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index e6741b104..50e267665 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -553,6 +553,7 @@ async function dispatchDiscordCommandInteraction(params: { parentId: threadParentId, parentName: threadParentName, parentSlug: threadParentSlug, + scope: isThreadChannel ? "thread" : "channel", }) : null; if (channelConfig?.enabled === false) { diff --git a/src/slack/monitor/channel-config.ts b/src/slack/monitor/channel-config.ts index 712371d98..e83205eb7 100644 --- a/src/slack/monitor/channel-config.ts +++ b/src/slack/monitor/channel-config.ts @@ -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; - 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) {