feat: error handling
All checks were successful
Node.js CI / Lint and Test (pull_request) Successful in 37s

This commit is contained in:
Naomi Carrigan 2025-02-10 21:45:20 -08:00
parent c63003bb75
commit 7191aeead5
Signed by: naomi
SSH Key Fingerprint: SHA256:rca1iUI2OhAM6n4FIUaFcZcicmri0jgocqKiTTAfrt8
9 changed files with 324 additions and 206 deletions

View File

@ -28,6 +28,22 @@ const commands: Record<
"summarise": summarise, "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({ const client = new Client({
intents: [], intents: [],
}); });

View File

@ -13,56 +13,66 @@ import {
MessageFlags, MessageFlags,
type ChatInputCommandInteraction, type ChatInputCommandInteraction,
} from "discord.js"; } from "discord.js";
import { logger } from "../utils/logger.js";
import { replyToError } from "../utils/replyToError.js";
/** /**
* Responds with information about the bot. * Responds with information about the bot.
* @param interaction -- The interaction payload from Discord. * @param interaction -- The interaction payload from Discord.
*/ */
// eslint-disable-next-line max-lines-per-function -- Refactor at a later time.
export const about = async( export const about = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const version = process.env.npm_package_version ?? "Unknown"; const version = process.env.npm_package_version ?? "Unknown";
const commit = execSync("git rev-parse --short HEAD").toString(). const commit = execSync("git rev-parse --short HEAD").toString().
trim(); trim();
const embed = new EmbedBuilder(); const embed = new EmbedBuilder();
embed.setTitle("About Cordelia Taryne"); embed.setTitle("About Cordelia Taryne");
embed.setDescription( embed.setDescription(
// eslint-disable-next-line stylistic/max-len -- It's a long string. // 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!", "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( embed.addFields(
{ {
name: "Running Version", name: "Running Version",
value: version, value: version,
}, },
{ {
name: "Current Commit", name: "Current Commit",
value: commit, value: commit,
}, },
); );
const supportButton = new ButtonBuilder(). const supportButton = new ButtonBuilder().
setLabel("Need help?"). setLabel("Need help?").
setStyle(ButtonStyle.Link). setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com"); setURL("https://chat.nhcarrigan.com");
const sourceButton = new ButtonBuilder(). const sourceButton = new ButtonBuilder().
setLabel("Source Code"). setLabel("Source Code").
setStyle(ButtonStyle.Link). setStyle(ButtonStyle.Link).
setURL("https://git.nhcarrigan.com/nhcarrigan/aria-iuvo"); setURL("https://git.nhcarrigan.com/nhcarrigan/aria-iuvo");
const subscribeButton = new ButtonBuilder(). const subscribeButton = new ButtonBuilder().
setStyle(ButtonStyle.Premium). setStyle(ButtonStyle.Premium).
setSKUId("1338672773261951026"); setSKUId("1338672773261951026");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents( const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
supportButton, supportButton,
sourceButton, sourceButton,
subscribeButton, subscribeButton,
); );
await interaction.editReply({ await interaction.editReply({
components: [ row ], components: [ row ],
embeds: [ embed ], embeds: [ embed ],
}); });
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("about command", error);
}
}
}; };

View File

