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 902 additions and 982 deletions
+5 -14
View File
@@ -8,31 +8,22 @@ on:
- main
jobs:
ci:
name: CI
runs-on: ubuntu-latest
lint:
name: Lint and Test
steps:
- name: Checkout Source Files
uses: actions/checkout@v4
- name: Use Node.js v24
- name: Use Node.js v22
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10
- name: Ensure Dependencies are Pinned
uses: naomi-lgbt/dependency-pin-check@main
with:
language: javascript
dev-dependencies: true
peer-dependencies: true
optional-dependencies: true
version: 9
- name: Install Dependencies
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
-25
View File
@@ -1,25 +0,0 @@
# Package Manager Configuration
# Force pnpm usage - breaks npm/yarn intentionally
node-linker=pnpm
# Security: Disable all lifecycle scripts
ignore-scripts=true
enable-pre-post-scripts=false
# Security: Require packages to be 10+ days old before installation
minimum-release-age=14400
# Security: Verify package integrity hashes
verify-store-integrity=true
# Security: Enforce strict trust policies
trust-policy=strict
# Security: Strict peer dependency resolution
strict-peer-dependencies=true
# Performance: Use symlinks for node_modules
symlink=true
# Lockfile: Ensure lockfile is not modified during install
frozen-lockfile=false
+1 -1
View File
@@ -8,7 +8,7 @@ Cordelia is a multi-purpose AI powered assistant for Discord.
## 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
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "cordelia-taryne",
"version": "1.1.0",
"version": "1.0.0",
"description": "An AI-powered multi-purpose assistant for Discord.",
"main": "index.js",
"type": "module",
@@ -14,17 +14,17 @@
"author": "Naomi Carrigan",
"license": "See license in LICENSE.md",
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/eslint-config": "5.1.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "22.15.21",
"eslint": "10.0.0",
"typescript": "5.8.3"
"@types/node": "22.13.1",
"eslint": "9.20.0",
"typescript": "5.7.3"
},
"dependencies": {
"@anthropic-ai/sdk": "0.52.0",
"@nhcarrigan/discord-analytics": "0.0.6",
"@nhcarrigan/logger": "1.1.1",
"discord.js": "14.19.3",
"fastify": "5.3.3"
"@anthropic-ai/sdk": "0.36.3",
"@nhcarrigan/logger": "1.0.0",
"discord.js": "14.18.0",
"fastify": "5.2.1",
"winston": "3.17.0"
}
}
+641 -383
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -3,7 +3,6 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { DiscordAnalytics } from "@nhcarrigan/discord-analytics";
import { Client, Events, type ChatInputCommandInteraction } from "discord.js";
import { about } from "./modules/about.js";
import { alt } from "./modules/alt.js";
@@ -29,30 +28,11 @@ 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: [],
});
const analytics = new DiscordAnalytics(client, logger);
client.on(Events.InteractionCreate, (interaction) => {
void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction });
if (interaction.isChatInputCommand()) {
const handler = commands[interaction.commandName];
if (handler) {
@@ -71,7 +51,6 @@ client.on(Events.EntitlementDelete, (entitlement) => {
client.on(Events.ClientReady, () => {
void logger.log("debug", "Bot is ready.");
analytics.startCron();
});
instantiateServer();
-10
View File
@@ -13,18 +13,14 @@ 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<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const version = process.env.npm_package_version ?? "Unknown";
@@ -69,10 +65,4 @@ export const about = async(
components: [ row ],
embeds: [ embed ],
});
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("about command", error);
}
}
};
+3 -12
View File
@@ -8,13 +8,11 @@ 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 { Base64ImageSource } from "@anthropic-ai/sdk/resources/index.js";
import type { ImageBlockParam } from "@anthropic-ai/sdk/resources/index.js";
const isValidContentType = (
type: string,
): type is Base64ImageSource["media_type"] => {
): type is ImageBlockParam["source"]["media_type"] => {
return [
"image/jpg",
"image/jpeg",
@@ -33,7 +31,6 @@ const isValidContentType = (
export const alt = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const sub = await isSubscribed(interaction);
if (!sub) {
@@ -100,7 +97,7 @@ export const alt = async(
role: "user",
},
],
model: "claude-sonnet-4-20250514",
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,
});
@@ -116,10 +113,4 @@ export const alt = async(
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);
}
}
};
+1 -10
View File
@@ -8,8 +8,6 @@ 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
@@ -19,7 +17,6 @@ import { replyToError } from "../utils/replyToError.js";
export const evaluate = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const sub = await isSubscribed(interaction);
if (!sub) {
@@ -31,7 +28,7 @@ export const evaluate = async(
// 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",
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,
});
@@ -47,10 +44,4 @@ export const evaluate = async(
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);
}
}
};
+1 -10
View File
@@ -8,8 +8,6 @@ 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
@@ -19,7 +17,6 @@ import { replyToError } from "../utils/replyToError.js";
export const mood = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const sub = await isSubscribed(interaction);
if (!sub) {
@@ -32,7 +29,7 @@ export const mood = async(
// 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",
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,
});
@@ -48,10 +45,4 @@ export const mood = async(
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);
}
}
};
+1 -10
View File
@@ -8,8 +8,6 @@ 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
@@ -19,7 +17,6 @@ import { replyToError } from "../utils/replyToError.js";
export const proofread = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const sub = await isSubscribed(interaction);
if (!sub) {
@@ -32,7 +29,7 @@ export const proofread = async(
// 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",
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,
});
@@ -48,10 +45,4 @@ export const proofread = async(
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);
}
}
};
+1 -10
View File
@@ -8,8 +8,6 @@ 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
@@ -19,7 +17,6 @@ import { replyToError } from "../utils/replyToError.js";
export const query = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const sub = await isSubscribed(interaction);
if (!sub) {
@@ -32,7 +29,7 @@ export const query = async(
// 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",
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,
});
@@ -48,10 +45,4 @@ export const query = async(
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);
}
}
};
+1 -10
View File
@@ -8,8 +8,6 @@ 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
@@ -19,7 +17,6 @@ import { replyToError } from "../utils/replyToError.js";
export const summarise = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const sub = await isSubscribed(interaction);
if (!sub) {
@@ -32,7 +29,7 @@ export const summarise = async(
// 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",
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,
});
@@ -48,10 +45,4 @@ export const summarise = async(
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);
}
}
};
-4
View File
@@ -19,12 +19,8 @@ const html = `<!DOCTYPE html>
<body>
<main>
<h1>Cordelia Taryne</h1>
<img src="https://cdn.nhcarrigan.com/new-avatars/cordelia-full.png" width="250" alt="Cordelia" />
<section>
<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>
<h2>Links</h2>
+1 -1
View File
@@ -26,7 +26,7 @@ export const isSubscribed = async(
if (!isEntitled && interaction.user.id !== "465650873650118659") {
const subscribeButton = new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId("1338672773261951026");
setSKUId("1338596712121499669");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
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.",
});
};