/** * @copyright NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan */ import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; import { ids } from "../config/ids.js"; import { anthropic } from "../utils/anthropic.js"; import { logger } from "../utils/logger.js"; import { amariPersonality } from "../utils/makeAiRequest.js"; import type { Amari } from "../interfaces/amari.js"; type ValidImageMediaType = | "image/gif" | "image/jpeg" | "image/png" | "image/webp"; const validImageTypes = new Set([ "image/gif", "image/jpeg", "image/png", "image/webp", ]); const isValidContentType = (type: string): type is ValidImageMediaType => { return validImageTypes.has(type); }; const altTextSystemPrompt = `${amariPersonality}\n\nYour role in this` + " conversation is to generate descriptive and accessible alt-text for the" + " user's image. Be as descriptive as possible. Do not include ANYTHING in" + " your response EXCEPT the actual alt-text. Wrap the text in a multi-line" + " code block for easy copying."; /** * Downloads an image and asks Claude to generate alt-text for it. * @param imageUrl - The URL of the image to process. * @param contentType - The validated MIME type of the image. * @returns The generated alt-text, or null if the request fails. */ const generateAltText = async( imageUrl: string, contentType: ValidImageMediaType, ): Promise => { const downloadRequest = await fetch(imageUrl); const blob = await downloadRequest.arrayBuffer(); const base64 = Buffer.from(blob).toString("base64"); const response = await anthropic.messages.create({ // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. max_tokens: 2000, messages: [ { content: [ { source: { data: base64, // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. media_type: contentType, type: "base64", }, type: "image", }, ], role: "user", }, ], model: "claude-sonnet-4-6", system: altTextSystemPrompt, }); const textContent = response.content.find((block) => { return block.type === "text"; }); return textContent?.type === "text" ? textContent.text : null; }; /** * Validates the attachment is a supported image, downloads it, and sends it * to Claude to generate descriptive alt-text. * @param _amari - The Amari instance (unused but kept for handler consistency). * @param interaction - The Discord slash command interaction. */ // eslint-disable-next-line max-lines-per-function, complexity -- Image validation requires multiple checks. export const altText = async( _amari: Amari, interaction: ChatInputCommandInteraction, ): Promise => { if (interaction.user.id !== ids.users.naomi) { await interaction.reply({ content: "This command is restricted to Naomi.", flags: [ MessageFlags.Ephemeral ], }); return; } const image = interaction.options.getAttachment("image", true); const { contentType, height, width, size, url } = image; if ( contentType === null || !isValidContentType(contentType) || height === null || width === null ) { await interaction.reply({ content: "That does not appear to be a valid image.", flags: [ MessageFlags.Ephemeral ], }); return; } if (size > 5 * 1024 * 1024) { await interaction.reply({ // eslint-disable-next-line stylistic/max-len -- Long user-facing string. content: "That image is too large. Please provide an image that is less than 5MB.", flags: [ MessageFlags.Ephemeral ], }); return; } if (height > 8000 || width > 8000) { await interaction.reply({ // eslint-disable-next-line stylistic/max-len -- Long user-facing string. content: "That image is too large. Please provide an image less than 8000 pixels in either dimension.", flags: [ MessageFlags.Ephemeral ], }); return; } await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try { const result = await generateAltText(url, contentType); await interaction.editReply({ content: result ?? "I'm sorry, I wasn't able to generate alt-text for that image.", }); } catch (error) { await logger.error("alt-text command", error instanceof Error ? error : new Error(String(error))); await interaction.editReply({ content: "Something went wrong whilst generating the alt-text.", }); } };