diff --git a/src/index.ts b/src/index.ts index ac1965f..bec89e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,22 @@ const commands: Record< "summarise": summarise, }; +process.on("unhandledRejection", (error) => { + if (error instanceof Error) { + void logger.error("Unhandled Rejection", error); + return; + } + void logger.error("unhandled rejection", new Error(String(error))); +}); + +process.on("uncaughtException", (error) => { + if (error instanceof Error) { + void logger.error("Uncaught Exception", error); + return; + } + void logger.error("uncaught exception", new Error(String(error))); +}); + const client = new Client({ intents: [], }); diff --git a/src/modules/about.ts b/src/modules/about.ts index bfa8376..7a26e75 100644 --- a/src/modules/about.ts +++ b/src/modules/about.ts @@ -13,56 +13,66 @@ import { MessageFlags, type ChatInputCommandInteraction, } from "discord.js"; +import { logger } from "../utils/logger.js"; +import { replyToError } from "../utils/replyToError.js"; /** * Responds with information about the bot. * @param interaction -- The interaction payload from Discord. */ +// eslint-disable-next-line max-lines-per-function -- Refactor at a later time. export const about = async( interaction: ChatInputCommandInteraction, ): Promise => { - await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + try { + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); - const version = process.env.npm_package_version ?? "Unknown"; - const commit = execSync("git rev-parse --short HEAD").toString(). - trim(); + const version = process.env.npm_package_version ?? "Unknown"; + const commit = execSync("git rev-parse --short HEAD").toString(). + trim(); - const embed = new EmbedBuilder(); - embed.setTitle("About Cordelia Taryne"); - embed.setDescription( + const embed = new EmbedBuilder(); + embed.setTitle("About Cordelia Taryne"); + embed.setDescription( // eslint-disable-next-line stylistic/max-len -- It's a long string. - "Cordelia Taryne is a Discord bot that uses Anthropic to provide assistive features. She is developed by NHCarrigan. To use the bot, type `/` and select one of her commands!", - ); - embed.addFields( - { - name: "Running Version", - value: version, - }, - { - name: "Current Commit", - value: commit, - }, - ); + "Cordelia Taryne is a Discord bot that uses Anthropic to provide assistive features. She is developed by NHCarrigan. To use the bot, type `/` and select one of her commands!", + ); + embed.addFields( + { + name: "Running Version", + value: version, + }, + { + name: "Current Commit", + value: commit, + }, + ); - const supportButton = new ButtonBuilder(). - setLabel("Need help?"). - setStyle(ButtonStyle.Link). - setURL("https://chat.nhcarrigan.com"); - const sourceButton = new ButtonBuilder(). - setLabel("Source Code"). - setStyle(ButtonStyle.Link). - setURL("https://git.nhcarrigan.com/nhcarrigan/aria-iuvo"); - const subscribeButton = new ButtonBuilder(). - setStyle(ButtonStyle.Premium). - setSKUId("1338672773261951026"); - const row = new ActionRowBuilder().addComponents( - supportButton, - sourceButton, - subscribeButton, - ); + const supportButton = new ButtonBuilder(). + setLabel("Need help?"). + setStyle(ButtonStyle.Link). + setURL("https://chat.nhcarrigan.com"); + const sourceButton = new ButtonBuilder(). + setLabel("Source Code"). + setStyle(ButtonStyle.Link). + setURL("https://git.nhcarrigan.com/nhcarrigan/aria-iuvo"); + const subscribeButton = new ButtonBuilder(). + setStyle(ButtonStyle.Premium). + setSKUId("1338672773261951026"); + const row = new ActionRowBuilder().addComponents( + supportButton, + sourceButton, + subscribeButton, + ); - await interaction.editReply({ - components: [ row ], - embeds: [ embed ], - }); + await interaction.editReply({ + components: [ row ], + embeds: [ embed ], + }); + } catch (error) { + await replyToError(interaction); + if (error instanceof Error) { + await logger.error("about command", error); + } + } }; diff --git a/src/modules/alt.ts b/src/modules/alt.ts index 8bcaea2..fe7c284 100644 --- a/src/modules/alt.ts +++ b/src/modules/alt.ts @@ -8,6 +8,8 @@ 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 = ( @@ -31,86 +33,93 @@ const isValidContentType = ( export const alt = async( interaction: ChatInputCommandInteraction, ): Promise => { - await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); - const sub = await isSubscribed(interaction); - if (!sub) { - return; - } + 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; + const image = interaction.options.getAttachment("image", true); + const { contentType, height, width, size, url } = image; - // Claude supports JPG, PNG, GIF, WEBP - if ( - contentType === null + // 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; - } + ) { + 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: + // 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; - } + }); + return; + } - // Max dimensions are 8000px - if (height > 8000 || width > 8000) { - await interaction.editReply({ - content: + // 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; - } + }); + return; + } - const downloadRequest = await fetch(url); + const downloadRequest = await fetch(url); - const blob = await downloadRequest.arrayBuffer(); - const base64 = Buffer.from(blob).toString("base64"); + const blob = await downloadRequest.arrayBuffer(); + const base64 = Buffer.from(blob).toString("base64"); - const messages = await ai.messages.create({ + 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", + 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", }, - 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, - }); + ], + 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"; - }); + const response = messages.content.find((message) => { + return message.type === "text"; + }); - await interaction.editReply( - response?.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"); + 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); + } + } }; diff --git a/src/modules/evaluate.ts b/src/modules/evaluate.ts index 0605917..b59c502 100644 --- a/src/modules/evaluate.ts +++ b/src/modules/evaluate.ts @@ -8,6 +8,8 @@ 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"; /** * Accepts an arbitrary code snippet from the user, then sends @@ -17,31 +19,38 @@ import { isSubscribed } from "../utils/isSubscribed.js"; export const evaluate = async( interaction: ChatInputCommandInteraction, ): Promise => { - await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); - const sub = await isSubscribed(interaction); - if (!sub) { - return; - } + try { + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + const sub = await isSubscribed(interaction); + if (!sub) { + return; + } - const code = interaction.options.getString("code", true); - const messages = await ai.messages.create({ + const code = interaction.options.getString("code", true); + const messages = await ai.messages.create({ // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. - max_tokens: 2000, - messages: [ { content: code, role: "user" } ], - model: "claude-3-5-sonnet-latest", - system: `${personality} Your role in this conversation is to evaluate the user's code and provide the result. Wrap ONLY THE CODE RESULT in a multi-line code block for easy copying.`, - temperature: 1, - }); + max_tokens: 2000, + messages: [ { content: code, role: "user" } ], + model: "claude-3-5-sonnet-latest", + system: `${personality} Your role in this conversation is to evaluate the user's code and provide the result. Wrap ONLY THE CODE RESULT in a multi-line code block for easy copying.`, + temperature: 1, + }); - const response = messages.content.find((message) => { - return message.type === "text"; - }); + const response = messages.content.find((message) => { + return message.type === "text"; + }); - await interaction.editReply( - response?.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, "evaluate"); + const { usage } = messages; + await calculateCost(usage, interaction.user.username, "evaluate"); + } catch (error) { + await replyToError(interaction); + if (error instanceof Error) { + await logger.error("evaluate command", error); + } + } }; diff --git a/src/modules/mood.ts b/src/modules/mood.ts index 3d0dbc3..c8f4a3d 100644 --- a/src/modules/mood.ts +++ b/src/modules/mood.ts @@ -8,6 +8,8 @@ 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"; /** * Accepts a text snippet from the user. Submits it to Anthropic @@ -17,32 +19,39 @@ import { isSubscribed } from "../utils/isSubscribed.js"; export const mood = async( interaction: ChatInputCommandInteraction, ): Promise => { - await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); - const sub = await isSubscribed(interaction); - if (!sub) { - return; - } + try { + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + const sub = await isSubscribed(interaction); + if (!sub) { + return; + } - const prompt = interaction.options.getString("text", true); + const prompt = interaction.options.getString("text", true); - const messages = await ai.messages.create({ + const messages = await ai.messages.create({ // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. - max_tokens: 2000, - messages: [ { content: prompt, role: "user" } ], - model: "claude-3-5-sonnet-latest", - system: `${personality} Your role in this conversation is to analyse the text the user provides for the overall sentiment and mood of the author.`, - temperature: 1, - }); + max_tokens: 2000, + messages: [ { content: prompt, role: "user" } ], + model: "claude-3-5-sonnet-latest", + system: `${personality} Your role in this conversation is to analyse the text the user provides for the overall sentiment and mood of the author.`, + temperature: 1, + }); - const response = messages.content.find((message) => { - return message.type === "text"; - }); + const response = messages.content.find((message) => { + return message.type === "text"; + }); - await interaction.editReply( - response?.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, "mood"); + const { usage } = messages; + await calculateCost(usage, interaction.user.username, "mood"); + } catch (error) { + await replyToError(interaction); + if (error instanceof Error) { + await logger.error("mood command", error); + } + } }; diff --git a/src/modules/proofread.ts b/src/modules/proofread.ts index 56dd70d..f8092b4 100644 --- a/src/modules/proofread.ts +++ b/src/modules/proofread.ts @@ -8,6 +8,8 @@ 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"; /** * Accepts a text snippet from the user. Submits it to Anthropic @@ -17,32 +19,39 @@ import { isSubscribed } from "../utils/isSubscribed.js"; export const proofread = async( interaction: ChatInputCommandInteraction, ): Promise => { - await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); - const sub = await isSubscribed(interaction); - if (!sub) { - return; - } + try { + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + const sub = await isSubscribed(interaction); + if (!sub) { + return; + } - const prompt = interaction.options.getString("text", true); + const prompt = interaction.options.getString("text", true); - const messages = await ai.messages.create({ + const messages = await ai.messages.create({ // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. - max_tokens: 2000, - messages: [ { content: prompt, role: "user" } ], - model: "claude-3-5-sonnet-latest", - system: `${personality} Your role in this conversation is to proofread the text the user has provided. You should identify spelling and grammatical errors using British English.`, - temperature: 1, - }); + max_tokens: 2000, + messages: [ { content: prompt, role: "user" } ], + model: "claude-3-5-sonnet-latest", + system: `${personality} Your role in this conversation is to proofread the text the user has provided. You should identify spelling and grammatical errors using British English.`, + temperature: 1, + }); - const response = messages.content.find((message) => { - return message.type === "text"; - }); + const response = messages.content.find((message) => { + return message.type === "text"; + }); - await interaction.editReply( - response?.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, "proofread"); + const { usage } = messages; + await calculateCost(usage, interaction.user.username, "proofread"); + } catch (error) { + await replyToError(interaction); + if (error instanceof Error) { + await logger.error("proofread command", error); + } + } }; diff --git a/src/modules/query.ts b/src/modules/query.ts index 30c9df0..93d36e5 100644 --- a/src/modules/query.ts +++ b/src/modules/query.ts @@ -8,6 +8,8 @@ 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"; /** * Accepts an arbitrary question from the user, then sends it to Anthropic @@ -17,32 +19,39 @@ import { isSubscribed } from "../utils/isSubscribed.js"; export const query = async( interaction: ChatInputCommandInteraction, ): Promise => { - await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); - const sub = await isSubscribed(interaction); - if (!sub) { - return; - } + try { + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + const sub = await isSubscribed(interaction); + if (!sub) { + return; + } - const prompt = interaction.options.getString("prompt", true); + const prompt = interaction.options.getString("prompt", true); - const messages = await ai.messages.create({ + const messages = await ai.messages.create({ // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. - max_tokens: 2000, - messages: [ { content: prompt, role: "user" } ], - model: "claude-3-5-sonnet-latest", - system: `${personality} 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.`, - temperature: 1, - }); + max_tokens: 2000, + messages: [ { content: prompt, role: "user" } ], + model: "claude-3-5-sonnet-latest", + system: `${personality} 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.`, + temperature: 1, + }); - const response = messages.content.find((message) => { - return message.type === "text"; - }); + const response = messages.content.find((message) => { + return message.type === "text"; + }); - await interaction.editReply( - response?.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, "query"); + const { usage } = messages; + await calculateCost(usage, interaction.user.username, "query"); + } catch (error) { + await replyToError(interaction); + if (error instanceof Error) { + await logger.error("query command", error); + } + } }; diff --git a/src/modules/summarise.ts b/src/modules/summarise.ts index 6b2d824..082779e 100644 --- a/src/modules/summarise.ts +++ b/src/modules/summarise.ts @@ -8,6 +8,8 @@ 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"; /** * Accepts a text snippet from the user. Submits it to Anthropic @@ -17,32 +19,39 @@ import { isSubscribed } from "../utils/isSubscribed.js"; export const summarise = async( interaction: ChatInputCommandInteraction, ): Promise => { - await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); - const sub = await isSubscribed(interaction); - if (!sub) { - return; - } + try { + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + const sub = await isSubscribed(interaction); + if (!sub) { + return; + } - const prompt = interaction.options.getString("text", true); + const prompt = interaction.options.getString("text", true); - const messages = await ai.messages.create({ + const messages = await ai.messages.create({ // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. - max_tokens: 2000, - messages: [ { content: prompt, role: "user" } ], - model: "claude-3-5-sonnet-latest", - system: `${personality} Your role in this conversation is to summarise the text the user has provided. Your goal is to reach 250 words or less. Wrap ONLY THE SUMMARY in multi-line code block so it is easy to copy.`, - temperature: 1, - }); + max_tokens: 2000, + messages: [ { content: prompt, role: "user" } ], + model: "claude-3-5-sonnet-latest", + system: `${personality} Your role in this conversation is to summarise the text the user has provided. Your goal is to reach 250 words or less. Wrap ONLY THE SUMMARY in multi-line code block so it is easy to copy.`, + temperature: 1, + }); - const response = messages.content.find((message) => { - return message.type === "text"; - }); + const response = messages.content.find((message) => { + return message.type === "text"; + }); - await interaction.editReply( - response?.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, "summarise"); + const { usage } = messages; + await calculateCost(usage, interaction.user.username, "summarise"); + } catch (error) { + await replyToError(interaction); + if (error instanceof Error) { + await logger.error("summarise command", error); + } + } }; diff --git a/src/utils/replyToError.ts b/src/utils/replyToError.ts new file mode 100644 index 0000000..bb5997e --- /dev/null +++ b/src/utils/replyToError.ts @@ -0,0 +1,38 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type ChatInputCommandInteraction, + type MessageContextMenuCommandInteraction, +} from "discord.js"; + +/** + * Responds to an interaction with a generic error message. + * @param interaction -- The interaction payload from Discord. + */ +export const replyToError = async( + interaction: + | ChatInputCommandInteraction + | MessageContextMenuCommandInteraction, +): Promise => { + const button = new ButtonBuilder().setLabel("Need help?"). + setStyle(ButtonStyle.Link). + setURL("https://chat.nhcarrigan.com"); + const row = new ActionRowBuilder().addComponents(button); + if (interaction.deferred || interaction.replied) { + await interaction.editReply({ + components: [ row ], + content: "An error occurred while running this command.", + }); + return; + } + await interaction.reply({ + components: [ row ], + content: "An error occurred while running this command.", + }); +};