fix(discord): honor thread allowlists in reactions

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Peter Steinberger
2026-01-17 22:39:09 +00:00
parent e63e483c38
commit 5aed38eebc
4 changed files with 101 additions and 19 deletions

View File

@@ -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("*");
});
});

View File

@@ -37,6 +37,8 @@ export type DiscordChannelConfigResolved = {
users?: Array<string | number>;
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<DiscordGuildEntryResolved["channels"]>,
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 };
}

View File

@@ -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<typeof import("../../config/config.js").loadConfig>;
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;

View File

@@ -10,6 +10,8 @@ export type SlackChannelConfigResolved = {
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
matchKey?: string;
matchSource?: "direct" | "wildcard";
};
function firstDefined<T>(...values: Array<T | undefined>) {
@@ -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 };