generated from nhcarrigan/template
feat: mentorship improvements and name mention notifications #14
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
prod
|
||||
data/welcomed.txt
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
@@ -29,4 +30,5 @@ export const handleMessageCreate = async(
|
||||
}
|
||||
await updateMentorshipThread(amari, message);
|
||||
await respondToMention(amari, message);
|
||||
await notifyNameMention(amari, message);
|
||||
};
|
||||
|
||||
+1
-1
@@ -159,7 +159,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);
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,6 +29,7 @@ export const processMentorshipRole = async(
|
||||
if (
|
||||
oldMember.roles.cache.has(ids.roles.mentorship)
|
||||
|| !updatedMember.roles.cache.has(ids.roles.mentorship)
|
||||
|| welcomedMentees.has(updatedMember.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -42,16 +47,17 @@ 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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user