generated from nhcarrigan/template
feat: add slash commands and context menu command #16
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
Generated
+36
@@ -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
|
||||
|
||||
@@ -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_TOKEN="op://Environment Variables - Naomi/Amari/baserow token"
|
||||
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"
|
||||
@@ -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}`,
|
||||
});
|
||||
};
|
||||
@@ -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}`,
|
||||
});
|
||||
};
|
||||
@@ -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)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? "",
|
||||
});
|
||||
Reference in New Issue
Block a user