From 9df2d9ddc4fe63c3ce9ab5b3b0d06fc5f9c1c400 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 3 Mar 2026 09:38:18 -0800 Subject: [PATCH 1/7] wip: commands --- commands.json | 86 +++++++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 36 ++++++++++++ prod.env | 5 +- src/commands/createIssue.ts | 91 +++++++++++++++++++++++++++++++ src/commands/createTask.ts | 100 ++++++++++++++++++++++++++++++++++ src/commands/onboardMentee.ts | 87 +++++++++++++++++++++++++++++ src/index.ts | 16 ++++++ src/interfaces/amari.ts | 1 + src/utils/anthropic.ts | 11 ++++ 10 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 commands.json create mode 100644 src/commands/createIssue.ts create mode 100644 src/commands/createTask.ts create mode 100644 src/commands/onboardMentee.ts create mode 100644 src/utils/anthropic.ts diff --git a/commands.json b/commands.json new file mode 100644 index 0000000..b0eb9cc --- /dev/null +++ b/commands.json @@ -0,0 +1,86 @@ +[ + { + "name": "onboard-mentee", + "description": "Onboard a new mentee to the nhcarrigan-mentorship GitHub org", + "options": [ + { + "name": "mentee", + "description": "The mentee's Discord user", + "type": 6, + "required": true + }, + { + "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": "create-task", + "description": "Create an AI-augmented task on Leantime", + "options": [ + { + "name": "title", + "description": "The task title", + "type": 3, + "required": true + }, + { + "name": "description", + "description": "Additional context for the task (AI will expand this)", + "type": 3, + "required": false + }, + { + "name": "priority", + "description": "Task priority level", + "type": 4, + "required": false, + "choices": [ + { "name": "Urgent", "value": 1 }, + { "name": "High", "value": 2 }, + { "name": "Medium", "value": 3 }, + { "name": "Low", "value": 4 } + ] + } + ] + }, + { + "name": "create-issue", + "description": "Create an AI-augmented issue on a Gitea repository", + "options": [ + { + "name": "owner", + "description": "The repository owner", + "type": 3, + "required": true + }, + { + "name": "repo", + "description": "The repository name", + "type": 3, + "required": true + }, + { + "name": "title", + "description": "The issue title", + "type": 3, + "required": true + }, + { + "name": "description", + "description": "Additional context for the issue (AI will expand this)", + "type": 3, + "required": false + } + ] + } +] diff --git a/package.json b/package.json index fc0e446..d370647 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "typescript": "5.9.3" }, "dependencies": { + "@anthropic-ai/sdk": "0.78.0", "@nhcarrigan/discord-analytics": "0.0.6", "@nhcarrigan/logger": "1.1.1", "@retroachievements/api": "2.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24317fb..46226ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: 0.78.0 + version: 0.78.0 '@nhcarrigan/discord-analytics': specifier: 0.0.6 version: 0.0.6(@nhcarrigan/logger@1.1.1)(discord.js@14.22.0) @@ -54,6 +57,15 @@ importers: 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': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -62,6 +74,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@discordjs/builders@1.11.3': resolution: {integrity: sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==} engines: {node: '>=16.11.0'} @@ -1610,6 +1626,10 @@ packages: json-schema-ref-resolver@2.0.1: 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: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2176,6 +2196,9 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -2392,6 +2415,10 @@ packages: snapshots: + '@anthropic-ai/sdk@0.78.0': + dependencies: + json-schema-to-ts: 3.1.1 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -2400,6 +2427,8 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/runtime@7.28.6': {} + '@discordjs/builders@1.11.3': dependencies: '@discordjs/formatters': 0.6.1 @@ -4193,6 +4222,11 @@ snapshots: dependencies: 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@1.0.0: {} @@ -4813,6 +4847,8 @@ snapshots: toad-cache@3.7.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@1.4.3(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/prod.env b/prod.env index 80d88a0..5cfe48d 100644 --- a/prod.env +++ b/prod.env @@ -6,4 +6,7 @@ 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" \ No newline at end of file +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" \ No newline at end of file diff --git a/src/commands/createIssue.ts b/src/commands/createIssue.ts new file mode 100644 index 0000000..e252d66 --- /dev/null +++ b/src/commands/createIssue.ts @@ -0,0 +1,91 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags } from "discord.js"; +import { ids } from "../config/ids.js"; +import { anthropic } from "../utils/anthropic.js"; +import { logger } from "../utils/logger.js"; +import type { ChatInputCommandInteraction } from "discord.js"; + +interface GiteaIssueResponse { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field. + html_url: string; + number: number; +} + +/** + * @param interaction + */ +export const createIssue = async( + interaction: ChatInputCommandInteraction, +): Promise => { + 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({ ephemeral: true }); + + const aiResponse = await anthropic.messages.create({ + max_tokens: 1000, + messages: [ + { + content: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description + ? `\nAdditional context: ${description}` + : ""}`, + role: "user", + }, + ], + model: "claude-haiku-4-5-20251001", + system: "You are a helpful assistant that creates well-structured Gitea issue bodies in markdown. Include relevant sections like 'Description' and 'Acceptance Criteria'. Be clear and actionable. Return only the body text.", + }); + + const firstContent = aiResponse.content[0]; + const augmentedBody = firstContent.type === "text" + ? firstContent.text + : description; + + const response = await fetch( + `https://git.nhcarrigan.com/api/v1/repos/${owner}/${repo}/issues`, + { + body: JSON.stringify({ + body: augmentedBody, + title, + }), + headers: { + "Authorization": `Bearer ${process.env.GITEA_KEY ?? ""}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + await interaction.editReply({ + content: `❌ Failed to create issue: ${errorText}`, + }); + return; + } + + const data: GiteaIssueResponse = await response.json(); + + await logger.metric("created_issue", 1, { + repository: `${owner}/${repo}`, + title, + }); + await interaction.editReply({ + content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`, + }); +}; diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts new file mode 100644 index 0000000..ddc8eb2 --- /dev/null +++ b/src/commands/createTask.ts @@ -0,0 +1,100 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags } from "discord.js"; +import { ids } from "../config/ids.js"; +import { anthropic } from "../utils/anthropic.js"; +import { logger } from "../utils/logger.js"; +import type { ChatInputCommandInteraction } from "discord.js"; + +interface LeantimeResponse { + error?: { message: string }; + result?: number; +} + +/** + * @param interaction + */ +export const createTask = async( + interaction: ChatInputCommandInteraction, +): Promise => { + 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({ ephemeral: true }); + + const aiResponse = await anthropic.messages.create({ + max_tokens: 500, + messages: [ + { + content: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description + ? `\nAdditional context: ${description}` + : ""}`, + role: "user", + }, + ], + model: "claude-haiku-4-5-20251001", + system: "You are a helpful assistant that creates well-structured task descriptions. Be concise and actionable. Return only the description text with no extra formatting or headers.", + }); + + const firstContent = aiResponse.content[0]; + const augmentedDesc = firstContent.type === "text" + ? firstContent.text + : description; + + 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: augmentedDesc, + + editorId: "1", + headline: title, + priority: priority.toString(), + + projectId: "1", + type: "task", + }, + }, + }), + headers: { + "Content-Type": "application/json", + "x-api-key": process.env.LEANTIME_KEY ?? "", + }, + method: "POST", + }); + + const data: LeantimeResponse = await response.json(); + + 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/dashboard/home#/tickets/showTicket/${taskId.toString()}` + : "https://board.nhcarrigan.com"; + + await logger.metric("created_task", 1, { title }); + await interaction.editReply({ + content: `✅ Task created: **${title}**\n${taskUrl}`, + }); +}; diff --git a/src/commands/onboardMentee.ts b/src/commands/onboardMentee.ts new file mode 100644 index 0000000..56a8732 --- /dev/null +++ b/src/commands/onboardMentee.ts @@ -0,0 +1,87 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags } from "discord.js"; +import { ids } from "../config/ids.js"; +import { logger } from "../utils/logger.js"; +import type { Amari } from "../interfaces/amari.js"; +import type { ChatInputCommandInteraction } from "discord.js"; + +/** + * @param amari + * @param interaction + */ +export const onboardMentee = async( + amari: Amari, + interaction: ChatInputCommandInteraction, +): Promise => { + 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); + const discordId = menteeUser.id; + + await interaction.deferReply({ ephemeral: true }); + + try { + const { data: installation } = await amari.githubApp.octokit.rest.apps.getOrgInstallation({ + org: "nhcarrigan-mentorship", + }); + const mentorshipOctokit = await amari.githubApp.getInstallationOctokit(installation.id); + + let repoUrl: string; + 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 { + repoUrl = `https://github.com/nhcarrigan-mentorship/${githubUsername}`; + } + + await mentorshipOctokit.rest.repos.addCollaborator({ + owner: "nhcarrigan-mentorship", + permission: "maintain", + repo: githubUsername, + username: 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 <@${discordId}>! 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.log("error", `Failed to onboard mentee: ${String(error)}`); + await interaction.editReply({ + content: `❌ Failed to onboard mentee: ${String(error)}`, + }); + } +}; diff --git a/src/index.ts b/src/index.ts index cf6c89a..5aabaac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,9 @@ import { } from "discord.js"; import { scheduleJob } from "node-schedule"; import { App } from "octokit"; +import { createIssue } from "./commands/createIssue.js"; +import { createTask } from "./commands/createTask.js"; +import { onboardMentee } from "./commands/onboardMentee.js"; import { ids } from "./config/ids.js"; import { handleMessageCreate } from "./events/handleMessageCreate.js"; import { cacheData } from "./modules/cacheData.js"; @@ -60,6 +63,7 @@ const amari: Amari = { partials: [ Partials.Channel ], }), github: octokit, + githubApp, lastRssItems: { freeCodeCamp: null, hackerNews: null, @@ -114,6 +118,18 @@ amari.discord.on(Events.InteractionCreate, (interaction) => { } return void interaction.message.delete(); } + if (interaction.isChatInputCommand()) { + const { commandName } = interaction; + if (commandName === "onboard-mentee") { + return void onboardMentee(amari, interaction); + } + if (commandName === "create-task") { + return void createTask(interaction); + } + if (commandName === "create-issue") { + return void createIssue(interaction); + } + } if (interaction.isAutocomplete()) { return void interaction; } diff --git a/src/interfaces/amari.ts b/src/interfaces/amari.ts index 40b1c45..b901846 100644 --- a/src/interfaces/amari.ts +++ b/src/interfaces/amari.ts @@ -10,6 +10,7 @@ import type { App } from "octokit"; export interface Amari { discord: Client; github: App["octokit"]; + githubApp: App; lastRssItems: { freeCodeCamp: string | null; hackerNews: string | null; diff --git a/src/utils/anthropic.ts b/src/utils/anthropic.ts new file mode 100644 index 0000000..7c08b45 --- /dev/null +++ b/src/utils/anthropic.ts @@ -0,0 +1,11 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import Anthropic from "@anthropic-ai/sdk"; + +export const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_KEY ?? "", +}); -- 2.52.0 From d0aaa7ec2fbc422ac759e8f0b782884db4be14f1 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 10:19:41 -0800 Subject: [PATCH 2/7] fix: resolve all linting issues in command files Refactored createIssue, createTask, and onboardMentee commands to extract helper functions, fix JSDoc descriptions, correct type handling, and satisfy all ESLint rules. Also fixed object-shorthand mixing in index.ts and the naming convention in anthropic.ts. --- src/commands/createIssue.ts | 71 +++++++++++------- src/commands/createTask.ts | 134 +++++++++++++++++++++------------- src/commands/onboardMentee.ts | 73 ++++++++++-------- src/index.ts | 2 +- src/utils/anthropic.ts | 1 + 5 files changed, 174 insertions(+), 107 deletions(-) diff --git a/src/commands/createIssue.ts b/src/commands/createIssue.ts index e252d66..8609ec1 100644 --- a/src/commands/createIssue.ts +++ b/src/commands/createIssue.ts @@ -4,11 +4,10 @@ * @author Naomi Carrigan */ -import { MessageFlags } from "discord.js"; +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 type { ChatInputCommandInteraction } from "discord.js"; interface GiteaIssueResponse { // eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field. @@ -16,8 +15,44 @@ interface GiteaIssueResponse { number: number; } +const issueSystemPrompt = "You are a helpful assistant that creates" + + " well-structured Gitea issue bodies in markdown. Include relevant" + + " sections like 'Description' and 'Acceptance Criteria'." + + " Be clear and actionable. Return only the body text."; + /** - * @param interaction + * Generates an AI-augmented Gitea issue body using Claude. + * @param description - Optional additional context for the issue. + * @param title - The subject of the Gitea issue. + * @returns The generated issue body text. + */ +const generateIssueBody = async( + description: string, + title: string, +): Promise => { + const aiResponse = await anthropic.messages.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. + max_tokens: 1000, + messages: [ + { + content: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description === "" + ? "" + : `\nAdditional context: ${description}`}`, + role: "user", + }, + ], + model: "claude-haiku-4-5-20251001", + system: issueSystemPrompt, + }); + const [ firstContent ] = aiResponse.content; + return firstContent?.type === "text" + ? firstContent.text + : description; +}; + +/** + * Creates a Gitea issue using AI-augmented body content. + * @param interaction - The Discord slash command interaction. */ export const createIssue = async( interaction: ChatInputCommandInteraction, @@ -37,34 +72,19 @@ export const createIssue = async( await interaction.deferReply({ ephemeral: true }); - const aiResponse = await anthropic.messages.create({ - max_tokens: 1000, - messages: [ - { - content: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description - ? `\nAdditional context: ${description}` - : ""}`, - role: "user", - }, - ], - model: "claude-haiku-4-5-20251001", - system: "You are a helpful assistant that creates well-structured Gitea issue bodies in markdown. Include relevant sections like 'Description' and 'Acceptance Criteria'. Be clear and actionable. Return only the body text.", - }); - - const firstContent = aiResponse.content[0]; - const augmentedBody = firstContent.type === "text" - ? firstContent.text - : description; + const augmentedBody = await generateIssueBody(description, title); const response = await fetch( `https://git.nhcarrigan.com/api/v1/repos/${owner}/${repo}/issues`, { body: JSON.stringify({ - body: augmentedBody, - title, + body: augmentedBody, + title: 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", @@ -79,11 +99,12 @@ export const createIssue = async( return; } - const data: GiteaIssueResponse = await response.json(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output. + const data = await response.json() as GiteaIssueResponse; await logger.metric("created_issue", 1, { repository: `${owner}/${repo}`, - title, + title: title, }); await interaction.editReply({ content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`, diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts index ddc8eb2..1b206ec 100644 --- a/src/commands/createTask.ts +++ b/src/commands/createTask.ts @@ -4,19 +4,94 @@ * @author Naomi Carrigan */ -import { MessageFlags } from "discord.js"; +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 type { ChatInputCommandInteraction } from "discord.js"; interface LeantimeResponse { error?: { message: string }; result?: number; } +const taskSystemPrompt = "You are a helpful assistant that creates" + + " well-structured task descriptions. Be concise and actionable." + + " Return only the description text with no extra formatting or headers."; + /** - * @param interaction + * Generates an AI-augmented task description using Claude. + * @param description - Optional additional context for the task. + * @param title - The subject of the Leantime task. + * @returns The generated task description text. + */ +const generateTaskDescription = async( + description: string, + title: string, +): Promise => { + const aiResponse = await anthropic.messages.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. + max_tokens: 500, + messages: [ + { + content: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description === "" + ? "" + : `\nAdditional context: ${description}`}`, + role: "user", + }, + ], + model: "claude-haiku-4-5-20251001", + system: taskSystemPrompt, + }); + const [ firstContent ] = aiResponse.content; + return firstContent.type === "text" + ? firstContent.text + : 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 => { + 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, @@ -35,51 +110,8 @@ export const createTask = async( await interaction.deferReply({ ephemeral: true }); - const aiResponse = await anthropic.messages.create({ - max_tokens: 500, - messages: [ - { - content: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description - ? `\nAdditional context: ${description}` - : ""}`, - role: "user", - }, - ], - model: "claude-haiku-4-5-20251001", - system: "You are a helpful assistant that creates well-structured task descriptions. Be concise and actionable. Return only the description text with no extra formatting or headers.", - }); - - const firstContent = aiResponse.content[0]; - const augmentedDesc = firstContent.type === "text" - ? firstContent.text - : description; - - 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: augmentedDesc, - - editorId: "1", - headline: title, - priority: priority.toString(), - - projectId: "1", - type: "task", - }, - }, - }), - headers: { - "Content-Type": "application/json", - "x-api-key": process.env.LEANTIME_KEY ?? "", - }, - method: "POST", - }); - - const data: LeantimeResponse = await response.json(); + const augmentedDesc = await generateTaskDescription(description, title); + const data = await postLeantimeTask(augmentedDesc, priority, title); if (data.error !== undefined) { await interaction.editReply({ @@ -89,9 +121,9 @@ export const createTask = async( } const taskId = data.result; - const taskUrl = taskId !== undefined - ? `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}` - : "https://board.nhcarrigan.com"; + 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({ diff --git a/src/commands/onboardMentee.ts b/src/commands/onboardMentee.ts index 56a8732..ac62f54 100644 --- a/src/commands/onboardMentee.ts +++ b/src/commands/onboardMentee.ts @@ -4,15 +4,52 @@ * @author Naomi Carrigan */ -import { MessageFlags } from "discord.js"; +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"; -import type { ChatInputCommandInteraction } from "discord.js"; /** - * @param amari - * @param interaction + * 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 => { + 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, @@ -29,35 +66,11 @@ export const onboardMentee = async( const menteeName = interaction.options.getString("mentee_name", true); const githubUsername = interaction.options.getString("github_username", true); const menteeUser = interaction.options.getUser("mentee", true); - const discordId = menteeUser.id; await interaction.deferReply({ ephemeral: true }); try { - const { data: installation } = await amari.githubApp.octokit.rest.apps.getOrgInstallation({ - org: "nhcarrigan-mentorship", - }); - const mentorshipOctokit = await amari.githubApp.getInstallationOctokit(installation.id); - - let repoUrl: string; - 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 { - repoUrl = `https://github.com/nhcarrigan-mentorship/${githubUsername}`; - } - - await mentorshipOctokit.rest.repos.addCollaborator({ - owner: "nhcarrigan-mentorship", - permission: "maintain", - repo: githubUsername, - username: githubUsername, - }); + const repoUrl = await setupMenteeRepository(amari, githubUsername); const channel = amari.discord.channels.cache.get(ids.channels.menteeChat) @@ -71,7 +84,7 @@ export const onboardMentee = async( } await channel.send({ - content: `Hey <@${discordId}>! I've created your mentorship repository: ${repoUrl}\n\nYou should have received an invitation to collaborate - please accept it to get started!`, + 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 }); diff --git a/src/index.ts b/src/index.ts index 5aabaac..1a2d6bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,7 +63,7 @@ const amari: Amari = { partials: [ Partials.Channel ], }), github: octokit, - githubApp, + githubApp: githubApp, lastRssItems: { freeCodeCamp: null, hackerNews: null, diff --git a/src/utils/anthropic.ts b/src/utils/anthropic.ts index 7c08b45..fb73f4a 100644 --- a/src/utils/anthropic.ts +++ b/src/utils/anthropic.ts @@ -4,6 +4,7 @@ * @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({ -- 2.52.0 From 1b40c529f9075a00078dc7c70f0a06bd439cd580 Mon Sep 17 00:00:00 2001 From: Teklu Date: Tue, 3 Mar 2026 11:09:25 -0800 Subject: [PATCH 3/7] feat: merge forward-to-owner context command (#13) --- src/commands/forwardToOwner.ts | 95 ++++++++++++++++++++++++++++++++++ src/config/ids.ts | 1 + src/index.ts | 41 +++++++++++---- src/scripts/deployGlobal.ts | 32 ++++++++++++ 4 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 src/commands/forwardToOwner.ts create mode 100644 src/scripts/deployGlobal.ts diff --git a/src/commands/forwardToOwner.ts b/src/commands/forwardToOwner.ts new file mode 100644 index 0000000..b638c28 --- /dev/null +++ b/src/commands/forwardToOwner.ts @@ -0,0 +1,95 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Teklu Abayneh + */ + +import { + ContextMenuCommandBuilder, + ApplicationCommandType, + type MessageContextMenuCommandInteraction, + DiscordAPIError, + ButtonBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonStyle, + type Message, +} from "discord.js"; +import { ids } from "../config/ids.js"; + +const buildForwardedEmbed = (message: Message): EmbedBuilder => { + const forwardedEmbed = new EmbedBuilder(). + setColor(0x58_65_F2). + setTitle(`Message from ${String(message.author.tag)}!`). + setDescription( + `${(message.attachments.size > 0 + ? `**Attachments:** ${String(message.attachments.size)} + file(s)\n\n` + : "\n") + + (message.embeds.length > 0 + ? `**Embeds:** ${String(message.embeds.length)}\n\n` + : "")} + \n${message.content}\n\n`, + ); + return forwardedEmbed; +}; +const buildViewButtonFunction = (message: Message): ButtonBuilder => { + const viewButton = new ButtonBuilder(). + setLabel("View Message"). + setURL(message.url). + setStyle(ButtonStyle.Link); + return viewButton; +}; + +const data = new ContextMenuCommandBuilder().setName("Forward to Naomi"). + setType(ApplicationCommandType.Message); + +const execute = async(interaction: MessageContextMenuCommandInteraction): +Promise => { + await interaction.deferReply({ ephemeral: true }); + + 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); + const forwardedEmbed = buildForwardedEmbed(message); + const viewButton = buildViewButtonFunction(message); + + const row = new ActionRowBuilder().addComponents( + viewButton, + ); + await naomi.send({ + components: [ row ], + embeds: [ forwardedEmbed ], + files: message.attachments.map((att) => { + return att.url; + }), + }); + + await interaction.editReply({ + content: "✅ Forwarded to your DMs!", + }); + } catch (error) { + let replyText = "❌ Failed to forward message."; + if (error instanceof DiscordAPIError && error.code === 50_007) { + replyText = `${replyText} (Naomi's DMs might be closed)`; + } + await interaction.editReply(replyText); + } +}; + +export const forwardOwnerDM = { + data, + execute, +}; diff --git a/src/config/ids.ts b/src/config/ids.ts index c2c42ce..174ae5f 100644 --- a/src/config/ids.ts +++ b/src/config/ids.ts @@ -70,5 +70,6 @@ export const ids = { amari: "1406431359345496255", naomi: "465650873650118659", nhcarrigan: "1382837581649150104", + teklu: "1381735115163570198", }, }; diff --git a/src/index.ts b/src/index.ts index 1a2d6bb..69d3d51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { scheduleJob } from "node-schedule"; import { App } from "octokit"; import { createIssue } from "./commands/createIssue.js"; import { createTask } from "./commands/createTask.js"; +import { forwardOwnerDM } from "./commands/forwardToOwner.js"; import { onboardMentee } from "./commands/onboardMentee.js"; import { ids } from "./config/ids.js"; import { handleMessageCreate } from "./events/handleMessageCreate.js"; @@ -44,6 +45,7 @@ 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( @@ -91,12 +93,18 @@ amari.discord.once(Events.ClientReady, () => { scheduleJob("post progress reminders", "0 9 * * 1-5", async() => { await postProgressReminders(amari); }); - setInterval(() => { - amari.recentlyActiveChannels = new Set(); - }, 10 * 60 * 1000); - setInterval(() => { - void checkRetroAchievements(amari); - }, 10 * 60 * 1000); + setInterval( + () => { + amari.recentlyActiveChannels = new Set(); + }, + 10 * 60 * 1000, + ); + setInterval( + () => { + void checkRetroAchievements(amari); + }, + 10 * 60 * 1000, + ); }); amari.discord.on(Events.MessageCreate, (message) => { @@ -109,14 +117,26 @@ amari.discord.on(Events.MessageCreate, (message) => { amari.discord.on(Events.InteractionCreate, (interaction) => { void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction }); + + if ( + interaction.isMessageContextMenuCommand() + && interaction.commandName === "Forward to Naomi" + ) { + void forwardOwnerDM.execute(interaction); + return; + } + if (interaction.isButton() && interaction.customId === "resolve") { if (interaction.user.id !== ids.users.naomi) { - return void interaction.reply({ + void interaction.reply({ content: "Who are you????", flags: [ MessageFlags.Ephemeral ], }); + return; } - return void interaction.message.delete(); + + void interaction.message.delete(); + return; } if (interaction.isChatInputCommand()) { const { commandName } = interaction; @@ -131,9 +151,10 @@ amari.discord.on(Events.InteractionCreate, (interaction) => { } } if (interaction.isAutocomplete()) { - return void interaction; + void interaction; + return; } - return void interaction.reply({ + void interaction.reply({ content: "What?", flags: [ MessageFlags.Ephemeral ], }); diff --git a/src/scripts/deployGlobal.ts b/src/scripts/deployGlobal.ts new file mode 100644 index 0000000..deebd9f --- /dev/null +++ b/src/scripts/deployGlobal.ts @@ -0,0 +1,32 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Teklu Abayneh + */ + +import { REST, Routes } from "discord.js"; +import { forwardOwnerDM } from "../commands/forwardToOwner.js"; +import { logger } from "../utils/logger.js"; + +const commands = [ forwardOwnerDM.data.toJSON() ]; +const token = process.env.BOT_TOKEN; +const clientId = process.env.GH_CLIENT_ID; + +if (token === undefined) { + throw new Error("BOT_TOKEN is missing from environment variables!"); +} +if (clientId === undefined) { + throw new Error("CLIENT_ID is missing from environment variables!"); +} + +const rest = new REST({ version: "10" }).setToken(token); +const requestCommand = async(): Promise => { + try { + await rest.put(Routes.applicationCommands(clientId), { body: commands }); + } catch (error) { + if (error instanceof Error) { + await logger.error("operation", error); + } + } +}; +void requestCommand(); -- 2.52.0 From 94247c2a68b4d07aafb4fae5fbc573724f43519e Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 11:44:20 -0800 Subject: [PATCH 4/7] refactor: align commands and interaction handler with project style - Replace forwardToOwner embed approach with getComponentsForNaomi + Components V2 - Extract InteractionCreate handler to src/events/handleInteractionCreate.ts - Replace deployGlobal.ts script with static commands.json payload - Fix minor error handling in createTask and onboardMentee --- commands.json | 119 +++++++++++++------------- src/commands/createTask.ts | 2 +- src/commands/forwardToOwner.ts | 79 +++++------------ src/commands/onboardMentee.ts | 4 +- src/events/handleInteractionCreate.ts | 85 ++++++++++++++++++ src/index.ts | 48 +---------- src/scripts/deployGlobal.ts | 32 ------- 7 files changed, 174 insertions(+), 195 deletions(-) create mode 100644 src/events/handleInteractionCreate.ts delete mode 100644 src/scripts/deployGlobal.ts diff --git a/commands.json b/commands.json index b0eb9cc..67bbbfb 100644 --- a/commands.json +++ b/commands.json @@ -1,86 +1,87 @@ [ - { - "name": "onboard-mentee", - "description": "Onboard a new mentee to the nhcarrigan-mentorship GitHub org", - "options": [ - { - "name": "mentee", - "description": "The mentee's Discord user", - "type": 6, - "required": true - }, - { - "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": "create-task", - "description": "Create an AI-augmented task on Leantime", - "options": [ - { - "name": "title", - "description": "The task title", - "type": 3, - "required": true - }, - { - "name": "description", - "description": "Additional context for the task (AI will expand this)", - "type": 3, - "required": false - }, - { - "name": "priority", - "description": "Task priority level", - "type": 4, - "required": false, - "choices": [ - { "name": "Urgent", "value": 1 }, - { "name": "High", "value": 2 }, - { "name": "Medium", "value": 3 }, - { "name": "Low", "value": 4 } - ] - } - ] - }, { "name": "create-issue", - "description": "Create an AI-augmented issue on a Gitea repository", + "type": 1, + "description": "Creates a Gitea issue with an AI-generated body.", "options": [ { "name": "owner", - "description": "The repository owner", + "description": "The owner of the repository.", "type": 3, "required": true }, { "name": "repo", - "description": "The repository name", + "description": "The name of the repository.", "type": 3, "required": true }, { "name": "title", - "description": "The issue title", + "description": "The issue title.", "type": 3, "required": true }, { "name": "description", - "description": "Additional context for the issue (AI will expand this)", + "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 } ] diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts index 1b206ec..ba5d528 100644 --- a/src/commands/createTask.ts +++ b/src/commands/createTask.ts @@ -43,7 +43,7 @@ const generateTaskDescription = async( system: taskSystemPrompt, }); const [ firstContent ] = aiResponse.content; - return firstContent.type === "text" + return firstContent?.type === "text" ? firstContent.text : description; }; diff --git a/src/commands/forwardToOwner.ts b/src/commands/forwardToOwner.ts index b638c28..b15683e 100644 --- a/src/commands/forwardToOwner.ts +++ b/src/commands/forwardToOwner.ts @@ -5,47 +5,21 @@ */ import { - ContextMenuCommandBuilder, - ApplicationCommandType, - type MessageContextMenuCommandInteraction, DiscordAPIError, - ButtonBuilder, - EmbedBuilder, - ActionRowBuilder, - ButtonStyle, - type Message, + MessageFlags, + type MessageContextMenuCommandInteraction, } from "discord.js"; import { ids } from "../config/ids.js"; +import { getComponentsForNaomi } from "../utils/getComponentsForNaomi.js"; +import { logger } from "../utils/logger.js"; -const buildForwardedEmbed = (message: Message): EmbedBuilder => { - const forwardedEmbed = new EmbedBuilder(). - setColor(0x58_65_F2). - setTitle(`Message from ${String(message.author.tag)}!`). - setDescription( - `${(message.attachments.size > 0 - ? `**Attachments:** ${String(message.attachments.size)} - file(s)\n\n` - : "\n") - + (message.embeds.length > 0 - ? `**Embeds:** ${String(message.embeds.length)}\n\n` - : "")} - \n${message.content}\n\n`, - ); - return forwardedEmbed; -}; -const buildViewButtonFunction = (message: Message): ButtonBuilder => { - const viewButton = new ButtonBuilder(). - setLabel("View Message"). - setURL(message.url). - setStyle(ButtonStyle.Link); - return viewButton; -}; - -const data = new ContextMenuCommandBuilder().setName("Forward to Naomi"). - setType(ApplicationCommandType.Message); - -const execute = async(interaction: MessageContextMenuCommandInteraction): -Promise => { +/** + * 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 => { await interaction.deferReply({ ephemeral: true }); if (interaction.user.id !== ids.users.naomi) { @@ -63,33 +37,26 @@ Promise => { try { const naomi = await interaction.client.users.fetch(ids.users.naomi); - const forwardedEmbed = buildForwardedEmbed(message); - const viewButton = buildViewButtonFunction(message); - - const row = new ActionRowBuilder().addComponents( - viewButton, - ); await naomi.send({ - components: [ row ], - embeds: [ forwardedEmbed ], - files: message.attachments.map((att) => { - return att.url; - }), - }); - - await interaction.editReply({ - content: "✅ Forwarded to your DMs!", + components: getComponentsForNaomi( + message.author, + message.content, + message.url, + ), + flags: [ MessageFlags.IsComponentsV2 ], }); + await logger.metric("forwarded_message", 1, { user: message.author.id }); + await interaction.editReply({ content: "✅ Forwarded to your DMs!" }); } catch (error) { let replyText = "❌ Failed to forward message."; if (error instanceof DiscordAPIError && error.code === 50_007) { replyText = `${replyText} (Naomi's DMs might be closed)`; } + if (error instanceof Error) { + await logger.error("forwardToOwner command", error); + } await interaction.editReply(replyText); } }; -export const forwardOwnerDM = { - data, - execute, -}; +export { forwardToOwner }; diff --git a/src/commands/onboardMentee.ts b/src/commands/onboardMentee.ts index ac62f54..bedd8ed 100644 --- a/src/commands/onboardMentee.ts +++ b/src/commands/onboardMentee.ts @@ -92,7 +92,9 @@ export const onboardMentee = async( content: `✅ Successfully onboarded **${menteeName}**!\nRepository: ${repoUrl}`, }); } catch (error) { - await logger.log("error", `Failed to onboard mentee: ${String(error)}`); + await logger.error("onboardmentee command", error instanceof Error + ? error + : new Error(String(error))); await interaction.editReply({ content: `❌ Failed to onboard mentee: ${String(error)}`, }); diff --git a/src/events/handleInteractionCreate.ts b/src/events/handleInteractionCreate.ts new file mode 100644 index 0000000..c66230b --- /dev/null +++ b/src/events/handleInteractionCreate.ts @@ -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 ], + }); +}; diff --git a/src/index.ts b/src/index.ts index 69d3d51..ab0d1a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,15 +10,11 @@ import { GatewayIntentBits, Events, Partials, - MessageFlags, } from "discord.js"; import { scheduleJob } from "node-schedule"; import { App } from "octokit"; -import { createIssue } from "./commands/createIssue.js"; -import { createTask } from "./commands/createTask.js"; -import { forwardOwnerDM } from "./commands/forwardToOwner.js"; -import { onboardMentee } from "./commands/onboardMentee.js"; import { ids } from "./config/ids.js"; +import { handleInteractionCreate } from "./events/handleInteractionCreate.js"; import { handleMessageCreate } from "./events/handleMessageCreate.js"; import { cacheData } from "./modules/cacheData.js"; import { checkRetroAchievements } from "./modules/checkAchievements.js"; @@ -117,47 +113,7 @@ amari.discord.on(Events.MessageCreate, (message) => { amari.discord.on(Events.InteractionCreate, (interaction) => { void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction }); - - if ( - interaction.isMessageContextMenuCommand() - && interaction.commandName === "Forward to Naomi" - ) { - void forwardOwnerDM.execute(interaction); - return; - } - - if (interaction.isButton() && interaction.customId === "resolve") { - if (interaction.user.id !== ids.users.naomi) { - void interaction.reply({ - content: "Who are you????", - flags: [ MessageFlags.Ephemeral ], - }); - return; - } - - void interaction.message.delete(); - return; - } - if (interaction.isChatInputCommand()) { - const { commandName } = interaction; - if (commandName === "onboard-mentee") { - return void onboardMentee(amari, interaction); - } - if (commandName === "create-task") { - return void createTask(interaction); - } - if (commandName === "create-issue") { - return void createIssue(interaction); - } - } - if (interaction.isAutocomplete()) { - void interaction; - return; - } - void interaction.reply({ - content: "What?", - flags: [ MessageFlags.Ephemeral ], - }); + handleInteractionCreate(amari, interaction); }); amari.discord.on(Events.ThreadCreate, (thread) => { diff --git a/src/scripts/deployGlobal.ts b/src/scripts/deployGlobal.ts deleted file mode 100644 index deebd9f..0000000 --- a/src/scripts/deployGlobal.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @copyright NHCarrigan - * @license Naomi's Public License - * @author Teklu Abayneh - */ - -import { REST, Routes } from "discord.js"; -import { forwardOwnerDM } from "../commands/forwardToOwner.js"; -import { logger } from "../utils/logger.js"; - -const commands = [ forwardOwnerDM.data.toJSON() ]; -const token = process.env.BOT_TOKEN; -const clientId = process.env.GH_CLIENT_ID; - -if (token === undefined) { - throw new Error("BOT_TOKEN is missing from environment variables!"); -} -if (clientId === undefined) { - throw new Error("CLIENT_ID is missing from environment variables!"); -} - -const rest = new REST({ version: "10" }).setToken(token); -const requestCommand = async(): Promise => { - try { - await rest.put(Routes.applicationCommands(clientId), { body: commands }); - } catch (error) { - if (error instanceof Error) { - await logger.error("operation", error); - } - } -}; -void requestCommand(); -- 2.52.0 From 5beebeff4405dec8fab348da5784206df4c71d87 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 11:53:59 -0800 Subject: [PATCH 5/7] feat: add makeAiRequest utility with Amari personality prompt - Centralise all Claude API calls through a shared makeAiRequest module - Apply a consistent Amari personality to all AI-generated content - Add try/catch with logger.error to all AI and external API call sites - Extract postGiteaIssue helper in createIssue for better structure --- src/commands/createIssue.ts | 132 ++++++++++++++++++++---------------- src/commands/createTask.ts | 73 ++++++++++---------- src/utils/makeAiRequest.ts | 54 +++++++++++++++ 3 files changed, 163 insertions(+), 96 deletions(-) create mode 100644 src/utils/makeAiRequest.ts diff --git a/src/commands/createIssue.ts b/src/commands/createIssue.ts index 8609ec1..be258ff 100644 --- a/src/commands/createIssue.ts +++ b/src/commands/createIssue.ts @@ -6,8 +6,8 @@ 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 { makeAiRequest } from "../utils/makeAiRequest.js"; interface GiteaIssueResponse { // eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field. @@ -15,39 +15,64 @@ interface GiteaIssueResponse { number: number; } -const issueSystemPrompt = "You are a helpful assistant that creates" - + " well-structured Gitea issue bodies in markdown. Include relevant" - + " sections like 'Description' and 'Acceptance Criteria'." - + " Be clear and actionable. Return only the body text."; +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 using Claude. + * 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. + * @returns The generated issue body text, or the original description as fallback. */ const generateIssueBody = async( description: string, title: string, ): Promise => { - const aiResponse = await anthropic.messages.create({ - // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. - max_tokens: 1000, - messages: [ - { - content: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description === "" - ? "" - : `\nAdditional context: ${description}`}`, - role: "user", - }, - ], - model: "claude-haiku-4-5-20251001", - system: issueSystemPrompt, + 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}`}`, }); - const [ firstContent ] = aiResponse.content; - return firstContent?.type === "text" - ? firstContent.text - : 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 => { + 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; }; /** @@ -72,41 +97,30 @@ export const createIssue = async( await interaction.deferReply({ ephemeral: true }); - const augmentedBody = await generateIssueBody(description, title); - - const response = await fetch( - `https://git.nhcarrigan.com/api/v1/repos/${owner}/${repo}/issues`, - { - body: JSON.stringify({ - body: augmentedBody, - title: 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) { - const errorText = await response.text(); - await interaction.editReply({ - content: `❌ Failed to create issue: ${errorText}`, + 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}`, }); - return; } - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output. - const data = await response.json() as GiteaIssueResponse; - - 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}`, - }); }; diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts index ba5d528..3f5c53e 100644 --- a/src/commands/createTask.ts +++ b/src/commands/createTask.ts @@ -6,46 +6,36 @@ 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 { makeAiRequest } from "../utils/makeAiRequest.js"; interface LeantimeResponse { error?: { message: string }; result?: number; } -const taskSystemPrompt = "You are a helpful assistant that creates" - + " well-structured task descriptions. Be concise and actionable." +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 using Claude. + * 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. + * @returns The generated task description text, or the original description as fallback. */ const generateTaskDescription = async( description: string, title: string, ): Promise => { - const aiResponse = await anthropic.messages.create({ - // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. - max_tokens: 500, - messages: [ - { - content: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description === "" - ? "" - : `\nAdditional context: ${description}`}`, - role: "user", - }, - ], - model: "claude-haiku-4-5-20251001", - system: taskSystemPrompt, + 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}`}`, }); - const [ firstContent ] = aiResponse.content; - return firstContent?.type === "text" - ? firstContent.text - : description; + return result ?? description; }; /** @@ -110,23 +100,32 @@ export const createTask = async( await interaction.deferReply({ ephemeral: true }); - const augmentedDesc = await generateTaskDescription(description, title); - const data = await postLeantimeTask(augmentedDesc, priority, title); + try { + const augmentedDesc = await generateTaskDescription(description, title); + const data = await postLeantimeTask(augmentedDesc, priority, title); - if (data.error !== undefined) { + 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: `❌ Failed to create task: ${data.error.message}`, + 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.", }); - 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}`, - }); }; diff --git a/src/utils/makeAiRequest.ts b/src/utils/makeAiRequest.ts new file mode 100644 index 0000000..c5b56e3 --- /dev/null +++ b/src/utils/makeAiRequest.ts @@ -0,0 +1,54 @@ +/** + * @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, Naomi Carrigan's personal" + + " assistant bot at NHCarrigan. You are organised, professional, and" + + " precise. You help manage software projects by producing clear," + + " structured, and immediately actionable content."; + +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 => { + 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 }; -- 2.52.0 From 3f89b7eb4fbdf4f8eb075b0fec883296d38bd076 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 12:04:20 -0800 Subject: [PATCH 6/7] fix: replace deprecated ephemeral flag and add error handling across modules - Replace `ephemeral: true` with `flags: [ MessageFlags.Ephemeral ]` in all command files - Add try/catch with logger.error to logMenteeJoin, checkAchievements, processMentorshipRole - Extract handleIssueOpened and handlePrOpened helpers in processGitHubEvent to reduce complexity - Add logger.error calls in all newly introduced catch blocks --- src/commands/createIssue.ts | 2 +- src/commands/createTask.ts | 2 +- src/commands/forwardToOwner.ts | 2 +- src/commands/onboardMentee.ts | 2 +- src/modules/checkAchievements.ts | 51 ++++++++------ src/modules/logMenteeJoin.ts | 44 +++++++----- src/modules/processGitHubEvent.ts | 101 ++++++++++++++++++--------- src/modules/processMentorshipRole.ts | 40 ++++++----- 8 files changed, 149 insertions(+), 95 deletions(-) diff --git a/src/commands/createIssue.ts b/src/commands/createIssue.ts index be258ff..07d5c28 100644 --- a/src/commands/createIssue.ts +++ b/src/commands/createIssue.ts @@ -95,7 +95,7 @@ export const createIssue = async( const title = interaction.options.getString("title", true); const description = interaction.options.getString("description") ?? ""; - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try { const augmentedBody = await generateIssueBody(description, title); diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts index 3f5c53e..f4d4fe5 100644 --- a/src/commands/createTask.ts +++ b/src/commands/createTask.ts @@ -98,7 +98,7 @@ export const createTask = async( const description = interaction.options.getString("description") ?? ""; const priority = interaction.options.getInteger("priority") ?? 3; - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try { const augmentedDesc = await generateTaskDescription(description, title); diff --git a/src/commands/forwardToOwner.ts b/src/commands/forwardToOwner.ts index b15683e..df9a635 100644 --- a/src/commands/forwardToOwner.ts +++ b/src/commands/forwardToOwner.ts @@ -20,7 +20,7 @@ import { logger } from "../utils/logger.js"; const forwardToOwner = async( interaction: MessageContextMenuCommandInteraction, ): Promise => { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); if (interaction.user.id !== ids.users.naomi) { await interaction.editReply("❌ Only Naomi can use this command."); diff --git a/src/commands/onboardMentee.ts b/src/commands/onboardMentee.ts index bedd8ed..19a5ab5 100644 --- a/src/commands/onboardMentee.ts +++ b/src/commands/onboardMentee.ts @@ -67,7 +67,7 @@ export const onboardMentee = async( const githubUsername = interaction.options.getString("github_username", true); const menteeUser = interaction.options.getUser("mentee", true); - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); try { const repoUrl = await setupMenteeRepository(amari, githubUsername); diff --git a/src/modules/checkAchievements.ts b/src/modules/checkAchievements.ts index 2819cda..2a20225 100644 --- a/src/modules/checkAchievements.ts +++ b/src/modules/checkAchievements.ts @@ -23,6 +23,7 @@ import { type MessageActionRowComponentBuilder, } from "discord.js"; import { ids } from "../config/ids.js"; +import { logger } from "../utils/logger.js"; import type { Amari } from "../interfaces/amari.js"; const username = "naomilgbt"; @@ -91,28 +92,34 @@ export const checkRetroAchievements = async( return; } - const auth = buildAuthorization({ username, webApiKey }); + try { + const auth = buildAuthorization({ username, webApiKey }); - 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 ], + 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 (error) { + if (error instanceof Error) { + await logger.error("checkRetroAchievements module", error); + } + } }; diff --git a/src/modules/logMenteeJoin.ts b/src/modules/logMenteeJoin.ts index 597f285..2a245de 100644 --- a/src/modules/logMenteeJoin.ts +++ b/src/modules/logMenteeJoin.ts @@ -20,25 +20,31 @@ export const logMenteeJoin = async( amari: Amari, member: GuildMember, ): Promise => { - 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; + 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; - if (response.count <= 0) { - return; + 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); + } } - - 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 }); }; diff --git a/src/modules/processGitHubEvent.ts b/src/modules/processGitHubEvent.ts index 90ffd0a..f5c7528 100644 --- a/src/modules/processGitHubEvent.ts +++ b/src/modules/processGitHubEvent.ts @@ -21,13 +21,78 @@ const isPull = (body: GithubPayload): body is PullRequestCreated => { return "pull_request" in body; }; +/** + * Handles a newly opened GitHub issue by auto-assigning Naomi. + * @param amari - Amari's instance. + * @param body - The parsed issue webhook payload. + */ +const handleIssueOpened = async( + amari: Amari, + body: IssueCreated, +): Promise => { + 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 => { + const { pull_request: pr, repository } = body; + const { number, user } = pr; + await logger.log("info", "Processing new PR"); + const { owner, name } = repository; + try { + await amari.github.rest.pulls.requestReviewers({ + owner: owner.login, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement. + pull_number: number, + repo: name, + reviewers: [ "naomi-lgbt" ], + }); + await logger.metric("processed_github_event", 1, { + action: "opened", + event: "pull_request", + user: user.login, + }); + } catch (error) { + if (error instanceof Error) { + await logger.error("processGitHubEvent module", error); + } + } +}; + /** * Handles a payload from a GitHub webhook. * @param amari - Amari's instance. * @param request - The Fastify request payload. * @param response - The Fastify reply class. */ -// eslint-disable-next-line max-statements, max-lines-per-function -- STFU. export const processGithubEvent = async( amari: Amari, request: FastifyRequest<{ @@ -58,40 +123,10 @@ export const processGithubEvent = async( const { action } = request.body; await response.status(200).send({ message: "Payload received!" }); if (action === "opened" && event === "issues" && isIssue(request.body)) { - await logger.log("info", "Processing new issue"); - const { issue, repository } = request.body; - const { number, user } = issue; - const { owner, name } = repository; - await amari.github.rest.issues.addAssignees({ - assignees: [ "naomi-lgbt" ], - // eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement. - issue_number: number, - owner: owner.login, - repo: name, - }); - await logger.metric("processed_github_event", 1, { - action: "opened", - event: "issue", - user: user.login, - }); + await handleIssueOpened(amari, request.body); return; } if (action === "opened" && event === "pull_request" && isPull(request.body)) { - const { pull_request: pr, repository } = request.body; - const { number, user } = pr; - await logger.log("info", "Processing new PR"); - await logger.metric("processed_github_event", 1, { - action: "opened", - event: "pull_request", - user: user.login, - }); - const { owner, name } = repository; - await amari.github.rest.pulls.requestReviewers({ - owner: owner.login, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement. - pull_number: number, - repo: name, - reviewers: [ "naomi-lgbt" ], - }); + await handlePrOpened(amari, request.body); } }; diff --git a/src/modules/processMentorshipRole.ts b/src/modules/processMentorshipRole.ts index d65277c..f9f43e0 100644 --- a/src/modules/processMentorshipRole.ts +++ b/src/modules/processMentorshipRole.ts @@ -34,20 +34,21 @@ export const processMentorshipRole = async( return; } - const channel - = amari.discord.channels.cache.get(ids.channels.menteeChat) - ?? await amari.discord.channels.fetch(ids.channels.menteeChat); + try { + 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; - } + 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! + 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: \`\`\` @@ -56,9 +57,14 @@ First name: Last name: \`\`\` Then read our [mentorship wiki]() for the next steps!`, - }); - addWelcomedMentee(updatedMember.id); - await logger.metric("processed_mentorship_role", 1, { - user: updatedMember.id, - }); + }); + 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); + } + } }; -- 2.52.0 From 5c77dc8eb573efe512c68ca495b8a357005706f2 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 12:41:58 -0800 Subject: [PATCH 7/7] feat: give Amari a proper personality prompt Co-Authored-By: Naomi Carrigan --- src/utils/makeAiRequest.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/utils/makeAiRequest.ts b/src/utils/makeAiRequest.ts index c5b56e3..9897b04 100644 --- a/src/utils/makeAiRequest.ts +++ b/src/utils/makeAiRequest.ts @@ -7,10 +7,24 @@ import { anthropic } from "./anthropic.js"; import { logger } from "./logger.js"; -const amariPersonality = "You are Amari, Naomi Carrigan's personal" - + " assistant bot at NHCarrigan. You are organised, professional, and" - + " precise. You help manage software projects by producing clear," - + " structured, and immediately actionable content."; +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; -- 2.52.0