generated from nhcarrigan/template
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:
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user