refactor(discord): centralize thread channel context

This commit is contained in:
Peter Steinberger
2026-04-22 19:37:16 +01:00
parent bbcd185215
commit 8bd387976d
5 changed files with 294 additions and 202 deletions

View File

@@ -41,6 +41,10 @@ export function resolveDiscordChannelNameSafe(channel: unknown): string | undefi
return resolveDiscordChannelStringPropertySafe(channel, "name");
}
export function resolveDiscordChannelIdSafe(channel: unknown): string | undefined {
return resolveDiscordChannelStringPropertySafe(channel, "id");
}
export function resolveDiscordChannelTopicSafe(channel: unknown): string | undefined {
return resolveDiscordChannelStringPropertySafe(channel, "topic");
}

View File

@@ -32,14 +32,10 @@ import {
resolveDiscordGuildEntry,
shouldEmitDiscordReactionNotification,
} from "./allow-list.js";
import {
resolveDiscordChannelInfoSafe,
resolveDiscordChannelParentIdSafe,
} from "./channel-access.js";
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { setPresence } from "./presence-cache.js";
import { isThreadArchived } from "./thread-bindings.discord-api.js";
import { resolveFetchedDiscordThreadLikeChannelContext } from "./thread-channel-context.js";
import { closeDiscordThreadSessions } from "./thread-session-close.js";
import { normalizeDiscordListenerTimeoutMs, runDiscordTaskWithTimeout } from "./timeouts.js";
@@ -77,6 +73,11 @@ type DiscordReactionRoutingParams = {
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
};
type DiscordReactionMode = "off" | "own" | "all" | "allowlist";
type DiscordReactionChannelConfig = ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
type DiscordReactionIngressAccess = Awaited<ReturnType<typeof authorizeDiscordReactionIngress>>;
type DiscordFetchedReactionMessage = { author?: User } | null;
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 30_000;
const discordEventQueueLog = createSubsystemLogger("discord/event-queue");
@@ -409,6 +410,117 @@ async function authorizeDiscordReactionIngress(
return { allowed: true };
}
async function handleDiscordThreadReactionNotification(params: {
reactionMode: DiscordReactionMode;
message: DiscordReactionEvent["message"];
parentId?: string;
resolveThreadChannelAccess: () => Promise<{
access: DiscordReactionIngressAccess;
channelConfig: DiscordReactionChannelConfig;
}>;
shouldNotifyReaction: (options: {
mode: DiscordReactionMode;
messageAuthorId?: string;
channelConfig?: DiscordReactionChannelConfig;
}) => boolean;
resolveReactionBase: () => { baseText: string; contextKey: string };
emitReaction: (text: string, parentPeerId?: string) => void;
emitReactionWithAuthor: (message: DiscordFetchedReactionMessage) => void;
}) {
if (params.reactionMode === "off") {
return;
}
if (params.reactionMode === "all" || params.reactionMode === "allowlist") {
const { access, channelConfig } = await params.resolveThreadChannelAccess();
if (
!access.allowed ||
!params.shouldNotifyReaction({ mode: params.reactionMode, channelConfig })
) {
return;
}
const { baseText } = params.resolveReactionBase();
params.emitReaction(baseText, params.parentId);
return;
}
const message = await params.message.fetch().catch(() => null);
const { access, channelConfig } = await params.resolveThreadChannelAccess();
const messageAuthorId = message?.author?.id ?? undefined;
if (
!access.allowed ||
!params.shouldNotifyReaction({
mode: params.reactionMode,
messageAuthorId,
channelConfig,
})
) {
return;
}
params.emitReactionWithAuthor(message);
}
async function handleDiscordChannelReactionNotification(params: {
isGuildMessage: boolean;
reactionMode: DiscordReactionMode;
message: DiscordReactionEvent["message"];
channelConfig: DiscordReactionChannelConfig;
parentId?: string;
authorizeReactionIngressForChannel: (
channelConfig: DiscordReactionChannelConfig,
) => Promise<DiscordReactionIngressAccess>;
shouldNotifyReaction: (options: {
mode: DiscordReactionMode;
messageAuthorId?: string;
channelConfig?: DiscordReactionChannelConfig;
}) => boolean;
resolveReactionBase: () => { baseText: string; contextKey: string };
emitReaction: (text: string, parentPeerId?: string) => void;
emitReactionWithAuthor: (message: DiscordFetchedReactionMessage) => void;
}) {
if (params.isGuildMessage) {
const access = await params.authorizeReactionIngressForChannel(params.channelConfig);
if (!access.allowed) {
return;
}
}
if (params.reactionMode === "off") {
return;
}
if (params.reactionMode === "all" || params.reactionMode === "allowlist") {
if (
!params.shouldNotifyReaction({
mode: params.reactionMode,
channelConfig: params.channelConfig,
})
) {
return;
}
const { baseText } = params.resolveReactionBase();
params.emitReaction(baseText, params.parentId);
return;
}
const message = await params.message.fetch().catch(() => null);
const messageAuthorId = message?.author?.id ?? undefined;
if (
!params.shouldNotifyReaction({
mode: params.reactionMode,
messageAuthorId,
channelConfig: params.channelConfig,
})
) {
return;
}
params.emitReactionWithAuthor(message);
}
async function handleDiscordReactionEvent(
params: {
data: DiscordReactionEvent;
@@ -449,16 +561,17 @@ async function handleDiscordReactionEvent(
if (!channel) {
return;
}
const channelInfo = resolveDiscordChannelInfoSafe(channel);
const channelName = channelInfo.name;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = channelInfo.type;
const channelContext = await resolveFetchedDiscordThreadLikeChannelContext({
client,
channel,
channelIdFallback: data.channel_id,
});
const channelName = channelContext.channelName;
const channelSlug = channelContext.channelSlug;
const channelType = channelContext.channelType;
const isDirectMessage = channelType === ChannelType.DM;
const isGroupDm = channelType === ChannelType.GroupDM;
const isThreadChannel =
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
const isThreadChannel = channelContext.isThreadChannel;
const memberRoleIds = Array.isArray(data.rawMember?.roles)
? data.rawMember.roles.map((roleId: string) => roleId)
: [];
@@ -490,9 +603,9 @@ async function handleDiscordReactionEvent(
return;
}
}
let parentId = resolveDiscordChannelParentIdSafe(channel);
let parentName: string | undefined;
let parentSlug = "";
const parentId = isThreadChannel ? channelContext.threadParentId : channelContext.parentId;
const parentName = isThreadChannel ? channelContext.threadParentName : undefined;
const parentSlug = isThreadChannel ? channelContext.threadParentSlug : "";
let reactionBase: { baseText: string; contextKey: string } | null = null;
const resolveReactionBase = () => {
if (reactionBase) {
@@ -535,9 +648,9 @@ async function handleDiscordReactionEvent(
});
};
const shouldNotifyReaction = (options: {
mode: "off" | "own" | "all" | "allowlist";
mode: DiscordReactionMode;
messageAuthorId?: string;
channelConfig?: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
channelConfig?: DiscordReactionChannelConfig;
}) =>
shouldEmitDiscordReactionNotification({
mode: options.mode,
@@ -557,14 +670,6 @@ async function handleDiscordReactionEvent(
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
emitReaction(text, parentId);
};
const loadThreadParentInfo = async () => {
if (!parentId) {
return;
}
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
parentName = parentInfo?.name;
parentSlug = parentName ? normalizeDiscordSlug(parentName) : "";
};
const resolveThreadChannelConfig = () =>
resolveDiscordChannelConfigWithFallback({
guildInfo,
@@ -577,77 +682,30 @@ async function handleDiscordReactionEvent(
scope: "thread",
});
const authorizeReactionIngressForChannel = async (
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>,
channelConfig: DiscordReactionChannelConfig,
) =>
await authorizeDiscordReactionIngress({
...reactionIngressBase,
channelConfig,
});
const resolveThreadChannelAccess = async (channelInfo: { parentId?: string } | null) => {
parentId = channelInfo?.parentId;
await loadThreadParentInfo();
const resolveThreadChannelAccess = async () => {
const channelConfig = resolveThreadChannelConfig();
const access = await authorizeReactionIngressForChannel(channelConfig);
return { access, channelConfig };
};
// Parallelize async operations for thread channels
if (isThreadChannel) {
const reactionMode = guildInfo?.reactionNotifications ?? "own";
// Early exit: skip fetching message if notifications are off
if (reactionMode === "off") {
return;
}
const channelInfoPromise = parentId
? Promise.resolve({ parentId })
: resolveDiscordChannelInfo(client, data.channel_id);
// Fast path: for "all" and "allowlist" modes, we don't need to fetch the message
if (reactionMode === "all" || reactionMode === "allowlist") {
const channelInfo = await channelInfoPromise;
const { access: threadAccess, channelConfig: threadChannelConfig } =
await resolveThreadChannelAccess(channelInfo);
if (!threadAccess.allowed) {
return;
}
if (
!shouldNotifyReaction({
mode: reactionMode,
channelConfig: threadChannelConfig,
})
) {
return;
}
const { baseText } = resolveReactionBase();
emitReaction(baseText, parentId);
return;
}
// For "own" mode, we need to fetch the message to check the author
const messagePromise = data.message.fetch().catch(() => null);
const [channelInfo, message] = await Promise.all([channelInfoPromise, messagePromise]);
const { access: threadAccess, channelConfig: threadChannelConfig } =
await resolveThreadChannelAccess(channelInfo);
if (!threadAccess.allowed) {
return;
}
const messageAuthorId = message?.author?.id ?? undefined;
if (
!shouldNotifyReaction({
mode: reactionMode,
messageAuthorId,
channelConfig: threadChannelConfig,
})
) {
return;
}
emitReactionWithAuthor(message);
await handleDiscordThreadReactionNotification({
reactionMode,
message: data.message,
parentId,
resolveThreadChannelAccess,
shouldNotifyReaction,
resolveReactionBase,
emitReaction,
emitReactionWithAuthor,
});
return;
}
@@ -662,39 +720,19 @@ async function handleDiscordReactionEvent(
parentSlug,
scope: "channel",
});
if (isGuildMessage) {
const channelAccess = await authorizeReactionIngressForChannel(channelConfig);
if (!channelAccess.allowed) {
return;
}
}
const reactionMode = guildInfo?.reactionNotifications ?? "own";
// Early exit: skip fetching message if notifications are off
if (reactionMode === "off") {
return;
}
// Fast path: for "all" and "allowlist" modes, we don't need to fetch the message
if (reactionMode === "all" || reactionMode === "allowlist") {
if (!shouldNotifyReaction({ mode: reactionMode, channelConfig })) {
return;
}
const { baseText } = resolveReactionBase();
emitReaction(baseText, parentId);
return;
}
// For "own" mode, we need to fetch the message to check the author
const message = await data.message.fetch().catch(() => null);
const messageAuthorId = message?.author?.id ?? undefined;
if (!shouldNotifyReaction({ mode: reactionMode, messageAuthorId, channelConfig })) {
return;
}
emitReactionWithAuthor(message);
await handleDiscordChannelReactionNotification({
isGuildMessage,
reactionMode,
message: data.message,
channelConfig,
parentId,
authorizeReactionIngressForChannel,
shouldNotifyReaction,
resolveReactionBase,
emitReaction,
emitReactionWithAuthor,
});
} catch (err) {
params.logger.error(danger(`discord reaction handler failed: ${String(err)}`));
}

View File

@@ -1,11 +1,5 @@
import { ChannelType, type Client } from "@buape/carbon";
import { normalizeDiscordSlug } from "./allow-list.js";
import {
resolveDiscordChannelNameSafe,
resolveDiscordChannelParentIdSafe,
} from "./channel-access.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { resolveDiscordThreadParentInfo } from "./threading.js";
import { resolveDiscordThreadLikeChannelContext } from "./thread-channel-context.js";
type DiscordInteractionChannel = {
id?: string;
@@ -31,48 +25,25 @@ export async function resolveDiscordNativeInteractionChannelContext(params: {
hasGuild: boolean;
channelIdFallback: string;
}): Promise<DiscordNativeInteractionChannelContext> {
const { channel } = params;
const channelType = channel?.type;
const channelContext = await resolveDiscordThreadLikeChannelContext({
client: params.client,
channel: params.channel,
channelIdFallback: params.channelIdFallback,
});
const channelType = channelContext.channelType;
const isDirectMessage = channelType === ChannelType.DM;
const isGroupDm = channelType === ChannelType.GroupDM;
const isThreadChannel =
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
const channelName = resolveDiscordChannelNameSafe(channel);
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const rawChannelId = channel?.id ?? params.channelIdFallback;
let threadParentId: string | undefined;
let threadParentName: string | undefined;
let threadParentSlug = "";
if (params.hasGuild && channel && isThreadChannel && rawChannelId) {
const channelInfo = await resolveDiscordChannelInfo(params.client, rawChannelId);
const parentInfo = await resolveDiscordThreadParentInfo({
client: params.client,
threadChannel: {
id: rawChannelId,
name: channelName,
parentId: resolveDiscordChannelParentIdSafe(channel),
parent: undefined,
},
channelInfo,
});
threadParentId = parentInfo.id;
threadParentName = parentInfo.name;
threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
}
return {
channelType,
isDirectMessage,
isGroupDm,
isThreadChannel,
channelName,
channelSlug,
rawChannelId,
threadParentId,
threadParentName,
threadParentSlug,
isThreadChannel: channelContext.isThreadChannel,
channelName: channelContext.channelName,
channelSlug: channelContext.channelSlug,
rawChannelId: channelContext.channelId,
threadParentId: params.hasGuild ? channelContext.threadParentId : undefined,
threadParentName: params.hasGuild ? channelContext.threadParentName : undefined,
threadParentSlug: params.hasGuild ? channelContext.threadParentSlug : "",
};
}

View File

@@ -0,0 +1,108 @@
import { ChannelType, type Client } from "@buape/carbon";
import { normalizeDiscordSlug } from "./allow-list.js";
import {
resolveDiscordChannelIdSafe,
resolveDiscordChannelInfoSafe,
resolveDiscordChannelParentIdSafe,
} from "./channel-access.js";
import { resolveDiscordChannelInfo, type DiscordChannelInfo } from "./message-utils.js";
import { resolveDiscordThreadParentInfo } from "./threading.js";
export type DiscordThreadLikeChannelContext = {
channelType?: ChannelType;
isThreadChannel: boolean;
channelId: string;
channelName?: string;
channelSlug: string;
parentId?: string;
threadParentId?: string;
threadParentName?: string;
threadParentSlug: string;
channelInfo: DiscordChannelInfo | null;
};
export function isDiscordThreadChannelType(type: ChannelType | number | undefined): boolean {
return (
type === ChannelType.PublicThread ||
type === ChannelType.PrivateThread ||
type === ChannelType.AnnouncementThread
);
}
function buildFetchedChannelInfo(channel: unknown): DiscordChannelInfo | null {
const channelInfo = resolveDiscordChannelInfoSafe(channel);
if (channelInfo.type === undefined) {
return null;
}
return {
type: channelInfo.type as ChannelType,
name: channelInfo.name,
topic: channelInfo.topic,
parentId: channelInfo.parentId,
ownerId: channelInfo.ownerId,
};
}
export async function resolveDiscordThreadLikeChannelContext(params: {
client: Client;
channel: unknown;
channelIdFallback?: string;
channelInfo?: DiscordChannelInfo | null;
}): Promise<DiscordThreadLikeChannelContext> {
const safeChannelInfo = resolveDiscordChannelInfoSafe(params.channel);
const channelId = resolveDiscordChannelIdSafe(params.channel) ?? params.channelIdFallback ?? "";
const channelInfo =
params.channelInfo !== undefined
? params.channelInfo
: channelId
? await resolveDiscordChannelInfo(params.client, channelId)
: null;
const channelType = (safeChannelInfo.type as ChannelType | undefined) ?? channelInfo?.type;
const channelName = safeChannelInfo.name ?? channelInfo?.name;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const parentId = resolveDiscordChannelParentIdSafe(params.channel) ?? channelInfo?.parentId;
const isThreadChannel = isDiscordThreadChannelType(channelType);
let threadParentId: string | undefined;
let threadParentName: string | undefined;
let threadParentSlug = "";
if (channelId && isThreadChannel) {
const parentInfo = await resolveDiscordThreadParentInfo({
client: params.client,
threadChannel: {
id: channelId,
name: channelName,
parentId,
parent: undefined,
},
channelInfo,
});
threadParentId = parentInfo.id;
threadParentName = parentInfo.name;
threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
}
return {
channelType,
isThreadChannel,
channelId,
channelName,
channelSlug,
parentId,
threadParentId,
threadParentName,
threadParentSlug,
channelInfo,
};
}
export async function resolveFetchedDiscordThreadLikeChannelContext(params: {
client: Client;
channel: unknown;
channelIdFallback?: string;
}): Promise<DiscordThreadLikeChannelContext> {
return await resolveDiscordThreadLikeChannelContext({
...params,
channelInfo: buildFetchedChannelInfo(params.channel),
});
}

View File

@@ -13,14 +13,9 @@ import {
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { formatMention } from "../mentions.js";
import { normalizeDiscordSlug } from "../monitor/allow-list.js";
import {
resolveDiscordChannelNameSafe,
resolveDiscordChannelParentIdSafe,
} from "../monitor/channel-access.js";
import { resolveDiscordChannelInfo } from "../monitor/message-utils.js";
import { resolveDiscordChannelNameSafe } from "../monitor/channel-access.js";
import { resolveDiscordSenderIdentity } from "../monitor/sender-identity.js";
import { resolveDiscordThreadParentInfo } from "../monitor/threading.js";
import { resolveDiscordThreadLikeChannelContext } from "../monitor/thread-channel-context.js";
import { authorizeDiscordVoiceIngress } from "./access.js";
import type { DiscordVoiceManager } from "./manager.js";
@@ -66,35 +61,12 @@ async function authorizeVoiceCommand(
}
const channelId = channelOverride?.id ?? channel?.id ?? "";
const rawChannelName = channelOverride?.name ?? resolveDiscordChannelNameSafe(channel);
const rawParentId = channelOverride?.parentId ?? resolveDiscordChannelParentIdSafe(channel);
const channelInfo = channelId
? await resolveDiscordChannelInfo(interaction.client, channelId)
: null;
const channelName = rawChannelName ?? channelInfo?.name;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const isThreadChannel =
channelInfo?.type === CarbonChannelType.PublicThread ||
channelInfo?.type === CarbonChannelType.PrivateThread ||
channelInfo?.type === CarbonChannelType.AnnouncementThread;
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug: string | undefined;
if (isThreadChannel && channelId) {
const parentInfo = await resolveDiscordThreadParentInfo({
client: interaction.client,
threadChannel: {
id: channelId,
name: channelName,
parentId: rawParentId ?? channelInfo?.parentId,
parent: undefined,
},
channelInfo,
});
parentId = parentInfo.id;
parentName = parentInfo.name;
parentSlug = parentName ? normalizeDiscordSlug(parentName) : undefined;
}
const channelContext = await resolveDiscordThreadLikeChannelContext({
client: interaction.client,
channel: channelOverride ?? channel,
channelIdFallback: channelId,
});
const channelName = channelOverride?.name ?? channelContext.channelName;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => roleId)
@@ -109,11 +81,11 @@ async function authorizeVoiceCommand(
guildId: interaction.guild.id,
channelId,
channelName,
channelSlug,
parentId,
parentName,
parentSlug,
scope: isThreadChannel ? "thread" : "channel",
channelSlug: channelContext.channelSlug,
parentId: channelOverride?.parentId ?? channelContext.threadParentId,
parentName: channelContext.threadParentName,
parentSlug: channelContext.threadParentSlug,
scope: channelContext.isThreadChannel ? "thread" : "channel",
channelLabel: channelId ? formatMention({ channelId }) : "This channel",
memberRoleIds,
sender: {
@@ -200,7 +172,6 @@ export function createDiscordVoiceCommand(params: VoiceCommandContext): CommandW
channelOverride: {
id: channel.id,
name: resolveDiscordChannelNameSafe(channel),
parentId: resolveDiscordChannelParentIdSafe(channel),
},
});
if (!access.ok) {