From 277e43e32c1da4a91c6517ca62b141f8e67a309b Mon Sep 17 00:00:00 2001 From: Shadow Date: Sat, 17 Jan 2026 15:26:59 -0600 Subject: [PATCH] Discord: inherit thread allowlists --- CHANGELOG.md | 1 + src/discord/monitor/allow-list.ts | 91 ++++++++++++------- .../monitor/message-handler.preflight.ts | 16 +++- src/discord/monitor/native-command.ts | 38 +++++++- 4 files changed, 103 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f00c0f346..996fd65d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.clawd.bot - Memory: split overly long lines to keep embeddings under token limits. - Memory: skip empty chunks to avoid invalid embedding inputs. - Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus. +- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow. ## 2026.1.17-1 diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 31a3d1042..a47300a5a 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -137,6 +137,34 @@ export function resolveDiscordGuildEntry(params: { return null; } +type DiscordChannelEntry = NonNullable[string]; + +function resolveDiscordChannelEntry( + channels: NonNullable, + channelId: string, + channelName?: string, + channelSlug?: string, +): 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; +} + +function resolveDiscordChannelConfigEntry( + entry: DiscordChannelEntry, +): DiscordChannelConfigResolved { + return { + allowed: entry.allow !== false, + requireMention: entry.requireMention, + skills: entry.skills, + enabled: entry.enabled, + users: entry.users, + systemPrompt: entry.systemPrompt, + autoThread: entry.autoThread, + }; +} + export function resolveDiscordChannelConfig(params: { guildInfo?: DiscordGuildEntryResolved | null; channelId: string; @@ -146,40 +174,35 @@ export function resolveDiscordChannelConfig(params: { const { guildInfo, channelId, channelName, channelSlug } = params; const channels = guildInfo?.channels; if (!channels) return null; - const byId = channels[channelId]; - if (byId) - return { - allowed: byId.allow !== false, - requireMention: byId.requireMention, - skills: byId.skills, - enabled: byId.enabled, - users: byId.users, - systemPrompt: byId.systemPrompt, - autoThread: byId.autoThread, - }; - if (channelSlug && channels[channelSlug]) { - const entry = channels[channelSlug]; - return { - allowed: entry.allow !== false, - requireMention: entry.requireMention, - skills: entry.skills, - enabled: entry.enabled, - users: entry.users, - systemPrompt: entry.systemPrompt, - autoThread: entry.autoThread, - }; - } - if (channelName && channels[channelName]) { - const entry = channels[channelName]; - return { - allowed: entry.allow !== false, - requireMention: entry.requireMention, - skills: entry.skills, - enabled: entry.enabled, - users: entry.users, - systemPrompt: entry.systemPrompt, - autoThread: entry.autoThread, - }; + const entry = resolveDiscordChannelEntry(channels, channelId, channelName, channelSlug); + if (!entry) return { allowed: false }; + return resolveDiscordChannelConfigEntry(entry); +} + +export function resolveDiscordChannelConfigWithFallback(params: { + guildInfo?: DiscordGuildEntryResolved | null; + channelId: string; + channelName?: string; + channelSlug: string; + parentId?: string; + parentName?: string; + parentSlug?: string; +}): DiscordChannelConfigResolved | null { + const { guildInfo, channelId, channelName, channelSlug, parentId, parentName, parentSlug } = + params; + const channels = guildInfo?.channels; + if (!channels) return null; + const entry = resolveDiscordChannelEntry(channels, channelId, channelName, channelSlug); + if (entry) return resolveDiscordChannelConfigEntry(entry); + if (parentId || parentName || parentSlug) { + const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : ""); + const parentEntry = resolveDiscordChannelEntry( + channels, + parentId ?? "", + parentName, + 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 9002b85c8..5568e6b60 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -22,7 +22,7 @@ import { isDiscordGroupAllowedByPolicy, normalizeDiscordAllowList, normalizeDiscordSlug, - resolveDiscordChannelConfig, + resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, resolveDiscordShouldRequireMention, resolveDiscordUserAllowed, @@ -236,13 +236,19 @@ export async function preflightDiscordMessage( guildInfo?.slug || (params.data.guild?.name ? normalizeDiscordSlug(params.data.guild.name) : ""); + const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : ""; + const baseSessionKey = route.sessionKey; const channelConfig = isGuildMessage - ? resolveDiscordChannelConfig({ + ? resolveDiscordChannelConfigWithFallback({ guildInfo, - channelId: threadParentId ?? message.channelId, - channelName: configChannelName, - channelSlug: configChannelSlug, + channelId: message.channelId, + channelName, + channelSlug: threadChannelSlug, + parentId: threadParentId ?? undefined, + parentName: threadParentName ?? undefined, + parentSlug: threadParentSlug, }) : null; if (isGuildMessage && channelConfig?.enabled === false) { diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 3148c1dfb..e6741b104 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -47,11 +47,13 @@ import { isDiscordGroupAllowedByPolicy, normalizeDiscordAllowList, normalizeDiscordSlug, - resolveDiscordChannelConfig, + resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, resolveDiscordUserAllowed, } from "./allow-list.js"; import { formatDiscordUserTag } from "./format.js"; +import { resolveDiscordChannelInfo } from "./message-utils.js"; +import { resolveDiscordThreadParentInfo } from "./threading.js"; type DiscordConfig = NonNullable["discord"]; @@ -499,8 +501,13 @@ async function dispatchDiscordCommandInteraction(params: { const channelType = channel?.type; const isDirectMessage = channelType === ChannelType.DM; const isGroupDm = channelType === ChannelType.GroupDM; + const isThreadChannel = + channelType === ChannelType.PublicThread || + channelType === ChannelType.PrivateThread || + channelType === ChannelType.AnnouncementThread; const channelName = channel && "name" in channel ? (channel.name as string) : undefined; const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const rawChannelId = channel?.id ?? ""; const ownerAllowList = normalizeDiscordAllowList(discordConfig?.dm?.allowFrom ?? [], [ "discord:", "user:", @@ -517,12 +524,35 @@ async function dispatchDiscordCommandInteraction(params: { guild: interaction.guild ?? undefined, guildEntries: discordConfig?.guilds, }); + let threadParentId: string | undefined; + let threadParentName: string | undefined; + let threadParentSlug = ""; + if (interaction.guild && channel && isThreadChannel && rawChannelId) { + // Threads inherit parent channel config unless explicitly overridden. + const channelInfo = await resolveDiscordChannelInfo(interaction.client, rawChannelId); + const parentInfo = await resolveDiscordThreadParentInfo({ + client: interaction.client, + threadChannel: { + id: rawChannelId, + name: channelName, + parentId: "parentId" in channel ? channel.parentId ?? undefined : undefined, + parent: undefined, + }, + channelInfo, + }); + threadParentId = parentInfo.id; + threadParentName = parentInfo.name; + threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : ""; + } const channelConfig = interaction.guild - ? resolveDiscordChannelConfig({ + ? resolveDiscordChannelConfigWithFallback({ guildInfo, - channelId: channel?.id ?? "", + channelId: rawChannelId, channelName, channelSlug, + parentId: threadParentId, + parentName: threadParentName, + parentSlug: threadParentSlug, }) : null; if (channelConfig?.enabled === false) { @@ -664,7 +694,7 @@ async function dispatchDiscordCommandInteraction(params: { } const isGuild = Boolean(interaction.guild); - const channelId = channel?.id ?? "unknown"; + const channelId = rawChannelId || "unknown"; const interactionId = interaction.rawData.id; const route = resolveAgentRoute({ cfg,