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<string, unknown>) {
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<string, unknown>, files: ReturnType<typeof normalizeFiles>) {
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<string, unknown> = {}) {
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<string, unknown>[]).slice(0, 10) : []
if (!content && files.length === 0 && embeds.length === 0) {
throw new Error('content, files, or embeds is required.')
}
const body: Record<string, unknown> = {
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<string, string> | 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,
})),
}
}