feat: initial prototype
Some checks failed
Node.js CI / Lint and Test (push) Has been cancelled

This commit is contained in:
Naomi Carrigan 2025-02-23 19:45:53 -08:00
parent bbf71b077e
commit 5fc6d664e4
Signed by: naomi
SSH Key Fingerprint: SHA256:rca1iUI2OhAM6n4FIUaFcZcicmri0jgocqKiTTAfrt8
25 changed files with 6276 additions and 14 deletions

38
.gitea/workflows/ci.yml Normal file
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

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
prod
coverage

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript"]
}

View File

@ -1,24 +1,14 @@
# New Repository Template # Gwen Abalise
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. Gwen is a Discord bot that offers a private thread based ticketing system for your community!
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 server](https://discord.com/oauth2/authorize?client_id=1343413943447584819&permissions=360777385984&integration_type=0&scope=bot+applications.commands)!
## 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
eslint.config.js Normal file
View File

@ -0,0 +1,5 @@
import NaomisConfig from "@nhcarrigan/eslint-config";
export default [
...NaomisConfig
]

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "gwen-abalise",
"version": "1.0.0",
"description": "A ticket system for Discord",
"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.1.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "22.13.1",
"@vitest/coverage-istanbul": "3.0.5",
"eslint": "9.20.0",
"prisma": "6.4.1",
"typescript": "5.7.3",
"vitest": "3.0.5"
},
"dependencies": {
"@anthropic-ai/sdk": "0.36.3",
"@nhcarrigan/logger": "1.0.0",
"@prisma/client": "6.4.1",
"discord.js": "14.18.0",
"fastify": "5.2.1"
}
}

5265
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

23
prisma/schema.prisma Normal file
View File

@ -0,0 +1,23 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("MONGO_URI")
}
model Tickets {
id String @id @default(auto()) @map("_id") @db.ObjectId
uuid String
threadId String @unique
open Boolean
@@index([uuid, open], map: "uuid_open")
}
model Roles {
id String @id @default(auto()) @map("_id") @db.ObjectId
serverId String @unique
roleId String
}

3
prod.env Normal file
View File

@ -0,0 +1,3 @@
DISCORD_TOKEN="op://Environment Variables - Naomi/Gwen Abalise/discord_token"
MONGO_URI="op://Environment Variables - Naomi/Gwen Abalise/mongo_uri"
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"

22
src/commands/about.ts Normal file
View File

@ -0,0 +1,22 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ApplicationIntegrationType,
SlashCommandBuilder,
InteractionContextType,
} from "discord.js";
const command = new SlashCommandBuilder().
setContexts(
InteractionContextType.Guild,
).
setIntegrationTypes(ApplicationIntegrationType.GuildInstall).
setName("about").
setDescription("Learn more about this bot!");
// 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()));

27
src/commands/role.ts Normal file
View File

@ -0,0 +1,27 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ApplicationIntegrationType,
SlashCommandBuilder,
InteractionContextType,
} from "discord.js";
const command = new SlashCommandBuilder().
setContexts(
InteractionContextType.Guild,
).
setIntegrationTypes(ApplicationIntegrationType.GuildInstall).
setName("role").
setDescription("Set the support role that is added to each ticket.").
addRoleOption((option) => {
return option.setName("role").
setDescription("The role that should be invited to each thread.").
setRequired(true);
});
// 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()));

29
src/commands/start.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ApplicationIntegrationType,
SlashCommandBuilder,
InteractionContextType,
ChannelType,
} from "discord.js";
const command = new SlashCommandBuilder().
setContexts(
InteractionContextType.Guild,
).
setIntegrationTypes(ApplicationIntegrationType.GuildInstall).
setName("start").
setDescription("Send the ticket start post in the specified channel.").
addChannelOption((option) => {
return option.setName("channel").
setDescription("The channel to send the ticket start post in.").
setRequired(true).
addChannelTypes(ChannelType.GuildText);
});
// 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()));

View File

@ -0,0 +1,14 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* Guild IDs that are granted free access to this bot and do NOT
* need to subscribe.
*/
export const entitledGuilds = [
"443134315778539530",
"1146133490933436476",
];

104
src/index.ts Normal file
View File

