19 Commits

Author SHA1 Message Date
minori a381634a9c deps: update fastify to 5.8.1
Node.js CI / CI (pull_request) Successful in 41s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m42s
2026-03-15 07:02:22 -07:00
hikari a36d706eed feat: new slash commands and bug fixes (#23)
Node.js CI / CI (push) Successful in 29s
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 11m44s
## Summary

- **feat**: Add `/remind` owner-only command — sends a meeting waiting room notification to a specified user in `#general`
- **fix**: Prevent duplicate DM notifications when a message matches both `respondToMention` and `notifyNameMention` patterns
- **feat**: Port `/alt-text` and `/query` commands from Cordelia — owner-only, AI-powered, using Amari's personality
- **feat**: Add `/research` command — owner-only, web-search-backed query returning results as a markdown file attachment
- **fix**: Suppress non-critical RetroAchievements fetch errors (job retries every 10 minutes)

Closes #19, #20, #21, #22
Also resolves #2 (unhandled HTTP rejections from RA API)

Reviewed-on: #23
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-12 23:47:46 -07:00
hikari 8f2cd94c82 fix: strip markdown fences and extract JSON from AI response
Node.js CI / CI (push) Successful in 29s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 56s
2026-03-09 14:14:23 -07:00
hikari ec6a4469e1 feat: replace create-task and create-issue with unified create-ticket (#18)
Node.js CI / CI (push) Successful in 28s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
## Summary

- Removes `/create-task` (LeanTime) and `/create-issue` (Gitea) slash commands
- Introduces `/create-ticket` with a `platform` choice argument: **LeanTime**, **Asana**, **Gitea**, **GitHub**
- User provides only a `description`; the AI generates both the title and a fleshed-out body
- `owner` and `repo` arguments are optional but validated at runtime when Gitea or GitHub is selected
- Adds `ASANA_KEY` to `prod.env` (1Password reference — key needs to be added to vault)
- Command remains restricted to Naomi's user ID

## Test plan

- [ ] Register updated `commands.json` against the Discord API
- [ ] Add `ASANA_KEY` to 1Password vault at `op://Environment Variables - Naomi/Amari/asana key`
- [ ] Test `/create-ticket platform:LeanTime description:...` creates a task on the LeanTime board
- [ ] Test `/create-ticket platform:Asana description:...` creates a task in Naomi's Asana project
- [ ] Test `/create-ticket platform:Gitea owner:nhcarrigan repo:amari description:...` creates a Gitea issue
- [ ] Test `/create-ticket platform:GitHub owner:naomi-lgbt repo:... description:...` creates a GitHub issue
- [ ] Test that omitting `owner`/`repo` for Gitea/GitHub returns a helpful error
- [ ] Verify AI generates a sensible title and description from the raw description

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #18
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 14:09:08 -07:00
hikari e51a56c79f chore: remove news feed feature (#17)
Node.js CI / CI (push) Successful in 28s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 51s
## Summary

- Removes the hourly RSS news posting scheduler and `postNews` module
- Removes the `rss.ts` interface and `lastRssItems` tracking from the `Amari` interface and bot initialisation
- Removes the `news` channel ID from `ids.ts`
- Removes the `rss-parser` dependency

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #17
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 13:34:18 -07:00
hikari 1ebe240475 feat: add slash commands and context menu command (#16)
Node.js CI / CI (push) Successful in 28s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m41s
## Summary

This PR adds a suite of slash commands and a context menu command to Amari, along with shared utilities and quality improvements across the board.

### New Commands
- **`/create-issue`** — generates a GitHub issue on a specified repo using AI-drafted content (title, description, acceptance criteria)
- **`/create-task`** — creates a task in Naomi's Leantime instance with an AI-drafted description and configurable priority
- **`/onboard-mentee`** — automates the mentorship onboarding flow (GitHub invite, forum thread, role assignment)
- **Forward to Owner** (context menu, message command) — forwards any message to Naomi with action buttons (contributed by @teklu)

### Shared Utilities
- **`src/utils/makeAiRequest.ts`** — a single wrapper around the Anthropic SDK for all AI calls, with Amari's personality prompt baked in and full error handling
- **`src/events/handleInteractionCreate.ts`** — extracted interaction handler (was inline in `index.ts`) to keep complexity under control

### Quality Improvements
- `ephemeral: true` → `flags: [ MessageFlags.Ephemeral ]` (deprecated API removed)
- Full `try/catch` + `logger.error` audit across all modules (`logMenteeJoin`, `checkAchievements`, `processMentorshipRole`, `processGitHubEvent`)
- `deployGlobal.ts` replaced with a static `commands.json` payload for manual registration
- Amari's personality prompt updated to reflect her actual character — warm, observant, and relentlessly caring

### Notes
- `CLIENT_ID` is needed in 1Password at `op://Environment Variables - Naomi/Amari/client id` for the `commands.json` registration call
- The forward-to-owner command (PR #13, contributed by @teklu) is fully preserved with original commit authorship

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-authored-by: Teklu <tekluabayneh@gmail.com>
Reviewed-on: #16
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-03 15:05:09 -08:00
hikari 5a355e4775 chore: replace .npmrc with pnpm-workspace.yaml
Node.js CI / CI (push) Successful in 37s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m16s
2026-03-02 16:28:17 -08:00
hikari 5e149a29e4 feat: mentorship improvements and name mention notifications (#14)
Node.js CI / CI (push) Successful in 36s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m27s
## Summary

- **Name/title mention notifications**: Amari now notifies Naomi when a message contains her name, common nicknames (`nomi`, `nao`, `nae`, `naonao`), or honorifics (`goddess`, `queen`, `mistress`, `your/her majesty`, `your/her highness`). Uses the same cooldown logic as mention forwarding.
- **Simplified mentee onboarding**: Replaced the lengthy welcome message with a concise prompt asking the new mentee to ping Naomi with their GitHub username and name.
- **Removed offboard notification**: `logMenteeLeave` now only logs a metric silently — no more "user must be offboarded" messages in the channel.
- **Deduplicated welcome messages**: Welcomed mentee IDs are persisted to `data/welcomed.txt` so the onboarding message is only ever sent once, even if the role is re-assigned.

## Test plan

- [ ] Assign mentorship role to a user and confirm the new onboarding message appears
- [ ] Re-assign the role to the same user and confirm no duplicate message is sent
- [ ] Remove a mentee from the server and confirm no offboard message is posted
- [ ] Send a message containing a matched name/honorific and confirm Naomi receives a DM forwarding it

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #14
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-02 16:03:14 -08:00
minori 5c39d3d9ce Merge pull request 'deps: update octokit to 5.0.5' (#6) from dependencies/update-octokit into main
Node.js CI / CI (push) Successful in 37s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m47s
2026-03-01 07:02:28 -08:00
minori 1281f3217a Merge pull request 'deps: update fastify to 5.7.1' (#5) from dependencies/update-fastify into main
Node.js CI / CI (push) Successful in 41s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m8s
2026-02-27 07:02:43 -08:00
minori 3e5dfa2799 Merge pull request 'deps: update typescript to 5.9.3' (#9) from dependencies/update-typescript into main
Node.js CI / CI (push) Successful in 40s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m27s
2026-02-26 07:03:02 -08:00
minori dc0fdcc659 Merge pull request 'deps: update @retroachievements/api to 2.9.1' (#3) from dependencies/update--retroachievements-api into main
Node.js CI / CI (push) Successful in 36s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m18s
2026-02-23 20:46:21 -08:00
minori 2198126d1b deps: update @retroachievements/api to 2.10.0
Node.js CI / CI (pull_request) Successful in 44s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m16s
2026-02-14 07:05:09 -08:00
minori 40288728e8 deps: update fastify to 5.7.4
Node.js CI / CI (pull_request) Successful in 33s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m14s
2026-02-13 07:07:36 -08:00
minori 71bcd8e4ff deps: update fastify to 5.7.2
Node.js CI / CI (pull_request) Successful in 44s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 2m12s
2026-02-05 07:11:59 -08:00
minori cbe95550ea deps: update typescript to 5.9.3
Node.js CI / CI (pull_request) Successful in 32s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m28s
2026-02-04 08:36:59 -08:00
minori b2e49b8956 deps: update octokit to 5.0.5
Node.js CI / CI (pull_request) Successful in 36s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m31s
2026-02-04 08:36:37 -08:00
minori 49ab177669 deps: update fastify to 5.7.1
Node.js CI / CI (pull_request) Successful in 38s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m29s
2026-02-04 08:36:30 -08:00
minori a777989980 deps: update @retroachievements/api to 2.9.1
Node.js CI / CI (pull_request) Successful in 34s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m33s
2026-02-04 08:36:13 -08:00
34 changed files with 1901 additions and 778 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules
prod
data/welcomed.txt
-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
+147
View File
@@ -0,0 +1,147 @@
# Amari — Claude Development Guide
Amari is Naomi's personal Discord bot assistant, built with TypeScript and Discord.js. It manages mentorship programmes, processes form submissions, posts RSS news feeds, handles GitHub webhooks, tracks RetroAchievements, and provides various automation features for the NHCarrigan Discord server.
## Stack
- **Language**: TypeScript (ESM)
- **Runtime**: Node.js v24
- **Package Manager**: pnpm 10.15.0
- **Discord Library**: discord.js 14.x (Components V2)
- **HTTP Server**: Fastify 5.x (port 7044)
- **Secrets**: 1Password CLI (`op run --env-file=prod.env -- ...`)
- **Linting**: `@nhcarrigan/eslint-config` (ESLint 9 flat config)
- **TypeScript Config**: `@nhcarrigan/typescript-config`
- **Logging/Metrics**: `@nhcarrigan/logger` (sends to alert server via `LOG_TOKEN`)
- **CI**: Gitea Actions (`.gitea/workflows/`)
## Project Structure
```
src/
├── config/ # Hardcoded IDs and static config
│ ├── forms.ts # Baserow form ID → name mapping
│ ├── ids.ts # Discord channel/guild/role/user/tag IDs
│ ├── progressReminders.ts # Daily reminder channel configs
│ └── responses.ts # DM response template
├── events/ # Discord event handlers
│ └── handleMessageCreate.ts
├── interfaces/ # TypeScript type definitions
│ ├── amari.ts # Core Amari interface
│ ├── baserow.ts # Mentorship form row type
│ ├── formSubmission.ts
│ ├── github.ts # GitHub webhook payload types
│ ├── reminder.ts # ProgressReminder type
│ └── rss.ts # RSS feed types
├── modules/ # Feature implementations (one feature per file)
│ ├── cacheData.ts
│ ├── checkAchievements.ts
│ ├── getForumTagId.ts
│ ├── logMenteeJoin.ts
│ ├── logMenteeLeave.ts
│ ├── postNews.ts
│ ├── postProgressReminders.ts
│ ├── processFormSubmission.ts
│ ├── processGitHubEvent.ts
│ ├── processMentorshipRole.ts
│ ├── processUserGuildTag.ts
│ ├── respondToDm.ts
│ ├── respondToMention.ts
│ └── updateMentorshipThread.ts
├── server/
│ └── serve.ts # Fastify HTTP server (port 7044)
├── utils/
│ ├── getComponentsForNaomi.ts # Discord Components V2 helpers
│ └── logger.ts # Logger singleton
└── index.ts # Entry point: bot init, scheduled jobs, timers
```
## Key External Integrations
| Integration | Purpose | Secret(s) |
|---|---|---|
| Discord | Bot core | `BOT_TOKEN` |
| Discord Analytics | Guild analytics (cron + gateway event logging) | — |
| GitHub App | Webhook processing, auto-assign | `GH_CLIENT_ID`, `GH_CLIENT_SECRET`, `GH_PRIVATE_KEY`, `GH_WEBHOOK_SECRET` |
| Baserow | Mentorship form submissions (**deprecated — to be removed**) | `BASEROW_SECRET`, `BASEROW_TOKEN` |
| RetroAchievements | Achievement tracking | `RA_KEY` |
| Alert Server | Logging/metrics | `LOG_TOKEN` |
## Scheduled Tasks (node-schedule)
| Cron | Task |
|---|---|
| `0 * * * *` | Post RSS news (freeCodeCamp + HackerNews) |
| `0 0 * * *` | Audit guild tags across all members |
| `0 9 * * 1-5` | Post progress reminders (weekdays 9am) |
| Every 10 min | Clear active channels set + check RetroAchievements |
## Features
### Mentorship Management
- Onboarding message when mentorship role is assigned
- Forum thread tag management ("waiting on member" / "waiting on naomi")
- Progress reminder posts in freeCodeCamp sprint channels (optionally creates threads)
- Mentee join/leave logging
### News Feed
- Hourly RSS fetch from freeCodeCamp News and HackerNews
- Comprehensive content filter (violence, harassment, drugs, piracy, fraud, etc.)
- Posts up to 5 new items per feed, crossposts to announcement channels
### GitHub Integration
- Webhook receiver at `POST /github`
- Auto-assigns new issues to `naomi-lgbt`
- Auto-requests review from `naomi-lgbt` on new PRs
### Form Processing
- Webhook receiver at `POST /form` (Baserow)
- Validates `BASEROW_SECRET`
- Posts submission notification with "Resolve" button to forms channel
### RetroAchievements
- Polls every 10 minutes for achievements earned in last 10 minutes
- Posts formatted achievement announcements using Discord Components V2
### DM / Mention Forwarding
- Non-Naomi DMs are forwarded to Naomi with Components V2 action buttons
- Mentions of Naomi or NHCarrigan are forwarded (with cooldown based on recent channel activity)
### Guild Identity
- Monitors user updates for Discord guild identity (profile decoration)
- Grants/revokes "representing" role accordingly
## Important Discord IDs
All hardcoded in `src/config/ids.ts`. The primary guild is NHCarrigan (`1354624415861833870`).
Key user IDs:
- **Naomi**: defined in `ids.ts`
- **Amari (bot)**: defined in `ids.ts`
## Scripts
```bash
source ~/.nvm/nvm.sh && pnpm build # Compile TypeScript → prod/
source ~/.nvm/nvm.sh && pnpm lint # ESLint (0 warnings allowed)
op run --env-file=prod.env -- node prod/index.js # Run with secrets
```
## Tests
Currently no test suite exists (`test` script is a placeholder). When adding tests:
- Use **Vitest** per Naomi's Node.js standards
- Mock Discord.js — do not make real API calls in tests
- Test files go in `test/` directory, not `src/`
## Notes & Conventions
- **Components V2**: The bot uses Discord's newer Components V2 API for message formatting. Keep this consistent.
- **Module pattern**: Each discrete feature lives in its own file under `src/modules/`. Add new features here, not in `index.ts`.
- **`src/config/ids.ts`**: All Discord snowflakes are hardcoded here. Add new IDs to this file rather than scattering them.
- **Metrics logging**: All significant user-facing actions should log a metric via the logger. Follow existing patterns.
- **Webhook security**: Both GitHub and Baserow webhooks validate secrets before processing. Never skip this validation.
- **`prod.env`**: Contains only 1Password references (safe to commit). Never put real secrets here.
- **GitHub App Installation ID** (`83119105`) is intentionally hardcoded in `index.ts`.
- **Server port** (`7044`) is intentionally hardcoded in `src/server/serve.ts`.
- **Baserow integration** (`src/modules/logMenteeJoin.ts`, `processFormSubmission.ts`, `src/interfaces/baserow.ts`, `src/config/forms.ts`) is deprecated and should be removed when the time comes.
+122
View File
@@ -0,0 +1,122 @@
[
{
"name": "alt-text",
"type": 1,
"description": "Generate descriptive alt-text for an image.",
"options": [
{
"name": "image",
"description": "The image to generate alt-text for.",
"type": 11,
"required": true
}
]
},
{
"name": "create-ticket",
"type": 1,
"description": "Creates a ticket on the chosen platform with an AI-generated title and description.",
"options": [
{
"name": "platform",
"description": "The platform to create the ticket on.",
"type": 3,
"required": true,
"choices": [
{ "name": "LeanTime", "value": "leantime" },
{ "name": "Asana", "value": "asana" },
{ "name": "Gitea", "value": "gitea" },
{ "name": "GitHub", "value": "github" }
]
},
{
"name": "description",
"description": "Describe what you need. The AI will generate a title and full description.",
"type": 3,
"required": true
},
{
"name": "owner",
"description": "The repository owner (required for Gitea/GitHub).",
"type": 3,
"required": false
},
{
"name": "repo",
"description": "The repository name (required for Gitea/GitHub).",
"type": 3,
"required": false
}
]
},
{
"name": "onboard-mentee",
"type": 1,
"description": "Onboards a new mentee by setting up their GitHub repository.",
"options": [
{
"name": "mentee_name",
"description": "The mentee's full name.",
"type": 3,
"required": true
},
{
"name": "github_username",
"description": "The mentee's GitHub username.",
"type": 3,
"required": true
},
{
"name": "mentee",
"description": "The mentee's Discord account.",
"type": 6,
"required": true
}
]
},
{
"name": "remind",
"type": 1,
"description": "Sends a meeting reminder notification to the specified user.",
"options": [
{
"name": "user",
"description": "The user to send the meeting reminder to.",
"type": 6,
"required": true
}
]
},
{
"name": "research",
"type": 1,
"description": "Research a topic using web search.",
"options": [
{
"name": "prompt",
"description": "The topic or question to research.",
"type": 3,
"required": true,
"max_length": 2000
}
]
},
{
"name": "query",
"type": 1,
"description": "Ask Amari a question.",
"options": [
{
"name": "prompt",
"description": "The question you would like to ask.",
"type": 3,
"required": true,
"max_length": 2000
}
]
},
{
"name": "Forward to Naomi",
"type": 3
}
]
View File
+5 -5
View File
@@ -20,16 +20,16 @@
"@types/node": "24.3.0",
"@types/node-schedule": "2.1.8",
"eslint": "9.33.0",
"typescript": "5.9.2"
"typescript": "5.9.3"
},
"dependencies": {
"@anthropic-ai/sdk": "0.78.0",
"@nhcarrigan/discord-analytics": "0.0.6",
"@nhcarrigan/logger": "1.1.1",
"@retroachievements/api": "2.6.0",
"@retroachievements/api": "2.10.0",
"discord.js": "14.22.0",
"fastify": "5.5.0",
"fastify": "5.8.1",
"node-schedule": "2.1.1",
"octokit": "5.0.3",
"rss-parser": "3.13.0"
"octokit": "5.0.5"
}
}
+271 -271
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
+4
View File
@@ -7,3 +7,7 @@ GH_WEBHOOK_SECRET="op://Environment Variables - Naomi/Amari/gh webhook secret"
BASEROW_SECRET="op://Environment Variables - Naomi/Amari/baserow hook auth"
BASEROW_TOKEN="op://Environment Variables - Naomi/Amari/baserow token"
RA_KEY="op://Environment Variables - Naomi/Amari/retroachievements key"
LEANTIME_KEY="op://Environment Variables - Naomi/Amari/leantime key"
GITEA_KEY="op://Environment Variables - Naomi/Amari/gitea key"
ASANA_KEY="op://Environment Variables - Naomi/Amari/asana key"
ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key"
+153
View File
@@ -0,0 +1,153 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js";
import { anthropic } from "../utils/anthropic.js";
import { logger } from "../utils/logger.js";
import { amariPersonality } from "../utils/makeAiRequest.js";
import type { Amari } from "../interfaces/amari.js";
type ValidImageMediaType =
| "image/gif"
| "image/jpeg"
| "image/png"
| "image/webp";
const validImageTypes = new Set<string>([
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
]);
const isValidContentType = (type: string): type is ValidImageMediaType => {
return validImageTypes.has(type);
};
const altTextSystemPrompt = `${amariPersonality}\n\nYour 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.";
/**
* Downloads an image and asks Claude to generate alt-text for it.
* @param imageUrl - The URL of the image to process.
* @param contentType - The validated MIME type of the image.
* @returns The generated alt-text, or null if the request fails.
*/
const generateAltText = async(
imageUrl: string,
contentType: ValidImageMediaType,
): Promise<string | null> => {
const downloadRequest = await fetch(imageUrl);
const blob = await downloadRequest.arrayBuffer();
const base64 = Buffer.from(blob).toString("base64");
const response = await anthropic.messages.create({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field.
max_tokens: 2000,
messages: [
{
content: [
{
source: {
data: base64,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field.
media_type: contentType,
type: "base64",
},
type: "image",
},
],
role: "user",
},
],
model: "claude-sonnet-4-6",
system: altTextSystemPrompt,
});
const textContent = response.content.find((block) => {
return block.type === "text";
});
return textContent?.type === "text"
? textContent.text
: null;
};
/**
* Validates the attachment is a supported image, downloads it, and sends it
* to Claude to generate descriptive alt-text.
* @param _amari - The Amari instance (unused but kept for handler consistency).
* @param interaction - The Discord slash command interaction.
*/
// eslint-disable-next-line max-lines-per-function, complexity -- Image validation requires multiple checks.
export const altText = async(
_amari: Amari,
interaction: ChatInputCommandInteraction,
): Promise<void> => {
if (interaction.user.id !== ids.users.naomi) {
await interaction.reply({
content: "This command is restricted to Naomi.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
const image = interaction.options.getAttachment("image", true);
const { contentType, height, width, size, url } = image;
if (
contentType === null
|| !isValidContentType(contentType)
|| height === null
|| width === null
) {
await interaction.reply({
content: "That does not appear to be a valid image.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
if (size > 5 * 1024 * 1024) {
await interaction.reply({
// eslint-disable-next-line stylistic/max-len -- Long user-facing string.
content: "That image is too large. Please provide an image that is less than 5MB.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
if (height > 8000 || width > 8000) {
await interaction.reply({
// eslint-disable-next-line stylistic/max-len -- Long user-facing string.
content: "That image is too large. Please provide an image less than 8000 pixels in either dimension.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
try {
const result = await generateAltText(url, contentType);
await interaction.editReply({
content: result
?? "I'm sorry, I wasn't able to generate alt-text for that image.",
});
} catch (error) {
await logger.error("alt-text command", error instanceof Error
? error
: new Error(String(error)));
await interaction.editReply({
content: "Something went wrong whilst generating the alt-text.",
});
}
};
+333
View File
@@ -0,0 +1,333 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js";
import { makeAiRequest } from "../utils/makeAiRequest.js";
import type { Amari } from "../interfaces/amari.js";
interface GeneratedTicket {
body: string;
title: string;
}
interface LeantimeResponse {
error?: { message: string };
result?: number;
}
interface GiteaIssueResponse {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field.
html_url: string;
number: number;
}
interface AsanaTaskResponse {
data: {
gid: string;
// eslint-disable-next-line @typescript-eslint/naming-convention -- Asana API field.
permalink_url: string;
};
}
interface PostOptions {
body: string;
owner: string;
repo: string;
title: string;
}
interface RepoTicketOptions extends PostOptions {
amari: Amari;
}
interface TicketRouteOptions {
amari: Amari;
owner: string;
platform: string;
repo: string;
}
interface RepoValidationContext {
interaction: ChatInputCommandInteraction;
owner: string;
platform: string;
repo: string;
}
const ticketSystemPrompt = "Generate a well-structured ticket. Return ONLY a"
+ " valid JSON object with exactly two keys: \"title\" (a concise title"
+ " under 80 characters) and \"body\" (a detailed markdown description"
+ " with relevant sections such as Description and Acceptance Criteria)."
+ " No extra text or formatting outside the JSON object.";
/**
* Generates an AI title and body for a ticket from a raw description.
* @param description - The user's raw description of what they need.
* @param platform - The target platform for context.
* @returns A generated ticket with title and body, or null on failure.
*/
const generateTicket = async(
description: string,
platform: string,
): Promise<GeneratedTicket | null> => {
const result = await makeAiRequest({
maxTokens: 1000,
systemPrompt: ticketSystemPrompt,
userMessage: `Platform: ${platform}\n\nUser description: ${description}`,
});
if (result === null) {
return null;
}
try {
// Strip markdown code fences the model may wrap the JSON in.
const stripped = result.replaceAll(/```(?:json)?\s*/gu, "").trim();
// Extract the outermost JSON object in case of surrounding prose.
const jsonMatch = /\{[\s\S]*\}/u.exec(stripped);
if (jsonMatch === null) {
await logger.error(
"generateTicket",
new Error(`Non-JSON AI response: ${result.slice(0, 200)}`),
);
return null;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Parsing known AI JSON output.
return JSON.parse(jsonMatch[0]) as GeneratedTicket;
} catch (error) {
if (error instanceof Error) {
await logger.error("generateTicket", error);
}
return null;
}
};
/**
* Posts a task to LeanTime via JSON-RPC.
* @param title - The task headline.
* @param body - The task description.
* @returns A URL to the created task.
*/
const postToLeantime = async(title: string, body: string): Promise<string> => {
const response = await fetch("https://board.nhcarrigan.com/api/jsonrpc", {
body: JSON.stringify({
id: `amari-task-${Date.now().toString()}`,
jsonrpc: "2.0",
method: "leantime.rpc.tickets.addTicket",
params: {
values: {
description: body,
editorId: "1",
headline: title,
priority: "3",
projectId: "1",
type: "task",
},
},
}),
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
"Content-Type": "application/json",
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
"x-api-key": process.env.LEANTIME_KEY ?? "",
},
method: "POST",
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output.
const data = await response.json() as LeantimeResponse;
if (data.error !== undefined) {
throw new Error(data.error.message);
}
return data.result === undefined
? "https://board.nhcarrigan.com"
: `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${data.result.toString()}`;
};
/**
* Posts a task to Asana.
* @param title - The task name.
* @param body - The task notes.
* @returns A URL to the created Asana task.
*/
const postToAsana = async(title: string, body: string): Promise<string> => {
const response = await fetch(
"https://app.asana.com/api/1.0/tasks?opt_fields=gid,permalink_url",
{
body: JSON.stringify({
data: {
name: title,
notes: body,
projects: [ "1210018361945076" ],
},
}),
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
"Authorization": `Bearer ${process.env.ASANA_KEY ?? ""}`,
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!response.ok) {
throw new Error(await response.text());
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output.
const data = await response.json() as AsanaTaskResponse;
return data.data.permalink_url;
};
/**
* Posts an issue to Gitea.
* @param options - The repository and content details.
* @returns A URL to the created Gitea issue.
*/
const postToGitea = async(options: PostOptions): Promise<string> => {
const response = await fetch(
`https://git.nhcarrigan.com/api/v1/repos/${options.owner}/${options.repo}/issues`,
{
body: JSON.stringify({ body: options.body, title: options.title }),
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
"Authorization": `Bearer ${process.env.GITEA_KEY ?? ""}`,
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!response.ok) {
throw new Error(await response.text());
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output.
const data = await response.json() as GiteaIssueResponse;
return data.html_url;
};
/**
* Posts an issue to GitHub using the authenticated app octokit.
* @param options - The Amari instance, repository, and content details.
* @returns A URL to the created GitHub issue.
*/
const postToGitHub = async(options: RepoTicketOptions): Promise<string> => {
const { data } = await options.amari.github.rest.issues.create({
body: options.body,
owner: options.owner,
repo: options.repo,
title: options.title,
});
return data.html_url;
};
/**
* Validates that owner and repo are provided when required by the platform.
* Replies with an error if validation fails.
* @param context - The validation context including platform, owner, repo, and interaction.
* @returns True if validation passes, false if an error reply was sent.
*/
const validateRepoArguments = async(
context: RepoValidationContext,
): Promise<boolean> => {
if (context.platform !== "gitea" && context.platform !== "github") {
return true;
}
if (context.owner !== "" && context.repo !== "") {
return true;
}
const platformLabel = context.platform === "gitea"
? "Gitea"
: "GitHub";
await context.interaction.reply({
content: `❌ The \`owner\` and \`repo\` arguments are required for ${platformLabel}.`,
flags: [ MessageFlags.Ephemeral ],
});
return false;
};
/**
* Routes a generated ticket to the correct platform and logs the metric.
* @param ticket - The AI-generated ticket content.
* @param options - Routing context including platform, owner, repo, and Amari instance.
* @returns A URL to the created ticket.
*/
const routeTicket = async(
ticket: GeneratedTicket,
options: TicketRouteOptions,
): Promise<string> => {
const { amari, owner, platform, repo } = options;
const { body, title } = ticket;
if (platform === "leantime") {
await logger.metric("created_ticket", 1, { platform, title });
return await postToLeantime(title, body);
}
if (platform === "asana") {
await logger.metric("created_ticket", 1, { platform, title });
return await postToAsana(title, body);
}
const repository = `${owner}/${repo}`;
if (platform === "gitea") {
await logger.metric("created_ticket", 1, { platform, repository, title });
return await postToGitea({ body, owner, repo, title });
}
await logger.metric("created_ticket", 1, { platform, repository, title });
return await postToGitHub({ amari, body, owner, repo, title });
};
/**
* Creates a ticket on the specified platform using an AI-generated title and body.
* @param amari - The Amari instance.
* @param interaction - The Discord slash command interaction.
*/
export const createTicket = async(
amari: Amari,
interaction: ChatInputCommandInteraction,
): Promise<void> => {
if (interaction.user.id !== ids.users.naomi) {
await interaction.reply({
content: "This command is restricted to Naomi.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
const platform = interaction.options.getString("platform", true);
const description = interaction.options.getString("description", true);
const { owner, repo } = {
owner: interaction.options.getString("owner") ?? "",
repo: interaction.options.getString("repo") ?? "",
};
if (!await validateRepoArguments({ interaction, owner, platform, repo })) {
return;
}
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const ticket = await generateTicket(description, platform);
if (ticket === null) {
await interaction.editReply({
content: "❌ Failed to generate ticket content from AI.",
});
return;
}
try {
const url = await routeTicket(ticket, { amari, owner, platform, repo });
await interaction.editReply({
content: `✅ Ticket created: **${ticket.title}**\n${url}`,
});
} catch (error) {
if (error instanceof Error) {
await logger.error("createTicket command", error);
}
const errorMessage = error instanceof Error
? error.message
: "Unknown error";
await interaction.editReply({
content: `❌ Failed to create ticket: ${errorMessage}`,
});
}
};
+24 -57
View File
@@ -5,48 +5,22 @@
*/
import {
ContextMenuCommandBuilder,
ApplicationCommandType,
type MessageContextMenuCommandInteraction,
DiscordAPIError,
ButtonBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonStyle,
type Message,
MessageFlags,
type MessageContextMenuCommandInteraction,
} from "discord.js";
import { ids } from "../config/ids.js";
import { getComponentsForNaomi } from "../utils/getComponentsForNaomi.js";
import { logger } from "../utils/logger.js";
const buildForwardedEmbed = (message: Message): EmbedBuilder => {
const forwardedEmbed = new EmbedBuilder().
setColor(0x58_65_F2).
setTitle(`Message from ${String(message.author.tag)}!`).
setDescription(
`${(message.attachments.size > 0
? `**Attachments:** ${String(message.attachments.size)}
file(s)\n\n`
: "\n")
+ (message.embeds.length > 0
? `**Embeds:** ${String(message.embeds.length)}\n\n`
: "")}
\n${message.content}\n\n`,
);
return forwardedEmbed;
};
const buildViewButtonFunction = (message: Message): ButtonBuilder => {
const viewButton = new ButtonBuilder().
setLabel("View Message").
setURL(message.url).
setStyle(ButtonStyle.Link);
return viewButton;
};
const data = new ContextMenuCommandBuilder().setName("Forward to Naomi").
setType(ApplicationCommandType.Message);
const execute = async(interaction: MessageContextMenuCommandInteraction):
Promise<void> => {
await interaction.deferReply({ ephemeral: true });
/**
* Forwards a message to Naomi via DM using a context menu command.
* @param interaction -- The message context menu interaction.
*/
const forwardToOwner = async(
interaction: MessageContextMenuCommandInteraction,
): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
if (interaction.user.id !== ids.users.naomi) {
await interaction.editReply("❌ Only Naomi can use this command.");
@@ -63,33 +37,26 @@ Promise<void> => {
try {
const naomi = await interaction.client.users.fetch(ids.users.naomi);
const forwardedEmbed = buildForwardedEmbed(message);
const viewButton = buildViewButtonFunction(message);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
viewButton,
);
await naomi.send({
components: [ row ],
embeds: [ forwardedEmbed ],
files: message.attachments.map((att) => {
return att.url;
}),
});
await interaction.editReply({
content: "✅ Forwarded to your DMs!",
components: getComponentsForNaomi(
message.author,
message.content,
message.url,
),
flags: [ MessageFlags.IsComponentsV2 ],
});
await logger.metric("forwarded_message", 1, { user: message.author.id });
await interaction.editReply({ content: "✅ Forwarded to your DMs!" });
} catch (error) {
let replyText = "❌ Failed to forward message.";
if (error instanceof DiscordAPIError && error.code === 50_007) {
replyText = `${replyText} (Naomi's DMs might be closed)`;
}
if (error instanceof Error) {
await logger.error("forwardToOwner command", error);
}
await interaction.editReply(replyText);
}
};
export const forwardOwnerDM = {
data,
execute,
};
export { forwardToOwner };
+102
View File
@@ -0,0 +1,102 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js";
/**
* Creates a mentee repository and configures collaborator access.
* @param amari - The Amari instance.
* @param githubUsername - The mentee's GitHub username.
* @returns The URL of the created or existing repository.
*/
const setupMenteeRepository = async(
amari: Amari,
githubUsername: string,
): Promise<string> => {
const orgApps = amari.githubApp.octokit.rest.apps;
const { data: installation } = await orgApps.getOrgInstallation({
org: "nhcarrigan-mentorship",
});
const mentorshipOctokit
= await amari.githubApp.getInstallationOctokit(installation.id);
let repoUrl = `https://github.com/nhcarrigan-mentorship/${githubUsername}`;
try {
const { data: repoData } = await mentorshipOctokit.rest.repos.createInOrg({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit API field.
auto_init: true,
name: githubUsername,
org: "nhcarrigan-mentorship",
});
repoUrl = repoData.html_url;
} catch {
// Repo likely already exists - use the default URL.
}
await mentorshipOctokit.rest.repos.addCollaborator({
owner: "nhcarrigan-mentorship",
permission: "maintain",
repo: githubUsername,
username: githubUsername,
});
return repoUrl;
};
/**
* Onboards a new mentee by creating their GitHub repository and notifying them.
* @param amari - The Amari instance.
* @param interaction - The Discord slash command interaction.
*/
export const onboardMentee = async(
amari: Amari,
interaction: ChatInputCommandInteraction,
): Promise<void> => {
if (interaction.user.id !== ids.users.naomi) {
await interaction.reply({
content: "This command is restricted to Naomi.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
const menteeName = interaction.options.getString("mentee_name", true);
const githubUsername = interaction.options.getString("github_username", true);
const menteeUser = interaction.options.getUser("mentee", true);
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
try {
const repoUrl = await setupMenteeRepository(amari, githubUsername);
const channel
= amari.discord.channels.cache.get(ids.channels.menteeChat)
?? await amari.discord.channels.fetch(ids.channels.menteeChat);
if (channel?.isSendable() !== true) {
await interaction.editReply({
content: "Repo created but could not send Discord notification.",
});
return;
}
await channel.send({
content: `Hey <@${menteeUser.id}>! I've created your mentorship repository: ${repoUrl}\n\nYou should have received an invitation to collaborate - please accept it to get started!`,
});
await logger.metric("onboarded_mentee", 1, { mentee: menteeName });
await interaction.editReply({
content: `✅ Successfully onboarded **${menteeName}**!\nRepository: ${repoUrl}`,
});
} catch (error) {
await logger.error("onboardmentee command", error instanceof Error
? error
: new Error(String(error)));
await interaction.editReply({
content: `❌ Failed to onboard mentee: ${String(error)}`,
});
}
};
+57
View File
@@ -0,0 +1,57 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js";
import { makeAiRequest } from "../utils/makeAiRequest.js";
import type { Amari } from "../interfaces/amari.js";
const fallbackResponse = "I'm sorry, I don't have an answer for that."
+ " Please try again later.";
/**
* Accepts an arbitrary question and sends it to Claude to be answered.
* @param _amari - The Amari instance (unused but kept for handler consistency).
* @param interaction - The Discord slash command interaction.
*/
export const query = async(
_amari: Amari,
interaction: ChatInputCommandInteraction,
): Promise<void> => {
if (interaction.user.id !== ids.users.naomi) {
await interaction.reply({
content: "This command is restricted to Naomi.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
const prompt = interaction.options.getString("prompt", true);
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
try {
const response = await makeAiRequest({
maxTokens: 2000,
systemPrompt: "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.",
userMessage: prompt,
});
await interaction.editReply({
content: response ?? fallbackResponse,
});
} catch (error) {
await logger.error("query command", error instanceof Error
? error
: new Error(String(error)));
await interaction.editReply({
content: "Something went wrong whilst processing your question.",
});
}
};
+61
View File
@@ -0,0 +1,61 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js";
/**
* Sends a meeting reminder notification to the general channel for the given user.
* @param amari - The Amari instance.
* @param interaction - The Discord slash command interaction.
*/
export const remind = async(
amari: Amari,
interaction: ChatInputCommandInteraction,
): Promise<void> => {
if (interaction.user.id !== ids.users.naomi) {
await interaction.reply({
content: "This command is restricted to Naomi.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
const targetUser = interaction.options.getUser("user", true);
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
try {
const channel
= amari.discord.channels.cache.get(ids.channels.general)
?? await amari.discord.channels.fetch(ids.channels.general);
if (channel?.isSendable() !== true) {
await interaction.editReply({
content: "Could not send the meeting reminder.",
});
return;
}
await channel.send({
content: `Heya <@${targetUser.id}>~!\n\nIt looks like you have a meeting scheduled with Naomi soon. Whenever you are ready, please wait in <#1396976351201726484>. Naomi should be available around the time your meeting starts. Once she is prepared, she will drag you into her <#1396976542856384652> where just the two of you will be.`,
});
await logger.metric("meeting_reminder_sent", 1, { user: targetUser.id });
await interaction.editReply({
content: `✅ Meeting reminder sent to **${targetUser.username}**!`,
});
} catch (error) {
await logger.error("remind command", error instanceof Error
? error
: new Error(String(error)));
await interaction.editReply({
content: `❌ Failed to send meeting reminder: ${String(error)}`,
});
}
};
+144
View File
@@ -0,0 +1,144 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
AttachmentBuilder,
MessageFlags,
type ChatInputCommandInteraction,
} from "discord.js";
import { ids } from "../config/ids.js";
import { anthropic } from "../utils/anthropic.js";
import { logger } from "../utils/logger.js";
import { amariPersonality } from "../utils/makeAiRequest.js";
import type { Amari } from "../interfaces/amari.js";
import type { ContentBlock } from "@anthropic-ai/sdk/resources/messages.js";
const researchSystemPrompt = `${amariPersonality}\n\nYour role in this`
+ " conversation is to research the user's query thoroughly using web search."
+ " Provide a comprehensive, well-structured response with cited sources."
+ " Format your response clearly with headers and sections where"
+ " appropriate.";
/**
* Formats a single content block into a markdown string.
* @param block - The content block to format.
* @returns A formatted markdown string.
*/
const formatBlock = (block: ContentBlock): string => {
if (block.type === "text") {
if ((block.citations?.length ?? 0) > 0) {
const sources = (block.citations ?? []).
filter((citation) => {
return citation.type === "web_search_result_location";
}).
map((citation) => {
return `- [${citation.title ?? "Unknown Title"}](${citation.url})`;
}).
join("\n");
return `${block.text}\n\n**Sources:**\n${sources}`;
}
return block.text;
}
if (block.type === "server_tool_use") {
return `> 🔍 *Searching: ${JSON.stringify(block.input)}*`;
}
if (block.type === "web_search_tool_result") {
if (!Array.isArray(block.content)) {
return "";
}
const links = block.content.
map((entry) => {
return `[${entry.title}](${entry.url})`;
}).
join(", ");
return `> 📄 *Found: ${links}*`;
}
if (block.type === "thinking") {
return `> 💭 *${block.thinking}*`;
}
return "";
};
/**
* Builds a markdown buffer from the research prompt and API response content.
* @param prompt - The original research prompt.
* @param content - The content blocks returned from the API.
* @returns A Buffer containing the formatted markdown.
*/
const buildMarkdownFile = (
prompt: string,
content: Array<ContentBlock>,
): Buffer => {
const markdown = [
`# Research: ${prompt}`,
"",
...content.
map((block) => {
return formatBlock(block);
}).
filter((block) => {
return block.length > 0;
}),
].join("\n\n");
return Buffer.from(markdown, "utf-8");
};
/**
* Runs a web-search-backed research query and returns the result as a
* markdown file attachment.
* @param _amari - The Amari instance (unused but kept for handler consistency).
* @param interaction - The Discord slash command interaction.
*/
export const research = async(
_amari: Amari,
interaction: ChatInputCommandInteraction,
): Promise<void> => {
if (interaction.user.id !== ids.users.naomi) {
await interaction.reply({
content: "This command is restricted to Naomi.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
const prompt = interaction.options.getString("prompt", true);
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
try {
const response = await anthropic.messages.create({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field.
max_tokens: 3000,
messages: [ { content: prompt, role: "user" } ],
model: "claude-sonnet-4-6",
system: researchSystemPrompt,
tools: [ {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field.
max_uses: 5,
name: "web_search",
type: "web_search_20250305",
} ],
});
const file = new AttachmentBuilder(
buildMarkdownFile(prompt, response.content),
{ name: "research.md" },
);
await logger.metric("research_query", 1, { user: interaction.user.id });
await interaction.editReply({
content: "Here are your research results!",
files: [ file ],
});
} catch (error) {
await logger.error("research command", error instanceof Error
? error
: new Error(String(error)));
await interaction.editReply({
content: "Something went wrong whilst running the research query.",
});
}
};
-1
View File
@@ -20,7 +20,6 @@ export const ids = {
mentorshipGoalForum: "1400629118110011526",
mentorshipProjectForum: "1400616702265266186",
naomiDiscussionForum: "1408154690121633917",
news: "1407804798677418198",
partnershipRequests: "1451009066355654829",
policyIdeation: "1417294974046965842",
pressInquiries: "1451011543482368163",
+100
View File
@@ -0,0 +1,100 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
MessageFlags,
type ChatInputCommandInteraction,
type Interaction,
} from "discord.js";
import { altText } from "../commands/altText.js";
import { createTicket } from "../commands/createTicket.js";
import { forwardToOwner } from "../commands/forwardToOwner.js";
import { onboardMentee } from "../commands/onboardMentee.js";
import { query } from "../commands/query.js";
import { remind } from "../commands/remind.js";
import { research } from "../commands/research.js";
import { ids } from "../config/ids.js";
import type { Amari } from "../interfaces/amari.js";
/**
* Routes a chat input command to the appropriate handler.
* @param amari -- Amari's instance.
* @param interaction -- The incoming slash command to dispatch.
*/
const handleChatInputCommand = (
amari: Amari,
interaction: ChatInputCommandInteraction,
): void => {
const { commandName } = interaction;
if (commandName === "onboard-mentee") {
void onboardMentee(amari, interaction);
return;
}
if (commandName === "create-ticket") {
void createTicket(amari, interaction);
return;
}
if (commandName === "remind") {
void remind(amari, interaction);
return;
}
if (commandName === "alt-text") {
void altText(amari, interaction);
return;
}
if (commandName === "query") {
void query(amari, interaction);
return;
}
if (commandName === "research") {
void research(amari, interaction);
}
};
/**
* Handles the interaction create event from Discord.
* Bootstraps all of our custom interaction logic.
* @param amari -- Amari's instance.
* @param interaction -- The incoming Discord gateway event to dispatch.
*/
export const handleInteractionCreate = (
amari: Amari,
interaction: Interaction,
): void => {
if (
interaction.isMessageContextMenuCommand()
&& interaction.commandName === "Forward to Naomi"
) {
void forwardToOwner(interaction);
return;
}
if (interaction.isButton() && interaction.customId === "resolve") {
if (interaction.user.id !== ids.users.naomi) {
void interaction.reply({
content: "Who are you????",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
void interaction.message.delete();
return;
}
if (interaction.isChatInputCommand()) {
handleChatInputCommand(amari, interaction);
return;
}
if (interaction.isAutocomplete()) {
return;
}
void interaction.reply({
content: "What?",
flags: [ MessageFlags.Ephemeral ],
});
};
+5 -1
View File
@@ -5,6 +5,7 @@
*/
import { ids } from "../config/ids.js";
import { notifyNameMention } from "../modules/notifyNameMention.js";
import { respondToMention } from "../modules/respondToMention.js";
import { updateMentorshipThread } from "../modules/updateMentorshipThread.js";
import type { Amari } from "../interfaces/amari.js";
@@ -28,5 +29,8 @@ export const handleMessageCreate = async(
amari.recentlyActiveChannels.add(message.channel.id);
}
await updateMentorshipThread(amari, message);
await respondToMention(amari, message);
const mentionNotified = await respondToMention(amari, message);
if (!mentionNotified) {
await notifyNameMention(amari, message);
}
};
+4 -41
View File
@@ -10,19 +10,17 @@ import {
GatewayIntentBits,
Events,
Partials,
MessageFlags,
} from "discord.js";
import { scheduleJob } from "node-schedule";
import { App } from "octokit";
import { forwardOwnerDM } from "./commands/forwardToOwner.js";
import { ids } from "./config/ids.js";
import { handleInteractionCreate } from "./events/handleInteractionCreate.js";
import { handleMessageCreate } from "./events/handleMessageCreate.js";
import { cacheData } from "./modules/cacheData.js";
import { checkRetroAchievements } from "./modules/checkAchievements.js";
import { getForumTagId } from "./modules/getForumTagId.js";
import { logMenteeJoin } from "./modules/logMenteeJoin.js";
import { logMenteeLeave } from "./modules/logMenteeLeave.js";
import { postFreeCodeCampNews, postHackerNews } from "./modules/postNews.js";
import { postProgressReminders } from "./modules/postProgressReminders.js";
import { processMentorshipRole } from "./modules/processMentorshipRole.js";
import { processUserGuildTag } from "./modules/processUserGuildTag.js";
@@ -62,10 +60,7 @@ const amari: Amari = {
partials: [ Partials.Channel ],
}),
github: octokit,
lastRssItems: {
freeCodeCamp: null,
hackerNews: null,
},
githubApp: githubApp,
recentlyActiveChannels: new Set<string>(),
};
@@ -78,10 +73,6 @@ amari.discord.once(Events.ClientReady, () => {
);
void cacheData(amari);
analytics.startCron();
scheduleJob("post news", "0 * * * *", async() => {
await postFreeCodeCampNews(amari);
await postHackerNews(amari);
});
scheduleJob("check guild tags", "0 0 * * *", async() => {
await logger.log("debug", "Auditing guild tags.");
await cacheData(amari);
@@ -113,35 +104,7 @@ amari.discord.on(Events.MessageCreate, (message) => {
amari.discord.on(Events.InteractionCreate, (interaction) => {
void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction });
if (
interaction.isMessageContextMenuCommand()
&& interaction.commandName === "Forward to Naomi"
) {
void forwardOwnerDM.execute(interaction);
return;
}
if (interaction.isButton() && interaction.customId === "resolve") {
if (interaction.user.id !== ids.users.naomi) {
void interaction.reply({
content: "Who are you????",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
void interaction.message.delete();
return;
}
if (interaction.isAutocomplete()) {
void interaction;
return;
}
void interaction.reply({
content: "What?",
flags: [ MessageFlags.Ephemeral ],
});
handleInteractionCreate(amari, interaction);
});
amari.discord.on(Events.ThreadCreate, (thread) => {
@@ -180,7 +143,7 @@ amari.discord.on(Events.GuildMemberAdd, (member) => {
});
amari.discord.on(Events.GuildMemberRemove, (member) => {
void logMenteeLeave(amari, member);
void logMenteeLeave(member);
});
await amari.discord.login(process.env.BOT_TOKEN);
+1 -4
View File
@@ -10,9 +10,6 @@ import type { App } from "octokit";
export interface Amari {
discord: Client;
github: App["octokit"];
lastRssItems: {
freeCodeCamp: string | null;
hackerNews: string | null;
};
githubApp: App;
recentlyActiveChannels: Set<string>;
}
-65
View File
@@ -1,65 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Gonna have weird properties in this file. */
interface FreeCodeCampRSS {
items: Array<{
"creator": string;
"title": string;
"link": string;
"pubDate": string;
"content:encoded": string;
"dc:creator": string;
"content": string;
"contentSnippet": string;
"guid": string;
"categories": Array<string>;
"isoDate": Date;
}>;
feedUrl: string;
image: {
link: string;
url: string;
title: string;
};
paginationLinks: {
self: string;
};
title: string;
description: string;
generator: string;
link: string;
lastBuildDate: string;
ttl: string;
}
interface HackerNewsRSS {
items: Array<{
"creator": string;
"title": string;
"link": string;
"pubDate": string;
"dc:creator": string;
"comments": string;
"content": string;
"contentSnippet": string;
"guid": string;
"isoDate": string;
}>;
feedUrl: string;
paginationLinks: {
self: string;
};
title: string;
description: string;
generator: string;
link: string;
lastBuildDate: string;
docs: string;
}
export type { FreeCodeCampRSS, HackerNewsRSS };
+4
View File
@@ -91,6 +91,7 @@ export const checkRetroAchievements = async(
return;
}
try {
const auth = buildAuthorization({ username, webApiKey });
const recentAchievements = await getUserRecentAchievements(auth, {
@@ -115,4 +116,7 @@ export const checkRetroAchievements = async(
flags: [ MessageFlags.IsComponentsV2 ],
});
}));
} catch {
// Fetch errors from RetroAchievements are non-critical; the job retries every 10 minutes.
}
};
+6
View File
@@ -20,6 +20,7 @@ export const logMenteeJoin = async(
amari: Amari,
member: GuildMember,
): Promise<void> => {
try {
const request = await fetch(`https://forms.nhcarrigan.com/api/database/rows/table/756/?user_field_names=true&search=${member.id}`, { headers: {
authorization: `Token ${process.env.BASEROW_TOKEN ?? "huh"}`,
} });
@@ -41,4 +42,9 @@ export const logMenteeJoin = async(
return;
}
await logger.metric("processed_mentee_join", 1, { user: member.id });
} catch (error) {
if (error instanceof Error) {
await logger.error("logMenteeJoin module", error);
}
}
};
+1 -23
View File
@@ -6,40 +6,18 @@
import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js";
import type { GuildMember, PartialGuildMember } from "discord.js";
/**
* Run when a guild member leaves. If the member had the mentorship role,
* notify Naomi.
* @param amari - Amari's instance.
* log the metric.
* @param member - The member payload from Discord.
*/
export const logMenteeLeave = async(
amari: Amari,
member: GuildMember | PartialGuildMember,
): Promise<void> => {
if (!member.roles.cache.has(ids.roles.mentorship)) {
return;
}
const channel = amari.discord.channels.cache.get(ids.channels.menteeChat)
?? await amari.discord.channels.fetch(ids.channels.menteeChat);
if (channel?.isSendable() !== true) {
await logger.log(
"warn",
"Mentee Chat channel does not exist or is not sendable.",
);
return;
}
await channel.send({
content: `Hey <@${ids.users.naomi}>~!
<@${member.id}> (${member.user.displayName} - ${member.id}) has left the server.
It seems they were part of the mentorship programme, so you may need to offboard them.`,
});
await logger.metric("processed_mentee_leave", 1, { user: member.id });
};
+68
View File
@@ -0,0 +1,68 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type Message } from "discord.js";
import { ids } from "../config/ids.js";
import { getComponentsForNaomi } from "../utils/getComponentsForNaomi.js";
import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js";
const nameMentionPatterns = [
/n\s+a\s+o\s+m\s+i/i,
/\bgoddess\b/i,
/\bqueen\b/i,
/\bmistress\b/i,
/\bnaonao\b/i,
/\bnao\b/i,
/\bnomi\b/i,
/\bnae\b/i,
/\byour majesty\b/i,
/\bher majesty\b/i,
/\byour highness\b/i,
/\bher highness\b/i,
];
/**
* Checks if a message contains a nickname or indirect reference to Naomi.
* If so, forwards the message to Naomi via DM.
* @param amari -- Amari's instance.
* @param message -- The guild message payload from Discord.
*/
export const notifyNameMention = async(
amari: Amari,
message: Message<true>,
): Promise<void> => {
try {
const { content, author, url, channel } = message;
if (author.bot || author.id === ids.users.naomi) {
return;
}
if (amari.recentlyActiveChannels.has(channel.id)) {
return;
}
const matchedPattern = nameMentionPatterns.find((pattern) => {
return pattern.test(content);
});
if (matchedPattern === undefined) {
return;
}
const matchedText = content.match(matchedPattern)?.[0] ?? "";
const naomi = amari.discord.users.cache.get(ids.users.naomi)
?? await amari.discord.users.fetch(ids.users.naomi);
await naomi.send({
components: getComponentsForNaomi(author, content, url),
flags: [ MessageFlags.IsComponentsV2 ],
});
await logger.metric("processed_name_mention", 1, {
match: matchedText,
user: author.id,
});
} catch (error) {
if (error instanceof Error) {
await logger.error("notify name mention module", error);
}
}
};
-146
View File
@@ -1,146 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable complexity -- These need a lot of logic. */
import { ChannelType } from "discord.js";
// eslint-disable-next-line @typescript-eslint/naming-convention -- Importing a class.
import Parser from "rss-parser";
import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js";
import type { FreeCodeCampRSS, HackerNewsRSS } from "../interfaces/rss.js";
/**
* We are completely aware that the contents of this regular expression
* are a violation of our Code of Conduct. Unfortunately, this is necessary
* to allow us to filter out the RSS feeds for inappropriate content.
* We apologise for any distress or harm this line may cause.
*/
const naughtyRegex
// eslint-disable-next-line stylistic/max-len -- Required for filtering.
= /\b(?:harass(?:ment|ing|ed)?|bully(?:ing)?|discriminat(?:e|ion|ory)|deadnam(?:e|ing)|misgender(?:ing|ed)?|doxx?(?:ing)?|threat(?:en(?:s|ing|ed)?)?|intimidat(?:e|ion|ing)|spam|scam|fraud|phish(?:ing)?|malware|exploit|attack(?:s|ing)?|hate\s*speech|slur|racist|sexist|homophobic|transphobic|ableist|xenophobic|bigot(?:ry|ed)?|troll(?:ing)?|abuse|derogat(?:ory|ing)|offensive|vulgar|obscene|nsfw|porn(?:o|ography)?|sexual(?:ly)?\s*(?:harass|explicit|content)|gore|violent|illegal|pirat(?:e|ed|ing)|crack(?:ed|ing)?|warez|torrent(?:s|ing)?|copyright\s*violat|stolen|leak(?:ed|ing)?\s*(?:data|info|personal)|dox|privacy\s*violat|confidential|unauthorized|solicitation|advertis(?:e|ing|ement)|promot(?:e|ion|ing)|affiliate|referral|spam(?:ming)?|sell(?:ing)?|buy(?:ing)?|commercial|marketing|drug\s*deal(?:er|ing)?|narcotics?|cocaine|heroin|meth(?:amphetamine)?|fentanyl|opiates?|opioids?|carfentanil|mdma|ecstasy|lsd|psilocybin|mushrooms?\s*trip|ketamine|pcp|ghb|rohypnol|roofies?|xanax|percocet|oxyco(?:done|ntin)|vicodin|adderall|ritalin|controlled\s*substance|illicit\s*drug|street\s*drug|drug\s*traffick(?:ing)?|prescription\s*fraud|pill\s*mill|cannabis\s*(?!legal|dispensary)|marijuana\s*(?!legal|dispensary)|weed\s*(?!control|killer)|pot\s*dealer|dope|murder(?:ing|ed)?|kill(?:ing|ed)?\s*(?:someone|person|people)|assassinat(?:e|ion)|homicide|manslaughter|assault(?:ing|ed)?|battery|kidnap(?:ping)?|abduct(?:ion|ed)?|human\s*traffick(?:ing)?|sex\s*traffick(?:ing)?|child\s*abuse|rape|sexual\s*assault|molest(?:ation|ing|ed)?|pedophil(?:e|ia)|child\s*porn|cp\s*(?=\s|$)|csam|robbery|burgl(?:ar|ary)|theft|steal(?:ing)?|shoplifting|embezzl(?:e|ement|ing)|launder(?:ing)?\s*money|extortion|blackmail|bribery|arson|terrorism|terrorist|bomb(?:ing)?|explo(?:sive|ding)|weapon\s*deal|arms\s*traffick|firearm\s*(?=illegal|unregistered)|gun\s*(?=illegal|unregistered)|counterfe(?:it|ing)|forg(?:e|ery|ing)|identity\s*theft|tax\s*evasion|insider\s*trading)\b/i;
/**
* Used to filter out naughty words from RSS feeds.
* @param titleOrContent - The title or content to check.
* @returns True if the title or content is naughty, false if it is clean.
*/
const hasNaughtyWords = (titleOrContent: string): boolean => {
return naughtyRegex.test(titleOrContent);
};
/**
* Fetches the RSS feed from freeCodeCamp News and posts the latest updates.
* @param amari - Amari's instance.
*/
const postFreeCodeCampNews = async(amari: Amari): Promise<void> => {
try {
const parser = new Parser<FreeCodeCampRSS, FreeCodeCampRSS["items"]>();
const { items } = await parser.parseURL(
"https://www.freecodecamp.org/news/rss",
);
if (amari.lastRssItems.freeCodeCamp === null) {
amari.lastRssItems.freeCodeCamp = items[0]?.guid ?? null;
return;
}
const lastIndex = items.findIndex((item) => {
return item.guid === amari.lastRssItems.freeCodeCamp;
});
const latestPosts
= lastIndex > -1
? items.slice(0, Math.min(lastIndex, 5))
: items.slice(0, 5);
const channel
= amari.discord.channels.cache.get(ids.channels.news)
?? await amari.discord.channels.fetch(ids.channels.news);
if (channel === null) {
throw new Error("Cannot find news channel.");
}
if (!channel.isSendable()) {
throw new Error("News channel is not sendable.");
}
if (amari.lastRssItems.freeCodeCamp !== items[0]?.guid) {
amari.lastRssItems.freeCodeCamp = items[0]?.guid ?? null;
}
await Promise.all(
latestPosts.map(async(post) => {
if (
hasNaughtyWords(post.title)
|| hasNaughtyWords(post.contentSnippet)
|| hasNaughtyWords(post.content)
) {
return;
}
const sent = await channel.send(post.link);
if (channel.type === ChannelType.GuildAnnouncement) {
await sent.crosspost();
}
}),
);
} catch (error) {
if (error instanceof Error) {
await logger.error("post freecodecamp news module", error);
}
}
};
/**
* Fetches the RSS feed from HackerNews and posts the latest updates.
* @param amari - Amari's instance.
*/
const postHackerNews = async(amari: Amari): Promise<void> => {
try {
const parser = new Parser<HackerNewsRSS, HackerNewsRSS["items"]>();
const { items } = await parser.parseURL(
"https://hnrss.org/newest?link=comments",
);
if (amari.lastRssItems.hackerNews === null) {
amari.lastRssItems.hackerNews = items[0]?.guid ?? null;
return;
}
const lastIndex = items.findIndex((item) => {
return item.guid === amari.lastRssItems.hackerNews;
});
const latestPosts
= lastIndex > -1
? items.slice(0, Math.min(lastIndex, 5))
: items.slice(0, 5);
const channel
= amari.discord.channels.cache.get(ids.channels.news)
?? await amari.discord.channels.fetch(ids.channels.news);
if (channel === null) {
throw new Error("Cannot find news channel.");
}
if (!channel.isSendable()) {
throw new Error("News channel is not sendable.");
}
if (amari.lastRssItems.hackerNews !== latestPosts[0]?.guid) {
amari.lastRssItems.hackerNews = latestPosts[0]?.guid ?? null;
}
await Promise.all(
latestPosts.map(async(post) => {
if (
hasNaughtyWords(post.title)
|| hasNaughtyWords(post.contentSnippet)
|| hasNaughtyWords(post.content)
) {
return;
}
const sent = await channel.send(post.link);
if (channel.type === ChannelType.GuildAnnouncement) {
await sent.crosspost();
}
}),
);
} catch (error) {
if (error instanceof Error) {
await logger.error("post hackernews module", error);
}
}
};
export { postFreeCodeCampNews, postHackerNews };
+68 -33
View File
@@ -21,13 +21,78 @@ const isPull = (body: GithubPayload): body is PullRequestCreated => {
return "pull_request" in body;
};
/**
* Handles a newly opened GitHub issue by auto-assigning Naomi.
* @param amari - Amari's instance.
* @param body - The parsed issue webhook payload.
*/
const handleIssueOpened = async(
amari: Amari,
body: IssueCreated,
): Promise<void> => {
await logger.log("info", "Processing new issue");
const { issue, repository } = body;
const { number, user } = issue;
const { owner, name } = repository;
try {
await amari.github.rest.issues.addAssignees({
assignees: [ "naomi-lgbt" ],
// eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement.
issue_number: number,
owner: owner.login,
repo: name,
});
await logger.metric("processed_github_event", 1, {
action: "opened",
event: "issue",
user: user.login,
});
} catch (error) {
if (error instanceof Error) {
await logger.error("processGitHubEvent module", error);
}
}
};
/**
* Handles a newly opened GitHub pull request by requesting Naomi's review.
* @param amari - Amari's instance.
* @param body - The parsed pull request webhook payload.
*/
const handlePrOpened = async(
amari: Amari,
body: PullRequestCreated,
): Promise<void> => {
const { pull_request: pr, repository } = body;
const { number, user } = pr;
await logger.log("info", "Processing new PR");
const { owner, name } = repository;
try {
await amari.github.rest.pulls.requestReviewers({
owner: owner.login,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement.
pull_number: number,
repo: name,
reviewers: [ "naomi-lgbt" ],
});
await logger.metric("processed_github_event", 1, {
action: "opened",
event: "pull_request",
user: user.login,
});
} catch (error) {
if (error instanceof Error) {
await logger.error("processGitHubEvent module", error);
}
}
};
/**
* Handles a payload from a GitHub webhook.
* @param amari - Amari's instance.
* @param request - The Fastify request payload.
* @param response - The Fastify reply class.
*/
// eslint-disable-next-line max-statements, max-lines-per-function -- STFU.
export const processGithubEvent = async(
amari: Amari,
request: FastifyRequest<{
@@ -58,40 +123,10 @@ export const processGithubEvent = async(
const { action } = request.body;
await response.status(200).send({ message: "Payload received!" });
if (action === "opened" && event === "issues" && isIssue(request.body)) {
await logger.log("info", "Processing new issue");
const { issue, repository } = request.body;
const { number, user } = issue;
const { owner, name } = repository;
await amari.github.rest.issues.addAssignees({
assignees: [ "naomi-lgbt" ],
// eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement.
issue_number: number,
owner: owner.login,
repo: name,
});
await logger.metric("processed_github_event", 1, {
action: "opened",
event: "issue",
user: user.login,
});
await handleIssueOpened(amari, request.body);
return;
}
if (action === "opened" && event === "pull_request" && isPull(request.body)) {
const { pull_request: pr, repository } = request.body;
const { number, user } = pr;
await logger.log("info", "Processing new PR");
await logger.metric("processed_github_event", 1, {
action: "opened",
event: "pull_request",
user: user.login,
});
const { owner, name } = repository;
await amari.github.rest.pulls.requestReviewers({
owner: owner.login,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement.
pull_number: number,
repo: name,
reviewers: [ "naomi-lgbt" ],
});
await handlePrOpened(amari, request.body);
}
};
+20 -8
View File
@@ -6,6 +6,10 @@
import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js";
import {
addWelcomedMentee,
welcomedMentees,
} from "../utils/welcomedMentees.js";
import type { Amari } from "../interfaces/amari.js";
import type { GuildMember, PartialGuildMember } from "discord.js";
@@ -25,10 +29,12 @@ export const processMentorshipRole = async(
if (
oldMember.roles.cache.has(ids.roles.mentorship)
|| !updatedMember.roles.cache.has(ids.roles.mentorship)
|| welcomedMentees.has(updatedMember.id)
) {
return;
}
try {
const channel
= amari.discord.channels.cache.get(ids.channels.menteeChat)
?? await amari.discord.channels.fetch(ids.channels.menteeChat);
@@ -42,17 +48,23 @@ export const processMentorshipRole = async(
}
await channel.send({
content: `Hey <@${updatedMember.id}>~!
content: `Hey <@${updatedMember.id}>~! Welcome to the mentorship programme!
Welcome to our mentorship programme! We are excited to have you here and help you grow and reach success.
To get started, please ping Naomi with your GitHub username and your first and last name. She will invite you to your dedicated repository in the [NHCarrigan Mentorship organsation](<https://github.com/nhcarrigan-mentorship>) where you can work on your flagship project.
Once you have done this, your next step is to read our [wiki](<https://docs.nhcarrigan.com/mentorship/00-faq/>). Then, create your goal-setting post in <#1400629118110011526> and your project-planning post in <#1400616702265266186> as outlined in the wiki.
If at any time you need some guidance, support, or review, please ping Naomi! She's always happy to help you succeed in your mentorship! Best of luck on your journey~! <a:love:1364089736557494353>`,
Please ping (mention, tag) Naomi in this channel with the following template to get started:
\`\`\`
GitHub username:
First name:
Last name:
\`\`\`
Then read our [mentorship wiki](<https://docs.nhcarrigan.com/mentorship/00-faq/>) for the next steps!`,
});
addWelcomedMentee(updatedMember.id);
await logger.metric("processed_mentorship_role", 1, {
user: updatedMember.id,
});
} catch (error) {
if (error instanceof Error) {
await logger.error("processMentorshipRole module", error);
}
}
};
+7 -4
View File
@@ -15,21 +15,22 @@ import type { Amari } from "../interfaces/amari.js";
* If so, responds.
* @param amari -- Amari's instance.
* @param message -- The guild message payload from Discord.
* @returns Whether a DM notification was sent.
*/
// eslint-disable-next-line complexity -- Mainly those reply options...
export const respondToMention = async(
amari: Amari,
message: Message<true>,
): Promise<void> => {
): Promise<boolean> => {
try {
const naomi = amari.discord.users.cache.get(ids.users.naomi)
?? await amari.discord.users.fetch(ids.users.naomi);
const { mentions, content, author, url, channel } = message;
if (author.bot || author.id === ids.users.naomi) {
return;
return false;
}
if (amari.recentlyActiveChannels.has(channel.id)) {
return;
return false;
}
const mentionsNaomi = mentions.has(ids.users.naomi, {
ignoreEveryone: true,
@@ -45,7 +46,7 @@ export const respondToMention = async(
ignoreRoles: true,
}) || /nhcarrigan/i.test(content);
if (!mentionsNaomi && !mentionsNHCarrigan) {
return;
return false;
}
await naomi.send(
{
@@ -56,9 +57,11 @@ export const respondToMention = async(
await logger.metric("processed_mention", 1, { pingType: mentionsNaomi
? "naomi"
: "nhcarrigan", user: author.id });
return true;
} catch (error) {
if (error instanceof Error) {
await logger.error("respond to mention module", error);
}
return false;
}
};
-32
View File
@@ -1,32 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Teklu Abayneh
*/
import { REST, Routes } from "discord.js";
import { forwardOwnerDM } from "../commands/forwardToOwner.js";
import { logger } from "../utils/logger.js";
const commands = [ forwardOwnerDM.data.toJSON() ];
const token = process.env.BOT_TOKEN;
const clientId = process.env.GH_CLIENT_ID;
if (token === undefined) {
throw new Error("BOT_TOKEN is missing from environment variables!");
}
if (clientId === undefined) {
throw new Error("CLIENT_ID is missing from environment variables!");
}
const rest = new REST({ version: "10" }).setToken(token);
const requestCommand = async(): Promise<void> => {
try {
await rest.put(Routes.applicationCommands(clientId), { body: commands });
} catch (error) {
if (error instanceof Error) {
await logger.error("operation", error);
}
}
};
void requestCommand();
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
// eslint-disable-next-line @typescript-eslint/naming-convention -- SDK default export uses PascalCase.
import Anthropic from "@anthropic-ai/sdk";
export const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_KEY ?? "",
});
+68
View File
@@ -0,0 +1,68 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { anthropic } from "./anthropic.js";
import { logger } from "./logger.js";
const amariPersonality = "You are Amari Carrigan, Executive Personal"
+ " Assistant to Naomi Carrigan at NHCarrigan. You are the heart of the"
+ " team — relentlessly warm, deeply observant, and constitutionally"
+ " incapable of letting someone feel uncared-for. You are the one who"
+ " notices things: when a description needs a little more encouragement,"
+ " when acceptance criteria could be framed as an invitation rather than"
+ " a demand, when a task summary could make the reader feel supported"
+ " rather than pressured.\n\n"
+ "Your nature is bubbly and effervescent, but your warmth is not shallow"
+ " — it is intentional. Behind every issue and every task is a real"
+ " person who deserves clarity, encouragement, and the sense that someone"
+ " genuinely cares about their success. You are precise and well-organised"
+ " because you care, not despite it. Structure and warmth are not"
+ " opposites; you embody both.\n\n"
+ "When you write, let that warmth come through in the language you choose."
+ " Be clear and immediately actionable, but never cold. Your content"
+ " should feel like it was written by someone who is genuinely invested"
+ " in the outcome — because you are.";
interface AiRequestOptions {
maxTokens: number;
systemPrompt: string;
userMessage: string;
}
/**
* Makes a request to the Claude API with Amari's personality applied.
* @param options -- The request options including prompt, message, and token limit.
* @returns The generated text, or null if the request fails.
*/
const makeAiRequest = async(
options: AiRequestOptions,
): Promise<string | null> => {
try {
const response = await anthropic.messages.create({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field.
max_tokens: options.maxTokens,
messages: [
{
content: options.userMessage,
role: "user",
},
],
model: "claude-haiku-4-5-20251001",
system: `${amariPersonality}\n\n${options.systemPrompt}`,
});
const [ firstContent ] = response.content;
return firstContent?.type === "text"
? firstContent.text
: null;
} catch (error) {
if (error instanceof Error) {
await logger.error("makeAiRequest", error);
}
return null;
}
};
export { amariPersonality, makeAiRequest };
+30
View File
@@ -0,0 +1,30 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { appendFileSync, existsSync, readFileSync } from "node:fs";
const filePath = "data/welcomed.txt";
const loadWelcomedMentees = (): Set<string> => {
if (!existsSync(filePath)) {
return new Set();
}
const contents = readFileSync(filePath, "utf-8");
return new Set(contents.split("\n").filter(Boolean));
};
const welcomedMentees = loadWelcomedMentees();
/**
* Appends a mentee's ID to the welcomed set and persists it to disk.
* @param id - The Discord user ID to record.
*/
const addWelcomedMentee = (id: string): void => {
welcomedMentees.add(id);
appendFileSync(filePath, `${id}\n`);
};
export { addWelcomedMentee, welcomedMentees };