feat: initial prototype #3

Merged
naomi merged 1 commits from feat/init into main 2025-07-19 19:51:51 -07:00
24 changed files with 5421 additions and 14 deletions
Showing only changes of commit fe70dcc49f - Show all commits
+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"],
}
+4 -14
View File
@@ -1,20 +1,10 @@
# New Repository Template # Sorielle
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. A Discord application that allows you to configure a venting channel where messages are automatically deleted.
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.] This page is currently deployed. [Add to your Discord!](https://discord.com/oauth2/authorize?client_id=1391489982887362761)
## Feedback and Bugs ## Feedback and Bugs
@@ -36,4 +26,4 @@ Copyright held by Naomi Carrigan.
## Contact ## Contact
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`. --> We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`.
+31
View File
@@ -0,0 +1,31 @@
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 configure = new SlashCommandBuilder()
.setName("configure")
.setDescription("Change your venting channel and timeout.")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.addChannelOption((option) =>
option
.setName("channel")
.setDescription("The channel to use for venting.")
.setRequired(true)
.addChannelTypes(ChannelType.GuildText))
.addIntegerOption((option) =>
option
.setName("timeout")
.setDescription("The number of seconds to wait before deleting messages.")
.setRequired(true)
.setMinValue(1)
.setMaxValue(60 * 60 * 24 * 7));
console.log(JSON.stringify([
about.toJSON(),
configure.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": "sorielle",
"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
+18
View File
@@ -0,0 +1,18 @@
// 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 Vents {
id String @id @default(auto()) @map("_id") @db.ObjectId
serverId String @unique
channelId String
ttl Int
}
+3
View File
@@ -0,0 +1,3 @@
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
DISCORD_TOKEN="op://Environment Variables - Naomi/Sorielle/token"
MONGO_URI="op://Environment Variables - Naomi/Sorielle/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 _sorielle - Sorielle'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(_sorielle, interaction) => {
try {
const components = [
new ContainerBuilder().
addTextDisplayComponents(
new TextDisplayBuilder().setContent("# About Sorielle"),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
"Hi there~! I am Sorielle, a bot that shares ASCII art.",
),
).
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, you will need to purchase the server subscription from my Discord store. Then you, or any of your server admins, can configure a venting channel and a timeout. Once your settings are configured, any message that gets sent in your specified venting channel will be automatically deleted after the timeout period.",
),
).
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.",
});
}
};
+100
View File
@@ -0,0 +1,100 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
TextDisplayBuilder,
ContainerBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
MessageFlags,
ChannelType,
PermissionFlagsBits,
} 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 `/config` command interaction.
* @param _sorielle - Sorielle's Discord instance (unused).
* @param interaction - The command interaction payload from Discord.
*/
/**
* Handles the `/config` command interaction.
* @param sorielle - Sorielle'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 config: Command = async(sorielle, interaction) => {
try {
const isEntitled = await checkGuildEntitlement(sorielle, interaction.guild);
if (!isEntitled) {
await sendUnentitledResponse(interaction);
return;
}
await interaction.deferReply({
flags: [ MessageFlags.Ephemeral ],
});
const channel = interaction.options.getChannel("channel", true, [
ChannelType.GuildText,
]);
const permissions = interaction.guild.members.me?.permissionsIn(channel);
const hasAllPermissions
= permissions !== undefined
&& permissions.has(PermissionFlagsBits.ManageMessages)
&& permissions.has(PermissionFlagsBits.ViewChannel)
&& permissions.has(PermissionFlagsBits.ReadMessageHistory);
if (!hasAllPermissions) {
await interaction.editReply({
content:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"I do not have permission to manage messages in that channel. Please ensure I have the `Manage Messages`, `View Channel`, and `Read Message History` permissions.",
});
return;
}
const seconds = interaction.options.getInteger("timeout", true);
const components = [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`Messages in <#${
channel.id
}> will be deleted after ${seconds.toLocaleString("en-GB")} seconds.`,
),
),
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.editReply({
components: components,
flags: [ MessageFlags.IsComponentsV2 ],
});
} catch (error) {
await errorHandler(error, "config command");
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- We want to ensure the interaction is replied to.
interaction.replied
? 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.",
})
: 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.",
flags: [ MessageFlags.Ephemeral ],
});
}
};
+11
View File
@@ -0,0 +1,11 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
const entitledGuilds = [
// Naomi's server.
"1354624415861833870",
];
export { entitledGuilds };
+37
View File
@@ -0,0 +1,37 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { about } from "../commands/about.js";
import { config } from "../commands/config.js";
import type { Command } from "../interfaces/command.js";
import type { Sorielle } from "../interfaces/sorielle.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,
config: config,
};
/**
* Processes a slash command.
* @param sorielle - Sorielle's Discord instance.
* @param interaction - The command interaction payload from Discord.
*/
const chatInputInteractionCreate = async(
sorielle: Sorielle,
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(sorielle, interaction);
};
export { chatInputInteractionCreate };
+41
View File
@@ -0,0 +1,41 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { checkGuildEntitlement } from "../utils/checkEntitlement.js";
import type { Sorielle } from "../interfaces/sorielle.js";
import type { Message } from "discord.js";
/**
* Processes a slash command.
* @param sorielle - Sorielle's Discord instance.
* @param message - The message payload from Discord.
*/
const messageCreate = async(
sorielle: Sorielle,
message: Message<true>,
): Promise<void> => {
const isEntitled = await checkGuildEntitlement(sorielle, message.guild);
if (!isEntitled) {
return;
}
const serverRecord = await sorielle.database.vents.findUnique({
where: {
serverId: message.guild.id,
},
});
if (!serverRecord) {
return;
}
if (serverRecord.channelId !== message.channel.id) {
return;
}
setTimeout(() => {
void message.delete().catch(() => {
return null;
});
}, serverRecord.ttl * 1000);
};
export { messageCreate };
+58
View File
@@ -0,0 +1,58 @@
/**
* @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 { Sorielle } from "./interfaces/sorielle.js";
const sorielle: Sorielle = {
database: new PrismaClient(),
discord: new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
],
}),
};
sorielle.discord.once(Events.ClientReady, () => {
void logger.log(
"debug",
`Logged in as ${sorielle.discord.user?.username ?? "unknown"}`,
);
});
sorielle.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(sorielle, interaction);
}
});
sorielle.discord.on(Events.MessageCreate, (message) => {
if (!message.inGuild()) {
return;
}
void messageCreate(sorielle, message);
});
await sorielle.database.$connect();
await sorielle.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 { Sorielle } from "./sorielle.js";
import type { ChatInputCommandInteraction } from "discord.js";
export type Command = (
sorielle: Sorielle,
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 Sorielle {
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("1396309949679800421"),
),
];
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>Sorielle</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Discord bot that automatically deletes messages in a configured 'venting' channel after a specific amount of time." />
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
</head>
<body>
<main>
<h1>Sorielle</h1>
<img src="https://cdn.nhcarrigan.com/new-avatars/sorielle.png" width="250" alt="Sorielle" />
<section>
<p>Discord bot that automatically deletes messages in a configured "venting" channel after a specific amount of time.</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/sorielle">
<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: 5019 }, (error) => {
if (error) {
void logger.error("instantiate server", error);
return;
}
void logger.log("debug", "Server listening on port 5019.");
});
} 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 { Sorielle } from "../interfaces/sorielle.js";
import type { Guild } from "discord.js";
/**
* Checks if a guild has subscribed.
* @param sorielle - Sorielle's Discord instance.
* @param guild - The guild to check.
* @returns A boolean indicating whether the guild has an active subscription.
*/
const checkGuildEntitlement = async(
sorielle: Sorielle,
guild: Guild,
): Promise<boolean> => {
if (entitledGuilds.includes(guild.id)) {
return true;
}
const entitlements = await sorielle.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(
"Sorielle",
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"]
}