refactor(discord): split preflight and native command helpers

This commit is contained in:
Peter Steinberger
2026-04-29 18:03:25 +01:00
parent 630629667c
commit dcd428e8c1
11 changed files with 569 additions and 267 deletions

View File

@@ -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.<guildId>.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 };
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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"}`,
);
}

View File

@@ -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<import("openclaw/plugin-sdk/config-types").OpenClawConfig["channels"]>["discord"]
>["pluralkit"];
abortSignal?: AbortSignal;
}): Promise<Awaited<ReturnType<typeof import("../pluralkit.js").fetchPluralKitMessageInfo>>> {
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;
}
}

View File

@@ -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<DiscordPreflightThreadContext | null> {
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,
};
}

View File

@@ -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<typeof import("../pluralkit.js").fetchPluralKitMessageInfo>
> = 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.<guildId>.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,
};
});
}

View File

@@ -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<typeof buildDiscordNativeCommandContext>;
effectiveRoute: NativeCommandEffectiveRoute;
channelConfig: DiscordChannelConfigResolved | null;
mediaLocalRoots: ReturnType<typeof getAgentScopedMediaLocalRoots>;
preferFollowUp: boolean;
responseEphemeral?: boolean;
suppressReplies?: boolean;
log: ReturnType<typeof createSubsystemLogger>;
}): Promise<void> {
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);
});
}

View File

@@ -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";
}

View File

@@ -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 };
}