@ -8,6 +8,8 @@ import { personality } from "../config/personality.js";
import { ai } from "../utils/ai.js"; import { ai } from "../utils/ai.js";
import { calculateCost } from "../utils/calculateCost.js"; import { calculateCost } from "../utils/calculateCost.js";
import { isSubscribed } from "../utils/isSubscribed.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"; import type { ImageBlockParam } from "@anthropic-ai/sdk/resources/index.js";
const isValidContentType = ( const isValidContentType = (
@ -31,86 +33,93 @@ const isValidContentType = (
export const alt = async( export const alt = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try {
const sub = await isSubscribed(interaction); await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
if (!sub) { const sub = await isSubscribed(interaction);
return; if (!sub) {
} return;
}
const image = interaction.options.getAttachment("image", true); const image = interaction.options.getAttachment("image", true);
const { contentType, height, width, size, url } = image; const { contentType, height, width, size, url } = image;
// Claude supports JPG, PNG, GIF, WEBP // Claude supports JPG, PNG, GIF, WEBP
if ( if (
contentType === null contentType === null
|| !isValidContentType(contentType) || !isValidContentType(contentType)
|| height === null || height === null
|| width === null || width === null
) { ) {
await interaction.editReply({ await interaction.editReply({
content: "That does not appear to be a valid image.", content: "That does not appear to be a valid image.",
}); });
return; return;
} }
// Max file size is 5MB // Max file size is 5MB
if (size > 5 * 1024 * 1024) { if (size > 5 * 1024 * 1024) {
await interaction.editReply({ await interaction.editReply({
content: content:
// eslint-disable-next-line stylistic/max-len -- It's a long string. // 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.", "That image is too large. Please provide an image that is less than 5MB.",
}); });
return; return;
} }
// Max dimensions are 8000px // Max dimensions are 8000px
if (height > 8000 || width > 8000) { if (height > 8000 || width > 8000) {
await interaction.editReply({ await interaction.editReply({
content: content:
// eslint-disable-next-line stylistic/max-len -- It's a long string. // 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.", "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 blob = await downloadRequest.arrayBuffer();
const base64 = Buffer.from(blob).toString("base64"); 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. // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_tokens: 2000, max_tokens: 2000,
messages: [ messages: [
{ {
content: [ content: [
{ {
source: { source: {
data: base64, data: base64,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required property syntax for SDK. // eslint-disable-next-line @typescript-eslint/naming-convention -- Required property syntax for SDK.
media_type: contentType, media_type: contentType,
type: "base64", type: "base64",
},
type: "image",
}, },
type: "image", ],
}, role: "user",
], },
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.`,
model: "claude-3-5-sonnet-latest", temperature: 1,
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) => { const response = messages.content.find((message) => {
return message.type === "text"; return message.type === "text";
}); });
await interaction.editReply( await interaction.editReply(
response?.text response?.text
?? "I'm sorry, I don't have an answer for that. Please try again later.", ?? "I'm sorry, I don't have an answer for that. Please try again later.",
); );
const { usage } = messages; const { usage } = messages;
await calculateCost(usage, interaction.user.username, "alt-text"); await calculateCost(usage, interaction.user.username, "alt-text");
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("alt-text command", error);
}
}
}; };

View File

@ -8,6 +8,8 @@ import { personality } from "../config/personality.js";
import { ai } from "../utils/ai.js"; import { ai } from "../utils/ai.js";
import { calculateCost } from "../utils/calculateCost.js"; import { calculateCost } from "../utils/calculateCost.js";
import { isSubscribed } from "../utils/isSubscribed.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 * Accepts an arbitrary code snippet from the user, then sends
@ -17,31 +19,38 @@ import { isSubscribed } from "../utils/isSubscribed.js";
export const evaluate = async( export const evaluate = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try {
const sub = await isSubscribed(interaction); await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
if (!sub) { const sub = await isSubscribed(interaction);
return; if (!sub) {
} return;
}
const code = interaction.options.getString("code", true); const code = interaction.options.getString("code", 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. // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_tokens: 2000, max_tokens: 2000,
messages: [ { content: code, role: "user" } ], messages: [ { content: code, role: "user" } ],
model: "claude-3-5-sonnet-latest", 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.`, 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, temperature: 1,
}); });
const response = messages.content.find((message) => { const response = messages.content.find((message) => {
return message.type === "text"; return message.type === "text";
}); });
await interaction.editReply( await interaction.editReply(
response?.text response?.text
?? "I'm sorry, I don't have an answer for that. Please try again later.", ?? "I'm sorry, I don't have an answer for that. Please try again later.",
); );
const { usage } = messages; const { usage } = messages;
await calculateCost(usage, interaction.user.username, "evaluate"); await calculateCost(usage, interaction.user.username, "evaluate");
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("evaluate command", error);
}
}
}; };

View File

@ -8,6 +8,8 @@ import { personality } from "../config/personality.js";
import { ai } from "../utils/ai.js"; import { ai } from "../utils/ai.js";
import { calculateCost } from "../utils/calculateCost.js"; import { calculateCost } from "../utils/calculateCost.js";
import { isSubscribed } from "../utils/isSubscribed.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 * 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( export const mood = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try {
const sub = await isSubscribed(interaction); await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
if (!sub) { const sub = await isSubscribed(interaction);
return; 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. // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_tokens: 2000, max_tokens: 2000,
messages: [ { content: prompt, role: "user" } ], messages: [ { content: prompt, role: "user" } ],
model: "claude-3-5-sonnet-latest", 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.`, 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, temperature: 1,
}); });
const response = messages.content.find((message) => { const response = messages.content.find((message) => {
return message.type === "text"; return message.type === "text";
}); });
await interaction.editReply( await interaction.editReply(
response?.text response?.text
?? "I'm sorry, I don't have an answer for that. Please try again later.", ?? "I'm sorry, I don't have an answer for that. Please try again later.",
); );
const { usage } = messages; const { usage } = messages;
await calculateCost(usage, interaction.user.username, "mood"); await calculateCost(usage, interaction.user.username, "mood");
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("mood command", error);
}
}
}; };

View File

@ -8,6 +8,8 @@ import { personality } from "../config/personality.js";
import { ai } from "../utils/ai.js"; import { ai } from "../utils/ai.js";
import { calculateCost } from "../utils/calculateCost.js"; import { calculateCost } from "../utils/calculateCost.js";
import { isSubscribed } from "../utils/isSubscribed.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 * 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( export const proofread = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try {
const sub = await isSubscribed(interaction); await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
if (!sub) { const sub = await isSubscribed(interaction);
return; 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. // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_tokens: 2000, max_tokens: 2000,
messages: [ { content: prompt, role: "user" } ], messages: [ { content: prompt, role: "user" } ],
model: "claude-3-5-sonnet-latest", 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.`, 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, temperature: 1,
}); });
const response = messages.content.find((message) => { const response = messages.content.find((message) => {
return message.type === "text"; return message.type === "text";
}); });
await interaction.editReply( await interaction.editReply(
response?.text response?.text
?? "I'm sorry, I don't have an answer for that. Please try again later.", ?? "I'm sorry, I don't have an answer for that. Please try again later.",
); );
const { usage } = messages; const { usage } = messages;
await calculateCost(usage, interaction.user.username, "proofread"); await calculateCost(usage, interaction.user.username, "proofread");
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("proofread command", error);
}
}
}; };

