wip: commands

This commit is contained in:
2026-03-03 09:38:18 -08:00
parent 5a355e4775
commit 9df2d9ddc4
10 changed files with 433 additions and 1 deletions
+86
View File
@@ -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
}
]
}
]
+1
View File
@@ -23,6 +23,7 @@
"typescript": "5.9.3" "typescript": "5.9.3"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.78.0",
"@nhcarrigan/discord-analytics": "0.0.6", "@nhcarrigan/discord-analytics": "0.0.6",
"@nhcarrigan/logger": "1.1.1", "@nhcarrigan/logger": "1.1.1",
"@retroachievements/api": "2.10.0", "@retroachievements/api": "2.10.0",
+36
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@anthropic-ai/sdk':
specifier: 0.78.0
version: 0.78.0
'@nhcarrigan/discord-analytics': '@nhcarrigan/discord-analytics':
specifier: 0.0.6 specifier: 0.0.6
version: 0.0.6(@nhcarrigan/logger@1.1.1)(discord.js@14.22.0) version: 0.0.6(@nhcarrigan/logger@1.1.1)(discord.js@14.22.0)
@@ -54,6 +57,15 @@ importers:
packages: packages:
'@anthropic-ai/sdk@0.78.0':
resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
'@babel/code-frame@7.27.1': '@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -62,6 +74,10 @@ packages:
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@discordjs/builders@1.11.3': '@discordjs/builders@1.11.3':
resolution: {integrity: sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==} resolution: {integrity: sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==}
engines: {node: '>=16.11.0'} engines: {node: '>=16.11.0'}
@@ -1610,6 +1626,10 @@ packages:
json-schema-ref-resolver@2.0.1: json-schema-ref-resolver@2.0.1:
resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==} resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==}
json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
json-schema-traverse@0.4.1: json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -2176,6 +2196,9 @@ packages:
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
engines: {node: '>=12'} engines: {node: '>=12'}
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
ts-api-utils@1.4.3: ts-api-utils@1.4.3:
resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -2392,6 +2415,10 @@ packages:
snapshots: snapshots:
'@anthropic-ai/sdk@0.78.0':
dependencies:
json-schema-to-ts: 3.1.1
'@babel/code-frame@7.27.1': '@babel/code-frame@7.27.1':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.27.1 '@babel/helper-validator-identifier': 7.27.1
@@ -2400,6 +2427,8 @@ snapshots:
'@babel/helper-validator-identifier@7.27.1': {} '@babel/helper-validator-identifier@7.27.1': {}
'@babel/runtime@7.28.6': {}
'@discordjs/builders@1.11.3': '@discordjs/builders@1.11.3':
dependencies: dependencies:
'@discordjs/formatters': 0.6.1 '@discordjs/formatters': 0.6.1
@@ -4193,6 +4222,11 @@ snapshots:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
json-schema-to-ts@3.1.1:
dependencies:
'@babel/runtime': 7.28.6
ts-algebra: 2.0.0
json-schema-traverse@0.4.1: {} json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {} json-schema-traverse@1.0.0: {}
@@ -4813,6 +4847,8 @@ snapshots:
toad-cache@3.7.0: {} toad-cache@3.7.0: {}
ts-algebra@2.0.0: {}
ts-api-utils@1.4.3(typescript@5.9.3): ts-api-utils@1.4.3(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
+3
View File
@@ -7,3 +7,6 @@ GH_WEBHOOK_SECRET="op://Environment Variables - Naomi/Amari/gh webhook secret"
BASEROW_SECRET="op://Environment Variables - Naomi/Amari/baserow hook auth" BASEROW_SECRET="op://Environment Variables - Naomi/Amari/baserow hook auth"
BASEROW_TOKEN="op://Environment Variables - Naomi/Amari/baserow token" BASEROW_TOKEN="op://Environment Variables - Naomi/Amari/baserow token"
RA_KEY="op://Environment Variables - Naomi/Amari/retroachievements key" RA_KEY="op://Environment Variables - Naomi/Amari/retroachievements key"
LEANTIME_KEY="op://Environment Variables - Naomi/Amari/leantime key"
GITEA_KEY="op://Environment Variables - Naomi/Amari/gitea key"
ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key"
+91
View File
@@ -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<void> => {
if (interaction.user.id !== ids.users.naomi) {
await interaction.reply({
content: "This command is restricted to Naomi.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
const owner = interaction.options.getString("owner", true);
const repo = interaction.options.getString("repo", true);
const title = interaction.options.getString("title", true);
const description = interaction.options.getString("description") ?? "";
await interaction.deferReply({ 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}`,
});
};
+100
View File
@@ -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<void> => {
if (interaction.user.id !== ids.users.naomi) {
await interaction.reply({
content: "This command is restricted to Naomi.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
const title = interaction.options.getString("title", true);
const description = interaction.options.getString("description") ?? "";
const priority = interaction.options.getInteger("priority") ?? 3;
await interaction.deferReply({ 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}`,
});
};
+87
View File
@@ -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<void> => {
if (interaction.user.id !== ids.users.naomi) {
await interaction.reply({
content: "This command is restricted to Naomi.",
flags: [ MessageFlags.Ephemeral ],
});
return;
}
const menteeName = interaction.options.getString("mentee_name", true);
const githubUsername = interaction.options.getString("github_username", true);
const menteeUser = interaction.options.getUser("mentee", true);
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)}`,
});
}
};
+16
View File
@@ -14,6 +14,9 @@ import {
} from "discord.js"; } from "discord.js";
import { scheduleJob } from "node-schedule"; import { scheduleJob } from "node-schedule";
import { App } from "octokit"; import { App } from "octokit";
import { createIssue } from "./commands/createIssue.js";
import { createTask } from "./commands/createTask.js";
import { onboardMentee } from "./commands/onboardMentee.js";
import { ids } from "./config/ids.js"; import { ids } from "./config/ids.js";
import { handleMessageCreate } from "./events/handleMessageCreate.js"; import { handleMessageCreate } from "./events/handleMessageCreate.js";
import { cacheData } from "./modules/cacheData.js"; import { cacheData } from "./modules/cacheData.js";
@@ -60,6 +63,7 @@ const amari: Amari = {
partials: [ Partials.Channel ], partials: [ Partials.Channel ],
}), }),
github: octokit, github: octokit,
githubApp,
lastRssItems: { lastRssItems: {
freeCodeCamp: null, freeCodeCamp: null,
hackerNews: null, hackerNews: null,
@@ -114,6 +118,18 @@ amari.discord.on(Events.InteractionCreate, (interaction) => {
} }
return void interaction.message.delete(); 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()) { if (interaction.isAutocomplete()) {
return void interaction; return void interaction;
} }
+1
View File
@@ -10,6 +10,7 @@ import type { App } from "octokit";
export interface Amari { export interface Amari {
discord: Client; discord: Client;
github: App["octokit"]; github: App["octokit"];
githubApp: App;
lastRssItems: { lastRssItems: {
freeCodeCamp: string | null; freeCodeCamp: string | null;
hackerNews: string | null; hackerNews: string | null;
+11
View File
@@ -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 ?? "",
});