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

### Explanation

_No response_

### Issue

_No response_

### Attestations

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

### Dependencies

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

### Style

- [x] I have run the linter and resolved any errors.
- [x] My pull request uses an appropriate title, matching the conventional commit standards.
- [x] 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: #3
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #3.
This commit is contained in:
2025-07-19 19:51:50 -07:00
committed by Naomi Carrigan
parent 78b4da6235
commit c84b984cb2
24 changed files with 5421 additions and 14 deletions
+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 ?? "",
);