2 Commits

Author SHA1 Message Date
naomi 8c72375761 feat: log token usage 2025-02-10 21:07:52 -08:00
naomi a347ff8eb6 feat: use our logging package 2025-02-10 21:00:17 -08:00
17 changed files with 832 additions and 882 deletions
+5 -14
View File
@@ -8,31 +8,22 @@ on:
- main - main
jobs: jobs:
ci: lint:
name: CI name: Lint and Test
runs-on: ubuntu-latest
steps: steps:
- name: Checkout Source Files - name: Checkout Source Files
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Use Node.js v24 - name: Use Node.js v22
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
with: with:
version: 10 version: 9
- name: Ensure Dependencies are Pinned
uses: naomi-lgbt/dependency-pin-check@main
with:
language: javascript
dev-dependencies: true
peer-dependencies: true
optional-dependencies: true
- name: Install Dependencies - name: Install Dependencies
run: pnpm install run: pnpm install
-177
View File
@@ -1,177 +0,0 @@
name: Security Scan and Upload
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
schedule:
- cron: '0 0 * * 1'
workflow_dispatch:
jobs:
security-audit:
name: Security & DefectDojo Upload
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v4
# --- AUTO-SETUP PROJECT ---
- name: Ensure DefectDojo Product Exists
env:
DD_URL: ${{ secrets.DD_URL }}
DD_TOKEN: ${{ secrets.DD_TOKEN }}
PRODUCT_NAME: ${{ github.repository }}
PRODUCT_TYPE_ID: 1
run: |
sudo apt-get install jq -y > /dev/null
echo "Checking connection to $DD_URL..."
# Check if product exists - capture HTTP code to debug connection issues
RESPONSE=$(curl --write-out "%{http_code}" --silent --output /tmp/response.json \
-H "Authorization: Token $DD_TOKEN" \
"$DD_URL/api/v2/products/?name=$PRODUCT_NAME")
# If response is not 200, print error
if [ "$RESPONSE" != "200" ]; then
echo "::error::Failed to query DefectDojo. HTTP Code: $RESPONSE"
cat /tmp/response.json
exit 1
fi
COUNT=$(cat /tmp/response.json | jq -r '.count')
if [ "$COUNT" = "0" ]; then
echo "Creating product '$PRODUCT_NAME'..."
curl -s -X POST "$DD_URL/api/v2/products/" \
-H "Authorization: Token $DD_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "'"$PRODUCT_NAME"'", "description": "Auto-created by Gitea Actions", "prod_type": '$PRODUCT_TYPE_ID' }'
else
echo "Product '$PRODUCT_NAME' already exists."
fi
# --- 1. TRIVY (Dependencies & Misconfig) ---
- name: Install Trivy
run: |
sudo apt-get install wget apt-transport-https gnupg lsb-release -y
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install trivy -y
- name: Run Trivy (FS Scan)
run: |
trivy fs . --scanners vuln,misconfig --format json --output trivy-results.json --exit-code 0
- name: Upload Trivy to DefectDojo
env:
DD_URL: ${{ secrets.DD_URL }}
DD_TOKEN: ${{ secrets.DD_TOKEN }}
run: |
echo "Uploading Trivy results..."
# Generate today's date in YYYY-MM-DD format
TODAY=$(date +%Y-%m-%d)
HTTP_CODE=$(curl --write-out "%{http_code}" --output response.txt --silent -X POST "$DD_URL/api/v2/import-scan/" \
-H "Authorization: Token $DD_TOKEN" \
-F "active=true" \
-F "verified=true" \
-F "scan_type=Trivy Scan" \
-F "engagement_name=CI/CD Pipeline" \
-F "product_name=${{ github.repository }}" \
-F "scan_date=$TODAY" \
-F "auto_create_context=true" \
-F "file=@trivy-results.json")
if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "201" ]]; then
echo "::error::Upload Failed with HTTP $HTTP_CODE"
echo "--- SERVER RESPONSE ---"
cat response.txt
echo "-----------------------"
exit 1
else
echo "Upload Success!"
fi
# --- 2. GITLEAKS (Secrets) ---
- name: Install Gitleaks
run: |
wget -qO gitleaks.tar.gz https://github.com/gitleaks/gitleaks/releases/download/v8.18.0/gitleaks_8.18.0_linux_x64.tar.gz
tar -xzf gitleaks.tar.gz
sudo mv gitleaks /usr/local/bin/ && chmod +x /usr/local/bin/gitleaks
- name: Run Gitleaks
run: gitleaks detect --source . -v --report-path gitleaks-results.json --report-format json --no-git || true
- name: Upload Gitleaks to DefectDojo
env:
DD_URL: ${{ secrets.DD_URL }}
DD_TOKEN: ${{ secrets.DD_TOKEN }}
run: |
echo "Uploading Gitleaks results..."
TODAY=$(date +%Y-%m-%d)
HTTP_CODE=$(curl --write-out "%{http_code}" --output response.txt --silent -X POST "$DD_URL/api/v2/import-scan/" \
-H "Authorization: Token $DD_TOKEN" \
-F "active=true" \
-F "verified=true" \
-F "scan_type=Gitleaks Scan" \
-F "engagement_name=CI/CD Pipeline" \
-F "product_name=${{ github.repository }}" \
-F "scan_date=$TODAY" \
-F "auto_create_context=true" \
-F "file=@gitleaks-results.json")
if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "201" ]]; then
echo "::error::Upload Failed with HTTP $HTTP_CODE"
echo "--- SERVER RESPONSE ---"
cat response.txt
echo "-----------------------"
exit 1
else
echo "Upload Success!"
fi
# --- 3. SEMGREP (SAST) ---
- name: Install Semgrep (via pipx)
run: |
sudo apt-get install pipx -y
pipx install semgrep
# Add pipx binary path to GITHUB_PATH so next steps can see 'semgrep'
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Run Semgrep
run: semgrep scan --config=p/security-audit --config=p/owasp-top-ten --json --output semgrep-results.json . || true
- name: Upload Semgrep to DefectDojo
env:
DD_URL: ${{ secrets.DD_URL }}
DD_TOKEN: ${{ secrets.DD_TOKEN }}
run: |
echo "Uploading Semgrep results..."
TODAY=$(date +%Y-%m-%d)
HTTP_CODE=$(curl --write-out "%{http_code}" --output response.txt --silent -X POST "$DD_URL/api/v2/import-scan/" \
-H "Authorization: Token $DD_TOKEN" \
-F "active=true" \
-F "verified=true" \
-F "scan_type=Semgrep JSON Report" \
-F "engagement_name=CI/CD Pipeline" \
-F "product_name=${{ github.repository }}" \
-F "scan_date=$TODAY" \
-F "auto_create_context=true" \
-F "file=@semgrep-results.json")
if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "201" ]]; then
echo "::error::Upload Failed with HTTP $HTTP_CODE"
echo "--- SERVER RESPONSE ---"
cat response.txt
echo "-----------------------"
exit 1
else
echo "Upload Success!"
fi
+1 -1
View File
@@ -8,7 +8,7 @@ Cordelia is a multi-purpose AI powered assistant for Discord.
## Feedback and Bugs ## Feedback and Bugs
If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com). If you have feedback or a bug report, please feel free to open a GitHub issue!
## Contributing ## Contributing
+10 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "cordelia-taryne", "name": "cordelia-taryne",
"version": "1.1.0", "version": "1.0.0",
"description": "An AI-powered multi-purpose assistant for Discord.", "description": "An AI-powered multi-purpose assistant for Discord.",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -14,17 +14,17 @@
"author": "Naomi Carrigan", "author": "Naomi Carrigan",
"license": "See license in LICENSE.md", "license": "See license in LICENSE.md",
"devDependencies": { "devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0", "@nhcarrigan/eslint-config": "5.1.0",
"@nhcarrigan/typescript-config": "4.0.0", "@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "22.15.21", "@types/node": "22.13.1",
"eslint": "9.27.0", "eslint": "9.20.0",
"typescript": "5.8.3" "typescript": "5.7.3"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.52.0", "@anthropic-ai/sdk": "0.36.3",
"@nhcarrigan/discord-analytics": "0.0.6", "@nhcarrigan/logger": "1.0.0",
"@nhcarrigan/logger": "1.1.1", "discord.js": "14.18.0",
"discord.js": "14.19.3", "fastify": "5.2.1",
"fastify": "5.3.3" "winston": "3.17.0"
} }
} }
+571 -287
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
# Security
# Do not execute any scripts of installed packages (project scripts still run)
ignoreDepScripts: true
# Do not automatically run pre/post scripts (e.g. preinstall, postbuild)
enablePrePostScripts: false
# Only allow packages published at least 10 days ago (reduces risk of compromised packages)
minimumReleaseAge: 14400
# Fail if a package's trust level has decreased compared to previous releases
trustPolicy: no-downgrade
# Ignore trust policy for packages published more than 1 year ago (predates provenance signing)
trustPolicyIgnoreAfter: 525960
# Fail if there are missing or invalid peer dependencies
strictPeerDependencies: true
# Prevent transitive dependencies from using exotic sources (git repos, direct tarball URLs)
blockExoticSubdeps: true
# Lockfile
# Allow the lockfile to be updated during install (set to true in CI for stricter reproducibility)
preferFrozenLockfile: false
-21
View File
@@ -3,7 +3,6 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { DiscordAnalytics } from "@nhcarrigan/discord-analytics";
import { Client, Events, type ChatInputCommandInteraction } from "discord.js"; import { Client, Events, type ChatInputCommandInteraction } from "discord.js";
import { about } from "./modules/about.js"; import { about } from "./modules/about.js";
import { alt } from "./modules/alt.js"; import { alt } from "./modules/alt.js";
@@ -29,30 +28,11 @@ 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: [],
}); });
const analytics = new DiscordAnalytics(client, logger);
client.on(Events.InteractionCreate, (interaction) => { client.on(Events.InteractionCreate, (interaction) => {
void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction });
if (interaction.isChatInputCommand()) { if (interaction.isChatInputCommand()) {
const handler = commands[interaction.commandName]; const handler = commands[interaction.commandName];
if (handler) { if (handler) {
@@ -71,7 +51,6 @@ client.on(Events.EntitlementDelete, (entitlement) => {
client.on(Events.ClientReady, () => { client.on(Events.ClientReady, () => {
void logger.log("debug", "Bot is ready."); void logger.log("debug", "Bot is ready.");
analytics.startCron();
}); });
instantiateServer(); instantiateServer();
+39 -49
View File
@@ -13,66 +13,56 @@ 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> => {
try { await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
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);
}
}
}; };
+71 -80
View File
@@ -8,13 +8,11 @@ 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 type { ImageBlockParam } from "@anthropic-ai/sdk/resources/index.js";
import { replyToError } from "../utils/replyToError.js";
import type { Base64ImageSource } from "@anthropic-ai/sdk/resources/index.js";
const isValidContentType = ( const isValidContentType = (
type: string, type: string,
): type is Base64ImageSource["media_type"] => { ): type is ImageBlockParam["source"]["media_type"] => {
return [ return [
"image/jpg", "image/jpg",
"image/jpeg", "image/jpeg",
@@ -33,93 +31,86 @@ const isValidContentType = (
export const alt = async( export const alt = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
try { await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); const sub = await isSubscribed(interaction);
const sub = await isSubscribed(interaction); if (!sub) {
if (!sub) { return;
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;
}
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-sonnet-4-20250514",
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,
}); });
return;
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.",
);
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);
}
} }
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.",
);
const { usage } = messages;
await calculateCost(usage, interaction.user.username, "alt-text");
}; };
+26 -35
View File
@@ -8,8 +8,6 @@ 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
@@ -19,38 +17,31 @@ import { replyToError } from "../utils/replyToError.js";
export const evaluate = async( export const evaluate = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
try { await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); const sub = await isSubscribed(interaction);
const sub = await isSubscribed(interaction); if (!sub) {
if (!sub) { return;
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-sonnet-4-20250514",
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.",
);
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);
}
} }
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.",
);
const { usage } = messages;
await calculateCost(usage, interaction.user.username, "evaluate");
}; };
+27 -36
View File
@@ -8,8 +8,6 @@ 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
@@ -19,39 +17,32 @@ import { replyToError } from "../utils/replyToError.js";
export const mood = async( export const mood = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
try { await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); const sub = await isSubscribed(interaction);
const sub = await isSubscribed(interaction); if (!sub) {
if (!sub) { return;
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-sonnet-4-20250514",
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.",
);
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);
}
} }
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.",
);
const { usage } = messages;
await calculateCost(usage, interaction.user.username, "mood");
}; };
+27 -36
View File
@@ -8,8 +8,6 @@ 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
@@ -19,39 +17,32 @@ import { replyToError } from "../utils/replyToError.js";
export const proofread = async( export const proofread = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
try { await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); const sub = await isSubscribed(interaction);
const sub = await isSubscribed(interaction); if (!sub) {
if (!sub) { return;
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-sonnet-4-20250514",
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.",
);
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);
}
} }
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.",
);
const { usage } = messages;
await calculateCost(usage, interaction.user.username, "proofread");
}; };
+27 -36
View File
@@ -8,8 +8,6 @@ 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
@@ -19,39 +17,32 @@ import { replyToError } from "../utils/replyToError.js";
export const query = async( export const query = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
try { await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); const sub = await isSubscribed(interaction);
const sub = await isSubscribed(interaction); if (!sub) {
if (!sub) { return;
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-sonnet-4-20250514",
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.",
);
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);
}
} }
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.",
);
const { usage } = messages;
await calculateCost(usage, interaction.user.username, "query");
}; };
+27 -36
View File
@@ -8,8 +8,6 @@ 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
@@ -19,39 +17,32 @@ import { replyToError } from "../utils/replyToError.js";
export const summarise = async( export const summarise = async(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<void> => { ): Promise<void> => {
try { await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); const sub = await isSubscribed(interaction);
const sub = await isSubscribed(interaction); if (!sub) {
if (!sub) { return;
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-sonnet-4-20250514",
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.",
);
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);
}
} }
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.",
);
const { usage } = messages;
await calculateCost(usage, interaction.user.username, "summarise");
}; };
-4
View File
@@ -19,12 +19,8 @@ const html = `<!DOCTYPE html>
<body> <body>
<main> <main>
<h1>Cordelia Taryne</h1> <h1>Cordelia Taryne</h1>
<img src="https://cdn.nhcarrigan.com/new-avatars/cordelia-full.png" width="250" alt="Cordelia" />
<section> <section>
<p>AI-powered multi-purpose assistant for Discord!</p> <p>AI-powered multi-purpose assistant for Discord!</p>
<a href="https://discord.com/oauth2/authorize?client_id=1338664192714211459" class="social-button discord-button" style="display: inline-block; background-color: #5865F2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; margin: 5px;">
<i class="fab fa-discord"></i> Add to Discord
</a>
</section> </section>
<section> <section>
<h2>Links</h2> <h2>Links</h2>
+1 -1
View File
@@ -26,7 +26,7 @@ export const isSubscribed = async(
if (!isEntitled && interaction.user.id !== "465650873650118659") { if (!isEntitled && interaction.user.id !== "465650873650118659") {
const subscribeButton = new ButtonBuilder(). const subscribeButton = new ButtonBuilder().
setStyle(ButtonStyle.Premium). setStyle(ButtonStyle.Premium).
setSKUId("1338672773261951026"); setSKUId("1338596712121499669");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents( const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
subscribeButton, subscribeButton,
); );
-38
View File
@@ -1,38 +0,0 @@
/**
* @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.",
});
};