refactor(channels): share channel config matching
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
41
src/channels/channel-config.ts
Normal file
41
src/channels/channel-config.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export type ChannelEntryMatch<T> = {
|
||||||
|
entry?: T;
|
||||||
|
key?: string;
|
||||||
|
wildcardEntry?: T;
|
||||||
|
wildcardKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildChannelKeyCandidates(
|
||||||
|
...keys: Array<string | undefined | null>
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<T>(params: {
|
||||||
|
entries?: Record<string, T>;
|
||||||
|
keys: string[];
|
||||||
|
wildcardKey?: string;
|
||||||
|
}): ChannelEntryMatch<T> {
|
||||||
|
const entries = params.entries ?? {};
|
||||||
|
const match: ChannelEntryMatch<T> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
normalizeDiscordSlug,
|
normalizeDiscordSlug,
|
||||||
registerDiscordListener,
|
registerDiscordListener,
|
||||||
resolveDiscordChannelConfig,
|
resolveDiscordChannelConfig,
|
||||||
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordReplyTarget,
|
resolveDiscordReplyTarget,
|
||||||
resolveDiscordShouldRequireMention,
|
resolveDiscordShouldRequireMention,
|
||||||
@@ -160,6 +161,46 @@ describe("discord guild/channel resolution", () => {
|
|||||||
});
|
});
|
||||||
expect(channel?.allowed).toBe(false);
|
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", () => {
|
describe("discord mention gating", () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
normalizeDiscordAllowList,
|
normalizeDiscordAllowList,
|
||||||
normalizeDiscordSlug,
|
normalizeDiscordSlug,
|
||||||
resolveDiscordChannelConfig,
|
resolveDiscordChannelConfig,
|
||||||
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordCommandAuthorized,
|
resolveDiscordCommandAuthorized,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordShouldRequireMention,
|
resolveDiscordShouldRequireMention,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Guild, User } from "@buape/carbon";
|
import type { Guild, User } from "@buape/carbon";
|
||||||
|
|
||||||
|
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../../channels/channel-config.js";
|
||||||
import { formatDiscordUserTag } from "./format.js";
|
import { formatDiscordUserTag } from "./format.js";
|
||||||
|
|
||||||
export type DiscordAllowList = {
|
export type DiscordAllowList = {
|
||||||
@@ -138,17 +139,25 @@ export function resolveDiscordGuildEntry(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DiscordChannelEntry = NonNullable<DiscordGuildEntryResolved["channels"]>[string];
|
type DiscordChannelEntry = NonNullable<DiscordGuildEntryResolved["channels"]>[string];
|
||||||
|
type DiscordChannelLookup = {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
};
|
||||||
|
type DiscordChannelScope = "channel" | "thread";
|
||||||
|
|
||||||
function resolveDiscordChannelEntry(
|
function resolveDiscordChannelEntry(
|
||||||
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
|
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
|
||||||
channelId: string,
|
params: DiscordChannelLookup & { allowNameMatch?: boolean },
|
||||||
channelName?: string,
|
|
||||||
channelSlug?: string,
|
|
||||||
): DiscordChannelEntry | null {
|
): DiscordChannelEntry | null {
|
||||||
if (channelId && channels[channelId]) return channels[channelId];
|
const allowNameMatch = params.allowNameMatch !== false;
|
||||||
if (channelSlug && channels[channelSlug]) return channels[channelSlug];
|
const keys = buildChannelKeyCandidates(
|
||||||
if (channelName && channels[channelName]) return channels[channelName];
|
params.id,
|
||||||
return null;
|
allowNameMatch ? params.slug : undefined,
|
||||||
|
allowNameMatch ? params.name : undefined,
|
||||||
|
);
|
||||||
|
const { entry } = resolveChannelEntryMatch({ entries: channels, keys });
|
||||||
|
return entry ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDiscordChannelConfigEntry(
|
function resolveDiscordChannelConfigEntry(
|
||||||
@@ -174,7 +183,11 @@ export function resolveDiscordChannelConfig(params: {
|
|||||||
const { guildInfo, channelId, channelName, channelSlug } = params;
|
const { guildInfo, channelId, channelName, channelSlug } = params;
|
||||||
const channels = guildInfo?.channels;
|
const channels = guildInfo?.channels;
|
||||||
if (!channels) return null;
|
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 };
|
if (!entry) return { allowed: false };
|
||||||
return resolveDiscordChannelConfigEntry(entry);
|
return resolveDiscordChannelConfigEntry(entry);
|
||||||
}
|
}
|
||||||
@@ -187,21 +200,34 @@ export function resolveDiscordChannelConfigWithFallback(params: {
|
|||||||
parentId?: string;
|
parentId?: string;
|
||||||
parentName?: string;
|
parentName?: string;
|
||||||
parentSlug?: string;
|
parentSlug?: string;
|
||||||
|
scope?: DiscordChannelScope;
|
||||||
}): DiscordChannelConfigResolved | null {
|
}): DiscordChannelConfigResolved | null {
|
||||||
const { guildInfo, channelId, channelName, channelSlug, parentId, parentName, parentSlug } =
|
const {
|
||||||
params;
|
guildInfo,
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
channelSlug,
|
||||||
|
parentId,
|
||||||
|
parentName,
|
||||||
|
parentSlug,
|
||||||
|
scope,
|
||||||
|
} = params;
|
||||||
const channels = guildInfo?.channels;
|
const channels = guildInfo?.channels;
|
||||||
if (!channels) return null;
|
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 (entry) return resolveDiscordChannelConfigEntry(entry);
|
||||||
if (parentId || parentName || parentSlug) {
|
if (parentId || parentName || parentSlug) {
|
||||||
const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
|
const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
|
||||||
const parentEntry = resolveDiscordChannelEntry(
|
const parentEntry = resolveDiscordChannelEntry(channels, {
|
||||||
channels,
|
id: parentId ?? "",
|
||||||
parentId ?? "",
|
name: parentName,
|
||||||
parentName,
|
slug: resolvedParentSlug,
|
||||||
resolvedParentSlug,
|
});
|
||||||
);
|
|
||||||
if (parentEntry) return resolveDiscordChannelConfigEntry(parentEntry);
|
if (parentEntry) return resolveDiscordChannelConfigEntry(parentEntry);
|
||||||
}
|
}
|
||||||
return { allowed: false };
|
return { allowed: false };
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export async function preflightDiscordMessage(
|
|||||||
parentId: threadParentId ?? undefined,
|
parentId: threadParentId ?? undefined,
|
||||||
parentName: threadParentName ?? undefined,
|
parentName: threadParentName ?? undefined,
|
||||||
parentSlug: threadParentSlug,
|
parentSlug: threadParentSlug,
|
||||||
|
scope: threadChannel ? "thread" : "channel",
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
if (isGuildMessage && channelConfig?.enabled === false) {
|
if (isGuildMessage && channelConfig?.enabled === false) {
|
||||||
|
|||||||
@@ -553,6 +553,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
parentId: threadParentId,
|
parentId: threadParentId,
|
||||||
parentName: threadParentName,
|
parentName: threadParentName,
|
||||||
parentSlug: threadParentSlug,
|
parentSlug: threadParentSlug,
|
||||||
|
scope: isThreadChannel ? "thread" : "channel",
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
if (channelConfig?.enabled === false) {
|
if (channelConfig?.enabled === false) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SlackReactionNotificationMode } from "../../config/config.js";
|
import type { SlackReactionNotificationMode } from "../../config/config.js";
|
||||||
import type { SlackMessageEvent } from "../types.js";
|
import type { SlackMessageEvent } from "../types.js";
|
||||||
|
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../../channels/channel-config.js";
|
||||||
import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js";
|
import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js";
|
||||||
|
|
||||||
export type SlackChannelConfigResolved = {
|
export type SlackChannelConfigResolved = {
|
||||||
@@ -77,31 +78,17 @@ export function resolveSlackChannelConfig(params: {
|
|||||||
const keys = Object.keys(entries);
|
const keys = Object.keys(entries);
|
||||||
const normalizedName = channelName ? normalizeSlackSlug(channelName) : "";
|
const normalizedName = channelName ? normalizeSlackSlug(channelName) : "";
|
||||||
const directName = channelName ? channelName.trim() : "";
|
const directName = channelName ? channelName.trim() : "";
|
||||||
const candidates = [
|
const candidates = buildChannelKeyCandidates(
|
||||||
channelId,
|
channelId,
|
||||||
channelName ? `#${directName}` : "",
|
channelName ? `#${directName}` : undefined,
|
||||||
directName,
|
directName,
|
||||||
normalizedName,
|
normalizedName,
|
||||||
].filter(Boolean);
|
);
|
||||||
|
const { entry: matched, wildcardEntry: fallback } = resolveChannelEntryMatch({
|
||||||
let matched:
|
entries,
|
||||||
| {
|
keys: candidates,
|
||||||
enabled?: boolean;
|
wildcardKey: "*",
|
||||||
allow?: boolean;
|
});
|
||||||
requireMention?: boolean;
|
|
||||||
allowBots?: boolean;
|
|
||||||
users?: Array<string | number>;
|
|
||||||
skills?: string[];
|
|
||||||
systemPrompt?: string;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (candidate && entries[candidate]) {
|
|
||||||
matched = entries[candidate];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fallback = entries["*"];
|
|
||||||
|
|
||||||
const requireMentionDefault = defaultRequireMention ?? true;
|
const requireMentionDefault = defaultRequireMention ?? true;
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user