diff --git a/Dockerfile b/Dockerfile index 9533cf2..a443ee0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app RUN corepack enable COPY package.json pnpm-lock.yaml ./ +COPY pnpm-workspace.yaml ./ COPY prisma ./prisma/ RUN pnpm install --frozen-lockfile @@ -16,6 +17,7 @@ WORKDIR /app RUN corepack enable COPY package.json pnpm-lock.yaml ./ +COPY pnpm-workspace.yaml ./ COPY prisma.config.ts ./ COPY prisma ./prisma/ RUN pnpm install --prod --frozen-lockfile diff --git a/README.md b/README.md index 49ea2b9..46c4e9b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Quest is capable of: - Utilties such as reminders. - Complete ticket system. - Welcoming new members. +- Fun stuff such as confessions! ## Links diff --git a/src/commands/utility/setupConfessions.ts b/src/commands/utility/setupConfessions.ts new file mode 100644 index 0000000..c25eb17 --- /dev/null +++ b/src/commands/utility/setupConfessions.ts @@ -0,0 +1,74 @@ +import { Command } from '@sapphire/framework'; +import { emojis } from '#utils/emoji.js'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + PermissionFlagsBits +} from 'discord.js'; +import { updateSettings } from '#lib/settings.js'; + +export class SetupConfessionsCommand extends Command { + public constructor(context: Command.LoaderContext, options: Command.Options) { + super(context, { ...options }); + } + + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand((builder) => + builder + .setName('setup-confessions') + .setDescription('Post the confession panel in a channel.') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addChannelOption((option) => + option + .setName('panel-channel') + .setDescription('The channel where the confession panel should be posted') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true) + ) + .addChannelOption((option) => + option + .setName('confession-channel') + .setDescription('The channel where confessions should be sent') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true) + ) + ); + } + + public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { + if (!interaction.guild) { + await interaction.reply({ + content: `${emojis.rightArrow2} This command can only be used in a server.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const panelChannel = interaction.options.getChannel('panel-channel', true, [ChannelType.GuildText]); + const confessionChannel = interaction.options.getChannel('confession-channel', true, [ChannelType.GuildText]); + + const button = new ButtonBuilder() + .setCustomId('create-confession') + .setLabel('Create Confession') + .setStyle(ButtonStyle.Success); + + const row = new ActionRowBuilder().addComponents(button); + + await panelChannel.send({ + content: `**Create a confession by clicking the button below!**`, + components: [row] + }); + + await updateSettings(interaction.guild.id, interaction.guild.name, { + confessionChannelId: confessionChannel.id + }); + + await interaction.reply({ + content: `${emojis.rightArrow2} Confession panel sent in ${panelChannel}. Confessions will be posted in ${confessionChannel}.`, + flags: MessageFlags.Ephemeral + }); + } +} \ No newline at end of file diff --git a/src/interaction-handlers/confession/confessionHandler.ts b/src/interaction-handlers/confession/confessionHandler.ts new file mode 100644 index 0000000..0135e71 --- /dev/null +++ b/src/interaction-handlers/confession/confessionHandler.ts @@ -0,0 +1,332 @@ +import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + LabelBuilder, + MessageFlags, + ModalBuilder, + TextChannel, + TextInputBuilder, + TextInputStyle, + type ButtonInteraction +} from 'discord.js'; +import { + getModeratorIds, + buildConfessionLink, + getConfessionContext, + removeConfessionContext, + storeConfessionContext +} from '#lib/confessions.js'; +import { getSettings } from '#lib/settings.js'; +import { emojis } from '#utils/emoji.js'; + +function parseConfessionButton(customId: string) { + const [action, messageId] = customId.split(':'); + return { action, messageId }; +} + +export class ConfessionButtonHandler extends InteractionHandler { + public constructor(ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options) { + super(ctx, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button + }); + } + + public override parse(interaction: ButtonInteraction) { + if ( + interaction.customId !== 'create-confession' && + !interaction.customId.startsWith('report-confession:') && + !interaction.customId.startsWith('delete-confession:') + ) { + return this.none(); + } + + return this.some(); + } + + public async run(interaction: ButtonInteraction) { + if (interaction.customId === 'create-confession') { + if (!interaction.inGuild() || !interaction.guild) { + await interaction.reply({ + content: `${emojis.rightArrow2} This button can only be used in a server.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const settings = await getSettings(interaction.guild.id, interaction.guild.name); + + if (!settings.confessionChannelId) { + await interaction.reply({ + content: `${emojis.rightArrow2} Confessions are not configured yet.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const confessionInput = new TextInputBuilder() + .setCustomId('confession-text') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(1_000); + + const confessionLabel = new LabelBuilder().setLabel('Confession').setTextInputComponent(confessionInput); + + const modal = new ModalBuilder() + .setCustomId('create-confession-modal') + .setTitle('Create Confession') + .addLabelComponents(confessionLabel); + + await interaction.showModal(modal); + + let modalSubmit; + + try { + modalSubmit = await interaction.awaitModalSubmit({ + filter: (modalInteraction) => + modalInteraction.customId === 'create-confession-modal' && + modalInteraction.user.id === interaction.user.id, + time: 60_000 + }); + } catch { + return; + } + + const confession = modalSubmit.fields.getTextInputValue('confession-text'); + const confessionChannel = await interaction.guild.channels.fetch(settings.confessionChannelId).catch(() => null); + + if (!(confessionChannel instanceof TextChannel)) { + await modalSubmit.reply({ + content: `${emojis.rightArrow2} The configured confession channel is unavailable.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const embed = new EmbedBuilder().setTitle('Confession').setDescription(confession).setTimestamp(); + + const message = await confessionChannel.send({ embeds: [embed] }); + + let thread; + + try { + thread = await message.startThread({ + name: `confession-${message.id}` + }); + } catch (error) { + await message.delete().catch(() => null); + throw error; + } + + const reportButton = new ButtonBuilder() + .setCustomId(`report-confession:${message.id}`) + .setLabel('Report') + .setStyle(ButtonStyle.Danger); + + storeConfessionContext({ + guildId: interaction.guild.id, + channelId: confessionChannel.id, + messageId: message.id, + threadId: thread.id + }); + + const row = new ActionRowBuilder().addComponents(reportButton); + + try { + await message.edit({ components: [row] }); + } catch (error) { + await thread.delete().catch(() => null); + await message.delete().catch(() => null); + throw error; + } + + await modalSubmit.reply({ + content: `${emojis.rightArrow2} Confession sent.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const parsed = parseConfessionButton(interaction.customId); + + if (parsed.action === 'report-confession') { + if (!interaction.inGuild() || !interaction.guild || !parsed.messageId) { + await interaction.reply({ + content: `${emojis.rightArrow2} This confession cannot be reported right now.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const context = getConfessionContext(parsed.messageId); + + if (!context) { + await interaction.reply({ + content: `${emojis.rightArrow2} This confession is no longer available.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const moderators = getModeratorIds(); + + if (moderators.length === 0) { + await interaction.reply({ + content: `${emojis.rightArrow2} No bot moderators are configured.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const reasonInput = new TextInputBuilder() + .setCustomId('confession-report-reason') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(1_000); + + const reasonLabel = new LabelBuilder().setLabel('Reason').setTextInputComponent(reasonInput); + + const modal = new ModalBuilder() + .setCustomId(`report-confession-modal:${parsed.messageId}`) + .setTitle('Report Confession') + .addLabelComponents(reasonLabel); + + await interaction.showModal(modal); + + let modalSubmit; + + try { + modalSubmit = await interaction.awaitModalSubmit({ + filter: (modalInteraction) => + modalInteraction.customId === `report-confession-modal:${parsed.messageId}` && + modalInteraction.user.id === interaction.user.id, + time: 60_000 + }); + } catch { + return; + } + + const reason = modalSubmit.fields.getTextInputValue('confession-report-reason'); + const channel = await interaction.client.channels.fetch(context.channelId).catch(() => null); + + if (!(channel instanceof TextChannel)) { + await modalSubmit.reply({ + content: `${emojis.rightArrow2} The confession channel is no longer available.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const message = await channel.messages.fetch(context.messageId).catch(() => null); + + if (!message) { + await modalSubmit.reply({ + content: `${emojis.rightArrow2} That confession no longer exists.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const deleteButton = new ButtonBuilder() + .setCustomId(`delete-confession:${parsed.messageId}`) + .setLabel('Delete Confession') + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents(deleteButton); + const confessionText = message.embeds[0]?.description ?? 'No confession content was found.'; + const link = buildConfessionLink(context.guildId, context.channelId, context.messageId); + + const reportEmbed = new EmbedBuilder() + .setTitle('Confession Reported') + .addFields( + { name: 'Guild', value: `${interaction.guild.name} (${interaction.guild.id})` }, + { name: 'Channel', value: `<#${context.channelId}>` }, + { name: 'Reported by', value: `${modalSubmit.user.tag} (${modalSubmit.user.id})` }, + { name: 'Reason', value: reason }, + { name: 'Confession', value: confessionText.slice(0, 1024) }, + { name: 'Link', value: link } + ) + .setTimestamp(); + + const deliveries = await Promise.allSettled( + moderators.map(async (moderatorId) => { + const user = await interaction.client.users.fetch(moderatorId); + await user.send({ embeds: [reportEmbed], components: [row] }); + }) + ); + + const delivered = deliveries.some((result) => result.status === 'fulfilled'); + + if (!delivered) { + await modalSubmit.reply({ + content: `${emojis.rightArrow2} I could not contact any configured moderators.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + await modalSubmit.reply({ + content: `${emojis.rightArrow2} Your report was sent to the bot moderators.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + if (parsed.action === 'delete-confession') { + if (interaction.inGuild() || !parsed.messageId) { + await interaction.reply({ + content: `${emojis.rightArrow2} This action can only be used from a moderator DM.` + }); + return; + } + + const context = getConfessionContext(parsed.messageId); + + if (!context) { + await interaction.reply({ + content: `${emojis.rightArrow2} This confession is no longer available.` + }); + return; + } + + const moderatorIds = getModeratorIds(); + + if (!moderatorIds.includes(interaction.user.id)) { + await interaction.reply({ + content: `${emojis.rightArrow2} You are not configured as a bot moderator.` + }); + return; + } + + const channel = await interaction.client.channels.fetch(context.channelId).catch(() => null); + + if (!(channel instanceof TextChannel)) { + await interaction.reply({ + content: `${emojis.rightArrow2} The confession channel is no longer available.` + }); + return; + } + + const message = await channel.messages.fetch(context.messageId).catch(() => null); + + if (message) { + const deletedEmbed = new EmbedBuilder() + .setTitle('Confession') + .setDescription('This confession was deleted by moderators.') + .setColor(0xed4245) + .setTimestamp(); + + await message.edit({ embeds: [deletedEmbed], components: [] }).catch(() => null); + } + + removeConfessionContext(context.messageId); + + await interaction.reply({ + content: `${emojis.rightArrow2} Confession marked as deleted.` + }); + } + } +} \ No newline at end of file diff --git a/src/lib/confessions.ts b/src/lib/confessions.ts new file mode 100644 index 0000000..6e2cb91 --- /dev/null +++ b/src/lib/confessions.ts @@ -0,0 +1,28 @@ +export type ConfessionContext = { + guildId: string; + channelId: string; + messageId: string; + threadId: string; +}; + +const confessionContexts = new Map(); + +export function getModeratorIds() { + return [...new Set((process.env.MODERATORS ?? '').split(',').map((id) => id.trim()).filter(Boolean))]; +} + +export function buildConfessionLink(guildId: string, channelId: string, messageId: string) { + return `https://discord.com/channels/${guildId}/${channelId}/${messageId}`; +} + +export function storeConfessionContext(context: ConfessionContext) { + confessionContexts.set(context.messageId, context); +} + +export function getConfessionContext(messageId: string) { + return confessionContexts.get(messageId) ?? null; +} + +export function removeConfessionContext(messageId: string) { + confessionContexts.delete(messageId); +} \ No newline at end of file diff --git a/src/lib/settings.ts b/src/lib/settings.ts index b236976..9323532 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -6,6 +6,7 @@ export type ServerSettings = { welcomeChannelId: string | null; ticketCategoryId: string | null; staffRole: string | null; + confessionChannelId: string | null; loggingEnabled?: boolean; loggingChannelId?: string | null; }; @@ -15,6 +16,7 @@ export const DefaultSettings: ServerSettings = { welcomeChannelId: null, ticketCategoryId: null, staffRole: null, + confessionChannelId: null, loggingEnabled: false, loggingChannelId: null, }; diff --git a/src/listeners/ready.ts b/src/listeners/ready.ts index 804241c..c810cdc 100644 --- a/src/listeners/ready.ts +++ b/src/listeners/ready.ts @@ -19,7 +19,7 @@ export class ReadyListener extends Listener { console.log(`Ready! Logged in as ${client.user.tag}`); client.user.setActivity({ - name: `Automod! | Shard ${client.shard?.ids?.[0] ?? 0}`, + name: `🤐 /setup-confessions | Shard ${client.shard?.ids?.[0] ?? 0}`, type: ActivityType.Custom });