@ -0,0 +1,104 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { PrismaClient } from "@prisma/client";
import { Client, Events, GatewayIntentBits } from "discord.js";
import { about } from "./modules/about.js";
import { close } from "./modules/close.js";
import { open } from "./modules/open.js";
import { role } from "./modules/role.js";
import { start } from "./modules/start.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.Guilds ],
});
const database = new PrismaClient();
// eslint-disable-next-line max-lines-per-function -- One too many...
client.on(Events.InteractionCreate, (interaction) => {
if (interaction.isButton()) {
if (!interaction.inCachedGuild()) {
void interaction.reply({
content: "I'm sorry, but I can only be used in a server.",
ephemeral: true,
});
return;
}
switch (interaction.customId) {
case "open":
void open(interaction, database);
break;
case "close":
void close(interaction, database);
break;
default:
void interaction.reply({
content: "I'm sorry, I don't know what to do with this button.",
ephemeral: true,
});
break;
}
}
if (interaction.isChatInputCommand()) {
if (!interaction.inCachedGuild()) {
void interaction.reply({
content: "I'm sorry, but I can only be used in a server.",
ephemeral: true,
});
return;
}
switch (interaction.commandName) {
case "about":
void about(interaction);
break;
case "start":
void start(interaction, database);
break;
case "role":
void role(interaction, database);
break;
default:
void interaction.reply({
content: `I'm sorry, I don't know the ${interaction.commandName} command.`,
ephemeral: true,
});
break;
}
}
});
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);

78
src/modules/about.ts Normal file
View File

@ -0,0 +1,78 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { execSync } from "node:child_process";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
MessageFlags,
type ChatInputCommandInteraction,
} from "discord.js";
import { logger } from "../utils/logger.js";
import { replyToError } from "../utils/replyToError.js";
/**
* Responds with information about the bot.
* @param interaction -- The interaction payload from Discord.
*/
// eslint-disable-next-line max-lines-per-function -- We're iteraly one over.
export const about = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const version = process.env.npm_package_version ?? "Unknown";
const commit = execSync("git rev-parse --short HEAD").toString().
trim();
const embed = new EmbedBuilder();
embed.setTitle("Gwen Abalise");
embed.setDescription(
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"Gwen uses private threads to provide a clean and user-friendly ticketing system for your server.",
);
embed.addFields(
{
name: "Version",
value: version,
},
{
name: "Current Commit",
value: commit,
},
);
const supportButton = new ButtonBuilder().
setLabel("Need help?").
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com");
const sourceButton = new ButtonBuilder().
setLabel("Source code").
setStyle(ButtonStyle.Link).
setURL("https://git.nhcarrigan.com/nhcarrigan/gwen-abalise");
const subscribeButton = new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId("1343419585117945936");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
supportButton,
sourceButton,
subscribeButton,
);
await interaction.editReply({
components: [ row ],
embeds: [ embed ],
});
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("about command", error);
}
}
};

115
src/modules/close.ts Normal file
View File

@ -0,0 +1,115 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ChannelType,
MessageFlags,
type ButtonInteraction,
} from "discord.js";
import { isSubscribed } from "../utils/isSubscribed.js";
import { logger } from "../utils/logger.js";
import { replyToError } from "../utils/replyToError.js";
import type { PrismaClient } from "@prisma/client";
/**
* Closes a ticket when a user clicks on the button.
* @param interaction - The interaction payload from Discord.
* @param database - The Prisma client.
*/
// eslint-disable-next-line max-lines-per-function, max-statements -- We're close!
export const close = async(
interaction: ButtonInteraction<"cached">,
database: PrismaClient,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const subscribed = await isSubscribed(interaction);
if (!subscribed) {
return;
}
const supportRole = await database.roles.findUnique({
where: {
serverId: interaction.guild.id,
},
});
if (!supportRole) {
await interaction.editReply({
content:
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"No support role has been set for this server. Please notify the admins.",
});
return;
}
const { channel, user } = interaction;
if (channel?.type !== ChannelType.PrivateThread) {
await interaction.editReply({
content: "How did this button show up outside of a thread???",
});
return;
}
const foundTicket = await database.tickets.findUnique({
where: {
threadId: channel.id,
},
});
if (!foundTicket) {
await interaction.editReply({
content: `I do not have a record for this ticket...`,
});
return;
}
if (!foundTicket.open) {
await interaction.editReply({
content: `This ticket has already been closed...`,
});
return;
}
await channel.members.remove(foundTicket.uuid);
await channel.send({
allowedMentions: {
parse: [],
},
content: `# Ticket Closed
This ticket has been closed by <@${user.id}> and locked. The member has been removed from the thread, and this thread is private so they cannot add themselves back.
Staff are welcome to discuss further here. The thread is preserved for logging.
Discord will auto-archive it, or if you are done discussing and want it out of the way you may archive it manually.`,
});
await database.tickets.update({
data: {
open: false,
},
where: {
id: foundTicket.id,
},
});
await interaction.editReply({
content: `Ticket closed~!`,
}).catch(() => {
return null;
});
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("close command", error);
}
}
};

