import { discordBotRequest } from './bot.ts' import { inputRecord, pickString, requireLiveMutation, snowflake } from './validation.ts' export type PostMessageFileInput = { name?: string filename?: string dataBase64?: string data_base64?: string contentType?: string content_type?: string description?: string } function base64ToBytes(base64: string): Uint8Array { const binary = atob(String(base64 || '').replace(/\s+/g, '')) const bytes = new Uint8Array(binary.length) for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index) } return bytes } function normalizeFiles(input: Record) { const files = Array.isArray(input.files) ? (input.files as PostMessageFileInput[]) : [] return files.map((file, index) => { const name = pickString(file?.name, file?.filename) || 'file-' + index const dataBase64 = typeof file?.dataBase64 === 'string' ? file.dataBase64 : typeof file?.data_base64 === 'string' ? file.data_base64 : '' if (!dataBase64) throw new Error('files[' + index + '].dataBase64 is required.') return { name, contentType: pickString(file?.contentType, file?.content_type) || 'application/octet-stream', description: pickString(file?.description) || undefined, dataBase64, } }) } function buildMultipart(body: Record, files: ReturnType) { const boundary = '----KodyDiscordBoundary' + Math.random().toString(36).slice(2) const encoder = new TextEncoder() const parts: Uint8Array[] = [] parts.push( encoder.encode( '--' + boundary + '\r\nContent-Disposition: form-data; name="payload_json"\r\nContent-Type: application/json\r\n\r\n' + JSON.stringify(body) + '\r\n', ), ) files.forEach((file, index) => { parts.push( encoder.encode( '--' + boundary + '\r\nContent-Disposition: form-data; name="files[' + index + ']"; filename="' + file.name.replace(/"/g, '') + '"\r\nContent-Type: ' + file.contentType + '\r\n\r\n', ), ) parts.push(base64ToBytes(file.dataBase64)) parts.push(encoder.encode('\r\n')) }) parts.push(encoder.encode('--' + boundary + '--\r\n')) const totalLength = parts.reduce((sum, part) => sum + part.length, 0) const multipartBody = new Uint8Array(totalLength) let offset = 0 for (const part of parts) { multipartBody.set(part, offset) offset += part.length } return { body: multipartBody, contentType: 'multipart/form-data; boundary=' + boundary } } type PostedMessage = { id?: string channel_id?: string content?: string attachments?: Array<{ id?: string; filename?: string; url?: string; size?: number }> } /** * Post a Discord message to a channel through the bot-token lane. * * Provide `channelId` plus `content`, `embeds`, or base64 `files`. Set * `dryRun: true` to inspect the payload. A live post requires `confirm: true`. * * @param params.secretName - Bot-token secret; default `discordBotToken`. */ export default async function postMessage(params: Record = {}) { const input = inputRecord(params) const channelId = snowflake(input, 'channelId', ['channel_id']) const content = typeof input.content === 'string' ? input.content.trim() : '' const files = normalizeFiles(input) const replyToMessageId = pickString(input.replyToMessageId, input.reply_to_message_id) const embeds = Array.isArray(input.embeds) ? (input.embeds as Record[]).slice(0, 10) : [] if (!content && files.length === 0 && embeds.length === 0) { throw new Error('content, files, or embeds is required.') } const body: Record = { content, allowed_mentions: { replied_user: false }, } if (embeds.length > 0) body.embeds = embeds if (replyToMessageId) body.message_reference = { message_id: replyToMessageId } if (files.length > 0) { body.attachments = files.map((file, index) => ({ id: index, filename: file.name, description: file.description, })) } if (requireLiveMutation(input, 'post-message') === 'dryRun') { return { dryRun: true, channelId, replyToMessageId: replyToMessageId ?? null, body, fileCount: files.length } } let requestBody: BodyInit let extraHeaders: Record | undefined if (files.length > 0) { const multipart = buildMultipart(body, files) requestBody = multipart.body extraHeaders = { 'Content-Type': multipart.contentType } } else { requestBody = JSON.stringify(body) } const response = (await discordBotRequest('/channels/' + channelId + '/messages', input, { method: 'POST', body: requestBody, headers: extraHeaders, })) as PostedMessage return { id: response.id ?? null, channelId: response.channel_id || channelId, content: response.content || content, attachments: (response.attachments || []).map((attachment) => ({ id: attachment.id ?? null, filename: attachment.filename ?? null, url: attachment.url ?? null, size: attachment.size ?? null, })), } }