fix(discord): honor thread allowlists in reactions
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
24
src/channels/channel-config.test.ts
Normal file
24
src/channels/channel-config.test.ts
Normal 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("*");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,8 @@ export type DiscordChannelConfigResolved = {
|
|||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
autoThread?: boolean;
|
autoThread?: boolean;
|
||||||
|
matchKey?: string;
|
||||||
|
matchSource?: "direct" | "parent";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeDiscordAllowList(
|
export function normalizeDiscordAllowList(
|
||||||
@@ -145,33 +147,42 @@ type DiscordChannelLookup = {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
};
|
};
|
||||||
type DiscordChannelScope = "channel" | "thread";
|
type DiscordChannelScope = "channel" | "thread";
|
||||||
|
type DiscordChannelMatch = {
|
||||||
|
entry: DiscordChannelEntry;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
function resolveDiscordChannelEntry(
|
function resolveDiscordChannelEntry(
|
||||||
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
|
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
|
||||||
params: DiscordChannelLookup & { allowNameMatch?: boolean },
|
params: DiscordChannelLookup & { allowNameMatch?: boolean },
|
||||||
): DiscordChannelEntry | null {
|
): DiscordChannelMatch | null {
|
||||||
const allowNameMatch = params.allowNameMatch !== false;
|
const allowNameMatch = params.allowNameMatch !== false;
|
||||||
const keys = buildChannelKeyCandidates(
|
const keys = buildChannelKeyCandidates(
|
||||||
params.id,
|
params.id,
|
||||||
allowNameMatch ? params.slug : undefined,
|
allowNameMatch ? params.slug : undefined,
|
||||||
allowNameMatch ? params.name : undefined,
|
allowNameMatch ? params.name : undefined,
|
||||||
);
|
);
|
||||||
const { entry } = resolveChannelEntryMatch({ entries: channels, keys });
|
const { entry, key } = resolveChannelEntryMatch({ entries: channels, keys });
|
||||||
return entry ?? null;
|
if (!entry || !key) return null;
|
||||||
|
return { entry, key };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDiscordChannelConfigEntry(
|
function resolveDiscordChannelConfigEntry(
|
||||||
entry: DiscordChannelEntry,
|
match: DiscordChannelMatch,
|
||||||
|
matchSource: "direct" | "parent",
|
||||||
): DiscordChannelConfigResolved {
|
): DiscordChannelConfigResolved {
|
||||||
return {
|
const resolved: DiscordChannelConfigResolved = {
|
||||||
allowed: entry.allow !== false,
|
allowed: match.entry.allow !== false,
|
||||||
requireMention: entry.requireMention,
|
requireMention: match.entry.requireMention,
|
||||||
skills: entry.skills,
|
skills: match.entry.skills,
|
||||||
enabled: entry.enabled,
|
enabled: match.entry.enabled,
|
||||||
users: entry.users,
|
users: match.entry.users,
|
||||||
systemPrompt: entry.systemPrompt,
|
systemPrompt: match.entry.systemPrompt,
|
||||||
autoThread: entry.autoThread,
|
autoThread: match.entry.autoThread,
|
||||||
};
|
};
|
||||||
|
if (match.key) resolved.matchKey = match.key;
|
||||||
|
resolved.matchSource = matchSource;
|
||||||
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDiscordChannelConfig(params: {
|
export function resolveDiscordChannelConfig(params: {
|
||||||
@@ -189,7 +200,7 @@ export function resolveDiscordChannelConfig(params: {
|
|||||||
slug: channelSlug,
|
slug: channelSlug,
|
||||||
});
|
});
|
||||||
if (!entry) return { allowed: false };
|
if (!entry) return { allowed: false };
|
||||||
return resolveDiscordChannelConfigEntry(entry);
|
return resolveDiscordChannelConfigEntry(entry, "direct");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDiscordChannelConfigWithFallback(params: {
|
export function resolveDiscordChannelConfigWithFallback(params: {
|
||||||
@@ -220,7 +231,7 @@ export function resolveDiscordChannelConfigWithFallback(params: {
|
|||||||
slug: channelSlug,
|
slug: channelSlug,
|
||||||
allowNameMatch: scope !== "thread",
|
allowNameMatch: scope !== "thread",
|
||||||
});
|
});
|
||||||
if (entry) return resolveDiscordChannelConfigEntry(entry);
|
if (entry) return resolveDiscordChannelConfigEntry(entry, "direct");
|
||||||
if (parentId || parentName || parentSlug) {
|
if (parentId || parentName || parentSlug) {
|
||||||
const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
|
const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
|
||||||
const parentEntry = resolveDiscordChannelEntry(channels, {
|
const parentEntry = resolveDiscordChannelEntry(channels, {
|
||||||
@@ -228,7 +239,7 @@ export function resolveDiscordChannelConfigWithFallback(params: {
|
|||||||
name: parentName,
|
name: parentName,
|
||||||
slug: resolvedParentSlug,
|
slug: resolvedParentSlug,
|
||||||
});
|
});
|
||||||
if (parentEntry) return resolveDiscordChannelConfigEntry(parentEntry);
|
if (parentEntry) return resolveDiscordChannelConfigEntry(parentEntry, "parent");
|
||||||
}
|
}
|
||||||
return { allowed: false };
|
return { allowed: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ChannelType,
|
||||||
type Client,
|
type Client,
|
||||||
MessageCreateListener,
|
MessageCreateListener,
|
||||||
MessageReactionAddListener,
|
MessageReactionAddListener,
|
||||||
@@ -12,11 +13,12 @@ import { createSubsystemLogger } from "../../logging.js";
|
|||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
import {
|
import {
|
||||||
normalizeDiscordSlug,
|
normalizeDiscordSlug,
|
||||||
resolveDiscordChannelConfig,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
shouldEmitDiscordReactionNotification,
|
shouldEmitDiscordReactionNotification,
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
|
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
|
||||||
|
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
||||||
|
|
||||||
type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
|
type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||||
type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
|
type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
|
||||||
@@ -189,11 +191,34 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
if (!channel) return;
|
if (!channel) return;
|
||||||
const channelName = "name" in channel ? (channel.name ?? undefined) : undefined;
|
const channelName = "name" in channel ? (channel.name ?? undefined) : undefined;
|
||||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
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,
|
guildInfo,
|
||||||
channelId: data.channel_id,
|
channelId: data.channel_id,
|
||||||
channelName,
|
channelName,
|
||||||
channelSlug,
|
channelSlug,
|
||||||
|
parentId,
|
||||||
|
parentName,
|
||||||
|
parentSlug,
|
||||||
|
scope: isThreadChannel ? "thread" : "channel",
|
||||||
});
|
});
|
||||||
if (channelConfig?.allowed === false) return;
|
if (channelConfig?.allowed === false) return;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type SlackChannelConfigResolved = {
|
|||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
matchKey?: string;
|
||||||
|
matchSource?: "direct" | "wildcard";
|
||||||
};
|
};
|
||||||
|
|
||||||
function firstDefined<T>(...values: Array<T | undefined>) {
|
function firstDefined<T>(...values: Array<T | undefined>) {
|
||||||
@@ -84,7 +86,12 @@ export function resolveSlackChannelConfig(params: {
|
|||||||
directName,
|
directName,
|
||||||
normalizedName,
|
normalizedName,
|
||||||
);
|
);
|
||||||
const { entry: matched, wildcardEntry: fallback } = resolveChannelEntryMatch({
|
const {
|
||||||
|
entry: matched,
|
||||||
|
key: matchedKey,
|
||||||
|
wildcardEntry: fallback,
|
||||||
|
wildcardKey,
|
||||||
|
} = resolveChannelEntryMatch({
|
||||||
entries,
|
entries,
|
||||||
keys: candidates,
|
keys: candidates,
|
||||||
wildcardKey: "*",
|
wildcardKey: "*",
|
||||||
@@ -109,7 +116,22 @@ export function resolveSlackChannelConfig(params: {
|
|||||||
const users = firstDefined(resolved.users, fallback?.users);
|
const users = firstDefined(resolved.users, fallback?.users);
|
||||||
const skills = firstDefined(resolved.skills, fallback?.skills);
|
const skills = firstDefined(resolved.skills, fallback?.skills);
|
||||||
const systemPrompt = firstDefined(resolved.systemPrompt, fallback?.systemPrompt);
|
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 };
|
export type { SlackMessageEvent };
|
||||||
|
|||||||
Reference in New Issue
Block a user