diff --git a/extensions/discord/src/monitor/channel-access.ts b/extensions/discord/src/monitor/channel-access.ts index 81569ef80be..695741b764b 100644 --- a/extensions/discord/src/monitor/channel-access.ts +++ b/extensions/discord/src/monitor/channel-access.ts @@ -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"); } diff --git a/extensions/discord/src/monitor/listeners.ts b/extensions/discord/src/monitor/listeners.ts index 8caea16a745..22417320292 100644 --- a/extensions/discord/src/monitor/listeners.ts +++ b/extensions/discord/src/monitor/listeners.ts @@ -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; }; +type DiscordReactionMode = "off" | "own" | "all" | "allowlist"; +type DiscordReactionChannelConfig = ReturnType; +type DiscordReactionIngressAccess = Awaited>; +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; + 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; + 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, + 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)}`)); } diff --git a/extensions/discord/src/monitor/native-interaction-channel-context.ts b/extensions/discord/src/monitor/native-interaction-channel-context.ts index 5e8eda3142e..f38e130e805 100644 --- a/extensions/discord/src/monitor/native-interaction-channel-context.ts +++ b/extensions/discord/src/monitor/native-interaction-channel-context.ts @@ -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 { - 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 : "", }; } diff --git a/extensions/discord/src/monitor/thread-channel-context.ts b/extensions/discord/src/monitor/thread-channel-context.ts new file mode 100644 index 00000000000..6fe29457aa0 --- /dev/null +++ b/extensions/discord/src/monitor/thread-channel-context.ts @@ -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 { + 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 { + return await resolveDiscordThreadLikeChannelContext({ + ...params, + channelInfo: buildFetchedChannelInfo(params.channel), + }); +} diff --git a/extensions/discord/src/voice/command.ts b/extensions/discord/src/voice/command.ts index 185ba326d34..aa3502aae75 100644 --- a/extensions/discord/src/voice/command.ts +++ b/extensions/discord/src/voice/command.ts @@ -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) {