/** * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; import { personality } from "../config/personality.js"; import { ai } from "../utils/ai.js"; import { calculateCost } from "../utils/calculateCost.js"; import { isSubscribed } from "../utils/isSubscribed.js"; import { logger } from "../utils/logger.js"; import { replyToError } from "../utils/replyToError.js"; import type { ImageBlockParam } from "@anthropic-ai/sdk/resources/index.js"; const isValidContentType = ( type: string, ): type is ImageBlockParam["source"]["media_type"] => { return [ "image/jpg", "image/jpeg", "image/png", "image/gif", "image/webp", ].includes(type); }; /** * Validates the attachment is an image in the correct format, then downloads * it and sends it to Claude to generate alt-text. * @param interaction -- The interaction payload from Discord. */ // eslint-disable-next-line max-lines-per-function, complexity, max-statements -- This function is large but necessary. export const alt = async( interaction: ChatInputCommandInteraction, ): Promise => { try { await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); const sub = await isSubscribed(interaction); if (!sub) { return; } const image = interaction.options.getAttachment("image", true); const { contentType, height, width, size, url } = image; // Claude supports JPG, PNG, GIF, WEBP if ( contentType === null || !isValidContentType(contentType) || height === null || width === null ) { await interaction.editReply({ content: "That does not appear to be a valid image.", }); return; } // Max file size is 5MB if (size > 5 * 1024 * 1024) { await interaction.editReply({ content: // eslint-disable-next-line stylistic/max-len -- It's a long string. "That image is too large. Please provide an image that is less than 5MB.", }); return; } // Max dimensions are 8000px if (height > 8000 || width > 8000) { await interaction.editReply({ content: // eslint-disable-next-line stylistic/max-len -- It's a long string. "That image is too large. Please provide an image that is less than 8000 pixels high or wide.", }); return; } const downloadRequest = await fetch(url); const blob = await downloadRequest.arrayBuffer(); const base64 = Buffer.from(blob).toString("base64"); const messages = await ai.messages.create({ // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. max_tokens: 2000, messages: [ { content: [ { source: { data: base64, // eslint-disable-next-line @typescript-eslint/naming-convention -- Required property syntax for SDK. media_type: contentType, type: "base64", }, type: "image", }, ], role: "user", }, ], model: "claude-3-5-sonnet-latest", system: `${personality} Your 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.`, temperature: 1, }); const response = messages.content.find((message) => { return message.type === "text"; }); await interaction.editReply( response?.text ?? "I'm sorry, I don't have an answer for that. Please try again later.", ); const { usage } = messages; await calculateCost(usage, interaction.user.username, "alt-text"); } catch (error) { await replyToError(interaction); if (error instanceof Error) { await logger.error("alt-text command", error); } } };