View File

@ -8,6 +8,8 @@ import { personality } from "../config/personality.js";
import { ai } from "../utils/ai.js"; import { ai } from "../utils/ai.js";
import { calculateCost } from "../utils/calculateCost.js"; import { calculateCost } from "../utils/calculateCost.js";
import { isSubscribed } from "../utils/isSubscribed.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 * 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( export const query = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try {
const sub = await isSubscribed(interaction); await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
if (!sub) { const sub = await isSubscribed(interaction);
return; 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. // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_tokens: 2000, max_tokens: 2000,
messages: [ { content: prompt, role: "user" } ], messages: [ { content: prompt, role: "user" } ],
model: "claude-3-5-sonnet-latest", 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.`, 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, temperature: 1,
}); });
const response = messages.content.find((message) => { const response = messages.content.find((message) => {
return message.type === "text"; return message.type === "text";
}); });
await interaction.editReply( await interaction.editReply(
response?.text response?.text
?? "I'm sorry, I don't have an answer for that. Please try again later.", ?? "I'm sorry, I don't have an answer for that. Please try again later.",
); );
const { usage } = messages; const { usage } = messages;
await calculateCost(usage, interaction.user.username, "query"); await calculateCost(usage, interaction.user.username, "query");
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("query command", error);
}
}
}; };

View File

@ -8,6 +8,8 @@ import { personality } from "../config/personality.js";
import { ai } from "../utils/ai.js"; import { ai } from "../utils/ai.js";
import { calculateCost } from "../utils/calculateCost.js"; import { calculateCost } from "../utils/calculateCost.js";
import { isSubscribed } from "../utils/isSubscribed.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 * 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( export const summarise = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try {
const sub = await isSubscribed(interaction); await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
if (!sub) { const sub = await isSubscribed(interaction);
return; 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. // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_tokens: 2000, max_tokens: 2000,
messages: [ { content: prompt, role: "user" } ], messages: [ { content: prompt, role: "user" } ],
model: "claude-3-5-sonnet-latest", 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.`, 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, temperature: 1,
}); });
const response = messages.content.find((message) => { const response = messages.content.find((message) => {
return message.type === "text"; return message.type === "text";
}); });
await interaction.editReply( await interaction.editReply(
response?.text response?.text
?? "I'm sorry, I don't have an answer for that. Please try again later.", ?? "I'm sorry, I don't have an answer for that. Please try again later.",
); );
const { usage } = messages; const { usage } = messages;
await calculateCost(usage, interaction.user.username, "summarise"); await calculateCost(usage, interaction.user.username, "summarise");
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("summarise command", error);
}
}
}; };

38
src/utils/replyToError.ts Normal file
View File

@ -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<void> => {
const button = new ButtonBuilder().setLabel("Need help?").
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com");
const row = new ActionRowBuilder<ButtonBuilder>().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.",
});
};