116
src/modules/open.ts Normal file
View File

@ -0,0 +1,116 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChannelType,
MessageFlags,
type ButtonInteraction,
} from "discord.js";
import { isSubscribed } from "../utils/isSubscribed.js";
import { logger } from "../utils/logger.js";
import { replyToError } from "../utils/replyToError.js";
import type { PrismaClient } from "@prisma/client";
/**
* Opens a new ticket when a user clicks on the button.
* @param interaction - The interaction payload from Discord.
* @param database - The Prisma client.
*/
// eslint-disable-next-line max-lines-per-function, max-statements -- We're close!
export const open = async(
interaction: ButtonInteraction<"cached">,
database: PrismaClient,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const subscribed = await isSubscribed(interaction);
if (!subscribed) {
return;
}
const supportRole = await database.roles.findUnique({
where: {
serverId: interaction.guild.id,
},
});
if (!supportRole) {
await interaction.editReply({
content:
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"No support role has been set for this server. Please notify the admins.",
});
return;
}
const { channel, user } = interaction;
if (channel?.type !== ChannelType.GuildText) {
await interaction.editReply({
content: "How did this button show up in a non-text channel???",
});
return;
}
const hasTicket = await database.tickets.findFirst({
where: {
open: true,
uuid: user.id,
},
});
if (hasTicket) {
await interaction.editReply({
content: `You already have an open ticket: <#${hasTicket.threadId}>`,
});
return;
}
const ticket = await channel.threads.create({
autoArchiveDuration: 1440,
name: `ticket-${user.username}`,
type: ChannelType.PrivateThread,
});
const closeButton = new ButtonBuilder().setCustomId("close").
setStyle(ButtonStyle.Danger).
setLabel("Close Ticket").
setEmoji("🔒");
const row = new ActionRowBuilder<ButtonBuilder>().
addComponents(closeButton);
await ticket.send({
allowedMentions: {
roles: [ supportRole.roleId ],
users: [ user.id ],
},
components: [ row ],
content: `Hey <@${user.id}>, this is your private ticket with the <@&${supportRole.roleId}> team. How can we help you today?`,
});
await database.tickets.create({
data: {
open: true,
threadId: ticket.id,
uuid: user.id,
},
});
await interaction.editReply({
content: `Ticket created~!`,
});
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("open command", error);
}
}
};

69
src/modules/role.ts Normal file
View File

@ -0,0 +1,69 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
MessageFlags,
PermissionFlagsBits,
type ChatInputCommandInteraction,
} from "discord.js";
import { isSubscribed } from "../utils/isSubscribed.js";
import { logger } from "../utils/logger.js";
import { replyToError } from "../utils/replyToError.js";
import type { PrismaClient } from "@prisma/client";
/**
* Updates the role that is added to new tickets.
* @param interaction - The interaction payload from Discord.
* @param database - The Prisma client.
*/
export const role = async(
interaction: ChatInputCommandInteraction<"cached">,
database: PrismaClient,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const subscribed = await isSubscribed(interaction);
if (!subscribed) {
return;
}
if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) {
await interaction.editReply({
content:
"You must have the `MANAGE_GUILD` permission to use this command.",
});
return;
}
const supportRole = interaction.
options.
getRole("role", true);
await database.roles.upsert({
create: {
roleId: supportRole.id,
serverId: interaction.guild.id,
},
update: {
roleId: supportRole.id,
},
where: {
serverId: interaction.guild.id,
},
});
await interaction.editReply({
content: `Your support role has been set to <@&${supportRole.id}>. This role will be pinged in new tickets.`,
});
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("role command", error);
}
}
};

118
src/modules/start.ts Normal file
View File

