diff --git a/commands.json b/commands.json new file mode 100644 index 0000000..67bbbfb --- /dev/null +++ b/commands.json @@ -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 + } +] 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..07d5c28 --- /dev/null +++ b/src/commands/createIssue.ts @@ -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 => { + 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 => { + 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 => { + 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}`, + }); + } +}; diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts new file mode 100644 index 0000000..f4d4fe5 --- /dev/null +++ b/src/commands/createTask.ts @@ -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 => { + 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 => { + 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 => { + 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.", + }); + } +}; diff --git a/src/commands/forwardToOwner.ts b/src/commands/forwardToOwner.ts new file mode 100644 index 0000000..df9a635 --- /dev/null +++ b/src/commands/forwardToOwner.ts @@ -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 => { + 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 }; diff --git a/src/commands/onboardMentee.ts b/src/commands/onboardMentee.ts new file mode 100644 index 0000000..19a5ab5 --- /dev/null +++ b/src/commands/onboardMentee.ts @@ -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 => { + 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 => { + 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)}`, + }); + } +}; 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/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 cf6c89a..ab0d1a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,11 +10,11 @@ import { GatewayIntentBits, Events, Partials, - MessageFlags, } from "discord.js"; import { scheduleJob } from "node-schedule"; import { App } from "octokit"; import { ids } from "./config/ids.js"; +import { handleInteractionCreate } from "./events/handleInteractionCreate.js"; import { handleMessageCreate } from "./events/handleMessageCreate.js"; import { cacheData } from "./modules/cacheData.js"; import { checkRetroAchievements } from "./modules/checkAchievements.js"; @@ -41,6 +41,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( @@ -60,6 +61,7 @@ const amari: Amari = { partials: [ Partials.Channel ], }), github: octokit, + githubApp: githubApp, lastRssItems: { freeCodeCamp: null, hackerNews: null, @@ -87,12 +89,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) => { @@ -105,22 +113,7 @@ amari.discord.on(Events.MessageCreate, (message) => { amari.discord.on(Events.InteractionCreate, (interaction) => { void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction }); - if (interaction.isButton() && interaction.customId === "resolve") { - if (interaction.user.id !== ids.users.naomi) { - return void interaction.reply({ - content: "Who are you????", - flags: [ MessageFlags.Ephemeral ], - }); - } - return void interaction.message.delete(); - } - if (interaction.isAutocomplete()) { - return void interaction; - } - return void interaction.reply({ - content: "What?", - flags: [ MessageFlags.Ephemeral ], - }); + handleInteractionCreate(amari, interaction); }); amari.discord.on(Events.ThreadCreate, (thread) => { 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/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); + } + } }; diff --git a/src/utils/anthropic.ts b/src/utils/anthropic.ts new file mode 100644 index 0000000..fb73f4a --- /dev/null +++ b/src/utils/anthropic.ts @@ -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 ?? "", +}); diff --git a/src/utils/makeAiRequest.ts b/src/utils/makeAiRequest.ts new file mode 100644 index 0000000..9897b04 --- /dev/null +++ b/src/utils/makeAiRequest.ts @@ -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 => { + 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 };