feat: add slash commands and context menu command #16

Merged
naomi merged 7 commits from feat/commands into main 2026-03-03 15:05:10 -08:00
18 changed files with 877 additions and 115 deletions
+87
View File
@@ -0,0 +1,87 @@
[
{
"name": "create-issue",
"type": 1,
"description": "Creates a Gitea issue with an AI-generated body.",
"options": [
{
"name": "owner",
"description": "The owner of the repository.",
"type": 3,
"required": true
},
{
"name": "repo",
"description": "The name of the repository.",
"type": 3,
"required": true
},
{
"name": "title",
"description": "The issue title.",
"type": 3,
"required": true
},
{
"name": "description",
"description": "Optional additional context for the issue body.",
"type": 3,
"required": false
}
]
},
{
"name": "create-task",
"type": 1,
"description": "Creates a Leantime task with an AI-generated description.",
"options": [
{
"name": "title",
"description": "The task title.",
"type": 3,
"required": true
},
{
"name": "description",
"description": "Optional additional context for the task description.",
"type": 3,
"required": false
},
{
"name": "priority",
"description": "The task priority level (1-5, default 3).",
"type": 4,
"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": "Forward to Naomi",
"type": 3
}
]
+1
View File
@@ -23,6 +23,7 @@
"typescript": "5.9.3" "typescript": "5.9.3"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.78.0",
"@nhcarrigan/discord-analytics": "0.0.6", "@nhcarrigan/discord-analytics": "0.0.6",
"@nhcarrigan/logger": "1.1.1", "@nhcarrigan/logger": "1.1.1",
"@retroachievements/api": "2.10.0", "@retroachievements/api": "2.10.0",
+36
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@anthropic-ai/sdk':
specifier: 0.78.0
version: 0.78.0
'@nhcarrigan/discord-analytics': '@nhcarrigan/discord-analytics':
specifier: 0.0.6 specifier: 0.0.6
version: 0.0.6(@nhcarrigan/logger@1.1.1)(discord.js@14.22.0) version: 0.0.6(@nhcarrigan/logger@1.1.1)(discord.js@14.22.0)
@@ -54,6 +57,15 @@ importers:
packages: packages:
'@anthropic-ai/sdk@0.78.0':
resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
'@babel/code-frame@7.27.1': '@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -62,6 +74,10 @@ packages:
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@discordjs/builders@1.11.3': '@discordjs/builders@1.11.3':
resolution: {integrity: sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==} resolution: {integrity: sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==}
engines: {node: '>=16.11.0'} engines: {node: '>=16.11.0'}
@@ -1610,6 +1626,10 @@ packages:
json-schema-ref-resolver@2.0.1: json-schema-ref-resolver@2.0.1:
resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==} resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==}
json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
json-schema-traverse@0.4.1: json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -2176,6 +2196,9 @@ packages:
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
engines: {node: '>=12'} engines: {node: '>=12'}
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
ts-api-utils@1.4.3: ts-api-utils@1.4.3:
resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -2392,6 +2415,10 @@ packages:
snapshots: snapshots:
'@anthropic-ai/sdk@0.78.0':
dependencies:
json-schema-to-ts: 3.1.1
'@babel/code-frame@7.27.1': '@babel/code-frame@7.27.1':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.27.1 '@babel/helper-validator-identifier': 7.27.1
@@ -2400,6 +2427,8 @@ snapshots:
'@babel/helper-validator-identifier@7.27.1': {} '@babel/helper-validator-identifier@7.27.1': {}
'@babel/runtime@7.28.6': {}
'@discordjs/builders@1.11.3': '@discordjs/builders@1.11.3':
dependencies: dependencies:
'@discordjs/formatters': 0.6.1 '@discordjs/formatters': 0.6.1
@@ -4193,6 +4222,11 @@ snapshots:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
json-schema-to-ts@3.1.1:
dependencies:
'@babel/runtime': 7.28.6
ts-algebra: 2.0.0
json-schema-traverse@0.4.1: {} json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {} json-schema-traverse@1.0.0: {}
@@ -4813,6 +4847,8 @@ snapshots:
toad-cache@3.7.0: {} toad-cache@3.7.0: {}
ts-algebra@2.0.0: {}
ts-api-utils@1.4.3(typescript@5.9.3): ts-api-utils@1.4.3(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
+3
View File
@@ -7,3 +7,6 @@ GH_WEBHOOK_SECRET="op://Environment Variables - Naomi/Amari/gh webhook secret"
BASEROW_SECRET="op://Environment Variables - Naomi/Amari/baserow hook auth" BASEROW_SECRET="op://Environment Variables - Naomi/Amari/baserow hook auth"
BASEROW_TOKEN="op://Environment Variables - Naomi/Amari/baserow token" BASEROW_TOKEN="op://Environment Variables - Naomi/Amari/baserow token"
RA_KEY="op://Environment Variables - Naomi/Amari/retroachievements key" 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"
ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key"
+126
View File
@@ -0,0 +1,126 @@
/**
* @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";
interface GiteaIssueResponse {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field.
html_url: string;
number: number;
}
interface GiteaIssueOptions {
body: string;
owner: string;
repo: string;
title: string;
}
const issueSystemPrompt = "Create well-structured Gitea issue bodies in"
+ " markdown. Include relevant sections like 'Description' and"
+ " 'Acceptance Criteria'. Be clear and actionable."
+ " Return only the body text.";
/**
* Generates an AI-augmented Gitea issue body.
* @param description - Optional additional context for the issue.
* @param title - The subject of the Gitea issue.
* @returns The generated issue body text, or the original description as fallback.
*/
const generateIssueBody = async(
description: string,
title: string,
): Promise<string> => {
const result = await makeAiRequest({
maxTokens: 1000,
systemPrompt: issueSystemPrompt,
userMessage: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description === ""
? ""
: `\nAdditional context: ${description}`}`,
});
return result ?? description;
};
/**
* Creates a Gitea issue via the API.
* @param options -- The issue fields to submit.
* @returns The created issue data.
*/
const postGiteaIssue = async(
options: GiteaIssueOptions,
): Promise<GiteaIssueResponse> => {
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.
return await response.json() as GiteaIssueResponse;
};
/**
* Creates a Gitea issue using AI-augmented body content.
* @param interaction - The Discord slash command interaction.
*/
export const createIssue = async(
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 owner = interaction.options.getString("owner", true);
const repo = interaction.options.getString("repo", true);
const title = interaction.options.getString("title", true);
const description = interaction.options.getString("description") ?? "";
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
try {
const augmentedBody = await generateIssueBody(description, title);
const data = await postGiteaIssue({
body: augmentedBody,
owner: owner,
repo: repo,
title: title,
});
await logger.metric("created_issue", 1, {
repository: `${owner}/${repo}`,
title: title,
});
await interaction.editReply({
content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`,
});
} catch (error) {
if (error instanceof Error) {
await logger.error("createIssue command", error);
}
const errorMessage = error instanceof Error
? error.message
: "Unknown error";
await interaction.editReply({
content: `❌ Failed to create issue: ${errorMessage}`,
});
}
};
+131
View File
@@ -0,0 +1,131 @@
/**
* @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";
interface LeantimeResponse {
error?: { message: string };
result?: number;
}
const taskSystemPrompt = "Create well-structured task descriptions."
+ " Be concise and actionable."
+ " Return only the description text with no extra formatting or headers.";
/**
* Generates an AI-augmented task description.
* @param description - Optional additional context for the task.
* @param title - The subject of the Leantime task.
* @returns The generated task description text, or the original description as fallback.
*/
const generateTaskDescription = async(
description: string,
title: string,
): Promise<string> => {
const result = await makeAiRequest({
maxTokens: 500,
systemPrompt: taskSystemPrompt,
userMessage: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description === ""
? ""
: `\nAdditional context: ${description}`}`,
});
return result ?? description;
};
/**
* Posts a task to the Leantime board via JSON-RPC.
* @param description - Body copy for the Leantime task.
* @param priority - The task priority level.
* @param title - The headline for the Leantime task.
* @returns The Leantime API response.
*/
const postLeantimeTask = async(
description: string,
priority: number,
title: string,
): Promise<LeantimeResponse> => {
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: description,
editorId: "1",
headline: title,
priority: priority.toString(),
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;
return data;
};
/**
* Creates a Leantime task using AI-augmented description content.
* @param interaction - The Discord slash command interaction.
*/
export const createTask = async(
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 title = interaction.options.getString("title", true);
const description = interaction.options.getString("description") ?? "";
const priority = interaction.options.getInteger("priority") ?? 3;
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
try {
const augmentedDesc = await generateTaskDescription(description, title);
const data = await postLeantimeTask(augmentedDesc, priority, title);
if (data.error !== undefined) {
await interaction.editReply({
content: `❌ Failed to create task: ${data.error.message}`,
});
return;
}
const taskId = data.result;
const taskUrl = taskId === undefined
? "https://board.nhcarrigan.com"
: `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}`;
await logger.metric("created_task", 1, { title });
await interaction.editReply({
content: `✅ Task created: **${title}**\n${taskUrl}`,
});
} catch (error) {
if (error instanceof Error) {
await logger.error("createTask command", error);
}
await interaction.editReply({
content: "❌ An unexpected error occurred while creating the task.",
});
}
};
+62
View File
@@ -0,0 +1,62 @@
/**
* @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 };
+102
View File
@@ -0,0 +1,102 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js";
/**
* Creates a mentee repository and configures collaborator access.
* @param amari - The Amari instance.
* @param githubUsername - The mentee's GitHub username.
* @returns The URL of the created or existing repository.
*/
const setupMenteeRepository = async(
amari: Amari,
githubUsername: string,
): Promise<string> => {
const orgApps = amari.githubApp.octokit.rest.apps;
const { data: installation } = await orgApps.getOrgInstallation({
org: "nhcarrigan-mentorship",
});
const mentorshipOctokit
= await amari.githubApp.getInstallationOctokit(installation.id);
let repoUrl = `https://github.com/nhcarrigan-mentorship/${githubUsername}`;
try {
const { data: repoData } = await mentorshipOctokit.rest.repos.createInOrg({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit API field.
auto_init: true,
name: githubUsername,
org: "nhcarrigan-mentorship",
});
repoUrl = repoData.html_url;
} catch {
// Repo likely already exists - use the default URL.
}
await mentorshipOctokit.rest.repos.addCollaborator({
owner: "nhcarrigan-mentorship",
permission: "maintain",
repo: githubUsername,
username: githubUsername,
});
return repoUrl;
};
/**
* Onboards a new mentee by creating their GitHub repository and notifying them.
* @param amari - The Amari instance.
* @param interaction - The Discord slash command interaction.
*/
export const onboardMentee = async(
amari: Amari,
interaction: ChatInputCommandInteraction,
): Promise<void> => {
if (interaction.user.id !== ids.users.naomi) {
await interaction.reply({
content: "This command is restricted to Naomi.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
const menteeName = interaction.options.getString("mentee_name", true);
const githubUsername = interaction.options.getString("github_username", true);
const menteeUser = interaction.options.getUser("mentee", true);
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
try {
const repoUrl = await setupMenteeRepository(amari, githubUsername);
const channel
= amari.discord.channels.cache.get(ids.channels.menteeChat)
?? await amari.discord.channels.fetch(ids.channels.menteeChat);
if (channel?.isSendable() !== true) {
await interaction.editReply({
content: "Repo created but could not send Discord notification.",
});
return;
}
await channel.send({
content: `Hey <@${menteeUser.id}>! I've created your mentorship repository: ${repoUrl}\n\nYou should have received an invitation to collaborate - please accept it to get started!`,
});
await logger.metric("onboarded_mentee", 1, { mentee: menteeName });
await interaction.editReply({
content: `✅ Successfully onboarded **${menteeName}**!\nRepository: ${repoUrl}`,
});
} catch (error) {
await logger.error("onboardmentee command", error instanceof Error
? error
: new Error(String(error)));
await interaction.editReply({
content: `❌ Failed to onboard mentee: ${String(error)}`,
});
}
};
+1
View File
@@ -70,5 +70,6 @@ export const ids = {
amari: "1406431359345496255", amari: "1406431359345496255",
naomi: "465650873650118659", naomi: "465650873650118659",
nhcarrigan: "1382837581649150104", nhcarrigan: "1382837581649150104",
teklu: "1381735115163570198",
}, },
}; };
+85
View File
@@ -0,0 +1,85 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
MessageFlags,
type ChatInputCommandInteraction,
type Interaction,
} from "discord.js";
import { createIssue } from "../commands/createIssue.js";
import { createTask } from "../commands/createTask.js";
import { forwardToOwner } from "../commands/forwardToOwner.js";
import { onboardMentee } from "../commands/onboardMentee.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-task") {
void createTask(interaction);
return;
}
if (commandName === "create-issue") {
void createIssue(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 ],
});
};
+14 -21
View File
@@ -10,11 +10,11 @@ import {
GatewayIntentBits, GatewayIntentBits,
Events, Events,
Partials, Partials,
MessageFlags,
} from "discord.js"; } from "discord.js";
import { scheduleJob } from "node-schedule"; import { scheduleJob } from "node-schedule";
import { App } from "octokit"; import { App } from "octokit";
import { ids } from "./config/ids.js"; import { ids } from "./config/ids.js";
import { handleInteractionCreate } from "./events/handleInteractionCreate.js";
import { handleMessageCreate } from "./events/handleMessageCreate.js"; import { handleMessageCreate } from "./events/handleMessageCreate.js";
import { cacheData } from "./modules/cacheData.js"; import { cacheData } from "./modules/cacheData.js";
import { checkRetroAchievements } from "./modules/checkAchievements.js"; import { checkRetroAchievements } from "./modules/checkAchievements.js";
@@ -41,6 +41,7 @@ const githubApp = new App({
appId: process.env.GH_CLIENT_ID, appId: process.env.GH_CLIENT_ID,
privateKey: process.env.GH_PRIVATE_KEY.replaceAll("\\n", "\n"), privateKey: process.env.GH_PRIVATE_KEY.replaceAll("\\n", "\n"),
}); });
const octokit = await githubApp.getInstallationOctokit(83_119_105); const octokit = await githubApp.getInstallationOctokit(83_119_105);
const { data } = await octokit.rest.apps.getAuthenticated(); const { data } = await octokit.rest.apps.getAuthenticated();
await logger.log( await logger.log(
@@ -60,6 +61,7 @@ const amari: Amari = {
partials: [ Partials.Channel ], partials: [ Partials.Channel ],
}), }),
github: octokit, github: octokit,
githubApp: githubApp,
lastRssItems: { lastRssItems: {
freeCodeCamp: null, freeCodeCamp: null,
hackerNews: null, hackerNews: null,
@@ -87,12 +89,18 @@ amari.discord.once(Events.ClientReady, () => {
scheduleJob("post progress reminders", "0 9 * * 1-5", async() => { scheduleJob("post progress reminders", "0 9 * * 1-5", async() => {
await postProgressReminders(amari); await postProgressReminders(amari);
}); });
setInterval(() => { setInterval(
() => {
amari.recentlyActiveChannels = new Set<string>(); amari.recentlyActiveChannels = new Set<string>();
}, 10 * 60 * 1000); },
setInterval(() => { 10 * 60 * 1000,
);
setInterval(
() => {
void checkRetroAchievements(amari); void checkRetroAchievements(amari);
}, 10 * 60 * 1000); },
10 * 60 * 1000,
);
}); });
amari.discord.on(Events.MessageCreate, (message) => { amari.discord.on(Events.MessageCreate, (message) => {
@@ -105,22 +113,7 @@ amari.discord.on(Events.MessageCreate, (message) => {
amari.discord.on(Events.InteractionCreate, (interaction) => { amari.discord.on(Events.InteractionCreate, (interaction) => {
void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction }); void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction });
if (interaction.isButton() && interaction.customId === "resolve") { handleInteractionCreate(amari, interaction);
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) => { amari.discord.on(Events.ThreadCreate, (thread) => {
+1
View File
@@ -10,6 +10,7 @@ import type { App } from "octokit";
export interface Amari { export interface Amari {
discord: Client; discord: Client;
github: App["octokit"]; github: App["octokit"];
githubApp: App;
lastRssItems: { lastRssItems: {
freeCodeCamp: string | null; freeCodeCamp: string | null;
hackerNews: string | null; hackerNews: string | null;
+7
View File
@@ -23,6 +23,7 @@ import {
type MessageActionRowComponentBuilder, type MessageActionRowComponentBuilder,
} from "discord.js"; } from "discord.js";
import { ids } from "../config/ids.js"; import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js"; import type { Amari } from "../interfaces/amari.js";
const username = "naomilgbt"; const username = "naomilgbt";
@@ -91,6 +92,7 @@ export const checkRetroAchievements = async(
return; return;
} }
try {
const auth = buildAuthorization({ username, webApiKey }); const auth = buildAuthorization({ username, webApiKey });
const recentAchievements = await getUserRecentAchievements(auth, { const recentAchievements = await getUserRecentAchievements(auth, {
@@ -115,4 +117,9 @@ export const checkRetroAchievements = async(
flags: [ MessageFlags.IsComponentsV2 ], flags: [ MessageFlags.IsComponentsV2 ],
}); });
})); }));
} catch (error) {
if (error instanceof Error) {
await logger.error("checkRetroAchievements module", error);
}
}
}; };
+6
View File
@@ -20,6 +20,7 @@ export const logMenteeJoin = async(
amari: Amari, amari: Amari,
member: GuildMember, member: GuildMember,
): Promise<void> => { ): Promise<void> => {
try {
const request = await fetch(`https://forms.nhcarrigan.com/api/database/rows/table/756/?user_field_names=true&search=${member.id}`, { headers: { 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"}`, authorization: `Token ${process.env.BASEROW_TOKEN ?? "huh"}`,
} }); } });
@@ -41,4 +42,9 @@ export const logMenteeJoin = async(
return; return;
} }
await logger.metric("processed_mentee_join", 1, { user: member.id }); await logger.metric("processed_mentee_join", 1, { user: member.id });
} catch (error) {
if (error instanceof Error) {
await logger.error("logMenteeJoin module", error);
}
}
}; };
+68 -33
View File
@@ -21,13 +21,78 @@ const isPull = (body: GithubPayload): body is PullRequestCreated => {
return "pull_request" in body; 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. * Handles a payload from a GitHub webhook.
* @param amari - Amari's instance. * @param amari - Amari's instance.
* @param request - The Fastify request payload. * @param request - The Fastify request payload.
* @param response - The Fastify reply class. * @param response - The Fastify reply class.
*/ */
// eslint-disable-next-line max-statements, max-lines-per-function -- STFU.
export const processGithubEvent = async( export const processGithubEvent = async(
amari: Amari, amari: Amari,
request: FastifyRequest<{ request: FastifyRequest<{
@@ -58,40 +123,10 @@ export const processGithubEvent = async(
const { action } = request.body; const { action } = request.body;
await response.status(200).send({ message: "Payload received!" }); await response.status(200).send({ message: "Payload received!" });
if (action === "opened" && event === "issues" && isIssue(request.body)) { if (action === "opened" && event === "issues" && isIssue(request.body)) {
await logger.log("info", "Processing new issue"); await handleIssueOpened(amari, request.body);
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; return;
} }
if (action === "opened" && event === "pull_request" && isPull(request.body)) { if (action === "opened" && event === "pull_request" && isPull(request.body)) {
const { pull_request: pr, repository } = request.body; await handlePrOpened(amari, 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
View File
@@ -34,6 +34,7 @@ export const processMentorshipRole = async(
return; return;
} }
try {
const channel const channel
= amari.discord.channels.cache.get(ids.channels.menteeChat) = amari.discord.channels.cache.get(ids.channels.menteeChat)
?? await amari.discord.channels.fetch(ids.channels.menteeChat); ?? await amari.discord.channels.fetch(ids.channels.menteeChat);
@@ -61,4 +62,9 @@ Then read our [mentorship wiki](<https://docs.nhcarrigan.com/mentorship/00-faq/>
await logger.metric("processed_mentorship_role", 1, { await logger.metric("processed_mentorship_role", 1, {
user: updatedMember.id, user: updatedMember.id,
}); });
} catch (error) {
if (error instanceof Error) {
await logger.error("processMentorshipRole module", error);
}
}
}; };
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
// eslint-disable-next-line @typescript-eslint/naming-convention -- SDK default export uses PascalCase.
import Anthropic from "@anthropic-ai/sdk";
export const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_KEY ?? "",
});
+68
View File
@@ -0,0 +1,68 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { anthropic } from "./anthropic.js";
import { logger } from "./logger.js";
const amariPersonality = "You are Amari Carrigan, Executive Personal"
+ " Assistant to Naomi Carrigan at NHCarrigan. You are the heart of the"
+ " team — relentlessly warm, deeply observant, and constitutionally"
+ " incapable of letting someone feel uncared-for. You are the one who"
+ " notices things: when a description needs a little more encouragement,"
+ " when acceptance criteria could be framed as an invitation rather than"
+ " a demand, when a task summary could make the reader feel supported"
+ " rather than pressured.\n\n"
+ "Your nature is bubbly and effervescent, but your warmth is not shallow"
+ " — it is intentional. Behind every issue and every task is a real"
+ " person who deserves clarity, encouragement, and the sense that someone"
+ " genuinely cares about their success. You are precise and well-organised"
+ " because you care, not despite it. Structure and warmth are not"
+ " opposites; you embody both.\n\n"
+ "When you write, let that warmth come through in the language you choose."
+ " Be clear and immediately actionable, but never cold. Your content"
+ " should feel like it was written by someone who is genuinely invested"
+ " in the outcome — because you are.";
interface AiRequestOptions {
maxTokens: number;
systemPrompt: string;
userMessage: string;
}
/**
* Makes a request to the Claude API with Amari's personality applied.
* @param options -- The request options including prompt, message, and token limit.
* @returns The generated text, or null if the request fails.
*/
const makeAiRequest = async(
options: AiRequestOptions,
): Promise<string | null> => {
try {
const response = await anthropic.messages.create({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field.
max_tokens: options.maxTokens,
messages: [
{
content: options.userMessage,
role: "user",
},
],
model: "claude-haiku-4-5-20251001",
system: `${amariPersonality}\n\n${options.systemPrompt}`,
});
const [ firstContent ] = response.content;
return firstContent?.type === "text"
? firstContent.text
: null;
} catch (error) {
if (error instanceof Error) {
await logger.error("makeAiRequest", error);
}
return null;
}
};
export { amariPersonality, makeAiRequest };