generated from nhcarrigan/template
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85a982c03e |
+1
-2
@@ -1,3 +1,2 @@
|
||||
node_modules
|
||||
prod
|
||||
data/welcomed.txt
|
||||
prod
|
||||
@@ -0,0 +1,25 @@
|
||||
# Package Manager Configuration
|
||||
# Force pnpm usage - breaks npm/yarn intentionally
|
||||
node-linker=pnpm
|
||||
|
||||
# Security: Disable all lifecycle scripts
|
||||
ignore-scripts=true
|
||||
enable-pre-post-scripts=false
|
||||
|
||||
# Security: Require packages to be 10+ days old before installation
|
||||
minimum-release-age=14400
|
||||
|
||||
# Security: Verify package integrity hashes
|
||||
verify-store-integrity=true
|
||||
|
||||
# Security: Enforce strict trust policies
|
||||
trust-policy=strict
|
||||
|
||||
# Security: Strict peer dependency resolution
|
||||
strict-peer-dependencies=true
|
||||
|
||||
# Performance: Use symlinks for node_modules
|
||||
symlink=true
|
||||
|
||||
# Lockfile: Ensure lockfile is not modified during install
|
||||
frozen-lockfile=false
|
||||
@@ -1,147 +0,0 @@
|
||||
# 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
@@ -1,122 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
+6
-6
@@ -20,16 +20,16 @@
|
||||
"@types/node": "24.3.0",
|
||||
"@types/node-schedule": "2.1.8",
|
||||
"eslint": "9.33.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.78.0",
|
||||
"@nhcarrigan/discord-analytics": "0.0.6",
|
||||
"@nhcarrigan/logger": "1.1.1",
|
||||
"@retroachievements/api": "2.10.0",
|
||||
"discord.js": "14.22.0",
|
||||
"fastify": "5.7.4",
|
||||
"@retroachievements/api": "2.6.0",
|
||||
"discord.js": "14.25.1",
|
||||
"fastify": "5.5.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"octokit": "5.0.5"
|
||||
"octokit": "5.0.3",
|
||||
"rss-parser": "3.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+304
-302
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
# Security
|
||||
|
||||
# Do not execute any scripts of installed packages (project scripts still run)
|
||||
ignoreDepScripts: true
|
||||
# Do not automatically run pre/post scripts (e.g. preinstall, postbuild)
|
||||
enablePrePostScripts: false
|
||||
# Only allow packages published at least 10 days ago (reduces risk of compromised packages)
|
||||
minimumReleaseAge: 14400
|
||||
# Fail if a package's trust level has decreased compared to previous releases
|
||||
trustPolicy: no-downgrade
|
||||
# Ignore trust policy for packages published more than 1 year ago (predates provenance signing)
|
||||
trustPolicyIgnoreAfter: 525960
|
||||
# Fail if there are missing or invalid peer dependencies
|
||||
strictPeerDependencies: true
|
||||
# Prevent transitive dependencies from using exotic sources (git repos, direct tarball URLs)
|
||||
blockExoticSubdeps: true
|
||||
|
||||
# Lockfile
|
||||
|
||||
# Allow the lockfile to be updated during install (set to true in CI for stricter reproducibility)
|
||||
preferFrozenLockfile: false
|
||||
@@ -6,8 +6,4 @@ GH_PRIVATE_KEY="op://Environment Variables - Naomi/Amari/gh private key"
|
||||
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"
|
||||
RA_KEY="op://Environment Variables - Naomi/Amari/retroachievements key"
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* @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.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,333 +0,0 @@
|
||||
/**
|
||||
* @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}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Teklu Abayneh
|
||||
*/
|
||||
|
||||
import {
|
||||
DiscordAPIError,
|
||||
MessageFlags,
|
||||
type MessageContextMenuCommandInteraction,
|
||||
} from "discord.js";
|
||||
import { ids } from "../config/ids.js";
|
||||
import { getComponentsForNaomi } from "../utils/getComponentsForNaomi.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
/**
|
||||
* 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.");
|
||||
return;
|
||||
}
|
||||
|
||||
const message = interaction.targetMessage;
|
||||
if (message.author.id === ids.users.naomi) {
|
||||
await interaction.editReply(
|
||||
"No need to forward your own message to yourself 😄",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const naomi = await interaction.client.users.fetch(ids.users.naomi);
|
||||
await naomi.send({
|
||||
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 { forwardToOwner };
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* @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)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* @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.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* @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)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* @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
-1
@@ -20,6 +20,7 @@ export const ids = {
|
||||
mentorshipGoalForum: "1400629118110011526",
|
||||
mentorshipProjectForum: "1400616702265266186",
|
||||
naomiDiscussionForum: "1408154690121633917",
|
||||
news: "1407804798677418198",
|
||||
partnershipRequests: "1451009066355654829",
|
||||
policyIdeation: "1417294974046965842",
|
||||
pressInquiries: "1451011543482368163",
|
||||
@@ -69,6 +70,5 @@ export const ids = {
|
||||
amari: "1406431359345496255",
|
||||
naomi: "465650873650118659",
|
||||
nhcarrigan: "1382837581649150104",
|
||||
teklu: "1381735115163570198",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* @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,7 +5,6 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -29,8 +28,5 @@ export const handleMessageCreate = async(
|
||||
amari.recentlyActiveChannels.add(message.channel.id);
|
||||
}
|
||||
await updateMentorshipThread(amari, message);
|
||||
const mentionNotified = await respondToMention(amari, message);
|
||||
if (!mentionNotified) {
|
||||
await notifyNameMention(amari, message);
|
||||
}
|
||||
await respondToMention(amari, message);
|
||||
};
|
||||
|
||||
+34
-18
@@ -10,17 +10,18 @@ import {
|
||||
GatewayIntentBits,
|
||||
Events,
|
||||
Partials,
|
||||
MessageFlags,
|
||||
} from "discord.js";
|
||||
import { scheduleJob } from "node-schedule";
|
||||
import { App } from "octokit";
|
||||
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";
|
||||
@@ -40,7 +41,6 @@ const githubApp = new App({
|
||||
appId: process.env.GH_CLIENT_ID,
|
||||
privateKey: process.env.GH_PRIVATE_KEY.replaceAll("\\n", "\n"),
|
||||
});
|
||||
|
||||
const octokit = await githubApp.getInstallationOctokit(83_119_105);
|
||||
const { data } = await octokit.rest.apps.getAuthenticated();
|
||||
await logger.log(
|
||||
@@ -59,8 +59,11 @@ const amari: Amari = {
|
||||
],
|
||||
partials: [ Partials.Channel ],
|
||||
}),
|
||||
github: octokit,
|
||||
githubApp: githubApp,
|
||||
github: octokit,
|
||||
lastRssItems: {
|
||||
freeCodeCamp: null,
|
||||
hackerNews: null,
|
||||
},
|
||||
recentlyActiveChannels: new Set<string>(),
|
||||
};
|
||||
|
||||
@@ -73,6 +76,10 @@ 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);
|
||||
@@ -80,18 +87,12 @@ amari.discord.once(Events.ClientReady, () => {
|
||||
scheduleJob("post progress reminders", "0 9 * * 1-5", async() => {
|
||||
await postProgressReminders(amari);
|
||||
});
|
||||
setInterval(
|
||||
() => {
|
||||
amari.recentlyActiveChannels = new Set<string>();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
setInterval(
|
||||
() => {
|
||||
void checkRetroAchievements(amari);
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
setInterval(() => {
|
||||
amari.recentlyActiveChannels = new Set<string>();
|
||||
}, 10 * 60 * 1000);
|
||||
setInterval(() => {
|
||||
void checkRetroAchievements(amari);
|
||||
}, 10 * 60 * 1000);
|
||||
});
|
||||
|
||||
amari.discord.on(Events.MessageCreate, (message) => {
|
||||
@@ -104,7 +105,22 @@ amari.discord.on(Events.MessageCreate, (message) => {
|
||||
|
||||
amari.discord.on(Events.InteractionCreate, (interaction) => {
|
||||
void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction });
|
||||
handleInteractionCreate(amari, interaction);
|
||||
if (interaction.isButton() && interaction.customId === "resolve") {
|
||||
if (interaction.user.id !== ids.users.naomi) {
|
||||
return void interaction.reply({
|
||||
content: "Who are you????",
|
||||
flags: [ MessageFlags.Ephemeral ],
|
||||
});
|
||||
}
|
||||
return void interaction.message.delete();
|
||||
}
|
||||
if (interaction.isAutocomplete()) {
|
||||
return void interaction;
|
||||
}
|
||||
return void interaction.reply({
|
||||
content: "What?",
|
||||
flags: [ MessageFlags.Ephemeral ],
|
||||
});
|
||||
});
|
||||
|
||||
amari.discord.on(Events.ThreadCreate, (thread) => {
|
||||
@@ -143,7 +159,7 @@ amari.discord.on(Events.GuildMemberAdd, (member) => {
|
||||
});
|
||||
|
||||
amari.discord.on(Events.GuildMemberRemove, (member) => {
|
||||
void logMenteeLeave(member);
|
||||
void logMenteeLeave(amari, member);
|
||||
});
|
||||
|
||||
await amari.discord.login(process.env.BOT_TOKEN);
|
||||
|
||||
@@ -8,8 +8,11 @@ import type { Client } from "discord.js";
|
||||
import type { App } from "octokit";
|
||||
|
||||
export interface Amari {
|
||||
discord: Client;
|
||||
github: App["octokit"];
|
||||
githubApp: App;
|
||||
discord: Client;
|
||||
github: App["octokit"];
|
||||
lastRssItems: {
|
||||
freeCodeCamp: string | null;
|
||||
hackerNews: string | null;
|
||||
};
|
||||
recentlyActiveChannels: Set<string>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @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 };
|
||||
@@ -91,32 +91,28 @@ export const checkRetroAchievements = async(
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = buildAuthorization({ username, webApiKey });
|
||||
const auth = buildAuthorization({ username, webApiKey });
|
||||
|
||||
const recentAchievements = await getUserRecentAchievements(auth, {
|
||||
recentMinutes: 10,
|
||||
username: username,
|
||||
});
|
||||
const recentAchievements = await getUserRecentAchievements(auth, {
|
||||
recentMinutes: 10,
|
||||
username: username,
|
||||
});
|
||||
|
||||
if (recentAchievements.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = amari.discord.channels.cache.get(ids.channels.gaming)
|
||||
?? await amari.discord.channels.fetch(ids.channels.gaming);
|
||||
|
||||
if (channel?.isSendable() !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(recentAchievements.map(async(achievement) => {
|
||||
await channel.send({
|
||||
components: constructComponents(achievement),
|
||||
flags: [ MessageFlags.IsComponentsV2 ],
|
||||
});
|
||||
}));
|
||||
} catch {
|
||||
// Fetch errors from RetroAchievements are non-critical; the job retries every 10 minutes.
|
||||
if (recentAchievements.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = amari.discord.channels.cache.get(ids.channels.gaming)
|
||||
?? await amari.discord.channels.fetch(ids.channels.gaming);
|
||||
|
||||
if (channel?.isSendable() !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(recentAchievements.map(async(achievement) => {
|
||||
await channel.send({
|
||||
components: constructComponents(achievement),
|
||||
flags: [ MessageFlags.IsComponentsV2 ],
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -20,31 +20,25 @@ 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"}`,
|
||||
} });
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch accepts no generic here.
|
||||
const response = await request.json() as MentorshipRow;
|
||||
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"}`,
|
||||
} });
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch accepts no generic here.
|
||||
const response = await request.json() as MentorshipRow;
|
||||
|
||||
if (response.count <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = amari.discord.channels.cache.get(ids.channels.general)
|
||||
?? await amari.discord.channels.fetch(ids.channels.general);
|
||||
|
||||
if (channel?.isSendable() !== true) {
|
||||
await logger.log(
|
||||
"warn",
|
||||
"General channel does not exist or is not sendable.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await logger.metric("processed_mentee_join", 1, { user: member.id });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
await logger.error("logMenteeJoin module", error);
|
||||
}
|
||||
if (response.count <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = amari.discord.channels.cache.get(ids.channels.general)
|
||||
?? await amari.discord.channels.fetch(ids.channels.general);
|
||||
|
||||
if (channel?.isSendable() !== true) {
|
||||
await logger.log(
|
||||
"warn",
|
||||
"General channel does not exist or is not sendable.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await logger.metric("processed_mentee_join", 1, { user: member.id });
|
||||
};
|
||||
|
||||
@@ -6,18 +6,40 @@
|
||||
|
||||
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,
|
||||
* log the metric.
|
||||
* notify Naomi.
|
||||
* @param amari - Amari's instance.
|
||||
* @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 });
|
||||
};
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @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 };
|
||||
@@ -21,78 +21,13 @@ 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<{
|
||||
@@ -123,10 +58,40 @@ 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 handleIssueOpened(amari, 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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === "opened" && event === "pull_request" && isPull(request.body)) {
|
||||
await handlePrOpened(amari, 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" ],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
|
||||
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";
|
||||
|
||||
@@ -29,42 +25,34 @@ 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);
|
||||
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 <@${updatedMember.id}>~! Welcome to the mentorship programme!
|
||||
|
||||
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);
|
||||
}
|
||||
if (channel?.isSendable() !== true) {
|
||||
await logger.log(
|
||||
"warn",
|
||||
"Mentee Chat channel does not exist or is not sendable.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await channel.send({
|
||||
content: `Hey <@${updatedMember.id}>~!
|
||||
|
||||
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>`,
|
||||
});
|
||||
await logger.metric("processed_mentorship_role", 1, {
|
||||
user: updatedMember.id,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,22 +15,21 @@ 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<boolean> => {
|
||||
): Promise<void> => {
|
||||
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 false;
|
||||
return;
|
||||
}
|
||||
if (amari.recentlyActiveChannels.has(channel.id)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
const mentionsNaomi = mentions.has(ids.users.naomi, {
|
||||
ignoreEveryone: true,
|
||||
@@ -46,7 +45,7 @@ export const respondToMention = async(
|
||||
ignoreRoles: true,
|
||||
}) || /nhcarrigan/i.test(content);
|
||||
if (!mentionsNaomi && !mentionsNHCarrigan) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
await naomi.send(
|
||||
{
|
||||
@@ -57,11 +56,9 @@ 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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* @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 ?? "",
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* @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 };
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user