From 5aed38eebc09cd370d1529554a2eaf0e2d53111d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 22:39:09 +0000 Subject: [PATCH] fix(discord): honor thread allowlists in reactions Co-authored-by: Codex --- src/channels/channel-config.test.ts | 24 +++++++++++++++++ src/discord/monitor/allow-list.ts | 41 ++++++++++++++++++----------- src/discord/monitor/listeners.ts | 29 ++++++++++++++++++-- src/slack/monitor/channel-config.ts | 26 ++++++++++++++++-- 4 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 src/channels/channel-config.test.ts diff --git a/src/channels/channel-config.test.ts b/src/channels/channel-config.test.ts new file mode 100644 index 000000000..c3618c8ef --- /dev/null +++ b/src/channels/channel-config.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "./channel-config.js"; + +describe("buildChannelKeyCandidates", () => { + it("dedupes and trims keys", () => { + expect(buildChannelKeyCandidates(" a ", "a", "", "b", "b")).toEqual(["a", "b"]); + }); +}); + +describe("resolveChannelEntryMatch", () => { + it("returns matched entry and wildcard metadata", () => { + const entries = { a: { allow: true }, "*": { allow: false } }; + const match = resolveChannelEntryMatch({ + entries, + keys: ["missing", "a"], + wildcardKey: "*", + }); + expect(match.entry).toBe(entries.a); + expect(match.key).toBe("a"); + expect(match.wildcardEntry).toBe(entries["*"]); + expect(match.wildcardKey).toBe("*"); + }); +}); diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 3837ecb2d..72ecc6757 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -37,6 +37,8 @@ export type DiscordChannelConfigResolved = { users?: Array; systemPrompt?: string; autoThread?: boolean; + matchKey?: string; + matchSource?: "direct" | "parent"; }; export function normalizeDiscordAllowList( @@ -145,33 +147,42 @@ type DiscordChannelLookup = { slug?: string; }; type DiscordChannelScope = "channel" | "thread"; +type DiscordChannelMatch = { + entry: DiscordChannelEntry; + key: string; +}; function resolveDiscordChannelEntry( channels: NonNullable, params: DiscordChannelLookup & { allowNameMatch?: boolean }, -): DiscordChannelEntry | null { +): DiscordChannelMatch | 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; + const { entry, key } = resolveChannelEntryMatch({ entries: channels, keys }); + if (!entry || !key) return null; + return { entry, key }; } function resolveDiscordChannelConfigEntry( - entry: DiscordChannelEntry, + match: DiscordChannelMatch, + matchSource: "direct" | "parent", ): DiscordChannelConfigResolved { - return { - allowed: entry.allow !== false, - requireMention: entry.requireMention, - skills: entry.skills, - enabled: entry.enabled, - users: entry.users, - systemPrompt: entry.systemPrompt, - autoThread: entry.autoThread, + 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, }; + if (match.key) resolved.matchKey = match.key; + resolved.matchSource = matchSource; + return resolved; } export function resolveDiscordChannelConfig(params: { @@ -189,7 +200,7 @@ export function resolveDiscordChannelConfig(params: { slug: channelSlug, }); if (!entry) return { allowed: false }; - return resolveDiscordChannelConfigEntry(entry); + return resolveDiscordChannelConfigEntry(entry, "direct"); } export function resolveDiscordChannelConfigWithFallback(params: { @@ -220,7 +231,7 @@ export function resolveDiscordChannelConfigWithFallback(params: { slug: channelSlug, allowNameMatch: scope !== "thread", }); - if (entry) return resolveDiscordChannelConfigEntry(entry); + if (entry) return resolveDiscordChannelConfigEntry(entry, "direct"); if (parentId || parentName || parentSlug) { const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : ""); const parentEntry = resolveDiscordChannelEntry(channels, { @@ -228,7 +239,7 @@ export function resolveDiscordChannelConfigWithFallback(params: { name: parentName, slug: resolvedParentSlug, }); - if (parentEntry) return resolveDiscordChannelConfigEntry(parentEntry); + if (parentEntry) return resolveDiscordChannelConfigEntry(parentEntry, "parent"); } return { allowed: false }; } diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index a5f63bec5..2476eadce 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -1,4 +1,5 @@ import { + ChannelType, type Client, MessageCreateListener, MessageReactionAddListener, @@ -12,11 +13,12 @@ import { createSubsystemLogger } from "../../logging.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { normalizeDiscordSlug, - resolveDiscordChannelConfig, + resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, shouldEmitDiscordReactionNotification, } from "./allow-list.js"; import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js"; +import { resolveDiscordChannelInfo } from "./message-utils.js"; type LoadedConfig = ReturnType; type RuntimeEnv = import("../../runtime.js").RuntimeEnv; @@ -189,11 +191,34 @@ async function handleDiscordReactionEvent(params: { if (!channel) return; const channelName = "name" in channel ? (channel.name ?? undefined) : undefined; const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; - const channelConfig = resolveDiscordChannelConfig({ + const channelType = "type" in channel ? channel.type : undefined; + const isThreadChannel = + channelType === ChannelType.PublicThread || + channelType === ChannelType.PrivateThread || + channelType === ChannelType.AnnouncementThread; + let parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; + let parentName: string | undefined; + let parentSlug = ""; + if (isThreadChannel) { + if (!parentId) { + const channelInfo = await resolveDiscordChannelInfo(client, data.channel_id); + parentId = channelInfo?.parentId; + } + if (parentId) { + const parentInfo = await resolveDiscordChannelInfo(client, parentId); + parentName = parentInfo?.name; + parentSlug = parentName ? normalizeDiscordSlug(parentName) : ""; + } + } + const channelConfig = resolveDiscordChannelConfigWithFallback({ guildInfo, channelId: data.channel_id, channelName, channelSlug, + parentId, + parentName, + parentSlug, + scope: isThreadChannel ? "thread" : "channel", }); if (channelConfig?.allowed === false) return; diff --git a/src/slack/monitor/channel-config.ts b/src/slack/monitor/channel-config.ts index e83205eb7..793b49f7e 100644 --- a/src/slack/monitor/channel-config.ts +++ b/src/slack/monitor/channel-config.ts @@ -10,6 +10,8 @@ export type SlackChannelConfigResolved = { users?: Array; skills?: string[]; systemPrompt?: string; + matchKey?: string; + matchSource?: "direct" | "wildcard"; }; function firstDefined(...values: Array) { @@ -84,7 +86,12 @@ export function resolveSlackChannelConfig(params: { directName, normalizedName, ); - const { entry: matched, wildcardEntry: fallback } = resolveChannelEntryMatch({ + const { + entry: matched, + key: matchedKey, + wildcardEntry: fallback, + wildcardKey, + } = resolveChannelEntryMatch({ entries, keys: candidates, wildcardKey: "*", @@ -109,7 +116,22 @@ export function resolveSlackChannelConfig(params: { const users = firstDefined(resolved.users, fallback?.users); const skills = firstDefined(resolved.skills, fallback?.skills); const systemPrompt = firstDefined(resolved.systemPrompt, fallback?.systemPrompt); - return { allowed, requireMention, allowBots, users, skills, systemPrompt }; + const result: SlackChannelConfigResolved = { + allowed, + requireMention, + allowBots, + users, + skills, + systemPrompt, + }; + if (matchedKey) { + result.matchKey = matchedKey; + result.matchSource = "direct"; + } else if (wildcardKey && fallback) { + result.matchKey = wildcardKey; + result.matchSource = "wildcard"; + } + return result; } export type { SlackMessageEvent };