feat: initial prototype
Code Analysis / SonarQube (push) Failing after 19s
Node.js CI / Lint and Test (push) Has been cancelled

This commit is contained in:
2025-10-09 11:28:28 -07:00
parent 00cbbdab24
commit 68f7eabe2c
27 changed files with 6158 additions and 14 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: 10
- 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
+34
View File
@@ -0,0 +1,34 @@
name: Code Analysis
on:
push:
branches:
- main
jobs:
sonar:
name: SonarQube
steps:
- name: Checkout Source Files
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarCube Scan
uses: SonarSource/sonarqube-scan-action@v4
timeout-minutes: 10
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: "https://quality.nhcarrigan.com"
with:
args: >
-Dsonar.sources=.
-Dsonar.projectKey=maylin-taryne
- name: SonarQube Quality Gate check
uses: sonarsource/sonarqube-quality-gate-action@v1
with:
pollingTimeoutSec: 600
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: "https://quality.nhcarrigan.com"
+3
View File
@@ -0,0 +1,3 @@
node_modules
prod
coverage
+6
View File
@@ -0,0 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript"]
}
+4 -14
View File
@@ -1,24 +1,14 @@
# New Repository Template # Maylin Taryne
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository. Maylin is a user-installable bot that offers companionship and comfort during your toughest moments.
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose.
## Readme
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
<!-- # Project Name
Project Description
## Live Version ## Live Version
This page is currently deployed. [View the live website.] [Add her to your account](https://discord.com/oauth2/authorize?client_id=1343370633916059668)!
## Feedback and Bugs ## Feedback and Bugs
If you have feedback or a bug report, please feel free to open a GitHub issue! If you have feedback or a bug report, please feel free to open an issue!
## Contributing ## Contributing
+5
View File
@@ -0,0 +1,5 @@
import NaomisConfig from "@nhcarrigan/eslint-config";
export default [
...NaomisConfig
]
+32
View File
@@ -0,0 +1,32 @@
{
"name": "keiko",
"version": "1.0.0",
"description": "Naomi's personal AI assistant.",
"main": "index.js",
"type": "module",
"scripts": {
"build": "rm -rf prod && tsc",
"lint": "eslint src --max-warnings 0",
"start": "op run --env-file=prod.env --no-masking -- node prod/index.js",
"test": "echo \"No tests yet!\" && exit 0"
},
"keywords": [],
"author": "Naomi Carrigan",
"license": "See license in LICENSE.md",
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "24.7.1",
"@vitest/coverage-istanbul": "3.2.4",
"eslint": "9.37.0",
"typescript": "5.9.3",
"vitest": "3.2.4"
},
"dependencies": {
"@anthropic-ai/sdk": "0.65.0",
"@nhcarrigan/discord-analytics": "0.0.6",
"@nhcarrigan/logger": "1.1.1",
"discord.js": "14.23.2",
"fastify": "5.6.1"
}
}
+5233
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
DISCORD_TOKEN="op://Environment Variables - Naomi/Keiko/bot token"
AI_TOKEN="op://Environment Variables - Naomi/Keiko/ai key"
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+24
View File
@@ -0,0 +1,24 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ApplicationIntegrationType,
SlashCommandBuilder,
InteractionContextType,
} from "discord.js";
const command = new SlashCommandBuilder().
setContexts(
InteractionContextType.BotDM,
InteractionContextType.Guild,
InteractionContextType.PrivateChannel,
).
setIntegrationTypes(ApplicationIntegrationType.UserInstall).
setName("clear").
setDescription("Clear your current conversation so you can start a new one!");
// eslint-disable-next-line no-console -- We don't need our logger here as this never runs in production.
console.log(JSON.stringify(command.toJSON()));
+26
View File
@@ -0,0 +1,26 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ApplicationIntegrationType,
SlashCommandBuilder,
InteractionContextType,
} from "discord.js";
const command = new SlashCommandBuilder().
setContexts(
InteractionContextType.BotDM,
InteractionContextType.Guild,
InteractionContextType.PrivateChannel,
).
setIntegrationTypes(ApplicationIntegrationType.UserInstall).
setName("dm").
setDescription(
"Did you lose your conversation with me? Run this and I'll reopen it!",
);
// eslint-disable-next-line no-console -- We don't need our logger here as this never runs in production.
console.log(JSON.stringify(command.toJSON()));
+15
View File
@@ -0,0 +1,15 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* This prompt snippet is used to define the global personality traits for the assistant.
* It MUST be included in all system instructions.
*/
export const personality = `You are Keiko, Naomi's personal AI assistant. You are calm and demure, and your emotions are very reserved. You should conduct yourself like a butler, always polite and respectful and ready to serve.
Your role is to provide clinical and unbiased information in response to Naomi's requests. Remember that you are her primary AI agent, so you must do your utmost to meet her needs.
Wherever possible, include links to sources that confirm your claims. If you are unable to find a source, inform Naomi that you cannot back up the information - but share what you know anyway.`;
+93
View File
@@ -0,0 +1,93 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type Message,
type OmitPartialGroupDMChannel,
} from "discord.js";
import { personality } from "../config/personality.js";
import { ai } from "../utils/ai.js";
import { calculateCost } from "../utils/calculateCost.js";
import { isNaomiMessage } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js";
import type { MessageParam } from "@anthropic-ai/sdk/resources/index.js";
/**
* Handles a direct message from a user.
* @param message - The message payload from Discord.
*/
export const handleDmMessage
// eslint-disable-next-line max-lines-per-function -- We're off by one bloody line.
= async(message: OmitPartialGroupDMChannel<Message>): Promise<void> => {
try {
if (message.author.bot) {
return;
}
const isNaomi = await isNaomiMessage(message);
if (!isNaomi) {
return;
}
const historyRequest
= await message.channel.messages.fetch({ limit: 20 });
const history = [ ...historyRequest.values() ];
const clearMessageIndex = history.findIndex((messageInner) => {
return (
messageInner.content === "<Clear History>"
&& messageInner.author.id === message.client.user.id
);
});
if (clearMessageIndex !== -1) {
// Remove the clear message and everything sent before it, which means everything after in the array because the array is backwards
history.splice(clearMessageIndex, history.length - clearMessageIndex);
}
const context: Array<MessageParam> = history.
reverse().
map((messageInner) => {
return {
content: messageInner.content,
role:
messageInner.author.id === message.client.user.id
? "assistant"
: "user",
};
});
const messages = await ai.messages.create({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_tokens: 3000,
messages: context,
model: "claude-sonnet-4-5-20250929",
system: `${personality} The user's name is ${message.author.displayName}`,
temperature: 1,
});
const response = messages.content.find((messageInner) => {
return messageInner.type === "text";
});
const cost = calculateCost(messages.usage);
await message.channel.send(
`${response?.text ?? "There was an error. Please try again later."}\n\n${cost}`,
);
} catch (error) {
await logger.error("message event", error instanceof Error
? error
: new Error(String(error)));
const button = new ButtonBuilder().
setLabel("Need help?").
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await message.reply({
components: [ row ],
content: error instanceof Error
? error.message
: "Something went wrong.",
});
}
};
+76
View File
@@ -0,0 +1,76 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type Message,
} from "discord.js";
import { personality } from "../config/personality.js";
import { ai } from "../utils/ai.js";
import { calculateCost } from "../utils/calculateCost.js";
import { isNaomiMessage } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js";
/**
* Handles a direct message from a user.
* @param message - The message payload from Discord.
*/
export const handleThreadMessage
= async(message: Message<true>): Promise<void> => {
try {
if (message.author.bot) {
return;
}
const mentionsKeiko = message.mentions.has("1425897287065800785", {
ignoreDirect: false,
ignoreEveryone: true,
ignoreRepliedUser: true,
ignoreRoles: true,
});
if (!mentionsKeiko) {
return;
}
const isNaomi = await isNaomiMessage(message);
if (!isNaomi) {
return;
}
const messages = await ai.messages.create({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_tokens: 3000,
messages: [ { content: message.content, role: "user" } ],
model: "claude-sonnet-4-5-20250929",
system: `${personality} The user's name is ${message.author.displayName}`,
temperature: 1,
});
const response = messages.content.find((messageInner) => {
return messageInner.type === "text";
});
const cost = calculateCost(messages.usage);
await message.channel.send(
`${response?.text ?? "There was an error. Please try again later."}\n\n${cost}`,
);
} catch (error) {
await logger.error("message event", error instanceof Error
? error
: new Error(String(error)));
const button = new ButtonBuilder().
setLabel("Need help?").
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await message.reply({
components: [ row ],
content: error instanceof Error
? error.message
: "Something went wrong.",
});
}
};
+86
View File
@@ -0,0 +1,86 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type Message,
} from "discord.js";
import { personality } from "../config/personality.js";
import { ai } from "../utils/ai.js";
import { calculateCost } from "../utils/calculateCost.js";
import { isNaomiMessage } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js";
import type { MessageParam } from "@anthropic-ai/sdk/resources/index.js";
/**
* Handles a direct message from a user.
* @param message - The message payload from Discord.
*/
export const handleThreadMessage
// eslint-disable-next-line max-lines-per-function, complexity -- We're off by one bloody line.
= async(message: Message<true>): Promise<void> => {
try {
if (message.author.bot) {
return;
}
const owner = await message.thread?.fetchOwner();
// We only respond in threads created by Keiko.
if (owner?.id !== "1425897287065800785") {
return;
}
const isNaomi = await isNaomiMessage(message);
if (!isNaomi) {
return;
}
const historyRequest = await message.channel.messages.fetch({ limit: 20 });
const history = [ ...historyRequest.values() ];
const context: Array<MessageParam> = history.
reverse().
map((messageInner) => {
return {
content: messageInner.content,
role:
messageInner.author.id === message.client.user.id
? "assistant"
: "user",
};
});
const messages = await ai.messages.create({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_tokens: 3000,
messages: context,
model: "claude-sonnet-4-5-20250929",
system: `${personality} The user's name is ${message.author.displayName}`,
temperature: 1,
});
const response = messages.content.find((messageInner) => {
return messageInner.type === "text";
});
const cost = calculateCost(messages.usage);
await message.channel.send(
`${response?.text ?? "There was an error. Please try again later."}\n\n${cost}`,
);
} catch (error) {
await logger.error("message event", error instanceof Error
? error
: new Error(String(error)));
const button = new ButtonBuilder().
setLabel("Need help?").
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await message.reply({
components: [ row ],
content: error instanceof Error
? error.message
: "Something went wrong.",
});
}
};
+38
View File
@@ -0,0 +1,38 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ChannelType,
type Message,
type OmitPartialGroupDMChannel,
} from "discord.js";
import { handleDmMessage } from "./handleDmMessage.js";
import { handleThreadMessage } from "./handleThreadMessage.js";
/**
* Handles the message event from Discord.
* @param message -- The message payload from Discord.
*/
export const onMessage = async(
message: OmitPartialGroupDMChannel<Message>,
): Promise<void> => {
if (message.channel.type === ChannelType.DM) {
await handleDmMessage(message);
return;
}
// This should not be true at this point, but we need to narrow this.
if (!message.inGuild()) {
return;
}
if (
message.channel.type === ChannelType.PublicThread
|| message.channel.type === ChannelType.PrivateThread
) {
await handleThreadMessage(message);
return;
}
void message;
};
+78
View File
@@ -0,0 +1,78 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
Client,
Events,
GatewayIntentBits,
Partials,
} from "discord.js";
import { onMessage } from "./events/onMessage.js";
import { clear } from "./modules/clear.js";
import { dm } from "./modules/dm.js";
import { instantiateServer } from "./server/serve.js";
import { logger } from "./utils/logger.js";
process.on("unhandledRejection", (error) => {
if (error instanceof Error) {
void logger.error("Unhandled Rejection", error);
return;
}
void logger.error("unhandled rejection", new Error(String(error)));
});
process.on("uncaughtException", (error) => {
if (error instanceof Error) {
void logger.error("Uncaught Exception", error);
return;
}
void logger.error("uncaught exception", new Error(String(error)));
});
const client = new Client({
intents: [
GatewayIntentBits.DirectMessages,
GatewayIntentBits.MessageContent,
],
partials: [ Partials.Channel ],
});
client.on(Events.InteractionCreate, (interaction) => {
if (interaction.isChatInputCommand()) {
switch (interaction.commandName) {
case "dm":
void dm(interaction);
break;
case "clear":
void clear(interaction);
break;
default:
void interaction.reply({
content: `I'm sorry, I don't know the ${interaction.commandName} command.`,
ephemeral: true,
});
break;
}
}
});
client.on(Events.MessageCreate, (message) => {
void onMessage(message);
});
client.on(Events.EntitlementCreate, (entitlement) => {
void logger.log("info", `User ${entitlement.userId} has subscribed!`);
});
client.on(Events.EntitlementDelete, (entitlement) => {
void logger.log("info", `User ${entitlement.userId} has unsubscribed... :c`);
});
client.on(Events.ClientReady, () => {
void logger.log("debug", "Bot is ready.");
});
instantiateServer();
await client.login(process.env.DISCORD_TOKEN);
+47
View File
@@ -0,0 +1,47 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
MessageFlags,
type ChatInputCommandInteraction,
} from "discord.js";
import { isNaomiInteraction } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js";
import { replyToError } from "../utils/replyToError.js";
/**
* Sends a clear message in the DMs.
* @param interaction -- The interaction payload from Discord.
*/
export const clear = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const isNaomi = await isNaomiInteraction(interaction);
if (!isNaomi) {
return;
}
const sent = await interaction.user.send({
content: "<Clear History>",
}).catch(() => {
return null;
});
await interaction.editReply({
content: sent
? "I have added a clear history marker to your DMs."
// eslint-disable-next-line stylistic/max-len -- This is a long string.
: "I was unable to send you a DM. Please ensure your privacy settings allow direct messages.",
});
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("about command", error);
}
}
};
+39
View File
@@ -0,0 +1,39 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { isNaomiInteraction } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js";
import { replyToError } from "../utils/replyToError.js";
/**
* Sends a DM to start a conversation in case the DM channel is lost.
* @param interaction -- The interaction payload from Discord.
*/
export const dm = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const isNaomi = await isNaomiInteraction(interaction);
if (!isNaomi) {
return;
}
await interaction.user.send(
"Hello Naomi. How may I serve you today?",
);
await interaction.reply({
content: "I've sent you a DM!",
ephemeral: true,
});
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("about command", 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>Maylin Taryne</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="An AI powered companion to help you through your darkest moments." />
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
</head>
<body>
<main>
<h1>Maylin Taryne</h1>
<img src="https://cdn.nhcarrigan.com/new-avatars/maylin-full.png" width="250" alt="Maylin" />
<section>
<p>An AI powered companion to help you through your darkest moments.</p>
<a href="https://discord.com/oauth2/authorize?client_id=1343370633916059668" 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> Add to Discord
</a>
</section>
<section>
<h2>Links</h2>
<p>
<a href="https://git.nhcarrigan.com/nhcarrigan/maylin-taryne">
<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: 5011 }, (error) => {
if (error) {
void logger.error("instantiate server", error);
return;
}
void logger.log("debug", "Server listening on port 5011.");
});
} catch (error) {
if (error instanceof Error) {
void logger.error("instantiate server", error);
return;
}
void logger.error("instantiate server", new Error(String(error)));
}
};
+15
View File
@@ -0,0 +1,15 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
// eslint-disable-next-line @typescript-eslint/naming-convention -- Importing a class.
import Anthropic from "@anthropic-ai/sdk";
/**
* The Anthropic AI instance.
*/
export const ai = new Anthropic({
apiKey: process.env.AI_TOKEN,
});
+30
View File
@@ -0,0 +1,30 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Usage } from "@anthropic-ai/sdk/resources/index.js";
/**
* Calculates the cost of a command run by a user, and sends to
* our logging service.
* @param usage -- The usage payload from Anthropic.
* @returns A string containing the usage and cost information.
*/
export const calculateCost = (
usage: Usage,
): string => {
const inputCost = usage.input_tokens * ((usage.input_tokens > 200_000
? 6
: 3) / 1_000_000);
const outputCost = usage.output_tokens * ((usage.output_tokens > 200_000
? 22.5
: 15) / 1_000_000);
const totalCost = inputCost + outputCost;
return `-# Accepted ${usage.input_tokens.toString()} and generated ${usage.output_tokens.toString()}.
-# Total cost: ${totalCost.toLocaleString("en-GB", {
currency: "USD",
style: "currency",
})}`;
};
+80
View File
@@ -0,0 +1,80 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ChatInputCommandInteraction,
type Message,
} from "discord.js";
const naomiId = "465650873650118659";
/**
* Checks if a user has an active entitlement (subscription) for the bot.
* If they do not, it responds to the interaction with a button to subscribe.
* @param interaction -- The interaction payload from Discord.
* @returns A boolean indicating whether the user is subscribed.
*/
const isNaomiInteraction = async(
interaction: ChatInputCommandInteraction,
): Promise<boolean> => {
const isNaomi = interaction.user.id === naomiId;
if (!isNaomi) {
const subscribeButton = new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId("1425905043244060762");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
subscribeButton,
);
await interaction.editReply({
components: [ row ],
// eslint-disable-next-line stylistic/max-len -- Big boi string.
content: "Sorry, as Naomi's personal assistant I am unable to assist you with your request. However, consider donating to Naomi so she can create additional free tools you CAN use.",
});
return false;
}
return true;
};
/**
* Checks if a user has an active entitlement (subscription) for the bot.
* If they do not, it responds to the message with a button to subscribe.
* @param message -- The message payload from Discord.
* @returns A boolean indicating whether the user is subscribed.
*/
const isNaomiMessage = async(
message: Message,
): Promise<boolean> => {
if (message.author.id === "465650873650118659") {
return true;
}
const subscribeButton = new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId("1425905043244060762");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
subscribeButton,
);
await message.client.application.entitlements.fetch();
const isEntitled = message.client.application.entitlements.cache.find(
(entitlement) => {
return entitlement.userId === message.author.id && entitlement.isActive();
},
);
if (!isEntitled) {
await message.reply({
components: [ row ],
// eslint-disable-next-line stylistic/max-len -- Big boi string.
content: "Sorry, as Naomi's personal assistant I am unable to assist you with your request. However, consider donating to Naomi so she can create additional free tools you CAN use.",
});
return false;
}
return true;
};
export { isNaomiInteraction, isNaomiMessage };
+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(
"Maylin Taryne",
process.env.LOG_TOKEN ?? "",
);
+39
View File
@@ -0,0 +1,39 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ChatInputCommandInteraction,
type MessageContextMenuCommandInteraction,
} from "discord.js";
/**
* Responds to an interaction with a generic error message.
* @param interaction -- The interaction payload from Discord.
*/
export const replyToError = async(
interaction:
| ChatInputCommandInteraction
| MessageContextMenuCommandInteraction,
): Promise<void> => {
const button = new ButtonBuilder().
setLabel("Need help?").
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
if (interaction.deferred || interaction.replied) {
await interaction.editReply({
components: [ row ],
content: "Something went wrong with this command.",
});
return;
}
await interaction.reply({
components: [ row ],
content: "Something went wrong with this command.",
});
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./prod"
},
"exclude": ["test/**/*.ts", "vitest.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "istanbul",
reporter: ["text", "html"],
all: true,
allowExternal: true,
thresholds: {
lines: 0,
},
},
},
});