Files
amari/src/index.ts
T
hikari 1ebe240475
Node.js CI / CI (push) Successful in 28s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m41s
feat: add slash commands and context menu command (#16)
## Summary

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

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

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

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

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

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-authored-by: Teklu <tekluabayneh@gmail.com>
Reviewed-on: #16
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-03 15:05:09 -08:00

160 lines
4.5 KiB
TypeScript

/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { DiscordAnalytics } from "@nhcarrigan/discord-analytics";
import {
Client,
GatewayIntentBits,
Events,
Partials,
} 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";
import { respondToDm } from "./modules/respondToDm.js";
import { instantiateServer } from "./server/serve.js";
import { logger } from "./utils/logger.js";
import type { Amari } from "./interfaces/amari.js";
if (
process.env.GH_CLIENT_ID === undefined
|| process.env.GH_PRIVATE_KEY === undefined
) {
throw new Error("Cannot initialise GitHub!");
}
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(
"debug",
`Authenticated to GitHub as ${data?.name ?? "unknown"}`,
);
const amari: Amari = {
discord: new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.DirectMessages,
],
partials: [ Partials.Channel ],
}),
github: octokit,
githubApp: githubApp,
lastRssItems: {
freeCodeCamp: null,
hackerNews: null,
},
recentlyActiveChannels: new Set<string>(),
};
const analytics = new DiscordAnalytics(amari.discord, logger);
amari.discord.once(Events.ClientReady, () => {
void logger.log(
"debug",
`Authenticated to Discord as ${amari.discord.user?.username ?? "unknown"}`,
);
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);
});
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,
);
});
amari.discord.on(Events.MessageCreate, (message) => {
if (!message.inGuild()) {
void respondToDm(amari, message);
return;
}
void handleMessageCreate(amari, message);
});
amari.discord.on(Events.InteractionCreate, (interaction) => {
void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction });
handleInteractionCreate(amari, interaction);
});
amari.discord.on(Events.ThreadCreate, (thread) => {
if (thread.parent?.isThreadOnly() !== true) {
return;
}
const { bugReports, communityFeedback, featureRequests, policyIdeation }
= ids.channels;
if (
![ bugReports,
communityFeedback,
featureRequests,
policyIdeation ].includes(
thread.parent.id,
)
) {
return;
}
const tagId = getForumTagId(thread.parent.id);
if (tagId === null) {
return;
}
void thread.setAppliedTags([ tagId ]);
});
amari.discord.on(Events.UserUpdate, (_oldUser, updatedUser) => {
void processUserGuildTag(amari, updatedUser);
});
amari.discord.on(Events.GuildMemberUpdate, (oldMember, updatedMember) => {
void processMentorshipRole(amari, oldMember, updatedMember);
});
amari.discord.on(Events.GuildMemberAdd, (member) => {
void logMenteeJoin(amari, member);
});
amari.discord.on(Events.GuildMemberRemove, (member) => {
void logMenteeLeave(member);
});
await amari.discord.login(process.env.BOT_TOKEN);
instantiateServer(amari);