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
+38
View File
@@ -0,0 +1,38 @@
name: Node.js CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
name: Lint and Test
steps:
- name: Checkout Source Files
uses: actions/checkout@v4
- name: Use Node.js v22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Install Dependencies
run: pnpm install
- name: Lint Source Files
run: pnpm run lint
- name: Verify Build
run: pnpm run build
- name: Run Tests
run: pnpm run test
+2
View File
@@ -0,0 +1,2 @@
node_modules
prod
+5
View File
@@ -0,0 +1,5 @@
import NaomisConfig from "@nhcarrigan/eslint-config";
export default [
...NaomisConfig
];
+29
View File
@@ -0,0 +1,29 @@
{
"name": "amari",
"version": "0.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"build": "tsc",
"start": "op run --env-file=prod.env -- node prod/index.js",
"lint": "eslint src --max-warnings 0",
"test": "echo \"Error: no test specified\" && exit 0"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.15.0",
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "24.3.0",
"eslint": "9.33.0",
"typescript": "5.9.2"
},
"dependencies": {
"@nhcarrigan/logger": "1.0.0",
"discord.js": "14.21.0",
"fastify": "5.5.0"
}
}
+4633
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
BOT_TOKEN="op://Environment Variables - Naomi/Amari/bot token"
+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 ?? "",
);
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./prod"
}
}