feat: initial prototype
Node.js CI / Lint and Test (push) Successful in 37s

This commit is contained in:
2025-08-19 17:16:42 -07:00
parent b9579fede0
commit a05de6ba2a
18 changed files with 5164 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export const ids = {
channels: {
mentorshipGoalForum: "1400629118110011526",
mentorshipProjectForum: "1400616702265266186",
},
roles: {
nhcarrigan: "1355033209037127771",
},
tags: {
goal: {
member: "1406355263811752147",
naomi: "1406355221847871740",
},
project: {
member: "1406355801983025183",
naomi: "1406355770336870420",
},
},
users: {
amari: "1406431359345496255",
naomi: "465650873650118659",
nhcarrigan: "1382837581649150104",
},
};
+13
View File
@@ -0,0 +1,13 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Lots of big strings here. */
export const responses = {
dm: "Hiya! I am Naomi's personal assistant, so I am afraid I cannot help you. But I have forwarded your message to Naomi!",
naomiMentioned: "Hello~! It looks like you mentioned Naomi. I have notified her, and she will respond as soon as she can.",
teamMentioned: "Hello~! It looks like you mentioned our team. I have notified Naomi, and she will respond as soon as she can.",
};
+27
View File
@@ -0,0 +1,27 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { respondToMention } from "../modules/respondToMention.js";
import { updateMentorshipThread } from "../modules/updateMentorshipThread.js";
import type { Amari } from "../interfaces/amari.js";
import type { Message } from "discord.js";
/**
* Handles the message create event from Discord.
* Bootstraps all of our custom logic modules.
* @param amari -- Amari's instance.
* @param message -- The guild message payload from Discord.
*/
export const handleMessageCreate = async(
amari: Amari,
message: Message<true>,
): Promise<void> => {
if (message.author.bot || message.system) {
return;
}
await updateMentorshipThread(amari, message);
await respondToMention(amari, message);
};
+39
View File
@@ -0,0 +1,39 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Client, GatewayIntentBits, Events, Partials } from "discord.js";
import { handleMessageCreate } from "./events/handleMessageCreate.js";
import { respondToDm } from "./modules/respondToDm.js";
import { instantiateServer } from "./server/serve.js";
import { logger } from "./utils/logger.js";
import type { Amari } from "./interfaces/amari.js";
const amari: Amari = {
discord: new Client({ intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.DirectMessages,
],
partials: [ Partials.Channel ] }),
};
amari.discord.once(Events.ClientReady, () => {
void logger.log("debug",
`Authenticated to Discord as ${amari.discord.user?.username ?? "unknown"}`);
});
amari.discord.on(Events.MessageCreate, (message) => {
if (!message.inGuild()) {
void respondToDm(amari, message);
return;
}
void handleMessageCreate(amari, message);
});
await amari.discord.login(process.env.BOT_TOKEN);
instantiateServer();
+11
View File
@@ -0,0 +1,11 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Client } from "discord.js";
export interface Amari {
discord: Client;
}
+42
View File
@@ -0,0 +1,42 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type Message } from "discord.js";
import { ids } from "../config/ids.js";
import { responses } from "../config/responses.js";
import { getComponentsForNaomi } from "../utils/getComponentsForNaomi.js";
import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js";
/**
* Responds to a DM.
* @param amari -- Amari's instance.
* @param message -- The DM message payload from Discord.
*/
export const respondToDm = async(
amari: Amari,
message: Message,
): Promise<void> => {
try {
const { author, content, system, url } = message;
if (author.bot || system || author.id === ids.users.naomi) {
return;
}
const naomi = amari.discord.users.cache.get(ids.users.naomi)
?? await amari.discord.users.fetch(ids.users.naomi);
await naomi.send({
components: getComponentsForNaomi(author, content, url),
flags: [ MessageFlags.IsComponentsV2 ],
});
await message.reply({
content: responses.dm,
});
} catch (error) {
if (error instanceof Error) {
await logger.error("respond to DM module", error);
}
}
};
+77
View File
@@ -0,0 +1,77 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type Message } from "discord.js";
import { ids } from "../config/ids.js";
import { responses } from "../config/responses.js";
import { getComponentsForNaomi } from "../utils/getComponentsForNaomi.js";
import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js";
/**
* Checks if a message mentions Naomi or our team role.
* If so, responds.
* @param amari -- Amari's instance.
* @param message -- The guild message payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function, complexity -- Mainly those reply options...
export const respondToMention = async(
amari: Amari,
message: Message<true>,
): Promise<void> => {
try {
const naomi = amari.discord.users.cache.get(ids.users.naomi)
?? await amari.discord.users.fetch(ids.users.naomi);
const { mentions, content, author, url } = message;
if (author.bot || author.id === ids.users.naomi) {
return;
}
if (mentions.has(ids.users.naomi, {
ignoreEveryone: true,
ignoreRepliedUser: true,
ignoreRoles: true,
}) || /naomi/i.test(content)) {
await message.reply({
allowedMentions: {
repliedUser: false,
},
content: responses.naomiMentioned,
});
await naomi.send(
{
components: getComponentsForNaomi(author, content, url),
flags: [ MessageFlags.IsComponentsV2 ],
},
);
return;
}
if (mentions.has(ids.roles.nhcarrigan, {
ignoreEveryone: true,
ignoreRepliedUser: true,
}) || mentions.has(ids.users.nhcarrigan, {
ignoreEveryone: true,
ignoreRepliedUser: true,
ignoreRoles: true,
}) || /nhcarrigan/i.test(content)) {
await message.reply({
allowedMentions: {
repliedUser: false,
},
content: responses.naomiMentioned,
});
await naomi.send(
{
components: getComponentsForNaomi(author, content, url),
flags: [ MessageFlags.IsComponentsV2 ],
},
);
}
} catch (error) {
if (error instanceof Error) {
await logger.error("respond to mention module", error);
}
}
};
+55
View File
@@ -0,0 +1,55 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js";
import type { Message } from "discord.js";
/**
* Processes a message in a mentorship thread. Applies either
* the `waiting on member` tag or the `waiting on naomi` tag,
* depending on who sent the message.
* @param _amari -- Amari's instance.
* @param message -- The guild message payload from Discord.
*/
export const updateMentorshipThread = async(
_amari: Amari,
message: Message<true>,
): Promise<void> => {
try {
const { channel, author } = message;
if (!channel.isThread() || channel.parent?.isThreadOnly() !== true) {
return;
}
const { mentorshipGoalForum, mentorshipProjectForum } = ids.channels;
if (![
mentorshipGoalForum,
mentorshipProjectForum,
].includes(channel.parent.id)) {
return;
}
const memberTag = channel.parentId === mentorshipGoalForum
? ids.tags.goal.member
: ids.tags.project.member;
const naomiTag = channel.parentId === mentorshipGoalForum
? ids.tags.goal.naomi
: ids.tags.project.naomi;
if (author.id === ids.users.naomi) {
await channel.setAppliedTags([ ...channel.appliedTags.filter((tag) => {
return tag !== naomiTag;
}), memberTag ]);
return;
}
await channel.setAppliedTags([ ...channel.appliedTags.filter((tag) => {
return tag !== memberTag;
}), naomiTag ]);
} catch (error) {
if (error instanceof Error) {
await logger.error("update mentorship thread module", error);
}
}
};
+79
View File
@@ -0,0 +1,79 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import fastify from "fastify";
import { logger } from "../utils/logger.js";
const html = `<!DOCTYPE html>
<html>
<head>
<title>Amari</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Naomi's virtual personal assistant on Discord." />
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
</head>
<body>
<main>
<h1>Amari/h1>
<img src="https://cdn.nhcarrigan.com/new-avatars/amari.png" width="250" alt="Amari" />
<section>
<p>Naomi's Virtual Personal Assistant on Discord.</p>
<a href="https://chat.nhcarrigan.com" class="social-button discord-button" style="display: inline-block; background-color: #5865F2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; margin: 5px;">
<i class="fab fa-discord"></i> Join Discord
</a>
</section>
<section>
<h2>Links</h2>
<p>
<a href="https://git.nhcarrigan.com/nhcarrigan/amari">
<i class="fa-solid fa-code"></i> Source Code
</a>
</p>
<p>
<a href="https://docs.nhcarrigan.com/">
<i class="fa-solid fa-book"></i> Documentation
</a>
</p>
<p>
<a href="https://chat.nhcarrigan.com">
<i class="fa-solid fa-circle-info"></i> Support
</a>
</p>
</section>
</main>
</body>
</html>`;
/**
* Starts up a web server for health monitoring.
*/
export const instantiateServer = (): void => {
try {
const server = fastify({
logger: false,
});
server.get("/", (_request, response) => {
response.header("Content-Type", "text/html");
response.send(html);
});
server.listen({ port: 5044 }, (error) => {
if (error) {
void logger.error("instantiate server", error);
return;
}
void logger.log("debug", "Server listening on port 5044.");
});
} catch (error) {
if (error instanceof Error) {
void logger.error("instantiate server", error);
return;
}
void logger.error("instantiate server", new Error(String(error)));
}
};
+63
View File
@@ -0,0 +1,63 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { User, APIMessageTopLevelComponent } from "discord.js";
/**
* Generates the components to forward a message to Naomi.
* @param author -- The user object that created the message.
* @param content -- The text content of the message.
* @param url -- The link to the message.
* @returns An array of message component objects.
*/
export const getComponentsForNaomi
= (
author: User,
content: string,
url: string,
): Array<APIMessageTopLevelComponent> => {
return [
{
components: [
{
accessory: {
description: null,
media: {
url: author.displayAvatarURL(),
},
spoiler: false,
type: 11,
},
components: [
{
content: `# Message from ${author.displayName}!`,
type: 10,
},
{
content: content,
type: 10,
},
],
type: 9,
},
],
spoiler: false,
type: 17,
},
{
components: [
{
disabled: false,
label: "View Message",
style: 5,
type: 2,
url: url,
},
],
type: 1,
},
];
};
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
export const logger = new Logger(
"Amari",
process.env.LOG_TOKEN ?? "",
);