Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/discord

src/post-message.ts

155 lines · 4.9 KB · TypeScript
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,
		})),
	}
}