feat: first version of bot (#2)

### Explanation

This should set up everything we need for our initial launch. Test coverage is at 100% to ensure nothing breaks.

### 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

- [x] 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.
- [x] All new and existing tests pass locally with my changes.
- [x] Code coverage remains at or above the configured threshold.

### Documentation

Coming soon - I'm working on the infra for docs next

### Versioning

Major - My pull request introduces a breaking change.

Reviewed-on: https://codeberg.org/nhcarrigan/rig-task-bot/pulls/2
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit is contained in:
2024-09-30 01:41:25 +00:00
committed by Naomi the Technomancer
parent da6fbfd45e
commit 296a50fedd
49 changed files with 4816 additions and 3 deletions
+73
View File
@@ -0,0 +1,73 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { InteractionContextType, SlashCommandBuilder } from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
export const assign: Command = {
data: new SlashCommandBuilder().
setName("assign").
setDescription("Add or remove someone as a task assignee.").
setContexts(InteractionContextType.Guild).
addIntegerOption((option) => {
return option.
setName("task").
setDescription("The task number.").
setMinValue(1).
setRequired(true);
}).
addUserOption((option) => {
return option.
setName("assignee").
setDescription("The user to (un)assign.").
setRequired(true);
}),
run: async(bot, interaction) => {
try {
await interaction.deferReply({ ephemeral: true });
const numericalId = interaction.options.getInteger("task", true);
const user = interaction.options.getUser("assignee", true).id;
const task = await bot.database.tasks.findFirst({
where: {
numericalId,
},
});
if (!task) {
await interaction.editReply({
content: `Task ${String(numericalId)} not found.`,
});
return;
}
const shouldRemove = task.assignees.includes(user);
await bot.database.tasks.update({
data: shouldRemove
? {
assignees: task.assignees.filter((u) => {
return u !== user;
}),
}
: {
assignees: {
push: user,
},
},
where: {
numericalId,
},
});
await interaction.editReply({
content: `User <@${user}> ${shouldRemove
? "unassigned from"
: "assigned to"} task ${String(numericalId)}.`,
});
} catch (error) {
await errorHandler(bot, "assign command", error, interaction.replied
? interaction.editReply.bind(interaction)
: interaction.reply.bind(interaction));
}
},
};
+44
View File
@@ -0,0 +1,44 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { InteractionContextType, SlashCommandBuilder } from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
export const complete: Command = {
data: new SlashCommandBuilder().
setName("complete").
setDescription("Mark a task as completed.").
setContexts(InteractionContextType.Guild).
addIntegerOption((option) => {
return option.
setName("id").
setDescription("The ID of the task to complete.").
setMinValue(1).
setRequired(true);
}),
run: async(bot, interaction) => {
try {
await interaction.deferReply({ ephemeral: true });
const numericalId = interaction.options.getInteger("id", true);
const task = await bot.database.tasks.update({
data: { completed: true },
where: { numericalId },
}).catch(() => {
return null;
});
await interaction.editReply({
content: task
? `Task ${String(numericalId)} has been marked as complete.`
: `Task ${String(numericalId)} does not exist.`,
});
} catch (error) {
await errorHandler(bot, "view command", error, interaction.replied
? interaction.editReply.bind(interaction)
: interaction.reply.bind(interaction));
}
},
};
+60
View File
@@ -0,0 +1,60 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
InteractionContextType,
ModalBuilder,
SlashCommandBuilder,
TextInputBuilder,
TextInputStyle,
} from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
export const create: Command = {
data: new SlashCommandBuilder().
setName("create").
setDescription("Create a new task.").
setContexts(InteractionContextType.Guild),
run: async(bot, interaction) => {
try {
const title = new TextInputBuilder().
setLabel("Title").
setRequired(true).
setStyle(TextInputStyle.Short).
setCustomId("title");
const description = new TextInputBuilder().
setLabel("Description").
setRequired(true).
setStyle(TextInputStyle.Paragraph).
setCustomId("description");
const due = new TextInputBuilder().
setLabel("Due Date").
setRequired(true).
setStyle(TextInputStyle.Short).
setCustomId("dueDate");
const rowOne = new ActionRowBuilder<TextInputBuilder>().addComponents(
title,
);
const rowTwo = new ActionRowBuilder<TextInputBuilder>().addComponents(
description,
);
const rowThree = new ActionRowBuilder<TextInputBuilder>().addComponents(
due,
);
const modal = new ModalBuilder().
setCustomId("create-task").
setTitle("New Task").
addComponents(rowOne, rowTwo, rowThree);
await interaction.showModal(modal);
} catch (error) {
await errorHandler(bot, "create command", error, interaction.replied
? interaction.editReply.bind(interaction)
: interaction.reply.bind(interaction));
}
},
};
+62
View File
@@ -0,0 +1,62 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { InteractionContextType, SlashCommandBuilder } from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
export const deleteCommand: Command = {
data: new SlashCommandBuilder().
setName("delete").
setDescription(
// eslint-disable-next-line stylistic/max-len
"Mark a task as deleted. WARNING: This will scrub all PII from the task and CANNOT be undone.",
).
setContexts(InteractionContextType.Guild).
addIntegerOption((option) => {
return option.
setName("id").
setDescription("The ID of the task to delete.").
setMinValue(1).
setRequired(true);
}),
run: async(bot, interaction) => {
try {
await interaction.deferReply({ ephemeral: true });
const numericalId = interaction.options.getInteger("id", true);
const task = await bot.database.tasks.
update({
data: {
assignees: [],
deleted: true,
description: "This task has been deleted.",
dueAt: new Date(),
priority: "deleted",
tags: [],
title: "Deleted Task",
},
where: { numericalId },
}).
catch(() => {
return null;
});
await interaction.editReply({
content: task
? `Task ${String(numericalId)} has been marked as deleted.`
: `Task ${String(numericalId)} does not exist.`,
});
} catch (error) {
await errorHandler(
bot,
"view command",
error,
interaction.replied
? interaction.editReply.bind(interaction)
: interaction.reply.bind(interaction),
);
}
},
};
+87
View File
@@ -0,0 +1,87 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { InteractionContextType, SlashCommandBuilder } from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
import type { Prisma } from "@prisma/client";
export const list: Command = {
data: new SlashCommandBuilder().
setName("list").
setDescription("List all tasks, with optional filters.").
setContexts(InteractionContextType.Guild).
addStringOption((option) => {
return option.
setName("priority").
setDescription("List tasks under this priority.").
setRequired(false).
addChoices(
{ name: "Low", value: "low" },
{ name: "Medium", value: "medium" },
{ name: "High", value: "high" },
{ name: "Critical", value: "critical" },
{ name: "None", value: "none" },
);
}).
addStringOption((option) => {
return option.
setName("tag").
setDescription("List tasks with this tag.").
setRequired(false);
}).
addUserOption((option) => {
return option.
setName("assignee").
setDescription("List tasks assigned to this user.").
setRequired(false);
}).
addBooleanOption((option) => {
return option.
setName("completed").
setDescription("List completed tasks.").
setRequired(false);
}),
run: async(bot, interaction) => {
try {
await interaction.deferReply({ ephemeral: true });
const priority = interaction.options.getString("priority");
const tag = interaction.options.getString("tag");
const assignee = interaction.options.getUser("assignee");
const completed = interaction.options.getBoolean("completed") ?? false;
const query: Prisma.TasksWhereInput = {
completed: completed,
deleted: false,
};
if (priority !== null) {
query.priority = priority;
}
if (tag !== null) {
query.tags = { has: tag };
}
if (assignee !== null) {
query.assignees = { has: assignee.id };
}
const tasks = await bot.database.tasks.findMany({
where: query,
});
const taskList = tasks.sort((a, b) => {
return a.dueAt.getTime() - b.dueAt.getTime();
}).map((task) => {
return `- Task ${String(task.numericalId)}: ${task.title}`;
});
await interaction.editReply({
content: taskList.length > 0
? taskList.join("\n")
: "No tasks found with this current filter.",
});
} catch (error) {
await errorHandler(bot, "list command", error, interaction.replied
? interaction.editReply.bind(interaction)
: interaction.reply.bind(interaction));
}
},
};
+69
View File
@@ -0,0 +1,69 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { InteractionContextType, SlashCommandBuilder } from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
export const priority: Command = {
data: new SlashCommandBuilder().
setName("priority").
setDescription("Set the priority of a task.").
setContexts(InteractionContextType.Guild).
addIntegerOption((option) => {
return option.
setName("task").
setDescription("The task number.").
setMinValue(1).
setRequired(true);
}).
addStringOption((option) => {
return option.
setName("priority").
setDescription("The priority level.").
setRequired(true).
addChoices(
{ name: "Low", value: "low" },
{ name: "Medium", value: "medium" },
{ name: "High", value: "high" },
{ name: "Critical", value: "critical" },
{ name: "None", value: "none" },
);
}),
run: async(bot, interaction) => {
try {
await interaction.deferReply({ ephemeral: true });
const numericalId = interaction.options.getInteger("task", true);
const priorityValue = interaction.options.getString("priority", true);
const task = await bot.database.tasks.findFirst({
where: {
numericalId,
},
});
if (!task) {
await interaction.editReply({
content: `Task ${String(numericalId)} not found.`,
});
return;
}
await bot.database.tasks.update({
data: {
priority: priorityValue,
},
where: {
numericalId,
},
});
await interaction.editReply({
content: `Task ${String(numericalId)} priority set to ${priorityValue}.`,
});
} catch (error) {
await errorHandler(bot, "priority command", error, interaction.replied
? interaction.editReply.bind(interaction)
: interaction.reply.bind(interaction));
}
},
};
+73
View File
@@ -0,0 +1,73 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { InteractionContextType, SlashCommandBuilder } from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
export const tag: Command = {
data: new SlashCommandBuilder().
setName("tag").
setDescription("Add or remove a tag from a task.").
setContexts(InteractionContextType.Guild).
addIntegerOption((option) => {
return option.
setName("task").
setDescription("The task number.").
setMinValue(1).
setRequired(true);
}).
addStringOption((option) => {
return option.
setName("tag").
setDescription("The tag.").
setRequired(true);
}),
run: async(bot, interaction) => {
try {
await interaction.deferReply({ ephemeral: true });
const numericalId = interaction.options.getInteger("task", true);
const tagName = interaction.options.getString("tag", true);
const task = await bot.database.tasks.findFirst({
where: {
numericalId,
},
});
if (!task) {
await interaction.editReply({
content: `Task ${String(numericalId)} not found.`,
});
return;
}
const shouldRemove = task.tags.includes(tagName);
await bot.database.tasks.update({
data: shouldRemove
? {
tags: task.tags.filter((t) => {
return t !== tagName;
}),
}
: {
tags: {
push: tagName,
},
},
where: {
numericalId,
},
});
await interaction.editReply({
content: `Tag ${tagName} ${shouldRemove
? "removed from"
: "added to"} task ${String(numericalId)}.`,
});
} catch (error) {
await errorHandler(bot, "tag command", error, interaction.replied
? interaction.editReply.bind(interaction)
: interaction.reply.bind(interaction));
}
},
};
+68
View File
@@ -0,0 +1,68 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
InteractionContextType,
ModalBuilder,
SlashCommandBuilder,
TextInputBuilder,
TextInputStyle,
} from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
export const update: Command = {
data: new SlashCommandBuilder().
setName("update").
setDescription("Update a task.").
setContexts(InteractionContextType.Guild),
run: async(bot, interaction) => {
try {
const number = new TextInputBuilder().
setLabel("Task Number").
setRequired(true).
setStyle(TextInputStyle.Short).
setCustomId("taskNumber");
const title = new TextInputBuilder().
setLabel("Title").
setRequired(false).
setStyle(TextInputStyle.Short).
setCustomId("title");
const description = new TextInputBuilder().
setLabel("Description").
setRequired(false).
setStyle(TextInputStyle.Paragraph).
setCustomId("description");
const due = new TextInputBuilder().
setLabel("Due Date").
setRequired(false).
setStyle(TextInputStyle.Short).
setCustomId("dueDate");
const rowZero = new ActionRowBuilder<TextInputBuilder>().addComponents(
number,
);
const rowOne = new ActionRowBuilder<TextInputBuilder>().addComponents(
title,
);
const rowTwo = new ActionRowBuilder<TextInputBuilder>().addComponents(
description,
);
const rowThree = new ActionRowBuilder<TextInputBuilder>().addComponents(
due,
);
const modal = new ModalBuilder().
setCustomId("update-task").
setTitle("Update Task").
addComponents(rowZero, rowOne, rowTwo, rowThree);
await interaction.showModal(modal);
} catch (error) {
await errorHandler(bot, "update command", error, interaction.replied
? interaction.editReply.bind(interaction)
: interaction.reply.bind(interaction));
}
},
};
+61
View File
@@ -0,0 +1,61 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { InteractionContextType, SlashCommandBuilder } from "discord.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Command } from "../interfaces/command.js";
export const view: Command = {
data: new SlashCommandBuilder().
setName("view").
setDescription("View a task by its ID.").
setContexts(InteractionContextType.Guild).
addIntegerOption((option) => {
return option.
setName("id").
setDescription("The ID of the task to view.").
setMinValue(1).
setRequired(true);
}),
run: async(bot, interaction) => {
try {
await interaction.deferReply({ ephemeral: true });
const numericalId = interaction.options.getInteger("id", true);
const task = await bot.database.tasks.findUnique({
where: { numericalId },
});
if (!task) {
await interaction.editReply({ content: `Task ${String(numericalId)} not found.` });
return;
}
if (task.deleted) {
await interaction.editReply({ content: `Task ${String(numericalId)} has been deleted.` });
return;
}
await interaction.editReply({ embeds: [
{
description: task.description,
fields: [
{ inline: true, name: "Priority", value: task.priority },
{ inline: true, name: "Completed", value: task.completed
? "Yes"
: "No" },
{ name: "Tag", value: task.tags.join(", ") },
{ name: "Assignee", value: task.assignees.map((id) => {
return `<@${id}>`;
}).join(", ") },
],
title: `Task ${String(task.numericalId)}`,
},
] });
} catch (error) {
await errorHandler(bot, "view command", error, interaction.replied
? interaction.editReply.bind(interaction)
: interaction.reply.bind(interaction));
}
},
};
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { GatewayIntentBits } from "discord.js";
export const intents: Array<GatewayIntentBits> = [
GatewayIntentBits.Guilds,
];
+99
View File
@@ -0,0 +1,99 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { assign } from "../commands/assign.js";
import { complete } from "../commands/complete.js";
import { create } from "../commands/create.js";
import { deleteCommand } from "../commands/delete.js";
import { list } from "../commands/list.js";
import { priority } from "../commands/priority.js";
import { tag } from "../commands/tag.js";
import { update } from "../commands/update.js";
import { view } from "../commands/view.js";
import { defaultCommand } from "../modules/defaultCommand.js";
import {
createModal,
defaultModal,
updateModal,
type ModalHandler,
} from "../modules/modalHandlers.js";
import { errorHandler } from "../utils/errorHandler.js";
import type { Bot } from "../interfaces/bot.js";
import type { Command } from "../interfaces/command.js";
import type { Interaction } from "discord.js";
const commandMap: Record<string, Command> = {
assign: assign,
complete: complete,
create: create,
delete: deleteCommand,
list: list,
priority: priority,
tag: tag,
update: update,
view: view,
};
const modalMap: Record<string, ModalHandler> = {
// eslint-disable-next-line @typescript-eslint/naming-convention
"create-task": createModal,
// eslint-disable-next-line @typescript-eslint/naming-convention
"update-task": updateModal,
};
/**
* Handles all of the logic for interactions.
* If it's a slash command and we have the guild, try to run it.
* @param bot - The bot object, which contains the database and Discord client.
* @param interaction - The interaction payload from Discord.
*/
export const onInteractionCreate
// eslint-disable-next-line complexity
= async(bot: Bot, interaction: Interaction): Promise<void> => {
try {
if (interaction.isChatInputCommand()) {
if (!interaction.inCachedGuild()) {
await interaction.reply({
content: "How did you run this out of a guild?",
ephemeral: true,
});
return;
}
const command
= commandMap[interaction.commandName]?.run ?? defaultCommand;
await command(bot, interaction);
return;
}
if (interaction.isModalSubmit()) {
if (!interaction.inCachedGuild()) {
await interaction.reply({
content: "How did you get a modal outside of a guild?",
ephemeral: true,
});
return;
}
const handler = modalMap[interaction.customId] ?? defaultModal;
await handler(bot, interaction);
return;
}
throw new Error("Unknown interaction type.");
} catch (error) {
if (interaction.isAutocomplete()) {
await errorHandler(bot, "discord on interaction event", error);
return;
}
await errorHandler(
bot,
"discord on interaction event",
error,
interaction.replied
? interaction.editReply.bind(interaction)
: interaction.reply.bind(interaction),
);
}
};
+29
View File
@@ -0,0 +1,29 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { displayCommandCurl } from "../utils/displayCommandCurl.js";
import { errorHandler } from "../utils/errorHandler.js";
import { sendDebugLog } from "../utils/sendDebugLog.js";
import type { Bot } from "../interfaces/bot.js";
/**
* To be mounted on the ClientReady gateway event. Sends
* a message to the debug webhook to confirm the bot has
* authenticated to Discord.
* @param bot -- The bot object, containing the webhook client.
*/
export const onReady = async(bot: Bot): Promise<void> => {
try {
await sendDebugLog(bot, { content: "Bot has authenticated to Discord." });
await sendDebugLog(bot, { files: [ {
attachment: Buffer.from(displayCommandCurl(bot)),
name: "curl.sh",
} ] });
} catch (error) {
await errorHandler(bot, "discord on ready event", error);
}
};
+45
View File
@@ -3,3 +3,48 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { PrismaClient } from "@prisma/client";
import { Client, Events, WebhookClient } from "discord.js";
import { intents } from "./config/intents.js";
import { onInteractionCreate } from "./events/onInteractionCreate.js";
import { onReady } from "./events/onReady.js";
import { validateEnvironmentVariables }
from "./utils/validateEnvironmentVariables.js";
/**
* The entry point file. Handles starting up the application
* process and mounting the necessary event listeners.
*/
const boot = async(): Promise<void> => {
try {
const bot = {
database: new PrismaClient(),
discord: new Client({ intents }),
env: validateEnvironmentVariables(),
};
await bot.database.$connect();
bot.discord.on(Events.ClientReady, () => {
void onReady(bot);
});
bot.discord.on(Events.InteractionCreate, (interaction) => {
void onInteractionCreate(bot, interaction);
});
await bot.discord.login(bot.env.discordToken);
} catch (error) {
const hook = new WebhookClient({
url: process.env.DISCORD_DEBUG_WEBHOOK ?? "",
});
await hook.send({
content: `Error: ${JSON.stringify(error, null, 2)}`,
});
}
};
void boot();
export { boot };
+18
View File
@@ -0,0 +1,18 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { PrismaClient } from "@prisma/client";
import type { Client, WebhookClient } from "discord.js";
export interface Bot {
env: {
discordToken: string;
discordDebugWebhook: WebhookClient;
mongoUri: string;
};
discord: Client;
database: PrismaClient;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Bot } from "./bot.js";
import type { ChatInputCommandInteraction, SlashCommandOptionsOnlyBuilder }
from "discord.js";
export interface Command {
data: SlashCommandOptionsOnlyBuilder;
run: (bot: Bot, interaction:
ChatInputCommandInteraction<"cached">)=> Promise<void>;
}
+21
View File
@@ -0,0 +1,21 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Command } from "../interfaces/command.js";
/**
* Default handler to fall back to in the event a command
* is not found. This can happen if we delete command logic in the code
* but do not register the updates.
* @param _bot - The bot instance, unused here but necessary to match the type signatures.
* @param interaction - The interaction payload from Discord.
*/
export const defaultCommand: Command["run"] = async(_bot, interaction) => {
await interaction.reply({
content: `Interaction ${interaction.commandName} not found.`,
ephemeral: true,
});
};
+139
View File
@@ -0,0 +1,139 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { errorHandler } from "../utils/errorHandler.js";
import type { Bot } from "../interfaces/bot.js";
import type { Tasks } from "@prisma/client";
import type { ModalSubmitInteraction } from "discord.js";
type ModalHandler = (bot: Bot, modal: ModalSubmitInteraction)=> Promise<void>;
/**
* Fallback logic for the modal map. Reports the custom ID
* to be debugged.
* @param _bot - The bot object. Unused, but necessary to match the type signature.
* @param modal - The modal interaction payload from Discord.
*/
const defaultModal: ModalHandler = async(
_bot,
modal,
): Promise<void> => {
await modal.reply({
content: `Modal ${modal.customId} has no handler.`,
ephemeral: true,
});
};
/**
* Processes the logic for the modal to create a new
* task.
* @param bot - The bot object, which contains the database and Discord client.
* @param modal - The modal interaction payload from Discord.
*/
const createModal: ModalHandler = async(
bot,
modal,
): Promise<void> => {
try {
await modal.deferReply({
ephemeral: true,
});
const title = modal.fields.getTextInputValue("title");
const description = modal.fields.getTextInputValue("description");
const date = modal.fields.getTextInputValue("dueDate");
const parsedDate = new Date(date);
const dueDate = Number.isNaN(parsedDate.valueOf())
? new Date()
: parsedDate;
const id = await bot.database.tasks.count() + 1;
const task = await bot.database.tasks.create({
data: {
description: description,
dueAt: dueDate,
numericalId: id,
title: title,
},
});
await modal.editReply({
content: `Task ${String(task.numericalId)} created.`,
});
} catch (error) {
await errorHandler(bot, "create modal handler", error, modal.replied
? modal.editReply.bind(modal)
: modal.reply.bind(modal));
}
};
/**
* Processes the logic for the modal to update a
* task.
* @param bot - The bot object, which contains the database and Discord client.
* @param modal - The modal interaction payload from Discord.
*/
// eslint-disable-next-line max-statements, max-lines-per-function
const updateModal: ModalHandler = async(
bot,
modal,
): Promise<void> => {
try {
await modal.deferReply({
ephemeral: true,
});
const taskNumber = modal.fields.getTextInputValue("taskNumber");
const number = Number(taskNumber);
if (Number.isNaN(number)) {
await modal.editReply({
content: "Invalid task number.",
});
return;
}
const task = await bot.database.tasks.findFirst({
where: {
numericalId: number,
},
});
if (!task) {
await modal.editReply({
content: `Task ${taskNumber} not found.`,
});
return;
}
const title = modal.fields.getTextInputValue("title");
const description = modal.fields.getTextInputValue("description");
const date = modal.fields.getTextInputValue("dueDate");
const updateObject: Partial<Tasks> = {};
if (title !== "") {
updateObject.title = title;
}
if (description !== "") {
updateObject.description = description;
}
if (date !== "") {
const parsedDate = new Date(date);
const dueDate = Number.isNaN(parsedDate.valueOf())
? task.dueAt
: parsedDate;
updateObject.dueAt = dueDate;
}
await bot.database.tasks.update({
data: updateObject,
where: {
numericalId: number,
},
});
await modal.editReply({
content: `Task ${String(task.numericalId)} updated.`,
});
} catch (error) {
await errorHandler(bot, "update modal handler", error, modal.replied
? modal.editReply.bind(modal)
: modal.reply.bind(modal));
}
};
export { type ModalHandler, createModal, defaultModal, updateModal };
+54
View File
@@ -0,0 +1,54 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { assign } from "../commands/assign.js";
import { complete } from "../commands/complete.js";
import { create } from "../commands/create.js";
import { deleteCommand } from "../commands/delete.js";
import { list } from "../commands/list.js";
import { priority } from "../commands/priority.js";
import { tag } from "../commands/tag.js";
import { update } from "../commands/update.js";
import { view } from "../commands/view.js";
import type { Bot } from "../interfaces/bot.js";
/**
* Generates a CURL string which can be used to register the bot's commands.
* This is done manually - you should never register commands on something like the ready event.
* Normally, we would register in response to an owner command, but this bot will not be using
* the messgae content intent and we have no desire to parse a ping-prefixed message.
* @param bot - The bot object, needed only to dynamically load the ID. The token is NOT loaded, for security.
* @returns A CURL string which can be used in a terminal to register the bot's commands.
*/
export const displayCommandCurl = (bot: Bot): string => {
const commands = [
create,
update,
priority,
tag,
assign,
list,
view,
complete,
deleteCommand,
].map((c) => {
return c.data.toJSON();
});
const url = `https://discord.com/api/v10/applications/${bot.discord.user?.id ?? "{ID}"}/commands`;
const method = "PUT";
const headers = {
// eslint-disable-next-line @typescript-eslint/naming-convention
"Authorization": `Bot {TOKEN}`,
// eslint-disable-next-line @typescript-eslint/naming-convention
"Content-Type": "application/json",
};
const body = JSON.stringify(commands);
return `curl -X ${method} -H ${Object.entries(headers).
map(([ k, v ]) => {
return `"${k}: ${v}"`;
}).
join(" -H ")} --data '${body}' ${url}`;
};
+52
View File
@@ -0,0 +1,52 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
EmbedBuilder,
SnowflakeUtil,
type ChatInputCommandInteraction,
} from "discord.js";
import { sendDebugLog } from "./sendDebugLog.js";
import type { Bot } from "../interfaces/bot.js";
/**
* Parses an error event (re: from a catch block). If it
* is a proper Error object, extrapolates data. Sends information
* to the debug webhook, and assigns the error a Snowflake ID.
* Optionally forwards the ID to the user through a reply function.
* @param bot -- The bot object, containing the webhook client.
* @param context -- A brief description of the code module that threw the error.
* @param error -- The error payload, typed as unknown to comply with TypeScript's typedef.
* @param reply - OPTIONAL: A function to use to send the ID back to the user.
* @returns The Snowflake ID assigned to the error.
*/
export const errorHandler = async(
bot: Bot,
context: string,
error: unknown,
reply?:
| ChatInputCommandInteraction["reply"]
| ChatInputCommandInteraction["editReply"],
// eslint-disable-next-line @typescript-eslint/max-params
): Promise<string> => {
const id = SnowflakeUtil.generate();
const embed = new EmbedBuilder();
embed.setFooter({ text: `Error ID: ${id.toString()}` });
embed.setTitle(`Error: ${context}`);
if (error instanceof Error) {
embed.setDescription(error.message);
embed.addFields([
{ name: "Stack", value: `\`\`\`\n${String(error.stack).slice(0, 1000)}` },
]);
} else {
embed.setDescription(String(error).slice(0, 2000));
}
await sendDebugLog(bot, { embeds: [ embed ] });
if (reply) {
const content = `Oops! Something went wrong! Please reach out to us in our [support server](https://chat.nhcarrigan.com) and bring this error ID: ${id.toString()}`;
await reply({ content: content, ephemeral: true });
}
return id.toString();
};
+27
View File
@@ -0,0 +1,27 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Bot } from "../interfaces/bot.js";
import type { MessageCreateOptions } from "discord.js";
/**
* Quick wrapper to send a debug message to the webhook.
* Modularised for future expansion if needed.
* @param bot -- The bot object, containing the webhook client.
* @param message -- The message payload, compatible with Discord's API.
*/
export const sendDebugLog = async(
bot: Bot,
message: MessageCreateOptions,
): Promise<void> => {
await bot.env.discordDebugWebhook.send({
...message,
avatarURL:
bot.discord.user?.displayAvatarURL()
?? "https://cdn.nhcarrigan.com/profile.png",
username: bot.discord.user?.username ?? "RIG Task Bot",
});
};
+39
View File
@@ -0,0 +1,39 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { WebhookClient } from "discord.js";
import type { Bot } from "../interfaces/bot.js";
/**
* Confirms that environment variables are not undefined. If
* they are, throw an error to halt the program.
* @returns The environment variables, structured for the top-level bot to cache.
* @throws A ReferenceError for any missing variables, with a message indicating the missing variable.
*/
export const validateEnvironmentVariables = (): Bot["env"] => {
if (process.env.DISCORD_TOKEN === undefined
|| process.env.DISCORD_TOKEN === "") {
throw new ReferenceError("DISCORD_TOKEN cannot be undefined.");
}
if (process.env.DISCORD_DEBUG_WEBHOOK === undefined
|| process.env.DISCORD_DEBUG_WEBHOOK === "") {
throw new ReferenceError("DISCORD_DEBUG_WEBHOOK cannot be undefined.");
}
if (process.env.MONGO_URI === undefined
|| process.env.MONGO_URI === "") {
throw new ReferenceError("MONGO_URI cannot be undefined.");
}
return {
discordDebugWebhook:
new WebhookClient({ url: process.env.DISCORD_DEBUG_WEBHOOK }),
discordToken: process.env.DISCORD_TOKEN,
mongoUri: process.env.MONGO_URI,
};
};