2026-03-14 02:53:57 -07:00
import {
serializePayload ,
type MessagePayloadFile ,
type MessagePayloadObject ,
type RequestClient ,
} from "@buape/carbon" ;
import { ChannelType , Routes } from "discord-api-types/v10" ;
2026-03-16 21:13:56 -07:00
import { loadConfig , type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime" ;
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime" ;
2026-03-17 09:33:17 -07:00
import { loadWebMedia } from "openclaw/plugin-sdk/web-media" ;
2026-03-14 02:53:57 -07:00
import { resolveDiscordAccount } from "./accounts.js" ;
import { registerDiscordComponentEntries } from "./components-registry.js" ;
import {
buildDiscordComponentMessage ,
buildDiscordComponentMessageFlags ,
resolveDiscordComponentAttachmentName ,
type DiscordComponentMessageSpec ,
} from "./components.js" ;
import {
buildDiscordSendError ,
createDiscordClient ,
parseAndResolveRecipient ,
resolveChannelId ,
resolveDiscordChannelType ,
toDiscordFileBlob ,
stripUndefinedFields ,
SUPPRESS_NOTIFICATIONS_FLAG ,
} from "./send.shared.js" ;
import type { DiscordSendResult } from "./send.types.js" ;
const DISCORD_FORUM_LIKE_TYPES = new Set < number > ( [ ChannelType . GuildForum , ChannelType . GuildMedia ] ) ;
function extractComponentAttachmentNames ( spec : DiscordComponentMessageSpec ) : string [ ] {
const names : string [ ] = [ ] ;
for ( const block of spec . blocks ? ? [ ] ) {
if ( block . type === "file" ) {
names . push ( resolveDiscordComponentAttachmentName ( block . file ) ) ;
}
}
return names ;
}
type DiscordComponentSendOpts = {
cfg? : OpenClawConfig ;
accountId? : string ;
token? : string ;
rest? : RequestClient ;
silent? : boolean ;
replyTo? : string ;
sessionKey? : string ;
agentId? : string ;
mediaUrl? : string ;
mediaLocalRoots? : readonly string [ ] ;
filename? : string ;
} ;
export async function sendDiscordComponentMessage (
to : string ,
spec : DiscordComponentMessageSpec ,
opts : DiscordComponentSendOpts = { } ,
) : Promise < DiscordSendResult > {
const cfg = opts . cfg ? ? loadConfig ( ) ;
const accountInfo = resolveDiscordAccount ( { cfg , accountId : opts.accountId } ) ;
const { token , rest , request } = createDiscordClient ( opts , cfg ) ;
const recipient = await parseAndResolveRecipient ( to , opts . accountId , cfg ) ;
const { channelId } = await resolveChannelId ( rest , recipient , request ) ;
const channelType = await resolveDiscordChannelType ( rest , channelId ) ;
if ( channelType && DISCORD_FORUM_LIKE_TYPES . has ( channelType ) ) {
throw new Error ( "Discord components are not supported in forum-style channels" ) ;
}
const buildResult = buildDiscordComponentMessage ( {
spec ,
sessionKey : opts.sessionKey ,
agentId : opts.agentId ,
accountId : accountInfo.accountId ,
} ) ;
const flags = buildDiscordComponentMessageFlags ( buildResult . components ) ;
const finalFlags = opts . silent
? ( flags ? ? 0 ) | SUPPRESS_NOTIFICATIONS_FLAG
: ( flags ? ? undefined ) ;
const messageReference = opts . replyTo
? { message_id : opts.replyTo , fail_if_not_exists : false }
: undefined ;
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 ] ;
let files : MessagePayloadFile [ ] | undefined ;
if ( opts . mediaUrl ) {
const media = await loadWebMedia ( opts . mediaUrl , { localRoots : opts.mediaLocalRoots } ) ;
const filenameOverride = 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. ` ,
) ;
}
const fileData = toDiscordFileBlob ( media . buffer ) ;
files = [ { data : fileData , name : fileName } ] ;
} else if ( expectedAttachmentName ) {
throw new Error (
"Discord component file blocks require a media attachment (media/path/filePath)." ,
) ;
}
const payload : MessagePayloadObject = {
components : buildResult.components ,
. . . ( finalFlags ? { flags : finalFlags } : { } ) ,
. . . ( files ? { files } : { } ) ,
} ;
const body = stripUndefinedFields ( {
. . . serializePayload ( payload ) ,
. . . ( messageReference ? { message_reference : messageReference } : { } ) ,
} ) ;
let result : { id : string ; channel_id : string } ;
try {
result = ( await request (
( ) = >
rest . post ( Routes . channelMessages ( channelId ) , {
body ,
} ) as Promise < { id : string ; channel_id : string } > ,
"components" ,
) ) as { id : string ; channel_id : string } ;
} catch ( err ) {
throw await buildDiscordSendError ( err , {
channelId ,
rest ,
token ,
hasMedia : Boolean ( files ? . length ) ,
} ) ;
}
registerDiscordComponentEntries ( {
entries : buildResult.entries ,
modals : buildResult.modals ,
messageId : result.id ,
} ) ;
recordChannelActivity ( {
channel : "discord" ,
accountId : accountInfo.accountId ,
direction : "outbound" ,
} ) ;
return {
messageId : result.id ? ? "unknown" ,
channelId : result.channel_id ? ? channelId ,
} ;
}