@ -0,0 +1,118 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChannelType,
MessageFlags,
PermissionFlagsBits,
type ChatInputCommandInteraction,
} from "discord.js";
import { isSubscribed } from "../utils/isSubscribed.js";
import { logger } from "../utils/logger.js";
import { replyToError } from "../utils/replyToError.js";
import type { PrismaClient } from "@prisma/client";
/**
* Sends the standard ticket start post in the specified channel.
* @param interaction - The interaction payload from Discord.
* @param database - The Prisma client.
*/
// eslint-disable-next-line max-lines-per-function, max-statements -- We're close enough.
export const start = async(
interaction: ChatInputCommandInteraction<"cached">,
database: PrismaClient,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const subscribed = await isSubscribed(interaction);
if (!subscribed) {
return;
}
if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) {
await interaction.editReply({
content:
"You must have the `MANAGE_GUILD` permission to use this command.",
});
return;
}
const { me } = interaction.guild.members;
const requiredPermissions = [
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.CreatePrivateThreads,
PermissionFlagsBits.ManageThreads,
];
if (!me) {
await interaction.editReply(
{ content: "Error loading my permissions. Please try again later." },
);
return;
}
const channel = interaction.
options.
getChannel("channel", true, [ ChannelType.GuildText ]);
const permissions = channel.permissionsFor(me);
if (requiredPermissions.some((permission) => {
return !permissions.has(permission);
})) {
await interaction.editReply(
{
content:
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"I do not have the required permissions send messages or manage private ticket threads in that channel.",
},
);
return;
}
const role = await database.roles.findUnique({
where: {
serverId: interaction.guild.id,
},
});
if (!role) {
await interaction.editReply({
content:
"You must set a support role before starting the ticket system.",
});
return;
}
const button = new ButtonBuilder().setCustomId("open").
setLabel("Create Ticket").
setEmoji("🎫").
setStyle(ButtonStyle.Success);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await channel.send({
allowedMentions: {
parse: [],
},
components: [ row ],
content: `Please click the button below to create a new ticket with <@&${role.roleId}>.`,
});
await interaction.editReply({
content: "Your ticket system is ready!",
});
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("start command", error);
}
}
};

75
src/server/serve.ts Normal file
View File

@ -0,0 +1,75 @@
/**
* @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>Gwen Abalise</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Ticketing system for Discord!" />
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
</head>
<body>
<main>
<h1>Gwen Abalise</h1>
<section>
<p>Ticketing system for Discord!</p>
</section>
<section>
<h2>Links</h2>
<p>
<a href="https://git.nhcarrigan.com/nhcarrigan/gwen-abalise">
<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: 5010 }, (error) => {
if (error) {
void logger.error("instantiate server", error);
return;
}
void logger.log("debug", "Server listening on port 5010.");
});
} catch (error) {
if (error instanceof Error) {
void logger.error("instantiate server", error);
return;
}
void logger.error("instantiate server", new Error(String(error)));
}
};

55
src/utils/isSubscribed.ts Normal file
View File

@ -0,0 +1,55 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ButtonInteraction,
type ChatInputCommandInteraction,
} from "discord.js";
import { entitledGuilds } from "../config/entitledGuilds.js";
/**
* Checks if a server 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.
*/
export const isSubscribed = async(
interaction:
| ChatInputCommandInteraction<"cached">
| ButtonInteraction<"cached">,
): Promise<boolean> => {
if (entitledGuilds.includes(interaction.guild.id)) {
return true;
}
const isEntitled = interaction.entitlements.find((entitlement) => {
return (
entitlement.guildId === interaction.guild.id && entitlement.isActive()
);
});
if (!isEntitled && interaction.user.id !== "465650873650118659") {
const subscribeButton = new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId("1343419585117945936");
const helpButton = new ButtonBuilder().
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com").
setLabel("Need help?");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
subscribeButton,
helpButton,
);
await interaction.editReply({
components: [ row ],
// eslint-disable-next-line stylistic/max-len -- This is a long string.
content: "Your server does not appear to have an active subscription. Without one, the ticket system will not function.",
});
return false;
}
return true;
};

12
src/utils/logger.ts Normal file
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(
"Gwen Abalise",
process.env.LOG_TOKEN ?? "",
);

39
src/utils/replyToError.ts Normal file
View File

@ -0,0 +1,39 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ButtonInteraction,
type ChatInputCommandInteraction,
} 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
| ButtonInteraction,
): 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
tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./prod"
},
"exclude": ["test/**/*.ts", "vitest.config.ts"]
}

15
vitest.config.ts Normal file
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,
},
},
},
});