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

### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #6
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #6.
This commit is contained in:
2025-07-21 16:53:48 -07:00
committed by Naomi Carrigan
parent da76bb5327
commit 925f740370
26 changed files with 5887 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
+6
View File
@@ -0,0 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript"],
}
+52
View File
@@ -0,0 +1,52 @@
import { ApplicationIntegrationType, ChannelType, InteractionContextType, SlashCommandBuilder } from "discord.js";
const about = new SlashCommandBuilder()
.setName("about")
.setDescription("Get information about this application.")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall]);
const add = new SlashCommandBuilder()
.setName("add")
.setDescription("Add a word to your highlights.")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.addStringOption((option) =>
option
.setName("word")
.setDescription("The word to add to your highlights.")
.setRequired(true)
.setMaxLength(100),
)
const remove = new SlashCommandBuilder()
.setName("remove")
.setDescription("Remove a word from your highlights.")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.addStringOption((option) =>
option
.setName("word")
.setDescription("The word to remove from your highlights.")
.setRequired(true)
.setMaxLength(100),
)
const list = new SlashCommandBuilder()
.setName("list")
.setDescription("List your highlights.")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
const dm = new SlashCommandBuilder()
.setName("dm")
.setDescription("Confirm that Liora can DM you.")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
console.log(JSON.stringify([
about.toJSON(),
add.toJSON(),
remove.toJSON(),
list.toJSON(),
dm.toJSON()
]));
+11
View File
@@ -0,0 +1,11 @@
import NaomisConfig from "@nhcarrigan/eslint-config";
export default [
...NaomisConfig,
{
files: ["src/assets/*.ts"],
rules: {
"max-lines": "off",
},
},
];
+30
View File
@@ -0,0 +1,30 @@
{
"name": "liora",
"version": "0.0.0",
"description": "A Discord bot that automatically deletes messages from a venting channel.",
"main": "index.js",
"type": "module",
"scripts": {
"lint": "eslint ./src --max-warnings 0",
"build": "prisma generate && tsc",
"start": "op run --env-file=./prod.env -- node ./prod/index.js",
"test": "echo 'No tests yet' && exit 0"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.13.1",
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"eslint": "9.31.0",
"prisma": "6.12.0",
"typescript": "5.8.3"
},
"dependencies": {
"@nhcarrigan/logger": "1.0.0",
"@prisma/client": "6.12.0",
"discord.js": "14.21.0",
"fastify": "5.4.0"
}
}
+4721
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("MONGO_URI")
}
model Highlights {
id String @id @default(auto()) @map("_id") @db.ObjectId
serverId String
userId String
words String[]
@@unique([serverId, userId], map: "serverId_userId")
@@index([serverId], map: "serverId")
}
+3
View File
@@ -0,0 +1,3 @@
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
DISCORD_TOKEN="op://Environment Variables - Naomi/Liora/token"
MONGO_URI="op://Environment Variables - Naomi/Liora/mongo"
+89
View File
@@ -0,0 +1,89 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
TextDisplayBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
ContainerBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
MessageFlags,
} from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
/**
* Handles the `/about` command interaction.
* @param _liora - Liora's Discord instance (unused).
* @param interaction - The command interaction payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function -- It's mostly components.
export const about: Command = async(_liora, interaction) => {
try {
const components = [
new ContainerBuilder().
addTextDisplayComponents(
new TextDisplayBuilder().setContent("# About Liora"),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Hi there~! I am Liora, a bot that can send you a DM when a message contains a word you have added to your highlights.",
),
).
addSeparatorComponents(
new SeparatorBuilder().
setSpacing(SeparatorSpacingSize.Small).
setDivider(true),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent("## What can I do?"),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"To get started, a member of your community will need to purchase the server subscription from my Discord store. Then you can configure your highlights with the `/add` and `/remove` commands. When a word you have added is mentioned in the server, I will send you a DM with a link to the message.\n\nMake sure you have DMs enabled so I can send you notifications!",
),
).
addSeparatorComponents(
new SeparatorBuilder().
setSpacing(SeparatorSpacingSize.Small).
setDivider(true),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent("## What if I need help?"),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"My deepest apologies if I have made a mistake! Please reach out to us in our Discord server or on the forum, and we will do our best to assist you.",
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
];
await interaction.reply({
components: components,
flags: MessageFlags.IsComponentsV2,
});
} catch (error) {
await errorHandler(error, "about command");
await interaction.reply({
content:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"An error occurred while processing your request. Please try again later.",
});
}
};
+139
View File
@@ -0,0 +1,139 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
TextDisplayBuilder,
ContainerBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
MessageFlags,
} from "discord.js";
import { sendUnentitledResponse } from "../modules/sendUnentitledResponse.js";
import { checkGuildEntitlement } from "../utils/checkEntitlement.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
/**
* Handles the `/add` command interaction.
* @param liora - Liora's Discord instance.
* @param interaction - The command interaction payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function -- It's mostly components.
export const add: Command = async(liora, interaction) => {
try {
await interaction.deferReply({
flags: [ MessageFlags.Ephemeral ],
});
const isEntitled = await checkGuildEntitlement(liora, interaction.guild);
if (!isEntitled) {
await sendUnentitledResponse(interaction);
}
const wordToAdd = interaction.options.getString("word", true);
const record = await liora.database.highlights.upsert({
create: {
serverId: interaction.guildId,
userId: interaction.user.id,
words: [],
},
update: {},
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Prisma convention.
serverId_userId: {
serverId: interaction.guildId,
userId: interaction.user.id,
},
},
});
if (record.words.length >= 10) {
await interaction.editReply({
components: [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`I am so sorry, but you can only have 10 highlights per server. This helps me keep things running smoothly!`,
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
],
flags: [ MessageFlags.IsComponentsV2 ],
});
return;
}
if (record.words.includes(wordToAdd)) {
await interaction.editReply({
components: [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`Okay, I will notify you in DMs when \`${wordToAdd}\` is mentioned in this server.`,
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
],
flags: [ MessageFlags.IsComponentsV2 ],
});
return;
}
record.words.push(wordToAdd);
await liora.database.highlights.update({
data: {
words: record.words,
},
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Prisma convention.
serverId_userId: {
serverId: interaction.guildId,
userId: interaction.user.id,
},
},
});
await interaction.editReply({
components: [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`Okay, I will notify you in DMs when \`${wordToAdd}\` is mentioned in this server.`,
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
],
flags: [ MessageFlags.IsComponentsV2 ],
});
} catch (error) {
await errorHandler(error, "add command");
await interaction.editReply({
content:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"An error occurred while processing your request. Please try again later.",
});
}
};
+92
View File
@@ -0,0 +1,92 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
TextDisplayBuilder,
ContainerBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
MessageFlags,
} from "discord.js";
import { sendUnentitledResponse } from "../modules/sendUnentitledResponse.js";
import { checkGuildEntitlement } from "../utils/checkEntitlement.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
/**
* Handles the `/dm` command interaction.
* @param liora - Liora's Discord instance.
* @param interaction - The command interaction payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function -- It's mostly components.
export const dm: Command = async(liora, interaction) => {
try {
await interaction.deferReply({
flags: [ MessageFlags.Ephemeral ],
});
const isEntitled = await checkGuildEntitlement(liora, interaction.guild);
if (!isEntitled) {
await sendUnentitledResponse(interaction);
}
const sentDm = await interaction.user.send({
content: "Hello! This is a test DM from Liora.",
}).catch(() => {
return null;
});
if (!sentDm) {
await interaction.editReply({
components: [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`Oopsie! It looks like I am unable to send you a DM. Please check your privacy settings and try again.`,
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
],
flags: [ MessageFlags.IsComponentsV2 ],
});
return;
}
await interaction.editReply({
components: [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`Neato! You should have received a DM from me. If you did not, please let us know in our support channels.`,
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
],
flags: [ MessageFlags.IsComponentsV2 ],
});
} catch (error) {
await errorHandler(error, "dm command");
await interaction.editReply({
content:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"An error occurred while processing your request. Please try again later.",
});
}
};
+113
View File
@@ -0,0 +1,113 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
TextDisplayBuilder,
ContainerBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
MessageFlags,
SeparatorBuilder,
SeparatorSpacingSize,
} from "discord.js";
import { sendUnentitledResponse } from "../modules/sendUnentitledResponse.js";
import { checkGuildEntitlement } from "../utils/checkEntitlement.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
/**
* Handles the `/add` command interaction.
* @param liora - Liora's Discord instance.
* @param interaction - The command interaction payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function -- It's mostly components.
export const list: Command = async(liora, interaction) => {
try {
await interaction.deferReply({
flags: [ MessageFlags.Ephemeral ],
});
const isEntitled = await checkGuildEntitlement(liora, interaction.guild);
if (!isEntitled) {
await sendUnentitledResponse(interaction);
}
const record = await liora.database.highlights.upsert({
create: {
serverId: interaction.guildId,
userId: interaction.user.id,
words: [],
},
update: {},
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Prisma convention.
serverId_userId: {
serverId: interaction.guildId,
userId: interaction.user.id,
},
},
});
if (record.words.length <= 0) {
await interaction.editReply({
components: [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`Huh. It does not look like you have added any highlights for this server yet. You can do that with the \`/add\` command!`,
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
],
flags: [ MessageFlags.IsComponentsV2 ],
});
return;
}
await interaction.editReply({
components: [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`You have configured the following words to be highlighted in this server:`,
),
).
addSeparatorComponents(
new SeparatorBuilder().setDivider(true).
setSpacing(SeparatorSpacingSize.Small),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`\`${record.words.join("`, `")}\``,
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
],
flags: [ MessageFlags.IsComponentsV2 ],
});
} catch (error) {
await errorHandler(error, "list command");
await interaction.editReply({
content:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"An error occurred while processing your request. Please try again later.",
});
}
};
+117
View File
@@ -0,0 +1,117 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
TextDisplayBuilder,
ContainerBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
MessageFlags,
} from "discord.js";
import { sendUnentitledResponse } from "../modules/sendUnentitledResponse.js";
import { checkGuildEntitlement } from "../utils/checkEntitlement.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
/**
* Handles the `/remove` command interaction.
* @param liora - Liora's Discord instance.
* @param interaction - The command interaction payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function -- It's mostly components.
export const remove: Command = async(liora, interaction) => {
try {
await interaction.deferReply({
flags: [ MessageFlags.Ephemeral ],
});
const isEntitled = await checkGuildEntitlement(liora, interaction.guild);
if (!isEntitled) {
await sendUnentitledResponse(interaction);
}
const wordToRemove = interaction.options.getString("word", true);
const record = await liora.database.highlights.upsert({
create: {
serverId: interaction.guildId,
userId: interaction.user.id,
words: [],
},
update: {},
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Prisma convention.
serverId_userId: {
serverId: interaction.guildId,
userId: interaction.user.id,
},
},
});
if (!record.words.includes(wordToRemove)) {
await interaction.editReply({
components: [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`Oh dear, it looks like you have not added the word \`${wordToRemove}\` to your highlights!`,
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
],
flags: [ MessageFlags.IsComponentsV2 ],
});
return;
}
record.words = record.words.filter((word) => {
return word !== wordToRemove;
});
await liora.database.highlights.update({
data: {
words: record.words,
},
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Prisma convention.
serverId_userId: {
serverId: interaction.guildId,
userId: interaction.user.id,
},
},
});
await interaction.editReply({
components: [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`Okay, I will no longer notify you in DMs when \`${wordToRemove}\` is mentioned in this server.`,
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
],
flags: [ MessageFlags.IsComponentsV2 ],
});
} catch (error) {
await errorHandler(error, "remove command");
await interaction.editReply({
content:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"An error occurred while processing your request. Please try again later.",
});
}
};
+13
View File
@@ -0,0 +1,13 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
const entitledGuilds = [
// Naomi's server.
"1354624415861833870",
// FreeCodeCamp
"692816967895220344",
];
export { entitledGuilds };
+43
View File
@@ -0,0 +1,43 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { about } from "../commands/about.js";
import { add } from "../commands/add.js";
import { dm } from "../commands/dm.js";
import { list } from "../commands/list.js";
import { remove } from "../commands/remove.js";
import type { Command } from "../interfaces/command.js";
import type { Liora } from "../interfaces/liora.js";
import type { ChatInputCommandInteraction } from "discord.js";
const handlers: { _default: Command } & Record<string, Command> = {
_default: async(_, interaction): Promise<void> => {
await interaction.reply({
content: `Unknown command: ${interaction.commandName}`,
});
},
about: about,
add: add,
dm: dm,
list: list,
remove: remove,
};
/**
* Processes a slash command.
* @param liora - Liora's Discord instance.
* @param interaction - The command interaction payload from Discord.
*/
const chatInputInteractionCreate = async(
liora: Liora,
interaction: ChatInputCommandInteraction<"cached">,
): Promise<void> => {
const name = interaction.commandName;
// eslint-disable-next-line no-underscore-dangle -- We use _default as a fallback handler.
const handler = handlers[name] ?? handlers._default;
await handler(liora, interaction);
};
export { chatInputInteractionCreate };
+117
View File
@@ -0,0 +1,117 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
MessageFlags,
SeparatorBuilder,
SeparatorSpacingSize,
TextDisplayBuilder,
type Message,
} from "discord.js";
import { checkGuildEntitlement } from "../utils/checkEntitlement.js";
import type { Liora } from "../interfaces/liora.js";
/**
* Processes a slash command.
* @param liora - Liora's Discord instance.
* @param message - The message payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function -- It's mostly components.
const messageCreate = async(
liora: Liora,
message: Message<true>,
): Promise<void> => {
const isEntitled = await checkGuildEntitlement(liora, message.guild);
if (!isEntitled) {
return;
}
const highlights = await liora.database.highlights.findMany({
where: {
serverId: message.guild.id,
},
});
if (highlights.length <= 0) {
return;
}
await Promise.all(
// eslint-disable-next-line max-lines-per-function -- It's mostly components.
highlights.map(async(record) => {
// No need to notify the user if they are the one who sent the message.
if (record.userId === message.author.id) {
return;
}
if (record.words.length <= 0) {
return;
}
const foundWords = record.words.filter((word) => {
return message.content.toLowerCase().includes(word.toLowerCase());
});
if (foundWords.length <= 0) {
return;
}
const dm = await message.client.users.fetch(record.userId).catch(() => {
return null;
});
if (!dm) {
return;
}
await dm.send({
components: [
new ContainerBuilder().
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
"One of your highlights was mentioned in a message!",
),
).
addSeparatorComponents(
new SeparatorBuilder().
setSpacing(SeparatorSpacingSize.Small).
setDivider(true),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`The word(s) \`${foundWords.join(
", ",
)}\` were mentioned in the server \`${
message.guild.name
}\` by ${message.author.displayName}.`,
),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`You can view the message here: ${message.url}`,
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Jump to Message").
setURL(message.url),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Forum").
setURL("https://forum.nhcarrigan.com"),
),
],
flags: [ MessageFlags.IsComponentsV2 ],
}).catch(() => {
// If we cannot send the DM, we do not want to throw an error.
return null;
});
}),
);
};
export { messageCreate };
+59
View File
@@ -0,0 +1,59 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { PrismaClient } from "@prisma/client";
import {
Client,
Events,
GatewayIntentBits,
} from "discord.js";
import { chatInputInteractionCreate } from "./events/interactionCreate.js";
import { messageCreate } from "./events/messageCreate.js";
import { instantiateServer } from "./server/serve.js";
import { logger } from "./utils/logger.js";
import type { Liora } from "./interfaces/liora.js";
const liora: Liora = {
database: new PrismaClient(),
discord: new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
}),
};
liora.discord.once(Events.ClientReady, () => {
void logger.log(
"debug",
`Logged in as ${liora.discord.user?.username ?? "unknown"}`,
);
});
liora.discord.on(Events.InteractionCreate, (interaction) => {
if (interaction.isChatInputCommand()) {
if (!interaction.inCachedGuild()) {
void interaction.reply({
content: "How did you get here? This command is not available in DMs.",
});
return;
}
void chatInputInteractionCreate(liora, interaction);
}
});
liora.discord.on(Events.MessageCreate, (message) => {
if (!message.inGuild()) {
return;
}
void messageCreate(liora, message);
});
await liora.database.$connect();
await liora.discord.login(process.env.DISCORD_TOKEN);
instantiateServer();
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Liora } from "./liora.js";
import type { ChatInputCommandInteraction } from "discord.js";
export type Command = (
liora: Liora,
interaction: ChatInputCommandInteraction<"cached">
)=> Promise<void>;
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { PrismaClient } from "@prisma/client";
import type { Client } from "discord.js";
export interface Liora {
discord: Client;
database: PrismaClient;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
MessageFlags,
TextDisplayBuilder,
type ChatInputCommandInteraction,
} from "discord.js";
/**
* Responds with a default image and a button to subscribe.
* @param interaction - The interaction object from Discord.
*/
export const sendUnentitledResponse = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
const components = [
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Hmm, you do not seem to have an active subscription. If you wish for me to keep your secrets, you will need to rectify that.",
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId("1396962587471515769"),
),
];
await interaction.reply({
components: components,
flags: [ MessageFlags.IsComponentsV2 ],
});
};
+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>Liora</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Discord bot that sends you a DM when a specific word you configure is included in a message." />
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
</head>
<body>
<main>
<h1>Liora</h1>
<img src="https://cdn.nhcarrigan.com/new-avatars/liora.png" width="250" alt="Liora" />
<section>
<p>Discord bot that sends you a DM when a specific word you configure is included in a message.</p>
<a href="https://discord.com/oauth2/authorize?client_id=1391489982887362761" 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/liora">
<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: 5022 }, (error) => {
if (error) {
void logger.error("instantiate server", error);
return;
}
void logger.log("debug", "Server listening on port 5022.");
});
} catch (error) {
if (error instanceof Error) {
void logger.error("instantiate server", error);
return;
}
void logger.error("instantiate server", new Error(String(error)));
}
};
+32
View File
@@ -0,0 +1,32 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { entitledGuilds } from "../config/entitlements.js";
import type { Liora } from "../interfaces/liora.js";
import type { Guild } from "discord.js";
/**
* Checks if a guild has subscribed.
* @param liora - Liora's Discord instance.
* @param guild - The guild to check.
* @returns A boolean indicating whether the guild has an active subscription.
*/
const checkGuildEntitlement = async(
liora: Liora,
guild: Guild,
): Promise<boolean> => {
if (entitledGuilds.includes(guild.id)) {
return true;
}
const entitlements = await liora.discord.application?.entitlements.fetch({
excludeDeleted: true,
excludeEnded: true,
guild: guild,
});
return Boolean(entitlements && entitlements.size > 0);
};
export { checkGuildEntitlement };
+28
View File
@@ -0,0 +1,28 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { logger } from "./logger.js";
/**
* Generates a UUID for an error, sends the error to the logger,
* and returns the UUID to be shared with the user.
* @param error - The error to log.
* @param context - The context in which the error occurred.
* @returns A UUID string assigned to the error.
*/
export const errorHandler = async(
error: unknown,
context: string,
): Promise<string> => {
const id = crypto.randomUUID();
await logger.error(
`${context} - Error ID: ${id}`,
error instanceof Error
? error
: new Error(String(error)),
);
return id;
};
+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(
"Liora",
process.env.LOG_TOKEN ?? "",
);
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./prod"
},
"exclude": ["test", "vitest.config.ts"]
}