From dcd428e8c18a40728159cbe3da2d22c1a4fdbe86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 18:03:25 +0100 Subject: [PATCH] refactor(discord): split preflight and native command helpers --- ...essage-handler.preflight-channel-access.ts | 86 ++++++ ...ssage-handler.preflight-channel-context.ts | 55 ++++ .../message-handler.preflight-context.ts | 54 ++++ .../message-handler.preflight-history.ts | 23 ++ .../message-handler.preflight-logging.ts | 36 +++ .../message-handler.preflight-pluralkit.ts | 27 ++ .../message-handler.preflight-thread.ts | 49 ++++ .../src/monitor/message-handler.preflight.ts | 263 ++++++------------ .../src/monitor/native-command-agent-reply.ts | 123 ++++++++ .../src/monitor/native-command-bypass.ts | 13 + .../discord/src/monitor/native-command.ts | 107 ++----- 11 files changed, 569 insertions(+), 267 deletions(-) create mode 100644 extensions/discord/src/monitor/message-handler.preflight-channel-access.ts create mode 100644 extensions/discord/src/monitor/message-handler.preflight-channel-context.ts create mode 100644 extensions/discord/src/monitor/message-handler.preflight-context.ts create mode 100644 extensions/discord/src/monitor/message-handler.preflight-history.ts create mode 100644 extensions/discord/src/monitor/message-handler.preflight-logging.ts create mode 100644 extensions/discord/src/monitor/message-handler.preflight-pluralkit.ts create mode 100644 extensions/discord/src/monitor/message-handler.preflight-thread.ts create mode 100644 extensions/discord/src/monitor/native-command-agent-reply.ts create mode 100644 extensions/discord/src/monitor/native-command-bypass.ts diff --git a/extensions/discord/src/monitor/message-handler.preflight-channel-access.ts b/extensions/discord/src/monitor/message-handler.preflight-channel-access.ts new file mode 100644 index 00000000000..c4a1706255b --- /dev/null +++ b/extensions/discord/src/monitor/message-handler.preflight-channel-access.ts @@ -0,0 +1,86 @@ +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { logDebug } from "openclaw/plugin-sdk/text-runtime"; +import { + isDiscordGroupAllowedByPolicy, + resolveGroupDmAllow, + type DiscordChannelConfigResolved, + type DiscordGuildEntryResolved, +} from "./allow-list.js"; + +export function resolveDiscordPreflightChannelAccess(params: { + isGuildMessage: boolean; + isGroupDm: boolean; + groupPolicy: "open" | "disabled" | "allowlist"; + groupDmChannels?: string[]; + messageChannelId: string; + displayChannelName?: string; + displayChannelSlug: string; + guildInfo: DiscordGuildEntryResolved | null; + channelConfig: DiscordChannelConfigResolved | null; + channelMatchMeta: string; +}): { allowed: boolean; channelAllowlistConfigured: boolean; channelAllowed: boolean } { + if (params.isGuildMessage && params.channelConfig?.enabled === false) { + logDebug(`[discord-preflight] drop: channel disabled`); + logVerbose( + `Blocked discord channel ${params.messageChannelId} (channel disabled, ${params.channelMatchMeta})`, + ); + return { allowed: false, channelAllowlistConfigured: false, channelAllowed: false }; + } + + const groupDmAllowed = + params.isGroupDm && + resolveGroupDmAllow({ + channels: params.groupDmChannels, + channelId: params.messageChannelId, + channelName: params.displayChannelName, + channelSlug: params.displayChannelSlug, + }); + if (params.isGroupDm && !groupDmAllowed) { + return { allowed: false, channelAllowlistConfigured: false, channelAllowed: false }; + } + + const channelAllowlistConfigured = + Boolean(params.guildInfo?.channels) && Object.keys(params.guildInfo?.channels ?? {}).length > 0; + const channelAllowed = params.channelConfig?.allowed !== false; + if ( + params.isGuildMessage && + !isDiscordGroupAllowedByPolicy({ + groupPolicy: params.groupPolicy, + guildAllowlisted: Boolean(params.guildInfo), + channelAllowlistConfigured, + channelAllowed, + }) + ) { + if (params.groupPolicy === "disabled") { + logDebug(`[discord-preflight] drop: groupPolicy disabled`); + logVerbose(`discord: drop guild message (groupPolicy: disabled, ${params.channelMatchMeta})`); + } else if (!channelAllowlistConfigured) { + logDebug(`[discord-preflight] drop: groupPolicy allowlist, no channel allowlist configured`); + logVerbose( + `discord: drop guild message (groupPolicy: allowlist, no channel allowlist, ${params.channelMatchMeta})`, + ); + } else { + logDebug( + `[discord] Ignored message from channel ${params.messageChannelId} (not in guild allowlist). Add to guilds..channels to enable.`, + ); + logVerbose( + `Blocked discord channel ${params.messageChannelId} not in guild channel allowlist (groupPolicy: allowlist, ${params.channelMatchMeta})`, + ); + } + return { allowed: false, channelAllowlistConfigured, channelAllowed }; + } + + if (params.isGuildMessage && params.channelConfig?.allowed === false) { + logDebug(`[discord-preflight] drop: channelConfig.allowed===false`); + logVerbose( + `Blocked discord channel ${params.messageChannelId} not in guild channel allowlist (${params.channelMatchMeta})`, + ); + return { allowed: false, channelAllowlistConfigured, channelAllowed }; + } + if (params.isGuildMessage) { + logDebug(`[discord-preflight] pass: channel allowed`); + logVerbose(`discord: allow channel ${params.messageChannelId} (${params.channelMatchMeta})`); + } + + return { allowed: true, channelAllowlistConfigured, channelAllowed }; +} diff --git a/extensions/discord/src/monitor/message-handler.preflight-channel-context.ts b/extensions/discord/src/monitor/message-handler.preflight-channel-context.ts new file mode 100644 index 00000000000..298bed0db98 --- /dev/null +++ b/extensions/discord/src/monitor/message-handler.preflight-channel-context.ts @@ -0,0 +1,55 @@ +import { + normalizeDiscordSlug, + resolveDiscordChannelConfigWithFallback, + type DiscordGuildEntryResolved, +} from "./allow-list.js"; +import type { DiscordMessagePreflightContext } from "./message-handler.preflight.types.js"; + +export function resolveDiscordPreflightChannelContext(params: { + isGuildMessage: boolean; + messageChannelId: string; + channelName?: string; + guildName?: string; + guildInfo: DiscordGuildEntryResolved | null; + threadChannel: DiscordMessagePreflightContext["threadChannel"]; + threadParentId?: string; + threadParentName?: string; +}) { + const threadName = params.threadChannel?.name; + const configChannelName = params.threadParentName ?? params.channelName; + const configChannelSlug = configChannelName ? normalizeDiscordSlug(configChannelName) : ""; + const displayChannelName = threadName ?? params.channelName; + const displayChannelSlug = displayChannelName ? normalizeDiscordSlug(displayChannelName) : ""; + const guildSlug = + params.guildInfo?.slug || (params.guildName ? normalizeDiscordSlug(params.guildName) : ""); + + const threadChannelSlug = params.channelName ? normalizeDiscordSlug(params.channelName) : ""; + const threadParentSlug = params.threadParentName + ? normalizeDiscordSlug(params.threadParentName) + : ""; + + const channelConfig = params.isGuildMessage + ? resolveDiscordChannelConfigWithFallback({ + guildInfo: params.guildInfo, + channelId: params.messageChannelId, + channelName: params.channelName, + channelSlug: threadChannelSlug, + parentId: params.threadParentId, + parentName: params.threadParentName, + parentSlug: threadParentSlug, + scope: params.threadChannel ? "thread" : "channel", + }) + : null; + + return { + threadName, + configChannelName, + configChannelSlug, + displayChannelName, + displayChannelSlug, + guildSlug, + threadChannelSlug, + threadParentSlug, + channelConfig, + }; +} diff --git a/extensions/discord/src/monitor/message-handler.preflight-context.ts b/extensions/discord/src/monitor/message-handler.preflight-context.ts new file mode 100644 index 00000000000..167fbd974b7 --- /dev/null +++ b/extensions/discord/src/monitor/message-handler.preflight-context.ts @@ -0,0 +1,54 @@ +import type { + DiscordMessagePreflightContext, + DiscordMessagePreflightParams, +} from "./message-handler.preflight.types.js"; + +type SharedPreflightFields = + | "cfg" + | "discordConfig" + | "accountId" + | "token" + | "runtime" + | "botUserId" + | "abortSignal" + | "guildHistories" + | "historyLimit" + | "mediaMaxBytes" + | "textLimit" + | "replyToMode" + | "ackReactionScope" + | "groupPolicy" + | "threadBindings" + | "discordRestFetch"; + +type BuildDiscordMessagePreflightContextParams = Omit< + DiscordMessagePreflightContext, + SharedPreflightFields +> & { + preflightParams: DiscordMessagePreflightParams; +}; + +export function buildDiscordMessagePreflightContext({ + preflightParams, + ...fields +}: BuildDiscordMessagePreflightContextParams): DiscordMessagePreflightContext { + return { + cfg: preflightParams.cfg, + discordConfig: preflightParams.discordConfig, + accountId: preflightParams.accountId, + token: preflightParams.token, + runtime: preflightParams.runtime, + botUserId: preflightParams.botUserId, + abortSignal: preflightParams.abortSignal, + guildHistories: preflightParams.guildHistories, + historyLimit: preflightParams.historyLimit, + mediaMaxBytes: preflightParams.mediaMaxBytes, + textLimit: preflightParams.textLimit, + replyToMode: preflightParams.replyToMode, + ackReactionScope: preflightParams.ackReactionScope, + groupPolicy: preflightParams.groupPolicy, + ...fields, + threadBindings: preflightParams.threadBindings, + discordRestFetch: preflightParams.discordRestFetch, + }; +} diff --git a/extensions/discord/src/monitor/message-handler.preflight-history.ts b/extensions/discord/src/monitor/message-handler.preflight-history.ts new file mode 100644 index 00000000000..feae2ab7fd4 --- /dev/null +++ b/extensions/discord/src/monitor/message-handler.preflight-history.ts @@ -0,0 +1,23 @@ +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; +import { resolveTimestampMs } from "./format.js"; +import type { DiscordMessagePreflightContext } from "./message-handler.preflight.types.js"; +import { resolveDiscordMessageText } from "./message-utils.js"; + +export function buildDiscordPreflightHistoryEntry(params: { + isGuildMessage: boolean; + historyLimit: number; + message: DiscordMessagePreflightContext["message"]; + senderLabel: string; +}): HistoryEntry | undefined { + const textForHistory = resolveDiscordMessageText(params.message, { + includeForwarded: true, + }); + return params.isGuildMessage && params.historyLimit > 0 && textForHistory + ? { + sender: params.senderLabel, + body: textForHistory, + timestamp: resolveTimestampMs(params.message.timestamp), + messageId: params.message.id, + } + : undefined; +} diff --git a/extensions/discord/src/monitor/message-handler.preflight-logging.ts b/extensions/discord/src/monitor/message-handler.preflight-logging.ts new file mode 100644 index 00000000000..92295ec755e --- /dev/null +++ b/extensions/discord/src/monitor/message-handler.preflight-logging.ts @@ -0,0 +1,36 @@ +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { logDebug } from "openclaw/plugin-sdk/text-runtime"; +import type { DiscordChannelConfigResolved } from "./allow-list.js"; + +export function logDiscordPreflightChannelConfig(params: { + channelConfig: DiscordChannelConfigResolved | null; + channelMatchMeta: string; + channelId: string; +}) { + if (!shouldLogVerbose()) { + return; + } + const channelConfigSummary = params.channelConfig + ? `allowed=${params.channelConfig.allowed} enabled=${params.channelConfig.enabled ?? "unset"} requireMention=${params.channelConfig.requireMention ?? "unset"} ignoreOtherMentions=${params.channelConfig.ignoreOtherMentions ?? "unset"} matchKey=${params.channelConfig.matchKey ?? "none"} matchSource=${params.channelConfig.matchSource ?? "none"} users=${params.channelConfig.users?.length ?? 0} roles=${params.channelConfig.roles?.length ?? 0} skills=${params.channelConfig.skills?.length ?? 0}` + : "none"; + logDebug( + `[discord-preflight] channelConfig=${channelConfigSummary} channelMatchMeta=${params.channelMatchMeta} channelId=${params.channelId}`, + ); +} + +export function logDiscordPreflightInboundSummary(params: { + messageId: string; + guildId?: string; + channelId: string; + wasMentioned: boolean; + isDirectMessage: boolean; + isGroupDm: boolean; + hasContent: boolean; +}) { + if (!shouldLogVerbose()) { + return; + } + logVerbose( + `discord: inbound id=${params.messageId} guild=${params.guildId ?? "dm"} channel=${params.channelId} mention=${params.wasMentioned ? "yes" : "no"} type=${params.isDirectMessage ? "dm" : params.isGroupDm ? "group-dm" : "guild"} content=${params.hasContent ? "yes" : "no"}`, + ); +} diff --git a/extensions/discord/src/monitor/message-handler.preflight-pluralkit.ts b/extensions/discord/src/monitor/message-handler.preflight-pluralkit.ts new file mode 100644 index 00000000000..c63269e2d00 --- /dev/null +++ b/extensions/discord/src/monitor/message-handler.preflight-pluralkit.ts @@ -0,0 +1,27 @@ +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { isPreflightAborted, loadPluralKitRuntime } from "./message-handler.preflight-runtime.js"; +import type { DiscordMessageEvent } from "./message-handler.preflight.types.js"; + +export async function resolveDiscordPreflightPluralKitInfo(params: { + message: DiscordMessageEvent["message"]; + webhookId?: string | null; + config?: NonNullable< + NonNullable["discord"] + >["pluralkit"]; + abortSignal?: AbortSignal; +}): Promise>> { + if (!params.config?.enabled || params.webhookId) { + return null; + } + try { + const { fetchPluralKitMessageInfo } = await loadPluralKitRuntime(); + const info = await fetchPluralKitMessageInfo({ + messageId: params.message.id, + config: params.config, + }); + return isPreflightAborted(params.abortSignal) ? null : info; + } catch (err) { + logVerbose(`discord: pluralkit lookup failed for ${params.message.id}: ${String(err)}`); + return null; + } +} diff --git a/extensions/discord/src/monitor/message-handler.preflight-thread.ts b/extensions/discord/src/monitor/message-handler.preflight-thread.ts new file mode 100644 index 00000000000..373fffe1610 --- /dev/null +++ b/extensions/discord/src/monitor/message-handler.preflight-thread.ts @@ -0,0 +1,49 @@ +import type { ChannelType } from "../internal/discord.js"; +import { + isPreflightAborted, + loadDiscordThreadingRuntime, +} from "./message-handler.preflight-runtime.js"; +import type { DiscordMessagePreflightContext } from "./message-handler.preflight.types.js"; +import type { DiscordChannelInfo } from "./message-utils.js"; + +export type DiscordPreflightThreadContext = { + earlyThreadChannel: DiscordMessagePreflightContext["threadChannel"]; + earlyThreadParentId?: string; + earlyThreadParentName?: string; + earlyThreadParentType?: ChannelType; +}; + +export async function resolveDiscordPreflightThreadContext(params: { + client: DiscordMessagePreflightContext["client"]; + isGuildMessage: boolean; + message: DiscordMessagePreflightContext["message"]; + channelInfo: DiscordChannelInfo | null; + messageChannelId: string; + abortSignal?: AbortSignal; +}): Promise { + const { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } = + await loadDiscordThreadingRuntime(); + const earlyThreadChannel = resolveDiscordThreadChannel({ + isGuildMessage: params.isGuildMessage, + message: params.message, + channelInfo: params.channelInfo, + messageChannelId: params.messageChannelId, + }); + if (!earlyThreadChannel) { + return { earlyThreadChannel: null }; + } + const parentInfo = await resolveDiscordThreadParentInfo({ + client: params.client, + threadChannel: earlyThreadChannel, + channelInfo: params.channelInfo, + }); + if (isPreflightAborted(params.abortSignal)) { + return null; + } + return { + earlyThreadChannel, + earlyThreadParentId: parentInfo.id, + earlyThreadParentName: parentInfo.name, + earlyThreadParentType: parentInfo.type, + }; +} diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index b25703aef1e..56c4d8deb5c 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -9,29 +9,25 @@ import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-nati import { hasControlCommand } from "openclaw/plugin-sdk/command-detection"; import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; -import { - recordPendingHistoryEntryIfEnabled, - type HistoryEntry, -} from "openclaw/plugin-sdk/reply-history"; -import { getChildLogger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-history"; +import { getChildLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; import { logDebug } from "openclaw/plugin-sdk/text-runtime"; import { resolveDefaultDiscordAccountId } from "../accounts.js"; import { ChannelType, MessageType, type User } from "../internal/discord.js"; import { - isDiscordGroupAllowedByPolicy, - normalizeDiscordSlug, - resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, resolveDiscordMemberAccessState, resolveDiscordOwnerAccess, resolveDiscordShouldRequireMention, - resolveGroupDmAllow, } from "./allow-list.js"; import { resolveDiscordChannelInfoSafe, resolveDiscordChannelNameSafe } from "./channel-access.js"; -import { resolveDiscordSystemLocation, resolveTimestampMs } from "./format.js"; +import { resolveDiscordSystemLocation } from "./format.js"; import { resolveDiscordDmPreflightAccess } from "./message-handler.dm-preflight.js"; import { hydrateDiscordMessageIfNeeded } from "./message-handler.hydration.js"; +import { resolveDiscordPreflightChannelAccess } from "./message-handler.preflight-channel-access.js"; +import { resolveDiscordPreflightChannelContext } from "./message-handler.preflight-channel-context.js"; +import { buildDiscordMessagePreflightContext } from "./message-handler.preflight-context.js"; import { isBoundThreadBotSystemMessage, isDiscordThreadChannelMessage, @@ -40,13 +36,18 @@ import { resolvePreflightMentionRequirement, shouldIgnoreBoundThreadWebhookMessage, } from "./message-handler.preflight-helpers.js"; +import { buildDiscordPreflightHistoryEntry } from "./message-handler.preflight-history.js"; +import { + logDiscordPreflightChannelConfig, + logDiscordPreflightInboundSummary, +} from "./message-handler.preflight-logging.js"; +import { resolveDiscordPreflightPluralKitInfo } from "./message-handler.preflight-pluralkit.js"; import { isPreflightAborted, - loadDiscordThreadingRuntime, - loadPluralKitRuntime, loadPreflightAudioRuntime, loadSystemEventsRuntime, } from "./message-handler.preflight-runtime.js"; +import { resolveDiscordPreflightThreadContext } from "./message-handler.preflight-thread.js"; import type { DiscordMessagePreflightContext, DiscordMessagePreflightParams, @@ -109,23 +110,14 @@ export async function preflightDiscordMessage( const pluralkitConfig = params.discordConfig?.pluralkit; const webhookId = resolveDiscordWebhookId(message); - const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId; - let pluralkitInfo: Awaited< - ReturnType - > = null; - if (shouldCheckPluralKit) { - try { - const { fetchPluralKitMessageInfo } = await loadPluralKitRuntime(); - pluralkitInfo = await fetchPluralKitMessageInfo({ - messageId: message.id, - config: pluralkitConfig, - }); - if (isPreflightAborted(params.abortSignal)) { - return null; - } - } catch (err) { - logVerbose(`discord: pluralkit lookup failed for ${message.id}: ${String(err)}`); - } + const pluralkitInfo = await resolveDiscordPreflightPluralKitInfo({ + message, + webhookId, + config: pluralkitConfig, + abortSignal: params.abortSignal, + }); + if (isPreflightAborted(params.abortSignal)) { + return null; } const sender = resolveDiscordSenderIdentity({ author, @@ -248,30 +240,19 @@ export async function preflightDiscordMessage( "channel" in message ? (message as { channel?: unknown }).channel : undefined, ) : undefined); - const { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } = - await loadDiscordThreadingRuntime(); - const earlyThreadChannel = resolveDiscordThreadChannel({ + const threadContext = await resolveDiscordPreflightThreadContext({ + client: params.client, isGuildMessage, message, channelInfo, messageChannelId, + abortSignal: params.abortSignal, }); - let earlyThreadParentId: string | undefined; - let earlyThreadParentName: string | undefined; - let earlyThreadParentType: ChannelType | undefined; - if (earlyThreadChannel) { - const parentInfo = await resolveDiscordThreadParentInfo({ - client: params.client, - threadChannel: earlyThreadChannel, - channelInfo, - }); - if (isPreflightAborted(params.abortSignal)) { - return null; - } - earlyThreadParentId = parentInfo.id; - earlyThreadParentName = parentInfo.name; - earlyThreadParentType = parentInfo.type; + if (!threadContext) { + return null; } + const { earlyThreadChannel, earlyThreadParentId, earlyThreadParentName, earlyThreadParentType } = + threadContext; // Routing inputs are payload-derived, but config must come from the boundary // snapshot already threaded into the monitor path. @@ -371,114 +352,53 @@ export async function preflightDiscordMessage( const threadParentId = earlyThreadParentId; const threadParentName = earlyThreadParentName; const threadParentType = earlyThreadParentType; - const threadName = threadChannel?.name; - const configChannelName = threadParentName ?? channelName; - const configChannelSlug = configChannelName ? normalizeDiscordSlug(configChannelName) : ""; - const displayChannelName = threadName ?? channelName; - const displayChannelSlug = displayChannelName ? normalizeDiscordSlug(displayChannelName) : ""; - const guildSlug = - guildInfo?.slug || - (params.data.guild?.name ? normalizeDiscordSlug(params.data.guild.name) : ""); - - const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; - const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : ""; - - const channelConfig = isGuildMessage - ? resolveDiscordChannelConfigWithFallback({ - guildInfo, - channelId: messageChannelId, - channelName, - channelSlug: threadChannelSlug, - parentId: threadParentId ?? undefined, - parentName: threadParentName ?? undefined, - parentSlug: threadParentSlug, - scope: threadChannel ? "thread" : "channel", - }) - : null; - const channelMatchMeta = formatAllowlistMatchMeta(channelConfig); - if (shouldLogVerbose()) { - const channelConfigSummary = channelConfig - ? `allowed=${channelConfig.allowed} enabled=${channelConfig.enabled ?? "unset"} requireMention=${channelConfig.requireMention ?? "unset"} ignoreOtherMentions=${channelConfig.ignoreOtherMentions ?? "unset"} matchKey=${channelConfig.matchKey ?? "none"} matchSource=${channelConfig.matchSource ?? "none"} users=${channelConfig.users?.length ?? 0} roles=${channelConfig.roles?.length ?? 0} skills=${channelConfig.skills?.length ?? 0}` - : "none"; - logDebug( - `[discord-preflight] channelConfig=${channelConfigSummary} channelMatchMeta=${channelMatchMeta} channelId=${messageChannelId}`, - ); - } - if (isGuildMessage && channelConfig?.enabled === false) { - logDebug(`[discord-preflight] drop: channel disabled`); - logVerbose( - `Blocked discord channel ${messageChannelId} (channel disabled, ${channelMatchMeta})`, - ); - return null; - } - - const groupDmAllowed = - isGroupDm && - resolveGroupDmAllow({ - channels: params.groupDmChannels, - channelId: messageChannelId, - channelName: displayChannelName, - channelSlug: displayChannelSlug, - }); - if (isGroupDm && !groupDmAllowed) { - return null; - } - - const channelAllowlistConfigured = - Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; - const channelAllowed = channelConfig?.allowed !== false; - if ( - isGuildMessage && - !isDiscordGroupAllowedByPolicy({ - groupPolicy: params.groupPolicy, - guildAllowlisted: Boolean(guildInfo), - channelAllowlistConfigured, - channelAllowed, - }) - ) { - if (params.groupPolicy === "disabled") { - logDebug(`[discord-preflight] drop: groupPolicy disabled`); - logVerbose(`discord: drop guild message (groupPolicy: disabled, ${channelMatchMeta})`); - } else if (!channelAllowlistConfigured) { - logDebug(`[discord-preflight] drop: groupPolicy allowlist, no channel allowlist configured`); - logVerbose( - `discord: drop guild message (groupPolicy: allowlist, no channel allowlist, ${channelMatchMeta})`, - ); - } else { - logDebug( - `[discord] Ignored message from channel ${messageChannelId} (not in guild allowlist). Add to guilds..channels to enable.`, - ); - logVerbose( - `Blocked discord channel ${messageChannelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`, - ); - } - return null; - } - - if (isGuildMessage && channelConfig?.allowed === false) { - logDebug(`[discord-preflight] drop: channelConfig.allowed===false`); - logVerbose( - `Blocked discord channel ${messageChannelId} not in guild channel allowlist (${channelMatchMeta})`, - ); - return null; - } - if (isGuildMessage) { - logDebug(`[discord-preflight] pass: channel allowed`); - logVerbose(`discord: allow channel ${messageChannelId} (${channelMatchMeta})`); - } - - const textForHistory = resolveDiscordMessageText(message, { - includeForwarded: true, + const { + threadName, + configChannelName, + configChannelSlug, + displayChannelName, + displayChannelSlug, + guildSlug, + channelConfig, + } = resolveDiscordPreflightChannelContext({ + isGuildMessage, + messageChannelId, + channelName, + guildName: params.data.guild?.name, + guildInfo, + threadChannel, + threadParentId, + threadParentName, + }); + const channelMatchMeta = formatAllowlistMatchMeta(channelConfig); + logDiscordPreflightChannelConfig({ + channelConfig, + channelMatchMeta, + channelId: messageChannelId, + }); + const channelAccess = resolveDiscordPreflightChannelAccess({ + isGuildMessage, + isGroupDm, + groupPolicy: params.groupPolicy, + groupDmChannels: params.groupDmChannels, + messageChannelId, + displayChannelName, + displayChannelSlug, + guildInfo, + channelConfig, + channelMatchMeta, + }); + if (!channelAccess.allowed) { + return null; + } + const { channelAllowlistConfigured, channelAllowed } = channelAccess; + + const historyEntry = buildDiscordPreflightHistoryEntry({ + isGuildMessage, + historyLimit: params.historyLimit, + message, + senderLabel: sender.label, }); - const historyEntry = - isGuildMessage && params.historyLimit > 0 && textForHistory - ? ({ - sender: sender.label, - body: textForHistory, - timestamp: resolveTimestampMs(message.timestamp), - messageId: message.id, - } satisfies HistoryEntry) - : undefined; const threadOwnerId = threadChannel ? (resolveDiscordChannelInfoSafe(threadChannel).ownerId ?? channelInfo?.ownerId) @@ -539,11 +459,15 @@ export async function preflightDiscordMessage( senderIsPluralKit: sender.isPluralKit, transcript: preflightTranscript, }); - if (shouldLogVerbose()) { - logVerbose( - `discord: inbound id=${message.id} guild=${params.data.guild_id ?? "dm"} channel=${messageChannelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`, - ); - } + logDiscordPreflightInboundSummary({ + messageId: message.id, + guildId: params.data.guild_id ?? undefined, + channelId: messageChannelId, + wasMentioned, + isDirectMessage, + isGroupDm, + hasContent: Boolean(messageText), + }); const allowTextCommands = shouldHandleTextCommands({ cfg: params.cfg, @@ -694,21 +618,8 @@ export async function preflightDiscordMessage( logDebug( `[discord-preflight] success: route=${effectiveRoute.agentId} sessionKey=${effectiveRoute.sessionKey}`, ); - return { - cfg: params.cfg, - discordConfig: params.discordConfig, - accountId: params.accountId, - token: params.token, - runtime: params.runtime, - botUserId: params.botUserId, - abortSignal: params.abortSignal, - guildHistories: params.guildHistories, - historyLimit: params.historyLimit, - mediaMaxBytes: params.mediaMaxBytes, - textLimit: params.textLimit, - replyToMode: params.replyToMode, - ackReactionScope: params.ackReactionScope, - groupPolicy: params.groupPolicy, + return buildDiscordMessagePreflightContext({ + preflightParams: params, data, client: params.client, message, @@ -752,7 +663,5 @@ export async function preflightDiscordMessage( effectiveWasMentioned, canDetectMention, historyEntry, - threadBindings: params.threadBindings, - discordRestFetch: params.discordRestFetch, - }; + }); } diff --git a/extensions/discord/src/monitor/native-command-agent-reply.ts b/extensions/discord/src/monitor/native-command-agent-reply.ts new file mode 100644 index 00000000000..1ccf3440684 --- /dev/null +++ b/extensions/discord/src/monitor/native-command-agent-reply.ts @@ -0,0 +1,123 @@ +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking"; +import type { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; +import type { + ButtonInteraction, + CommandInteraction, + StringSelectMenuInteraction, +} from "../internal/discord.js"; +import type { DiscordChannelConfigResolved } from "./allow-list.js"; +import type { buildDiscordNativeCommandContext } from "./native-command-context.js"; +import { + deliverDiscordInteractionReply, + isDiscordUnknownInteraction, + safeDiscordInteractionCall, +} from "./native-command-reply.js"; +import { nativeCommandRuntime } from "./native-command.runtime.js"; +import type { DiscordConfig } from "./native-command.types.js"; + +type NativeCommandEffectiveRoute = { + accountId: string; + agentId: string; +}; + +export async function dispatchDiscordNativeAgentReply(params: { + cfg: OpenClawConfig; + discordConfig: DiscordConfig; + accountId: string; + interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction; + ctxPayload: ReturnType; + effectiveRoute: NativeCommandEffectiveRoute; + channelConfig: DiscordChannelConfigResolved | null; + mediaLocalRoots: ReturnType; + preferFollowUp: boolean; + responseEphemeral?: boolean; + suppressReplies?: boolean; + log: ReturnType; +}): Promise { + const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + cfg: params.cfg, + agentId: params.effectiveRoute.agentId, + channel: "discord", + accountId: params.effectiveRoute.accountId, + }); + const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(params.discordConfig); + + let didReply = false; + const dispatchResult = await nativeCommandRuntime.dispatchReplyWithDispatcher({ + ctx: params.ctxPayload, + cfg: params.cfg, + dispatcherOptions: { + ...replyPipeline, + humanDelay: resolveHumanDelayConfig(params.cfg, params.effectiveRoute.agentId), + deliver: async (payload) => { + if (params.suppressReplies) { + return; + } + try { + await deliverDiscordInteractionReply({ + interaction: params.interaction, + payload, + mediaLocalRoots: params.mediaLocalRoots, + textLimit: resolveTextChunkLimit(params.cfg, "discord", params.accountId, { + fallbackLimit: 2000, + }), + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ + cfg: params.cfg, + discordConfig: params.discordConfig, + accountId: params.accountId, + }), + preferFollowUp: params.preferFollowUp || didReply, + responseEphemeral: params.responseEphemeral, + chunkMode: resolveChunkMode(params.cfg, "discord", params.accountId), + }); + } catch (error) { + if (isDiscordUnknownInteraction(error)) { + logVerbose("discord: interaction reply skipped (interaction expired)"); + return; + } + throw error; + } + didReply = true; + }, + onError: (err, info) => { + const message = err instanceof Error ? (err.stack ?? err.message) : String(err); + params.log.error(`discord slash ${info.kind} reply failed: ${message}`); + }, + }, + replyOptions: { + skillFilter: params.channelConfig?.skills, + disableBlockStreaming: + typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined, + onModelSelected, + }, + }); + + if ( + params.suppressReplies || + didReply || + dispatchResult.counts.final !== 0 || + dispatchResult.counts.block !== 0 || + dispatchResult.counts.tool !== 0 + ) { + return; + } + + await safeDiscordInteractionCall("interaction empty fallback", async () => { + const payload = { + content: "✅ Done.", + ephemeral: true, + }; + if (params.preferFollowUp) { + await params.interaction.followUp(payload); + return; + } + await params.interaction.reply(payload); + }); +} diff --git a/extensions/discord/src/monitor/native-command-bypass.ts b/extensions/discord/src/monitor/native-command-bypass.ts new file mode 100644 index 00000000000..7bf6adad3a5 --- /dev/null +++ b/extensions/discord/src/monitor/native-command-bypass.ts @@ -0,0 +1,13 @@ +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; + +export function shouldBypassConfiguredAcpEnsure(commandName: string): boolean { + const normalized = normalizeLowercaseStringOrEmpty(commandName); + // Recovery slash commands still need configured ACP readiness so stale dead + // bindings are recreated before /new or /reset dispatches through them. + return normalized === "acp"; +} + +export function shouldBypassConfiguredAcpGuildGuards(commandName: string): boolean { + const normalized = normalizeLowercaseStringOrEmpty(commandName); + return normalized === "new" || normalized === "reset"; +} diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 32b5ea65d82..9d0230350f9 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -1,7 +1,4 @@ import { ApplicationCommandOptionType } from "discord-api-types/v10"; -import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming"; import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; @@ -19,7 +16,6 @@ import { import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking"; import { createSubsystemLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordAccountAllowFrom, resolveDiscordAccountDmPolicy, @@ -43,18 +39,22 @@ import { import { resolveDiscordChannelTopicSafe } from "./channel-access.js"; import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js"; import { handleDiscordDmCommandDecision } from "./dm-command-decision.js"; +import { dispatchDiscordNativeAgentReply } from "./native-command-agent-reply.js"; import { resolveDiscordGuildNativeCommandAuthorized, resolveDiscordNativeAutocompleteAuthorized, resolveDiscordNativeCommandAllowlistAccess, resolveDiscordNativeGroupDmAccess, } from "./native-command-auth.js"; +import { + shouldBypassConfiguredAcpEnsure, + shouldBypassConfiguredAcpGuildGuards, +} from "./native-command-bypass.js"; import { buildDiscordNativeCommandContext } from "./native-command-context.js"; import type { DispatchDiscordCommandInteractionResult } from "./native-command-dispatch.js"; import { deliverDiscordInteractionReply, hasRenderableReplyPayload, - isDiscordUnknownInteraction, safeDiscordInteractionCall, } from "./native-command-reply.js"; import { maybeDeliverDiscordDirectStatus } from "./native-command-status.js"; @@ -83,18 +83,6 @@ import type { ThreadBindingManager } from "./thread-bindings.js"; const log = createSubsystemLogger("discord/native-command"); export { __testing } from "./native-command.runtime.js"; -function shouldBypassConfiguredAcpEnsure(commandName: string): boolean { - const normalized = normalizeLowercaseStringOrEmpty(commandName); - // Recovery slash commands still need configured ACP readiness so stale dead - // bindings are recreated before /new or /reset dispatches through them. - return normalized === "acp"; -} - -function shouldBypassConfiguredAcpGuildGuards(commandName: string): boolean { - const normalized = normalizeLowercaseStringOrEmpty(commandName); - return normalized === "new" || normalized === "reset"; -} - export function createDiscordNativeCommand(params: { command: NativeCommandSpec; cfg: OpenClawConfig; @@ -659,82 +647,21 @@ async function dispatchDiscordCommandInteraction(params: { sender: { id: sender.id, name: sender.name, tag: sender.tag }, }); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + await dispatchDiscordNativeAgentReply({ cfg, - agentId: effectiveRoute.agentId, - channel: "discord", - accountId: effectiveRoute.accountId, - }); - const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig); - - let didReply = false; - const dispatchResult = await nativeCommandRuntime.dispatchReplyWithDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - ...replyPipeline, - humanDelay: resolveHumanDelayConfig(cfg, effectiveRoute.agentId), - deliver: async (payload) => { - if (suppressReplies) { - return; - } - try { - await deliverDiscordInteractionReply({ - interaction, - payload, - mediaLocalRoots, - textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { - fallbackLimit: 2000, - }), - maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }), - preferFollowUp: preferFollowUp || didReply, - responseEphemeral, - chunkMode: resolveChunkMode(cfg, "discord", accountId), - }); - } catch (error) { - if (isDiscordUnknownInteraction(error)) { - logVerbose("discord: interaction reply skipped (interaction expired)"); - return; - } - throw error; - } - didReply = true; - }, - onError: (err, info) => { - const message = err instanceof Error ? (err.stack ?? err.message) : String(err); - log.error(`discord slash ${info.kind} reply failed: ${message}`); - }, - }, - replyOptions: { - skillFilter: channelConfig?.skills, - disableBlockStreaming: - typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined, - onModelSelected, - }, + discordConfig, + accountId, + interaction, + ctxPayload, + effectiveRoute, + channelConfig, + mediaLocalRoots, + preferFollowUp, + responseEphemeral, + suppressReplies, + log, }); - // Fallback: if the agent turn produced no deliverable replies (for example, - // a skill only used message.send side effects), close the interaction with - // a minimal acknowledgment so Discord does not stay in a pending state. - if ( - !suppressReplies && - !didReply && - dispatchResult.counts.final === 0 && - dispatchResult.counts.block === 0 && - dispatchResult.counts.tool === 0 - ) { - await safeDiscordInteractionCall("interaction empty fallback", async () => { - const payload = { - content: "✅ Done.", - ephemeral: true, - }; - if (preferFollowUp) { - await interaction.followUp(payload); - return; - } - await interaction.reply(payload); - }); - } return { accepted: true, effectiveRoute }; }