feat: initial product prototype (#1)
Node.js CI / Lint and Test (push) Has been cancelled

### Explanation

_No response_

### Issue

_No response_

### Attestations

- [x] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [x] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [x] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [x] I have pinned the dependencies to a specific patch version.

### Style

- [x] I have run the linter and resolved any errors.
- [x] My pull request uses an appropriate title, matching the conventional commit standards.
- [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

Major - My pull request introduces a breaking change.

Reviewed-on: nhcarrigan/cordelia-taryne#1
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #1.
This commit is contained in:
2025-02-10 18:10:23 -08:00
committed by Naomi Carrigan
parent 70e7649c0c
commit 58bed952a7
29 changed files with 5763 additions and 13 deletions
+68
View File
@@ -0,0 +1,68 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { execSync } from "node:child_process";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
MessageFlags,
type ChatInputCommandInteraction,
} from "discord.js";
/**
* Responds with information about the bot.
* @param interaction -- The interaction payload from Discord.
*/
export const about = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
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 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,
},
);
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<ButtonBuilder>().addComponents(
supportButton,
sourceButton,
subscribeButton,
);
await interaction.editReply({
components: [ row ],
embeds: [ embed ],
});
};
+112
View File
@@ -0,0 +1,112 @@
/**
* @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 { isSubscribed } from "../utils/isSubscribed.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<void> => {
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.",
);
};
+43
View File
@@ -0,0 +1,43 @@
/**
* @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 { isSubscribed } from "../utils/isSubscribed.js";
/**
* Accepts an arbitrary code snippet from the user, then sends
* it to Anthropic to be evaluated.
* @param interaction -- The interaction payload from Discord.
*/
export const evaluate = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
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({
// 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,
});
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.",
);
};
+44
View File
@@ -0,0 +1,44 @@
/**
* @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 { isSubscribed } from "../utils/isSubscribed.js";
/**
* Accepts a text snippet from the user. Submits it to Anthropic
* to be analysed for mood and sentiment.
* @param interaction -- The interaction payload from Discord.
*/
export const mood = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const sub = await isSubscribed(interaction);
if (!sub) {
return;
}
const prompt = interaction.options.getString("text", 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: 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";
});
await interaction.editReply(
response?.text
?? "I'm sorry, I don't have an answer for that. Please try again later.",
);
};
+44
View File
@@ -0,0 +1,44 @@
/**
* @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 { isSubscribed } from "../utils/isSubscribed.js";
/**
* Accepts a text snippet from the user. Submits it to Anthropic
* to be proofread for spelling and grammatical errors.
* @param interaction -- The interaction payload from Discord.
*/
export const proofread = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const sub = await isSubscribed(interaction);
if (!sub) {
return;
}
const prompt = interaction.options.getString("text", 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: 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";
});
await interaction.editReply(
response?.text
?? "I'm sorry, I don't have an answer for that. Please try again later.",
);
};
+44
View File
@@ -0,0 +1,44 @@
/**
* @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 { isSubscribed } from "../utils/isSubscribed.js";
/**
* Accepts an arbitrary question from the user, then sends it to Anthropic
* to be answered.
* @param interaction -- The interaction payload from Discord.
*/
export const query = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const sub = await isSubscribed(interaction);
if (!sub) {
return;
}
const prompt = interaction.options.getString("prompt", 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: 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";
});
await interaction.editReply(
response?.text
?? "I'm sorry, I don't have an answer for that. Please try again later.",
);
};
+44
View File
@@ -0,0 +1,44 @@
/**
* @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 { isSubscribed } from "../utils/isSubscribed.js";
/**
* Accepts a text snippet from the user. Submits it to Anthropic
* to be summarised.
* @param interaction -- The interaction payload from Discord.
*/
export const summarise = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const sub = await isSubscribed(interaction);
if (!sub) {
return;
}
const prompt = interaction.options.getString("text", 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: 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";
});
await interaction.editReply(
response?.text
?? "I'm sorry, I don't have an answer for that. Please try again later.",
);
};