From efbb9a12967e1ef885f5838b7ccd54fc5bfca5bf Mon Sep 17 00:00:00 2001 From: geekhuashan Date: Fri, 3 Apr 2026 22:59:24 +0800 Subject: [PATCH] fix(discord): downgrade text-only component+media to classic message and auto-append file block --- extensions/discord/src/send.components.ts | 122 +++++++++++++++++----- 1 file changed, 96 insertions(+), 26 deletions(-) diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index a9f23ec9a44..7bef3526b70 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -17,6 +17,7 @@ import { type DiscordComponentMessageSpec, } from "./components.js"; import { loadOutboundMediaFromUrl } from "./runtime-api.js"; +import { sendMessageDiscord } from "./send.outbound.js"; import { buildDiscordSendError, createDiscordClient, @@ -41,6 +42,55 @@ function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): str return names; } +function hasComponentAttachmentBlock(spec: DiscordComponentMessageSpec): boolean { + return (spec.blocks ?? []).some((block) => block.type === "file"); +} + +function withImplicitComponentAttachmentBlock( + spec: DiscordComponentMessageSpec, + attachmentName: string | undefined, +): DiscordComponentMessageSpec { + if (!attachmentName || hasComponentAttachmentBlock(spec)) { + return spec; + } + return { + ...spec, + blocks: [ + ...(spec.blocks ?? []), + { + type: "file", + file: `attachment://${attachmentName}` as `attachment://${string}`, + }, + ], + }; +} + +function canSendAsClassicDiscordMessage(spec: DiscordComponentMessageSpec): boolean { + return (spec.blocks ?? []).every((block) => block.type === "text" || block.type === "file"); +} + +function collapseClassicComponentText(spec: DiscordComponentMessageSpec): string { + const parts: string[] = []; + const addPart = (value: string | undefined) => { + if (typeof value !== "string") { + return; + } + const trimmed = value.trim(); + if (!trimmed || parts.includes(trimmed)) { + return; + } + parts.push(trimmed); + }; + + addPart(spec.text); + for (const block of spec.blocks ?? []) { + if (block.type === "text") { + addPart(block.text); + } + } + return parts.join("\n\n"); +} + type DiscordComponentSendOpts = { cfg?: OpenClawConfig; accountId?: string; @@ -79,28 +129,12 @@ async function buildDiscordComponentPayload(params: { body: ReturnType; buildResult: ReturnType; }> { - const buildResult = buildDiscordComponentMessage({ - spec: params.spec, - sessionKey: params.opts.sessionKey, - agentId: params.opts.agentId, - accountId: params.accountId, - }); - const flags = buildDiscordComponentMessageFlags(buildResult.components); - const finalFlags = params.opts.silent - ? (flags ?? 0) | SUPPRESS_NOTIFICATIONS_FLAG - : (flags ?? undefined); const messageReference = params.opts.replyTo ? { message_id: params.opts.replyTo, fail_if_not_exists: false } : undefined; - const attachmentNames = extractComponentAttachmentNames(params.spec); - const uniqueAttachmentNames = [...new Set(attachmentNames)]; - if (uniqueAttachmentNames.length > 1) { - throw new Error( - "Discord component attachments currently support a single file. Use media-gallery for multiple files.", - ); - } - const expectedAttachmentName = uniqueAttachmentNames[0]; + let spec = params.spec; + let resolvedFileName: string | undefined; let files: MessagePayloadFile[] | undefined; if (params.opts.mediaUrl) { const media = await loadOutboundMediaFromUrl(params.opts.mediaUrl, { @@ -109,20 +143,42 @@ async function buildDiscordComponentPayload(params: { mediaReadFile: params.opts.mediaReadFile, }); const filenameOverride = params.opts.filename?.trim(); - const fileName = filenameOverride || media.fileName || "upload"; - if (expectedAttachmentName && expectedAttachmentName !== fileName) { - throw new Error( - `Component file block expects attachment "${expectedAttachmentName}", but the uploaded file is "${fileName}". Update components.blocks[].file or provide a matching filename.`, - ); - } + resolvedFileName = filenameOverride || media.fileName || "upload"; + spec = withImplicitComponentAttachmentBlock(spec, resolvedFileName); const fileData = toDiscordFileBlob(media.buffer); - files = [{ data: fileData, name: fileName }]; - } else if (expectedAttachmentName) { + files = [{ data: fileData, name: resolvedFileName }]; + } + + const attachmentNames = extractComponentAttachmentNames(spec); + const uniqueAttachmentNames = [...new Set(attachmentNames)]; + if (uniqueAttachmentNames.length > 1) { + throw new Error( + "Discord component attachments currently support a single file. Use media-gallery for multiple files.", + ); + } + const expectedAttachmentName = uniqueAttachmentNames[0]; + if (expectedAttachmentName && resolvedFileName && expectedAttachmentName !== resolvedFileName) { + throw new Error( + `Component file block expects attachment "${expectedAttachmentName}", but the uploaded file is "${resolvedFileName}". Update components.blocks[].file or provide a matching filename.`, + ); + } + if (!params.opts.mediaUrl && expectedAttachmentName) { throw new Error( "Discord component file blocks require a media attachment (media/path/filePath).", ); } + const buildResult = buildDiscordComponentMessage({ + spec, + sessionKey: params.opts.sessionKey, + agentId: params.opts.agentId, + accountId: params.accountId, + }); + const flags = buildDiscordComponentMessageFlags(buildResult.components); + const finalFlags = params.opts.silent + ? (flags ?? 0) | SUPPRESS_NOTIFICATIONS_FLAG + : (flags ?? undefined); + const payload: MessagePayloadObject = { components: buildResult.components, ...(finalFlags ? { flags: finalFlags } : {}), @@ -141,6 +197,20 @@ export async function sendDiscordComponentMessage( spec: DiscordComponentMessageSpec, opts: DiscordComponentSendOpts = {}, ): Promise { + if (opts.mediaUrl && canSendAsClassicDiscordMessage(spec)) { + return await sendMessageDiscord(to, collapseClassicComponentText(spec), { + cfg: opts.cfg, + accountId: opts.accountId, + token: opts.token, + rest: opts.rest, + mediaUrl: opts.mediaUrl, + filename: opts.filename, + mediaLocalRoots: opts.mediaLocalRoots, + replyTo: opts.replyTo, + silent: opts.silent, + }); + } + const cfg = opts.cfg ?? loadConfig(); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId }); const { token, rest, request } = createDiscordClient(opts, cfg);