mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:00:43 +00:00
refactor(discord): split preflight and native command helpers
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"}`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
123
extensions/discord/src/monitor/native-command-agent-reply.ts
Normal file
123
extensions/discord/src/monitor/native-command-agent-reply.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
13
extensions/discord/src/monitor/native-command-bypass.ts
Normal file
13
extensions/discord/src/monitor/native-command-bypass.ts
Normal 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";
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user