diff --git a/commands.json b/commands.json index fa05772..531bfa8 100644 --- a/commands.json +++ b/commands.json @@ -1,4 +1,17 @@ [ + { + "name": "alt-text", + "type": 1, + "description": "Generate descriptive alt-text for an image.", + "options": [ + { + "name": "image", + "description": "The image to generate alt-text for.", + "type": 11, + "required": true + } + ] + }, { "name": "create-ticket", "type": 1, @@ -74,6 +87,20 @@ } ] }, + { + "name": "query", + "type": 1, + "description": "Ask Amari a question.", + "options": [ + { + "name": "prompt", + "description": "The question you would like to ask.", + "type": 3, + "required": true, + "max_length": 2000 + } + ] + }, { "name": "Forward to Naomi", "type": 3 diff --git a/src/commands/altText.ts b/src/commands/altText.ts new file mode 100644 index 0000000..15f8fe7 --- /dev/null +++ b/src/commands/altText.ts @@ -0,0 +1,153 @@ +/** + * @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.", + }); + } +}; diff --git a/src/commands/query.ts b/src/commands/query.ts new file mode 100644 index 0000000..3e068a0 --- /dev/null +++ b/src/commands/query.ts @@ -0,0 +1,57 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; +import { ids } from "../config/ids.js"; +import { logger } from "../utils/logger.js"; +import { makeAiRequest } from "../utils/makeAiRequest.js"; +import type { Amari } from "../interfaces/amari.js"; + +const fallbackResponse = "I'm sorry, I don't have an answer for that." + + " Please try again later."; + +/** + * Accepts an arbitrary question and sends it to Claude to be answered. + * @param _amari - The Amari instance (unused but kept for handler consistency). + * @param interaction - The Discord slash command interaction. + */ +export const query = 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 prompt = interaction.options.getString("prompt", true); + + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + + try { + const response = await makeAiRequest({ + maxTokens: 2000, + systemPrompt: "Your role in this conversation is to answer the user's" + + " question to the best of your abilities. When possible, include" + + " links to relevant sources.", + userMessage: prompt, + }); + + await interaction.editReply({ + content: response ?? fallbackResponse, + }); + } catch (error) { + await logger.error("query command", error instanceof Error + ? error + : new Error(String(error))); + await interaction.editReply({ + content: "Something went wrong whilst processing your question.", + }); + } +}; diff --git a/src/events/handleInteractionCreate.ts b/src/events/handleInteractionCreate.ts index ba65752..72be571 100644 --- a/src/events/handleInteractionCreate.ts +++ b/src/events/handleInteractionCreate.ts @@ -9,9 +9,11 @@ import { type ChatInputCommandInteraction, type Interaction, } from "discord.js"; +import { altText } from "../commands/altText.js"; import { createTicket } from "../commands/createTicket.js"; import { forwardToOwner } from "../commands/forwardToOwner.js"; import { onboardMentee } from "../commands/onboardMentee.js"; +import { query } from "../commands/query.js"; import { remind } from "../commands/remind.js"; import { ids } from "../config/ids.js"; import type { Amari } from "../interfaces/amari.js"; @@ -36,6 +38,14 @@ const handleChatInputCommand = ( } if (commandName === "remind") { void remind(amari, interaction); + return; + } + if (commandName === "alt-text") { + void altText(amari, interaction); + return; + } + if (commandName === "query") { + void query(amari, interaction); } };