19 Commits

Author SHA1 Message Date
hikari 8b50d42eae chore: replace .npmrc with pnpm-workspace.yaml
Node.js CI / CI (push) Successful in 30s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m59s
2026-03-02 16:27:04 -08:00
hikari ead5b48023 docs: update feedback section to use support forum
Node.js CI / CI (push) Successful in 24s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m27s
2026-01-26 12:36:57 -08:00
naomi 7e34a98bdf feat: automated upload of .gitea/workflows/ci.yml
Node.js CI / CI (push) Successful in 19s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 59s
2025-12-22 19:42:04 +01:00
naomi ccd46b71d2 feat: automated upload of .gitea/workflows/ci.yml
Node.js CI / CI (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
2025-12-22 19:35:28 +01:00
naomi 7f409a0bde feat: automated upload of .gitea/workflows/ci.yml
Node.js CI / Lint and Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
2025-12-22 19:25:18 +01:00
naomi 56dd81ebf9 feat: automated upload of .npmrc
Node.js CI / Lint and Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
2025-12-22 19:16:13 +01:00
naomi 6a753ac823 feat: automated upload of .gitea/workflows/security.yml
Node.js CI / Lint and Test (push) Successful in 21s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m0s
2025-12-18 03:08:08 +01:00
naomi ed0f8d842c feat: automated upload of .gitea/workflows/security.yml
Node.js CI / Lint and Test (push) Successful in 21s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 59s
2025-12-17 23:26:08 +01:00
naomi 5050ce310f feat: automated upload of .gitea/workflows/security.yml
Node.js CI / Lint and Test (push) Successful in 23s
Security Scan / Security Audit (push) Failing after 5m2s
2025-12-12 03:37:53 +01:00
naomi c9ba2f7fab feat: automated delete of .gitea/workflows/sonar.yml
Node.js CI / Lint and Test (push) Successful in 19s
Security Scan / Trivy Security Scan (push) Failing after 4m46s
2025-12-12 00:15:07 +01:00
naomi 5e306a9f0b feat: automated upload of .gitea/workflows/security.yml
Node.js CI / Lint and Test (push) Successful in 23s
Security Scan / Trivy Security Scan (push) Failing after 4m49s
Code Analysis / SonarQube (push) Failing after 4m46s
2025-12-11 20:12:02 +01:00
naomi 4b563abece feat: add analytics
Code Analysis / SonarQube (push) Failing after 15s
Node.js CI / Lint and Test (push) Successful in 40s
2025-10-08 15:55:20 -07:00
naomi a7f597fb56 fix: broken tag
Code Analysis / SonarQube (push) Failing after 17s
Node.js CI / Lint and Test (push) Successful in 41s
2025-07-04 17:53:16 -07:00
naomi 5c8bab6d37 feat: invite button
Code Analysis / SonarQube (push) Failing after 16s
Node.js CI / Lint and Test (push) Successful in 40s
2025-07-04 17:40:35 -07:00
naomi c8bb2ab0c3 feat: avatar
Node.js CI / Lint and Test (push) Successful in 1m4s
Code Analysis / SonarQube (push) Failing after 1m17s
2025-05-22 19:24:48 -07:00
naomi 14f9d5b4d0 feat: migrate to claude sonnet 4 (#4)
Node.js CI / Lint and Test (push) Successful in 1m0s
Code Analysis / SonarQube (push) Failing after 1m14s
### Explanation

_No response_

### Issue

_No response_

### Attestations

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

### Dependencies

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

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] 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

_No response_

Reviewed-on: nhcarrigan/cordelia-taryne#4
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2025-05-22 11:15:33 -07:00
naomi 61f4fa05c6 fix: correct sku
Node.js CI / Lint and Test (push) Successful in 1m4s
Code Analysis / SonarQube (push) Successful in 1m19s
2025-04-09 09:37:42 -07:00
naomi eb9c1c4a46 chore: add sonar workflow
Node.js CI / Lint and Test (push) Successful in 57s
Code Analysis / SonarQube (push) Successful in 1m10s
2025-02-26 13:28:33 -08:00
naomi bb1c327160 feat: error handling (#3)
Node.js CI / Lint and Test (push) Successful in 38s
### 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

- [ ] 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

_No response_

Reviewed-on: nhcarrigan/cordelia-taryne#3
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2025-02-10 21:47:13 -08:00
17 changed files with 847 additions and 598 deletions
+14 -5
View File
@@ -8,22 +8,31 @@ on:
- main - main
jobs: jobs:
lint: ci:
name: Lint and Test name: CI
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 v22 - name: Use Node.js v24
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
with: with:
version: 9 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
- name: Install Dependencies - name: Install Dependencies
run: pnpm install run: pnpm install
+177
View File
@@ -0,0 +1,177 @@
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 feel free to open a GitHub issue! If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
## Contributing ## Contributing
+9 -8
View File
@@ -14,16 +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.1.0", "@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0", "@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "22.13.1", "@types/node": "22.15.21",
"eslint": "9.20.0", "eslint": "9.27.0",
"typescript": "5.7.3" "typescript": "5.8.3"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.36.3", "@anthropic-ai/sdk": "0.52.0",
"@nhcarrigan/logger": "1.0.0", "@nhcarrigan/discord-analytics": "0.0.6",
"discord.js": "14.18.0", "@nhcarrigan/logger": "1.1.1",
"fastify": "5.2.1" "discord.js": "14.19.3",
"fastify": "5.3.3"
} }
} }
+289 -375
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
# 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,6 +3,7 @@
* @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";
@@ -28,11 +29,30 @@ 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) {
@@ -51,6 +71,7 @@ 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();
+49 -39
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);
}
}
}; };
+73 -64
View File
@@ -8,11 +8,13 @@ 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 type { ImageBlockParam } from "@anthropic-ai/sdk/resources/index.js"; import { logger } from "../utils/logger.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 ImageBlockParam["source"]["media_type"] => { ): type is Base64ImageSource["media_type"] => {
return [ return [
"image/jpg", "image/jpg",
"image/jpeg", "image/jpeg",
@@ -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-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.`,
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);
}
}
}; };
+30 -21
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-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.`, 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);
}
}
}; };
+30 -21
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-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.`, 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);
}
}
}; };
+30 -21
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-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.`, 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);
}
}
}; };
+30 -21
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-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.`, 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);
}
}
}; };
+30 -21
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-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.`, 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);
}
}
}; };
+4
View File
@@ -19,8 +19,12 @@ 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("1338596712121499669"); setSKUId("1338672773261951026");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents( const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
subscribeButton, subscribeButton,
); );
+38
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.",
});
};