diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..874773d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": ["typescript"], +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99ce3ff..a586ece 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,7 +13,7 @@ datasource db { model Servers { id String @id @default(auto()) @map("_id") @db.ObjectId serverId String @unique - questionChannelId String - answerChannelId String - blockedUsers String[] + questionChannelId String @default("") + answerChannelId String @default("") + blockedUsers String[] @default([]) } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 7c67720..063563b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,9 @@ import { PrismaClient } from "@prisma/client"; import { Client, GatewayIntentBits, Events } from "discord.js"; +import { handleButton } from "./modules/handleButton.js"; import { handleChatCommand } from "./modules/handleChatCommand.js"; +import { handleModalSubmit } from "./modules/handleModalSubmit.js"; import { instantiateServer } from "./server/serve.js"; import { logger } from "./utils/logger.js"; import type { Veluna } from "./interfaces/veluna.js"; @@ -30,10 +32,10 @@ veluna.discord.on(Events.InteractionCreate, (interaction) => { return; } if (interaction.isButton()) { - // Do button stuff + void handleButton(interaction); } if (interaction.isModalSubmit()) { - // Do modal stuff + void handleModalSubmit(veluna, interaction); } if (interaction.isChatInputCommand()) { void handleChatCommand(veluna, interaction); diff --git a/src/modals/answer.ts b/src/modals/answer.ts new file mode 100644 index 0000000..ebdc3e8 --- /dev/null +++ b/src/modals/answer.ts @@ -0,0 +1,27 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ModalBuilder, + TextInputBuilder, + ActionRowBuilder, + TextInputStyle, +} from "discord.js"; + +const row = new ActionRowBuilder().addComponents( + new TextInputBuilder(). + setCustomId("textinput"). + setLabel("What is your answer?"). + setStyle(TextInputStyle.Paragraph). + setMaxLength(1000). + setMinLength(1). + setRequired(true), +); + +export const answer = new ModalBuilder(). + setCustomId("answer"). + setTitle("Provide an Answer!"). + addComponents(row); diff --git a/src/modals/ask.ts b/src/modals/ask.ts index b272fb4..8da6447 100644 --- a/src/modals/ask.ts +++ b/src/modals/ask.ts @@ -13,6 +13,7 @@ import { const row = new ActionRowBuilder().addComponents( new TextInputBuilder(). + setCustomId("textinput"). setLabel("What do you want to ask?"). setStyle(TextInputStyle.Paragraph). setMaxLength(1000). diff --git a/src/modules/handleButton.ts b/src/modules/handleButton.ts index e69de29..407d591 100644 --- a/src/modules/handleButton.ts +++ b/src/modules/handleButton.ts @@ -0,0 +1,27 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { answer } from "../modals/answer.js"; +import { ask } from "../modals/ask.js"; +import type { ButtonInteraction } from "discord.js"; + +/** + * Displays the appropriate modal when a button is pressed. + * @param interaction - The button interaction payload from Discord. + */ +export const handleButton = async( + interaction: ButtonInteraction, +): Promise => { + const { customId } = interaction; + + if (customId === "ask") { + await interaction.showModal(ask); + } + + if (customId === "answer") { + await interaction.showModal(answer); + } +}; diff --git a/src/modules/handleChatCommand.ts b/src/modules/handleChatCommand.ts index 8d53f57..d77e308 100644 --- a/src/modules/handleChatCommand.ts +++ b/src/modules/handleChatCommand.ts @@ -35,6 +35,7 @@ const buildQuery = (type: string, id: string): Query => { * @param veluna - Veluna's instance. * @param interaction - The interaction payload from Discord. */ +// eslint-disable-next-line max-lines-per-function -- Big boi function. export const handleChatCommand = async( veluna: Veluna, interaction: ChatInputCommandInteraction<"cached">, @@ -71,6 +72,24 @@ export const handleChatCommand = async( const channel = options. getChannel("channel", true, [ ChannelType.GuildText ]); + const me = await guild.members.fetchMe(). + catch(() => { + return null; + }); + + if (me?.permissions.has([ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.EmbedLinks, + ]) !== true) { + await interaction.editReply({ + content: + // eslint-disable-next-line stylistic/max-len -- Big boi string. + "I do not have permission to view and send messages in the channel provided. Please adjust my permissions and try again.", + }); + return; + } + const query = buildQuery(commandName, channel.id); await veluna.db.servers.upsert({ diff --git a/src/modules/handleModalSubmit.ts b/src/modules/handleModalSubmit.ts new file mode 100644 index 0000000..b011627 --- /dev/null +++ b/src/modules/handleModalSubmit.ts @@ -0,0 +1,163 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags, type ModalSubmitInteraction } from "discord.js"; +import type { Veluna } from "../interfaces/veluna.js"; + +/** + * Handles modal submissions. + * @param veluna - Veluna's instance. + * @param interaction - The modal submit interaction payload from Discord. + */ +// eslint-disable-next-line max-lines-per-function, complexity, max-statements -- Big boi function. +export const handleModalSubmit = async( + veluna: Veluna, + interaction: ModalSubmitInteraction, +): Promise => { + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + if (!interaction.inCachedGuild()) { + await interaction.editReply({ + content: "This interaction can only be used in a server.", + }); + return; + } + + const record = await veluna.db.servers.findUnique({ + where: { + serverId: interaction.guildId, + }, + }); + + if (!record) { + await interaction.editReply({ + content: + // eslint-disable-next-line stylistic/max-len -- Big boi string. + "This server is not registered in the database. Please configure your settings.", + }); + return; + } + + if (interaction.customId === "ask") { + if (record.questionChannelId === "") { + await interaction.editReply({ + content: "This server has not set a question channel.", + }); + return; + } + const question = interaction.fields.getTextInputValue("textinput"); + const channel + = veluna.discord.channels.cache.get(record.questionChannelId) + ?? await veluna.discord.channels.fetch(record.questionChannelId). + catch(() => { + return null; + }); + if (channel?.isSendable() !== true) { + await interaction.editReply({ + content: "The question channel set is not a text-based channel.", + }); + return; + } + await channel.send({ + components: [ + { + components: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API. + custom_id: "answer", + label: "Answer this question", + style: 3, + type: 2, + }, + ], + type: 1, + }, + ], + content: question, + }); + } + + if (interaction.customId === "answer") { + if (record.answerChannelId === "") { + await interaction.editReply({ + content: "This server has not set an answer channel.", + }); + return; + } + const { message, fields } = interaction; + if (!message) { + await interaction.editReply({ + content: "An error occurred while fetching the message.", + }); + + return; + } + const { content: question } = message; + const answer = fields.getTextInputValue("textinput"); + + if (question === "") { + await interaction.editReply({ + content: "An error occurred while fetching the question.", + }); + } + const channel + = veluna.discord.channels.cache.get(record.answerChannelId) + ?? await veluna.discord.channels.fetch(record.answerChannelId). + catch(() => { + return null; + }); + if (channel?.isSendable() !== true) { + await interaction.editReply({ + content: "The answer channel set is not a text-based channel.", + }); + return; + } + await channel.send({ + components: [ + { + components: [ + { + content: `# ${question}`, + type: 10, + }, + { + divider: true, + spacing: 1, + type: 14, + }, + { + content: answer, + type: 10, + }, + { + divider: true, + spacing: 1, + type: 14, + }, + { + content: `-# Brought to you by [NHCarrigan]()`, + type: 10, + }, + ], + spoiler: false, + type: 17, + }, + { + components: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API. + custom_id: "ask", + disabled: false, + label: "Ask your own?", + style: 3, + type: 2, + }, + ], + type: 1, + }, + ], + }); + } +};