diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index bbf36e641cd..2fbfa704c0d 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -16,11 +16,9 @@ import { withDiscordDeliveryRetry } from "./delivery-retry.js"; import { isLikelyDiscordVideoMedia } from "./media-detection.js"; import type { ThreadBindingRecord } from "./monitor/thread-bindings.js"; import { normalizeDiscordOutboundTarget } from "./normalize.js"; -import { - buildDiscordPresentationPayload, - normalizeDiscordApprovalPayload, - sendDiscordOutboundPayload, -} from "./outbound-payload.js"; +import { normalizeDiscordApprovalPayload } from "./outbound-approval.js"; +import { buildDiscordPresentationPayload } from "./outbound-components.js"; +import { sendDiscordOutboundPayload } from "./outbound-payload.js"; import { loadDiscordSendRuntime, resolveDiscordFormattingOptions, diff --git a/extensions/discord/src/outbound-approval.ts b/extensions/discord/src/outbound-approval.ts new file mode 100644 index 00000000000..fb791269dee --- /dev/null +++ b/extensions/discord/src/outbound-approval.ts @@ -0,0 +1,29 @@ +function hasApprovalChannelData(payload: { channelData?: unknown }): boolean { + const channelData = payload.channelData; + if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) { + return false; + } + return Boolean((channelData as { execApproval?: unknown }).execApproval); +} + +function neutralizeDiscordApprovalMentions(value: string): string { + return value + .replace(/@everyone/gi, "@\u200beveryone") + .replace(/@here/gi, "@\u200bhere") + .replace(/<@/g, "<@\u200b") + .replace(/<#/g, "<#\u200b"); +} + +export function normalizeDiscordApprovalPayload< + T extends { + text?: string; + channelData?: unknown; + }, +>(payload: T): T { + return hasApprovalChannelData(payload) && payload.text + ? { + ...payload, + text: neutralizeDiscordApprovalMentions(payload.text), + } + : payload; +} diff --git a/extensions/discord/src/outbound-components.ts b/extensions/discord/src/outbound-components.ts new file mode 100644 index 00000000000..ab5f3b3d20a --- /dev/null +++ b/extensions/discord/src/outbound-components.ts @@ -0,0 +1,81 @@ +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { readDiscordComponentSpec, type DiscordComponentMessageSpec } from "./components.js"; + +type DiscordComponentSendFn = typeof import("./send.components.js").sendDiscordComponentMessage; +type DiscordSharedInteractiveModule = typeof import("./shared-interactive.js"); +type OutboundPayload = Parameters>[0]["payload"]; + +let discordComponentSendPromise: Promise | undefined; +let discordSharedInteractivePromise: Promise | undefined; + +export async function sendDiscordComponentMessageLazy( + ...args: Parameters +): ReturnType { + discordComponentSendPromise ??= import("./send.components.js").then( + (module) => module.sendDiscordComponentMessage, + ); + return await ( + await discordComponentSendPromise + )(...args); +} + +function loadDiscordSharedInteractive(): Promise { + discordSharedInteractivePromise ??= import("./shared-interactive.js"); + return discordSharedInteractivePromise; +} + +function addPayloadTextFallback( + spec: DiscordComponentMessageSpec, + payload: Pick, +): DiscordComponentMessageSpec { + return spec.text + ? spec + : { + ...spec, + text: payload.text?.trim() ? payload.text : undefined, + }; +} + +export async function buildDiscordPresentationPayload(params: { + payload: Parameters>[0]["payload"]; + presentation: Parameters< + NonNullable + >[0]["presentation"]; +}): Promise { + const componentSpec = (await loadDiscordSharedInteractive()).buildDiscordPresentationComponents( + params.presentation, + ); + if (!componentSpec) { + return null; + } + return { + ...params.payload, + channelData: { + ...params.payload.channelData, + discord: { + ...(params.payload.channelData?.discord as Record | undefined), + presentationComponents: componentSpec, + }, + }, + }; +} + +export async function resolveDiscordComponentSpec( + payload: OutboundPayload, +): Promise { + const discordData = payload.channelData?.discord as + | { components?: unknown; presentationComponents?: DiscordComponentMessageSpec } + | undefined; + const rawComponentSpec = + discordData?.presentationComponents ?? readDiscordComponentSpec(discordData?.components); + if (rawComponentSpec) { + return addPayloadTextFallback(rawComponentSpec, payload); + } + if (!payload.interactive) { + return undefined; + } + const interactiveSpec = (await loadDiscordSharedInteractive()).buildDiscordInteractiveComponents( + payload.interactive, + ); + return interactiveSpec ? addPayloadTextFallback(interactiveSpec, payload) : undefined; +} diff --git a/extensions/discord/src/outbound-payload.ts b/extensions/discord/src/outbound-payload.ts index 6ee40dee0b8..c8a0dec529f 100644 --- a/extensions/discord/src/outbound-payload.ts +++ b/extensions/discord/src/outbound-payload.ts @@ -7,120 +7,13 @@ import { sendPayloadMediaSequenceOrFallback, sendTextMediaPayload, } from "openclaw/plugin-sdk/reply-payload"; -import { readDiscordComponentSpec, type DiscordComponentMessageSpec } from "./components.js"; +import { normalizeDiscordApprovalPayload } from "./outbound-approval.js"; +import { + resolveDiscordComponentSpec, + sendDiscordComponentMessageLazy, +} from "./outbound-components.js"; import { createDiscordPayloadSendContext } from "./outbound-send-context.js"; -type DiscordComponentSendFn = typeof import("./send.components.js").sendDiscordComponentMessage; -type DiscordSharedInteractiveModule = typeof import("./shared-interactive.js"); - -let discordComponentSendPromise: Promise | undefined; -let discordSharedInteractivePromise: Promise | undefined; - -async function sendDiscordComponentMessageLazy( - ...args: Parameters -): ReturnType { - discordComponentSendPromise ??= import("./send.components.js").then( - (module) => module.sendDiscordComponentMessage, - ); - return await ( - await discordComponentSendPromise - )(...args); -} - -function loadDiscordSharedInteractive(): Promise { - discordSharedInteractivePromise ??= import("./shared-interactive.js"); - return discordSharedInteractivePromise; -} - -function hasApprovalChannelData(payload: { channelData?: unknown }): boolean { - const channelData = payload.channelData; - if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) { - return false; - } - return Boolean((channelData as { execApproval?: unknown }).execApproval); -} - -function neutralizeDiscordApprovalMentions(value: string): string { - return value - .replace(/@everyone/gi, "@\u200beveryone") - .replace(/@here/gi, "@\u200bhere") - .replace(/<@/g, "<@\u200b") - .replace(/<#/g, "<#\u200b"); -} - -export function normalizeDiscordApprovalPayload< - T extends { - text?: string; - channelData?: unknown; - }, ->(payload: T): T { - return hasApprovalChannelData(payload) && payload.text - ? { - ...payload, - text: neutralizeDiscordApprovalMentions(payload.text), - } - : payload; -} - -export async function buildDiscordPresentationPayload(params: { - payload: Parameters>[0]["payload"]; - presentation: Parameters< - NonNullable - >[0]["presentation"]; -}): Promise { - const componentSpec = (await loadDiscordSharedInteractive()).buildDiscordPresentationComponents( - params.presentation, - ); - if (!componentSpec) { - return null; - } - return { - ...params.payload, - channelData: { - ...params.payload.channelData, - discord: { - ...(params.payload.channelData?.discord as Record | undefined), - presentationComponents: componentSpec, - }, - }, - }; -} - -function resolveDiscordComponentSpec( - payload: Parameters>[0]["payload"], -): Promise { - const discordData = payload.channelData?.discord as - | { components?: unknown; presentationComponents?: DiscordComponentMessageSpec } - | undefined; - const rawComponentSpec = - discordData?.presentationComponents ?? readDiscordComponentSpec(discordData?.components); - if (rawComponentSpec) { - return Promise.resolve( - rawComponentSpec.text - ? rawComponentSpec - : { - ...rawComponentSpec, - text: payload.text?.trim() ? payload.text : undefined, - }, - ); - } - if (!payload.interactive) { - return Promise.resolve(undefined); - } - return loadDiscordSharedInteractive().then((module) => { - const interactiveSpec = module.buildDiscordInteractiveComponents(payload.interactive); - if (!interactiveSpec) { - return undefined; - } - return interactiveSpec.text - ? interactiveSpec - : { - ...interactiveSpec, - text: payload.text?.trim() ? payload.text : undefined, - }; - }); -} - export async function sendDiscordOutboundPayload(params: { ctx: Parameters>[0]; fallbackAdapter: ChannelOutboundAdapter;