From a36d706eede9b4de0d1d846c637b784ba9d1ac5a Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 12 Mar 2026 23:47:46 -0700 Subject: [PATCH] feat: new slash commands and bug fixes (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **feat**: Add `/remind` owner-only command — sends a meeting waiting room notification to a specified user in `#general` - **fix**: Prevent duplicate DM notifications when a message matches both `respondToMention` and `notifyNameMention` patterns - **feat**: Port `/alt-text` and `/query` commands from Cordelia — owner-only, AI-powered, using Amari's personality - **feat**: Add `/research` command — owner-only, web-search-backed query returning results as a markdown file attachment - **fix**: Suppress non-critical RetroAchievements fetch errors (job retries every 10 minutes) Closes #19, #20, #21, #22 Also resolves #2 (unhandled HTTP rejections from RA API) Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/amari/pulls/23 Co-authored-by: Hikari Co-committed-by: Hikari --- commands.json | 54 +++++++++ src/commands/altText.ts | 153 ++++++++++++++++++++++++++ src/commands/query.ts | 57 ++++++++++ src/commands/remind.ts | 61 ++++++++++ src/commands/research.ts | 144 ++++++++++++++++++++++++ src/events/handleInteractionCreate.ts | 20 ++++ src/events/handleMessageCreate.ts | 6 +- src/modules/checkAchievements.ts | 7 +- src/modules/respondToMention.ts | 11 +- 9 files changed, 502 insertions(+), 11 deletions(-) create mode 100644 src/commands/altText.ts create mode 100644 src/commands/query.ts create mode 100644 src/commands/remind.ts create mode 100644 src/commands/research.ts diff --git a/commands.json b/commands.json index a4e05c6..ac582f7 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, @@ -61,6 +74,47 @@ } ] }, + { + "name": "remind", + "type": 1, + "description": "Sends a meeting reminder notification to the specified user.", + "options": [ + { + "name": "user", + "description": "The user to send the meeting reminder to.", + "type": 6, + "required": true + } + ] + }, + { + "name": "research", + "type": 1, + "description": "Research a topic using web search.", + "options": [ + { + "name": "prompt", + "description": "The topic or question to research.", + "type": 3, + "required": true, + "max_length": 2000 + } + ] + }, + { + "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/commands/remind.ts b/src/commands/remind.ts new file mode 100644 index 0000000..7dea591 --- /dev/null +++ b/src/commands/remind.ts @@ -0,0 +1,61 @@ +/** + * @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 type { Amari } from "../interfaces/amari.js"; + +/** + * Sends a meeting reminder notification to the general channel for the given user. + * @param amari - The Amari instance. + * @param interaction - The Discord slash command interaction. + */ +export const remind = 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 targetUser = interaction.options.getUser("user", true); + + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + + try { + const channel + = amari.discord.channels.cache.get(ids.channels.general) + ?? await amari.discord.channels.fetch(ids.channels.general); + + if (channel?.isSendable() !== true) { + await interaction.editReply({ + content: "Could not send the meeting reminder.", + }); + return; + } + + await channel.send({ + content: `Heya <@${targetUser.id}>~!\n\nIt looks like you have a meeting scheduled with Naomi soon. Whenever you are ready, please wait in <#1396976351201726484>. Naomi should be available around the time your meeting starts. Once she is prepared, she will drag you into her <#1396976542856384652> where just the two of you will be.`, + }); + + await logger.metric("meeting_reminder_sent", 1, { user: targetUser.id }); + await interaction.editReply({ + content: `✅ Meeting reminder sent to **${targetUser.username}**!`, + }); + } catch (error) { + await logger.error("remind command", error instanceof Error + ? error + : new Error(String(error))); + await interaction.editReply({ + content: `❌ Failed to send meeting reminder: ${String(error)}`, + }); + } +}; diff --git a/src/commands/research.ts b/src/commands/research.ts new file mode 100644 index 0000000..69ace58 --- /dev/null +++ b/src/commands/research.ts @@ -0,0 +1,144 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + AttachmentBuilder, + 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"; +import type { ContentBlock } from "@anthropic-ai/sdk/resources/messages.js"; + +const researchSystemPrompt = `${amariPersonality}\n\nYour role in this` + + " conversation is to research the user's query thoroughly using web search." + + " Provide a comprehensive, well-structured response with cited sources." + + " Format your response clearly with headers and sections where" + + " appropriate."; + +/** + * Formats a single content block into a markdown string. + * @param block - The content block to format. + * @returns A formatted markdown string. + */ +const formatBlock = (block: ContentBlock): string => { + if (block.type === "text") { + if ((block.citations?.length ?? 0) > 0) { + const sources = (block.citations ?? []). + filter((citation) => { + return citation.type === "web_search_result_location"; + }). + map((citation) => { + return `- [${citation.title ?? "Unknown Title"}](${citation.url})`; + }). + join("\n"); + return `${block.text}\n\n**Sources:**\n${sources}`; + } + return block.text; + } + if (block.type === "server_tool_use") { + return `> 🔍 *Searching: ${JSON.stringify(block.input)}*`; + } + if (block.type === "web_search_tool_result") { + if (!Array.isArray(block.content)) { + return ""; + } + const links = block.content. + map((entry) => { + return `[${entry.title}](${entry.url})`; + }). + join(", "); + return `> 📄 *Found: ${links}*`; + } + if (block.type === "thinking") { + return `> 💭 *${block.thinking}*`; + } + return ""; +}; + +/** + * Builds a markdown buffer from the research prompt and API response content. + * @param prompt - The original research prompt. + * @param content - The content blocks returned from the API. + * @returns A Buffer containing the formatted markdown. + */ +const buildMarkdownFile = ( + prompt: string, + content: Array, +): Buffer => { + const markdown = [ + `# Research: ${prompt}`, + "", + ...content. + map((block) => { + return formatBlock(block); + }). + filter((block) => { + return block.length > 0; + }), + ].join("\n\n"); + return Buffer.from(markdown, "utf-8"); +}; + +/** + * Runs a web-search-backed research query and returns the result as a + * markdown file attachment. + * @param _amari - The Amari instance (unused but kept for handler consistency). + * @param interaction - The Discord slash command interaction. + */ +export const research = 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 anthropic.messages.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. + max_tokens: 3000, + messages: [ { content: prompt, role: "user" } ], + model: "claude-sonnet-4-6", + system: researchSystemPrompt, + tools: [ { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. + max_uses: 5, + name: "web_search", + type: "web_search_20250305", + } ], + }); + + const file = new AttachmentBuilder( + buildMarkdownFile(prompt, response.content), + { name: "research.md" }, + ); + + await logger.metric("research_query", 1, { user: interaction.user.id }); + await interaction.editReply({ + content: "Here are your research results!", + files: [ file ], + }); + } catch (error) { + await logger.error("research command", error instanceof Error + ? error + : new Error(String(error))); + await interaction.editReply({ + content: "Something went wrong whilst running the research query.", + }); + } +}; diff --git a/src/events/handleInteractionCreate.ts b/src/events/handleInteractionCreate.ts index 6d8e613..3f1d47a 100644 --- a/src/events/handleInteractionCreate.ts +++ b/src/events/handleInteractionCreate.ts @@ -9,9 +9,13 @@ 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 { research } from "../commands/research.js"; import { ids } from "../config/ids.js"; import type { Amari } from "../interfaces/amari.js"; @@ -31,6 +35,22 @@ const handleChatInputCommand = ( } if (commandName === "create-ticket") { void createTicket(amari, interaction); + return; + } + if (commandName === "remind") { + void remind(amari, interaction); + return; + } + if (commandName === "alt-text") { + void altText(amari, interaction); + return; + } + if (commandName === "query") { + void query(amari, interaction); + return; + } + if (commandName === "research") { + void research(amari, interaction); } }; diff --git a/src/events/handleMessageCreate.ts b/src/events/handleMessageCreate.ts index 0f8f239..3292b50 100644 --- a/src/events/handleMessageCreate.ts +++ b/src/events/handleMessageCreate.ts @@ -29,6 +29,8 @@ export const handleMessageCreate = async( amari.recentlyActiveChannels.add(message.channel.id); } await updateMentorshipThread(amari, message); - await respondToMention(amari, message); - await notifyNameMention(amari, message); + const mentionNotified = await respondToMention(amari, message); + if (!mentionNotified) { + await notifyNameMention(amari, message); + } }; diff --git a/src/modules/checkAchievements.ts b/src/modules/checkAchievements.ts index 2a20225..18776a3 100644 --- a/src/modules/checkAchievements.ts +++ b/src/modules/checkAchievements.ts @@ -23,7 +23,6 @@ import { type MessageActionRowComponentBuilder, } from "discord.js"; import { ids } from "../config/ids.js"; -import { logger } from "../utils/logger.js"; import type { Amari } from "../interfaces/amari.js"; const username = "naomilgbt"; @@ -117,9 +116,7 @@ export const checkRetroAchievements = async( flags: [ MessageFlags.IsComponentsV2 ], }); })); - } catch (error) { - if (error instanceof Error) { - await logger.error("checkRetroAchievements module", error); - } + } catch { + // Fetch errors from RetroAchievements are non-critical; the job retries every 10 minutes. } }; diff --git a/src/modules/respondToMention.ts b/src/modules/respondToMention.ts index f2b1124..6d21aec 100644 --- a/src/modules/respondToMention.ts +++ b/src/modules/respondToMention.ts @@ -15,21 +15,22 @@ import type { Amari } from "../interfaces/amari.js"; * If so, responds. * @param amari -- Amari's instance. * @param message -- The guild message payload from Discord. + * @returns Whether a DM notification was sent. */ // eslint-disable-next-line complexity -- Mainly those reply options... export const respondToMention = async( amari: Amari, message: Message, -): Promise => { +): Promise => { try { const naomi = amari.discord.users.cache.get(ids.users.naomi) ?? await amari.discord.users.fetch(ids.users.naomi); const { mentions, content, author, url, channel } = message; if (author.bot || author.id === ids.users.naomi) { - return; + return false; } if (amari.recentlyActiveChannels.has(channel.id)) { - return; + return false; } const mentionsNaomi = mentions.has(ids.users.naomi, { ignoreEveryone: true, @@ -45,7 +46,7 @@ export const respondToMention = async( ignoreRoles: true, }) || /nhcarrigan/i.test(content); if (!mentionsNaomi && !mentionsNHCarrigan) { - return; + return false; } await naomi.send( { @@ -56,9 +57,11 @@ export const respondToMention = async( await logger.metric("processed_mention", 1, { pingType: mentionsNaomi ? "naomi" : "nhcarrigan", user: author.id }); + return true; } catch (error) { if (error instanceof Error) { await logger.error("respond to mention module", error); } + return false; } };