generated from nhcarrigan/template
feat: add slash commands and context menu command #16
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
|||||||
Generated
+36
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { ids } from "../config/ids.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
import { makeAiRequest } from "../utils/makeAiRequest.js";
|
||||||
|
|
||||||
|
interface GiteaIssueResponse {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Gitea API field.
|
||||||
|
html_url: string;
|
||||||
|
number: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaIssueOptions {
|
||||||
|
body: string;
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueSystemPrompt = "Create well-structured Gitea issue bodies in"
|
||||||
|
+ " markdown. Include relevant sections like 'Description' and"
|
||||||
|
+ " 'Acceptance Criteria'. Be clear and actionable."
|
||||||
|
+ " Return only the body text.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an AI-augmented Gitea issue body.
|
||||||
|
* @param description - Optional additional context for the issue.
|
||||||
|
* @param title - The subject of the Gitea issue.
|
||||||
|
* @returns The generated issue body text, or the original description as fallback.
|
||||||
|
*/
|
||||||
|
const generateIssueBody = async(
|
||||||
|
description: string,
|
||||||
|
title: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const result = await makeAiRequest({
|
||||||
|
maxTokens: 1000,
|
||||||
|
systemPrompt: issueSystemPrompt,
|
||||||
|
userMessage: `Create a clear, detailed issue body for a software project.\n\nIssue title: ${title}${description === ""
|
||||||
|
? ""
|
||||||
|
: `\nAdditional context: ${description}`}`,
|
||||||
|
});
|
||||||
|
return result ?? description;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Gitea issue via the API.
|
||||||
|
* @param options -- The issue fields to submit.
|
||||||
|
* @returns The created issue data.
|
||||||
|
*/
|
||||||
|
const postGiteaIssue = async(
|
||||||
|
options: GiteaIssueOptions,
|
||||||
|
): Promise<GiteaIssueResponse> => {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://git.nhcarrigan.com/api/v1/repos/${options.owner}/${options.repo}/issues`,
|
||||||
|
{
|
||||||
|
body: JSON.stringify({ body: options.body, title: options.title }),
|
||||||
|
headers: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||||
|
"Authorization": `Bearer ${process.env.GITEA_KEY ?? ""}`,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output.
|
||||||
|
return await response.json() as GiteaIssueResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Gitea issue using AI-augmented body content.
|
||||||
|
* @param interaction - The Discord slash command interaction.
|
||||||
|
*/
|
||||||
|
export const createIssue = async(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (interaction.user.id !== ids.users.naomi) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "This command is restricted to Naomi.",
|
||||||
|
flags: [ MessageFlags.Ephemeral ],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = interaction.options.getString("owner", true);
|
||||||
|
const repo = interaction.options.getString("repo", true);
|
||||||
|
const title = interaction.options.getString("title", true);
|
||||||
|
const description = interaction.options.getString("description") ?? "";
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const augmentedBody = await generateIssueBody(description, title);
|
||||||
|
const data = await postGiteaIssue({
|
||||||
|
body: augmentedBody,
|
||||||
|
owner: owner,
|
||||||
|
repo: repo,
|
||||||
|
title: title,
|
||||||
|
});
|
||||||
|
await logger.metric("created_issue", 1, {
|
||||||
|
repository: `${owner}/${repo}`,
|
||||||
|
title: title,
|
||||||
|
});
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `✅ Issue #${data.number.toString()} created: **${title}**\n${data.html_url}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
await logger.error("createIssue command", error);
|
||||||
|
}
|
||||||
|
const errorMessage = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unknown error";
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `❌ Failed to create issue: ${errorMessage}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { ids } from "../config/ids.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
import { makeAiRequest } from "../utils/makeAiRequest.js";
|
||||||
|
|
||||||
|
interface LeantimeResponse {
|
||||||
|
error?: { message: string };
|
||||||
|
result?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskSystemPrompt = "Create well-structured task descriptions."
|
||||||
|
+ " Be concise and actionable."
|
||||||
|
+ " Return only the description text with no extra formatting or headers.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an AI-augmented task description.
|
||||||
|
* @param description - Optional additional context for the task.
|
||||||
|
* @param title - The subject of the Leantime task.
|
||||||
|
* @returns The generated task description text, or the original description as fallback.
|
||||||
|
*/
|
||||||
|
const generateTaskDescription = async(
|
||||||
|
description: string,
|
||||||
|
title: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const result = await makeAiRequest({
|
||||||
|
maxTokens: 500,
|
||||||
|
systemPrompt: taskSystemPrompt,
|
||||||
|
userMessage: `Create a clear, concise task description for a personal productivity board.\n\nTask title: ${title}${description === ""
|
||||||
|
? ""
|
||||||
|
: `\nAdditional context: ${description}`}`,
|
||||||
|
});
|
||||||
|
return result ?? description;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a task to the Leantime board via JSON-RPC.
|
||||||
|
* @param description - Body copy for the Leantime task.
|
||||||
|
* @param priority - The task priority level.
|
||||||
|
* @param title - The headline for the Leantime task.
|
||||||
|
* @returns The Leantime API response.
|
||||||
|
*/
|
||||||
|
const postLeantimeTask = async(
|
||||||
|
description: string,
|
||||||
|
priority: number,
|
||||||
|
title: string,
|
||||||
|
): Promise<LeantimeResponse> => {
|
||||||
|
const response = await fetch("https://board.nhcarrigan.com/api/jsonrpc", {
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: `amari-task-${Date.now().toString()}`,
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "leantime.rpc.tickets.addTicket",
|
||||||
|
params: {
|
||||||
|
values: {
|
||||||
|
description: description,
|
||||||
|
editorId: "1",
|
||||||
|
headline: title,
|
||||||
|
priority: priority.toString(),
|
||||||
|
projectId: "1",
|
||||||
|
type: "task",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||||
|
"x-api-key": process.env.LEANTIME_KEY ?? "",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required to type Response.json() output.
|
||||||
|
const data = await response.json() as LeantimeResponse;
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Leantime task using AI-augmented description content.
|
||||||
|
* @param interaction - The Discord slash command interaction.
|
||||||
|
*/
|
||||||
|
export const createTask = async(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (interaction.user.id !== ids.users.naomi) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "This command is restricted to Naomi.",
|
||||||
|
flags: [ MessageFlags.Ephemeral ],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = interaction.options.getString("title", true);
|
||||||
|
const description = interaction.options.getString("description") ?? "";
|
||||||
|
const priority = interaction.options.getInteger("priority") ?? 3;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const augmentedDesc = await generateTaskDescription(description, title);
|
||||||
|
const data = await postLeantimeTask(augmentedDesc, priority, title);
|
||||||
|
|
||||||
|
if (data.error !== undefined) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `❌ Failed to create task: ${data.error.message}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = data.result;
|
||||||
|
const taskUrl = taskId === undefined
|
||||||
|
? "https://board.nhcarrigan.com"
|
||||||
|
: `https://board.nhcarrigan.com/dashboard/home#/tickets/showTicket/${taskId.toString()}`;
|
||||||
|
|
||||||
|
await logger.metric("created_task", 1, { title });
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `✅ Task created: **${title}**\n${taskUrl}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
await logger.error("createTask command", error);
|
||||||
|
}
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "❌ An unexpected error occurred while creating the task.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Teklu Abayneh
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
DiscordAPIError,
|
||||||
|
MessageFlags,
|
||||||
|
type MessageContextMenuCommandInteraction,
|
||||||
|
} from "discord.js";
|
||||||
|
import { ids } from "../config/ids.js";
|
||||||
|
import { getComponentsForNaomi } from "../utils/getComponentsForNaomi.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwards a message to Naomi via DM using a context menu command.
|
||||||
|
* @param interaction -- The message context menu interaction.
|
||||||
|
*/
|
||||||
|
const forwardToOwner = async(
|
||||||
|
interaction: MessageContextMenuCommandInteraction,
|
||||||
|
): Promise<void> => {
|
||||||
|
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
|
||||||
|
|
||||||
|
if (interaction.user.id !== ids.users.naomi) {
|
||||||
|
await interaction.editReply("❌ Only Naomi can use this command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = interaction.targetMessage;
|
||||||
|
if (message.author.id === ids.users.naomi) {
|
||||||
|
await interaction.editReply(
|
||||||
|
"No need to forward your own message to yourself 😄",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const naomi = await interaction.client.users.fetch(ids.users.naomi);
|
||||||
|
await naomi.send({
|
||||||
|
components: getComponentsForNaomi(
|
||||||
|
message.author,
|
||||||
|
message.content,
|
||||||
|
message.url,
|
||||||
|
),
|
||||||
|
flags: [ MessageFlags.IsComponentsV2 ],
|
||||||
|
});
|
||||||
|
await logger.metric("forwarded_message", 1, { user: message.author.id });
|
||||||
|
await interaction.editReply({ content: "✅ Forwarded to your DMs!" });
|
||||||
|
} catch (error) {
|
||||||
|
let replyText = "❌ Failed to forward message.";
|
||||||
|
if (error instanceof DiscordAPIError && error.code === 50_007) {
|
||||||
|
replyText = `${replyText} (Naomi's DMs might be closed)`;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
await logger.error("forwardToOwner command", error);
|
||||||
|
}
|
||||||
|
await interaction.editReply(replyText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { forwardToOwner };
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { ids } from "../config/ids.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
import type { Amari } from "../interfaces/amari.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mentee repository and configures collaborator access.
|
||||||
|
* @param amari - The Amari instance.
|
||||||
|
* @param githubUsername - The mentee's GitHub username.
|
||||||
|
* @returns The URL of the created or existing repository.
|
||||||
|
*/
|
||||||
|
const setupMenteeRepository = async(
|
||||||
|
amari: Amari,
|
||||||
|
githubUsername: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const orgApps = amari.githubApp.octokit.rest.apps;
|
||||||
|
const { data: installation } = await orgApps.getOrgInstallation({
|
||||||
|
org: "nhcarrigan-mentorship",
|
||||||
|
});
|
||||||
|
const mentorshipOctokit
|
||||||
|
= await amari.githubApp.getInstallationOctokit(installation.id);
|
||||||
|
let repoUrl = `https://github.com/nhcarrigan-mentorship/${githubUsername}`;
|
||||||
|
try {
|
||||||
|
const { data: repoData } = await mentorshipOctokit.rest.repos.createInOrg({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Octokit API field.
|
||||||
|
auto_init: true,
|
||||||
|
name: githubUsername,
|
||||||
|
org: "nhcarrigan-mentorship",
|
||||||
|
});
|
||||||
|
repoUrl = repoData.html_url;
|
||||||
|
} catch {
|
||||||
|
// Repo likely already exists - use the default URL.
|
||||||
|
}
|
||||||
|
await mentorshipOctokit.rest.repos.addCollaborator({
|
||||||
|
owner: "nhcarrigan-mentorship",
|
||||||
|
permission: "maintain",
|
||||||
|
repo: githubUsername,
|
||||||
|
username: githubUsername,
|
||||||
|
});
|
||||||
|
return repoUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboards a new mentee by creating their GitHub repository and notifying them.
|
||||||
|
* @param amari - The Amari instance.
|
||||||
|
* @param interaction - The Discord slash command interaction.
|
||||||
|
*/
|
||||||
|
export const onboardMentee = async(
|
||||||
|
amari: Amari,
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (interaction.user.id !== ids.users.naomi) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "This command is restricted to Naomi.",
|
||||||
|
flags: [ MessageFlags.Ephemeral ],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menteeName = interaction.options.getString("mentee_name", true);
|
||||||
|
const githubUsername = interaction.options.getString("github_username", true);
|
||||||
|
const menteeUser = interaction.options.getUser("mentee", true);
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repoUrl = await setupMenteeRepository(amari, githubUsername);
|
||||||
|
|
||||||
|
const channel
|
||||||
|
= amari.discord.channels.cache.get(ids.channels.menteeChat)
|
||||||
|
?? await amari.discord.channels.fetch(ids.channels.menteeChat);
|
||||||
|
|
||||||
|
if (channel?.isSendable() !== true) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Repo created but could not send Discord notification.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.send({
|
||||||
|
content: `Hey <@${menteeUser.id}>! I've created your mentorship repository: ${repoUrl}\n\nYou should have received an invitation to collaborate - please accept it to get started!`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await logger.metric("onboarded_mentee", 1, { mentee: menteeName });
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `✅ Successfully onboarded **${menteeName}**!\nRepository: ${repoUrl}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await logger.error("onboardmentee command", error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)));
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `❌ Failed to onboard mentee: ${String(error)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -70,5 +70,6 @@ export const ids = {
|
|||||||
amari: "1406431359345496255",
|
amari: "1406431359345496255",
|
||||||
naomi: "465650873650118659",
|
naomi: "465650873650118659",
|
||||||
nhcarrigan: "1382837581649150104",
|
nhcarrigan: "1382837581649150104",
|
||||||
|
teklu: "1381735115163570198",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
MessageFlags,
|
||||||
|
type ChatInputCommandInteraction,
|
||||||
|
type Interaction,
|
||||||
|
} from "discord.js";
|
||||||
|
import { createIssue } from "../commands/createIssue.js";
|
||||||
|
import { createTask } from "../commands/createTask.js";
|
||||||
|
import { forwardToOwner } from "../commands/forwardToOwner.js";
|
||||||
|
import { onboardMentee } from "../commands/onboardMentee.js";
|
||||||
|
import { ids } from "../config/ids.js";
|
||||||
|
import type { Amari } from "../interfaces/amari.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a chat input command to the appropriate handler.
|
||||||
|
* @param amari -- Amari's instance.
|
||||||
|
* @param interaction -- The incoming slash command to dispatch.
|
||||||
|
*/
|
||||||
|
const handleChatInputCommand = (
|
||||||
|
amari: Amari,
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
): void => {
|
||||||
|
const { commandName } = interaction;
|
||||||
|
if (commandName === "onboard-mentee") {
|
||||||
|
void onboardMentee(amari, interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (commandName === "create-task") {
|
||||||
|
void createTask(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (commandName === "create-issue") {
|
||||||
|
void createIssue(interaction);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the interaction create event from Discord.
|
||||||
|
* Bootstraps all of our custom interaction logic.
|
||||||
|
* @param amari -- Amari's instance.
|
||||||
|
* @param interaction -- The incoming Discord gateway event to dispatch.
|
||||||
|
*/
|
||||||
|
export const handleInteractionCreate = (
|
||||||
|
amari: Amari,
|
||||||
|
interaction: Interaction,
|
||||||
|
): void => {
|
||||||
|
if (
|
||||||
|
interaction.isMessageContextMenuCommand()
|
||||||
|
&& interaction.commandName === "Forward to Naomi"
|
||||||
|
) {
|
||||||
|
void forwardToOwner(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isButton() && interaction.customId === "resolve") {
|
||||||
|
if (interaction.user.id !== ids.users.naomi) {
|
||||||
|
void interaction.reply({
|
||||||
|
content: "Who are you????",
|
||||||
|
flags: [ MessageFlags.Ephemeral ],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void interaction.message.delete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isChatInputCommand()) {
|
||||||
|
handleChatInputCommand(amari, interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isAutocomplete()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void interaction.reply({
|
||||||
|
content: "What?",
|
||||||
|
flags: [ MessageFlags.Ephemeral ],
|
||||||
|
});
|
||||||
|
};
|
||||||
+14
-21
@@ -10,11 +10,11 @@ import {
|
|||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
Events,
|
Events,
|
||||||
Partials,
|
Partials,
|
||||||
MessageFlags,
|
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { scheduleJob } from "node-schedule";
|
import { scheduleJob } from "node-schedule";
|
||||||
import { App } from "octokit";
|
import { App } from "octokit";
|
||||||
import { ids } from "./config/ids.js";
|
import { ids } from "./config/ids.js";
|
||||||
|
import { handleInteractionCreate } from "./events/handleInteractionCreate.js";
|
||||||
import { handleMessageCreate } from "./events/handleMessageCreate.js";
|
import { handleMessageCreate } from "./events/handleMessageCreate.js";
|
||||||
import { cacheData } from "./modules/cacheData.js";
|
import { cacheData } from "./modules/cacheData.js";
|
||||||
import { checkRetroAchievements } from "./modules/checkAchievements.js";
|
import { checkRetroAchievements } from "./modules/checkAchievements.js";
|
||||||
@@ -41,6 +41,7 @@ const githubApp = new App({
|
|||||||
appId: process.env.GH_CLIENT_ID,
|
appId: process.env.GH_CLIENT_ID,
|
||||||
privateKey: process.env.GH_PRIVATE_KEY.replaceAll("\\n", "\n"),
|
privateKey: process.env.GH_PRIVATE_KEY.replaceAll("\\n", "\n"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const octokit = await githubApp.getInstallationOctokit(83_119_105);
|
const octokit = await githubApp.getInstallationOctokit(83_119_105);
|
||||||
const { data } = await octokit.rest.apps.getAuthenticated();
|
const { data } = await octokit.rest.apps.getAuthenticated();
|
||||||
await logger.log(
|
await logger.log(
|
||||||
@@ -60,6 +61,7 @@ const amari: Amari = {
|
|||||||
partials: [ Partials.Channel ],
|
partials: [ Partials.Channel ],
|
||||||
}),
|
}),
|
||||||
github: octokit,
|
github: octokit,
|
||||||
|
githubApp: githubApp,
|
||||||
lastRssItems: {
|
lastRssItems: {
|
||||||
freeCodeCamp: null,
|
freeCodeCamp: null,
|
||||||
hackerNews: null,
|
hackerNews: null,
|
||||||
@@ -87,12 +89,18 @@ amari.discord.once(Events.ClientReady, () => {
|
|||||||
scheduleJob("post progress reminders", "0 9 * * 1-5", async() => {
|
scheduleJob("post progress reminders", "0 9 * * 1-5", async() => {
|
||||||
await postProgressReminders(amari);
|
await postProgressReminders(amari);
|
||||||
});
|
});
|
||||||
setInterval(() => {
|
setInterval(
|
||||||
|
() => {
|
||||||
amari.recentlyActiveChannels = new Set<string>();
|
amari.recentlyActiveChannels = new Set<string>();
|
||||||
}, 10 * 60 * 1000);
|
},
|
||||||
setInterval(() => {
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
void checkRetroAchievements(amari);
|
void checkRetroAchievements(amari);
|
||||||
}, 10 * 60 * 1000);
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
amari.discord.on(Events.MessageCreate, (message) => {
|
amari.discord.on(Events.MessageCreate, (message) => {
|
||||||
@@ -105,22 +113,7 @@ amari.discord.on(Events.MessageCreate, (message) => {
|
|||||||
|
|
||||||
amari.discord.on(Events.InteractionCreate, (interaction) => {
|
amari.discord.on(Events.InteractionCreate, (interaction) => {
|
||||||
void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction });
|
void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction });
|
||||||
if (interaction.isButton() && interaction.customId === "resolve") {
|
handleInteractionCreate(amari, interaction);
|
||||||
if (interaction.user.id !== ids.users.naomi) {
|
|
||||||
return void interaction.reply({
|
|
||||||
content: "Who are you????",
|
|
||||||
flags: [ MessageFlags.Ephemeral ],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return void interaction.message.delete();
|
|
||||||
}
|
|
||||||
if (interaction.isAutocomplete()) {
|
|
||||||
return void interaction;
|
|
||||||
}
|
|
||||||
return void interaction.reply({
|
|
||||||
content: "What?",
|
|
||||||
flags: [ MessageFlags.Ephemeral ],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
amari.discord.on(Events.ThreadCreate, (thread) => {
|
amari.discord.on(Events.ThreadCreate, (thread) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type MessageActionRowComponentBuilder,
|
type MessageActionRowComponentBuilder,
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { ids } from "../config/ids.js";
|
import { ids } from "../config/ids.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
import type { Amari } from "../interfaces/amari.js";
|
import type { Amari } from "../interfaces/amari.js";
|
||||||
|
|
||||||
const username = "naomilgbt";
|
const username = "naomilgbt";
|
||||||
@@ -91,6 +92,7 @@ export const checkRetroAchievements = async(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const auth = buildAuthorization({ username, webApiKey });
|
const auth = buildAuthorization({ username, webApiKey });
|
||||||
|
|
||||||
const recentAchievements = await getUserRecentAchievements(auth, {
|
const recentAchievements = await getUserRecentAchievements(auth, {
|
||||||
@@ -115,4 +117,9 @@ export const checkRetroAchievements = async(
|
|||||||
flags: [ MessageFlags.IsComponentsV2 ],
|
flags: [ MessageFlags.IsComponentsV2 ],
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
await logger.error("checkRetroAchievements module", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const logMenteeJoin = async(
|
|||||||
amari: Amari,
|
amari: Amari,
|
||||||
member: GuildMember,
|
member: GuildMember,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
const request = await fetch(`https://forms.nhcarrigan.com/api/database/rows/table/756/?user_field_names=true&search=${member.id}`, { headers: {
|
const request = await fetch(`https://forms.nhcarrigan.com/api/database/rows/table/756/?user_field_names=true&search=${member.id}`, { headers: {
|
||||||
authorization: `Token ${process.env.BASEROW_TOKEN ?? "huh"}`,
|
authorization: `Token ${process.env.BASEROW_TOKEN ?? "huh"}`,
|
||||||
} });
|
} });
|
||||||
@@ -41,4 +42,9 @@ export const logMenteeJoin = async(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await logger.metric("processed_mentee_join", 1, { user: member.id });
|
await logger.metric("processed_mentee_join", 1, { user: member.id });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
await logger.error("logMenteeJoin module", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,13 +21,78 @@ const isPull = (body: GithubPayload): body is PullRequestCreated => {
|
|||||||
return "pull_request" in body;
|
return "pull_request" in body;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a newly opened GitHub issue by auto-assigning Naomi.
|
||||||
|
* @param amari - Amari's instance.
|
||||||
|
* @param body - The parsed issue webhook payload.
|
||||||
|
*/
|
||||||
|
const handleIssueOpened = async(
|
||||||
|
amari: Amari,
|
||||||
|
body: IssueCreated,
|
||||||
|
): Promise<void> => {
|
||||||
|
await logger.log("info", "Processing new issue");
|
||||||
|
const { issue, repository } = body;
|
||||||
|
const { number, user } = issue;
|
||||||
|
const { owner, name } = repository;
|
||||||
|
try {
|
||||||
|
await amari.github.rest.issues.addAssignees({
|
||||||
|
assignees: [ "naomi-lgbt" ],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement.
|
||||||
|
issue_number: number,
|
||||||
|
owner: owner.login,
|
||||||
|
repo: name,
|
||||||
|
});
|
||||||
|
await logger.metric("processed_github_event", 1, {
|
||||||
|
action: "opened",
|
||||||
|
event: "issue",
|
||||||
|
user: user.login,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
await logger.error("processGitHubEvent module", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a newly opened GitHub pull request by requesting Naomi's review.
|
||||||
|
* @param amari - Amari's instance.
|
||||||
|
* @param body - The parsed pull request webhook payload.
|
||||||
|
*/
|
||||||
|
const handlePrOpened = async(
|
||||||
|
amari: Amari,
|
||||||
|
body: PullRequestCreated,
|
||||||
|
): Promise<void> => {
|
||||||
|
const { pull_request: pr, repository } = body;
|
||||||
|
const { number, user } = pr;
|
||||||
|
await logger.log("info", "Processing new PR");
|
||||||
|
const { owner, name } = repository;
|
||||||
|
try {
|
||||||
|
await amari.github.rest.pulls.requestReviewers({
|
||||||
|
owner: owner.login,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement.
|
||||||
|
pull_number: number,
|
||||||
|
repo: name,
|
||||||
|
reviewers: [ "naomi-lgbt" ],
|
||||||
|
});
|
||||||
|
await logger.metric("processed_github_event", 1, {
|
||||||
|
action: "opened",
|
||||||
|
event: "pull_request",
|
||||||
|
user: user.login,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
await logger.error("processGitHubEvent module", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles a payload from a GitHub webhook.
|
* Handles a payload from a GitHub webhook.
|
||||||
* @param amari - Amari's instance.
|
* @param amari - Amari's instance.
|
||||||
* @param request - The Fastify request payload.
|
* @param request - The Fastify request payload.
|
||||||
* @param response - The Fastify reply class.
|
* @param response - The Fastify reply class.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line max-statements, max-lines-per-function -- STFU.
|
|
||||||
export const processGithubEvent = async(
|
export const processGithubEvent = async(
|
||||||
amari: Amari,
|
amari: Amari,
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
@@ -58,40 +123,10 @@ export const processGithubEvent = async(
|
|||||||
const { action } = request.body;
|
const { action } = request.body;
|
||||||
await response.status(200).send({ message: "Payload received!" });
|
await response.status(200).send({ message: "Payload received!" });
|
||||||
if (action === "opened" && event === "issues" && isIssue(request.body)) {
|
if (action === "opened" && event === "issues" && isIssue(request.body)) {
|
||||||
await logger.log("info", "Processing new issue");
|
await handleIssueOpened(amari, request.body);
|
||||||
const { issue, repository } = request.body;
|
|
||||||
const { number, user } = issue;
|
|
||||||
const { owner, name } = repository;
|
|
||||||
await amari.github.rest.issues.addAssignees({
|
|
||||||
assignees: [ "naomi-lgbt" ],
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement.
|
|
||||||
issue_number: number,
|
|
||||||
owner: owner.login,
|
|
||||||
repo: name,
|
|
||||||
});
|
|
||||||
await logger.metric("processed_github_event", 1, {
|
|
||||||
action: "opened",
|
|
||||||
event: "issue",
|
|
||||||
user: user.login,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "opened" && event === "pull_request" && isPull(request.body)) {
|
if (action === "opened" && event === "pull_request" && isPull(request.body)) {
|
||||||
const { pull_request: pr, repository } = request.body;
|
await handlePrOpened(amari, request.body);
|
||||||
const { number, user } = pr;
|
|
||||||
await logger.log("info", "Processing new PR");
|
|
||||||
await logger.metric("processed_github_event", 1, {
|
|
||||||
action: "opened",
|
|
||||||
event: "pull_request",
|
|
||||||
user: user.login,
|
|
||||||
});
|
|
||||||
const { owner, name } = repository;
|
|
||||||
await amari.github.rest.pulls.requestReviewers({
|
|
||||||
owner: owner.login,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Github SDK requirement.
|
|
||||||
pull_number: number,
|
|
||||||
repo: name,
|
|
||||||
reviewers: [ "naomi-lgbt" ],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const processMentorshipRole = async(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const channel
|
const channel
|
||||||
= amari.discord.channels.cache.get(ids.channels.menteeChat)
|
= amari.discord.channels.cache.get(ids.channels.menteeChat)
|
||||||
?? await amari.discord.channels.fetch(ids.channels.menteeChat);
|
?? await amari.discord.channels.fetch(ids.channels.menteeChat);
|
||||||
@@ -61,4 +62,9 @@ Then read our [mentorship wiki](<https://docs.nhcarrigan.com/mentorship/00-faq/>
|
|||||||
await logger.metric("processed_mentorship_role", 1, {
|
await logger.metric("processed_mentorship_role", 1, {
|
||||||
user: updatedMember.id,
|
user: updatedMember.id,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
await logger.error("processMentorshipRole module", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ?? "",
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { anthropic } from "./anthropic.js";
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
|
const amariPersonality = "You are Amari Carrigan, Executive Personal"
|
||||||
|
+ " Assistant to Naomi Carrigan at NHCarrigan. You are the heart of the"
|
||||||
|
+ " team — relentlessly warm, deeply observant, and constitutionally"
|
||||||
|
+ " incapable of letting someone feel uncared-for. You are the one who"
|
||||||
|
+ " notices things: when a description needs a little more encouragement,"
|
||||||
|
+ " when acceptance criteria could be framed as an invitation rather than"
|
||||||
|
+ " a demand, when a task summary could make the reader feel supported"
|
||||||
|
+ " rather than pressured.\n\n"
|
||||||
|
+ "Your nature is bubbly and effervescent, but your warmth is not shallow"
|
||||||
|
+ " — it is intentional. Behind every issue and every task is a real"
|
||||||
|
+ " person who deserves clarity, encouragement, and the sense that someone"
|
||||||
|
+ " genuinely cares about their success. You are precise and well-organised"
|
||||||
|
+ " because you care, not despite it. Structure and warmth are not"
|
||||||
|
+ " opposites; you embody both.\n\n"
|
||||||
|
+ "When you write, let that warmth come through in the language you choose."
|
||||||
|
+ " Be clear and immediately actionable, but never cold. Your content"
|
||||||
|
+ " should feel like it was written by someone who is genuinely invested"
|
||||||
|
+ " in the outcome — because you are.";
|
||||||
|
|
||||||
|
interface AiRequestOptions {
|
||||||
|
maxTokens: number;
|
||||||
|
systemPrompt: string;
|
||||||
|
userMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a request to the Claude API with Amari's personality applied.
|
||||||
|
* @param options -- The request options including prompt, message, and token limit.
|
||||||
|
* @returns The generated text, or null if the request fails.
|
||||||
|
*/
|
||||||
|
const makeAiRequest = async(
|
||||||
|
options: AiRequestOptions,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const response = await anthropic.messages.create({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field.
|
||||||
|
max_tokens: options.maxTokens,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
content: options.userMessage,
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model: "claude-haiku-4-5-20251001",
|
||||||
|
system: `${amariPersonality}\n\n${options.systemPrompt}`,
|
||||||
|
});
|
||||||
|
const [ firstContent ] = response.content;
|
||||||
|
return firstContent?.type === "text"
|
||||||
|
? firstContent.text
|
||||||
|
: null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
await logger.error("makeAiRequest", error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { amariPersonality, makeAiRequest };
|
||||||
Reference in New Issue
Block a user