generated from nhcarrigan/template
feat: add slash commands and context menu command (#16)
## Summary This PR adds a suite of slash commands and a context menu command to Amari, along with shared utilities and quality improvements across the board. ### New Commands - **`/create-issue`** — generates a GitHub issue on a specified repo using AI-drafted content (title, description, acceptance criteria) - **`/create-task`** — creates a task in Naomi's Leantime instance with an AI-drafted description and configurable priority - **`/onboard-mentee`** — automates the mentorship onboarding flow (GitHub invite, forum thread, role assignment) - **Forward to Owner** (context menu, message command) — forwards any message to Naomi with action buttons (contributed by @teklu) ### Shared Utilities - **`src/utils/makeAiRequest.ts`** — a single wrapper around the Anthropic SDK for all AI calls, with Amari's personality prompt baked in and full error handling - **`src/events/handleInteractionCreate.ts`** — extracted interaction handler (was inline in `index.ts`) to keep complexity under control ### Quality Improvements - `ephemeral: true` → `flags: [ MessageFlags.Ephemeral ]` (deprecated API removed) - Full `try/catch` + `logger.error` audit across all modules (`logMenteeJoin`, `checkAchievements`, `processMentorshipRole`, `processGitHubEvent`) - `deployGlobal.ts` replaced with a static `commands.json` payload for manual registration - Amari's personality prompt updated to reflect her actual character — warm, observant, and relentlessly caring ### Notes - `CLIENT_ID` is needed in 1Password at `op://Environment Variables - Naomi/Amari/client id` for the `commands.json` registration call - The forward-to-owner command (PR #13, contributed by @teklu) is fully preserved with original commit authorship ✨ This PR was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-authored-by: Teklu <tekluabayneh@gmail.com> Reviewed-on: #16 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #16.
This commit is contained in:
@@ -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"
|
||||
},
|
||||
"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
|
||||
|
||||
@@ -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"
|
||||
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",
|
||||
naomi: "465650873650118659",
|
||||
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 ],
|
||||
});
|
||||
};
|
||||
+16
-23
@@ -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<string>();
|
||||
}, 10 * 60 * 1000);
|
||||
setInterval(() => {
|
||||
void checkRetroAchievements(amari);
|
||||
}, 10 * 60 * 1000);
|
||||
setInterval(
|
||||
() => {
|
||||
amari.recentlyActiveChannels = new Set<string>();
|
||||
},
|
||||
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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,25 +20,31 @@ export const logMenteeJoin = async(
|
||||
amari: Amari,
|
||||
member: GuildMember,
|
||||
): Promise<void> => {
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -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<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.
|
||||
* @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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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](<https://docs.nhcarrigan.com/mentorship/00-faq/>) 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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