From d08b21492a0ef464922a07931ac2094286cebe51 Mon Sep 17 00:00:00 2001 From: Tarrgon <61888458+Tarrgon@users.noreply.github.com> Date: Tue, 19 May 2026 09:04:59 -0400 Subject: [PATCH] Add new features --- .../open-ticket-for-reported-message.ts | 40 +++++ src/commands/private-help.ts | 3 + src/context-menus/report.ts | 149 +++++++++--------- src/modals/open-ticket-modal.ts | 38 +---- src/modals/report-message.ts | 118 ++++++++++++++ src/shared/Database.ts | 7 +- src/types/database-types.d.ts | 1 + src/utils/private-help-utils.ts | 63 +++++++- 8 files changed, 310 insertions(+), 109 deletions(-) create mode 100644 src/buttons/open-ticket-for-reported-message.ts create mode 100644 src/modals/report-message.ts diff --git a/src/buttons/open-ticket-for-reported-message.ts b/src/buttons/open-ticket-for-reported-message.ts new file mode 100644 index 0000000..3c64d86 --- /dev/null +++ b/src/buttons/open-ticket-for-reported-message.ts @@ -0,0 +1,40 @@ +import { ButtonInteraction, Client, MessageFlags, MessageMentions } from 'discord.js'; +import { createPrivateHelpTicketThread } from '../utils'; + +export default { + name: 'open-ticket-for-reported-message', + handler: async function (client: Client, interaction: ButtonInteraction) { + const message = await interaction.message.fetch(); + const guild = await interaction.guild!.fetch(); + + const reportEmbed = message.embeds[0]!; + + const regex = new RegExp(MessageMentions.UsersPattern); + + const reportedMessageUrl = reportEmbed.fields[0].value; + const reporterId = regex.exec(reportEmbed.fields[2].value)!.groups!.id; + const additionalInfo = reportEmbed.fields[3]?.name == 'Additional Information' ? reportEmbed.fields[3].value : ''; + + const reporter = await guild.members.fetch(reporterId) ?? await client.users.fetch(reporterId); + + const thread = await createPrivateHelpTicketThread(client, guild, null, `Ticket creted by staff in response to message report: ${message.url}. Reported message: ${reportedMessageUrl}. ${additionalInfo ? `\n\nAdditional information provided in report:\n${additionalInfo.split('\n').map(c => `> ${c}`).join('\n')}` : ''}`, `Mod Ticket For Message Report From ${reporter.displayName}`, [reporterId, interaction.user.id]); + + if (thread) { + await interaction.reply({ + flags: [MessageFlags.Ephemeral], + content: `Ticket created: ${thread}` + }); + + const requestIndex = reportEmbed.fields.findIndex(f => f.name == 'User Requested Private Ticket'); + if (requestIndex != -1) reportEmbed.fields.splice(requestIndex, 1); + + reportEmbed.fields.push({ + name: 'Private Help Ticket', + value: thread.url, + inline: false + }); + + await message.edit({ embeds: [reportEmbed], components: [] }); + } + } +}; \ No newline at end of file diff --git a/src/commands/private-help.ts b/src/commands/private-help.ts index d5286a7..41140fd 100644 --- a/src/commands/private-help.ts +++ b/src/commands/private-help.ts @@ -1,4 +1,5 @@ import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { Database } from '../shared/Database'; export default { name: 'private-help', @@ -37,6 +38,8 @@ export default { await interaction.channel.send({ components: [row], content }); + await Database.setPrivateHelpChannel(interaction.guildId!, interaction.channelId); + interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' }); } }; \ No newline at end of file diff --git a/src/context-menus/report.ts b/src/context-menus/report.ts index a349161..e9a9ea5 100644 --- a/src/context-menus/report.ts +++ b/src/context-menus/report.ts @@ -1,18 +1,6 @@ -import { - ApplicationCommandType, - ApplicationIntegrationType, - Client, - ContextMenuCommandBuilder, - GuildTextBasedChannel, - InteractionContextType, - MessageContextMenuCommandInteraction, - MessageFlags -} from "discord.js"; +import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, LabelBuilder, MessageContextMenuCommandInteraction, MessageFlags, ModalBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; -import { Database } from "../shared/Database"; - -const cooldowns = new Map(); -const COOLDOWN_TIME = 300000; // 5 minutes +import { Database } from '../shared/Database'; export default { name: 'Report Message', @@ -23,79 +11,92 @@ export default { .setContexts(InteractionContextType.Guild) .setType(ApplicationCommandType.Message), - handler: async function ( - client: Client, - interaction: MessageContextMenuCommandInteraction - ) { + handler: async function (client: Client, interaction: MessageContextMenuCommandInteraction) { try { - const userId = interaction.user.id; - const now = Date.now(); + const modal = new ModalBuilder() + .setCustomId(`report-message_${interaction.targetMessage.channelId}_${interaction.targetMessage.id}`) + .setTitle(`Report message from ${interaction.targetMessage.member?.displayName ?? interaction.targetMessage.author.displayName}`); - const cooldownExpiration = cooldowns.get(userId); - if (cooldownExpiration && now < cooldownExpiration) { - const expiresAt = Math.floor(cooldownExpiration / 1000); - return interaction.reply({ - embeds: [{ - color: 0xffaa00, - description: `Slow down there, you can report another message .` - }], - flags: [MessageFlags.Ephemeral] - }); - } + const additionalInfoInput = new TextInputBuilder() + .setCustomId('additional-info') + .setMaxLength(1000) + .setStyle(TextInputStyle.Paragraph) + .setRequired(false); - cooldowns.set(userId, now + COOLDOWN_TIME); + const additionalInfoLabel = new LabelBuilder() + .setLabel('Additional Information') + .setDescription('Any additional information? This will help staff understand your report. 1000 characters maximum.') + .setTextInputComponent(additionalInfoInput); - setTimeout(() => { - cooldowns.delete(userId); - }, COOLDOWN_TIME); + const yesNoMenu = new StringSelectMenuBuilder() + .setCustomId('create-private-help-ticket') + .setRequired(false) + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('No') + .setDefault(true) + .setValue('no'), + new StringSelectMenuOptionBuilder() + .setLabel('Yes') + .setDefault(false) + .setValue('yes') + ); + + const createPrivateTicket = new LabelBuilder() + .setLabel('Create Private Help Ticket') + .setDescription('Should a private help ticket be opened with this report? (Does not bypass ticket restrictions)') + .setStringSelectMenuComponent(yesNoMenu); + + modal.addLabelComponents(additionalInfoLabel, createPrivateTicket); const guildSettings = await Database.getGuildSettings(interaction.guildId!); - const reportsChannel = - await client.channels.fetch( - guildSettings?.moderator_channel_id! - ) as GuildTextBasedChannel; + if (!guildSettings || !guildSettings.moderator_channel_id) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report' }); - await reportsChannel.send({ - embeds: [{ - title: 'New Message Report!', - fields: [ - { - name: 'Message', - value: interaction.targetMessage.url, - inline: false - }, - { - name: 'Author', - value: interaction.targetMessage.author.toString(), - inline: false - }, - { - name: 'Reporter', - value: interaction.user.toString(), - inline: false - } - ] - }] - }); + const reportsChannel = await interaction.guild!.channels.fetch(guildSettings.moderator_channel_id); - await interaction.reply({ - embeds: [{ - color: 0x014995, - description: - "Thanks for making a report! I've notified the moderators who can take further action." - }], - flags: [MessageFlags.Ephemeral] - }); + if (!reportsChannel || !reportsChannel.isSendable()) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report' }); + + await interaction.showModal(modal); + + // await reportsChannel.send({ + // embeds: [{ + // title: 'New Message Report!', + // fields: [ + // { + // name: 'Message', + // value: interaction.targetMessage.url, + // inline: false + // }, + // { + // name: 'Author', + // value: interaction.targetMessage.author.toString(), + // inline: false + // }, + // { + // name: 'Reporter', + // value: interaction.user.toString(), + // inline: false + // } + // ] + // }] + // }); + + // await interaction.reply({ + // embeds: [{ + // color: 0x014995, + // description: + // "Thanks for making a report! I've notified the moderators who can take further action." + // }], + // flags: [MessageFlags.Ephemeral] + // }); } catch (error) { console.error(error); if (!interaction.replied) { await interaction.reply({ - embeds: [{ - color: 0xff5555, - description: - "Hmm, an error occurred while processing your request. If you receive this error more than once, DM a moderator for further assistance." - }], + content: 'Hmm, an error occurred while processing your request. If you receive this error more than once, DM a moderator for further assistance.', flags: [MessageFlags.Ephemeral] }); } diff --git a/src/modals/open-ticket-modal.ts b/src/modals/open-ticket-modal.ts index eccdab9..16d9306 100644 --- a/src/modals/open-ticket-modal.ts +++ b/src/modals/open-ticket-modal.ts @@ -1,5 +1,6 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, Client, MessageFlags, ModalSubmitInteraction, TextChannel, ThreadAutoArchiveDuration } from 'discord.js'; +import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; import { Database } from '../shared/Database'; +import { createPrivateHelpTicketThread } from '../utils'; export default { name: 'open-ticket-modal', @@ -14,38 +15,9 @@ export default { const reason = interaction.fields.getTextInputValue('ticket-message'); - const channel = (await interaction.channel?.fetch()) as TextChannel; + const thread = await createPrivateHelpTicketThread(client, guild, member, reason); - const thread = await channel.threads.create({ - name: `${member.displayName}'s Ticket`, - autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, - invitable: false, - type: ChannelType.PrivateThread - }); - - await Database.createPrivateHelpTicket(interaction.user.id, thread.id); - - const closeButton = new ButtonBuilder() - .setCustomId('close-ticket') - .setLabel('Click here if you no longer need help') - .setStyle(ButtonStyle.Danger); - - const claimButton = new ButtonBuilder() - .setCustomId('claim-ticket') - .setLabel('Claim ticket') - .setStyle(ButtonStyle.Primary); - - const row = new ActionRowBuilder().addComponents(closeButton, claimButton); - - await thread.send({ - content: `${interaction.user} feel free to direct your questions at any <@&${guildSettings.private_help_role_id}>. Only you and staff members can see this channel.\n\n**Reason for contact:**\n${reason}\n\n-# Tickets will automatically close after 5 days of inactivity.`, - components: [row], - allowedMentions: { - users: [interaction.user.id], - roles: [guildSettings.private_help_role_id] - } - }); - - interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Your ticket has been created: ${thread}` }); + if (thread) interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Your ticket has been created: ${thread}` }); + else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a staff member.' }); } }; \ No newline at end of file diff --git a/src/modals/report-message.ts b/src/modals/report-message.ts new file mode 100644 index 0000000..9240af3 --- /dev/null +++ b/src/modals/report-message.ts @@ -0,0 +1,118 @@ +import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, GuildTextBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js'; +import { Database } from '../shared/Database'; +import { canOpenPrivateHelpTicket, createPrivateHelpTicketThread } from '../utils'; + +export default { + name: 'report-message', + handler: async function (client: Client, interaction: ModalSubmitInteraction, channelId: string, messageId: string) { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + const guild = await client.guilds.fetch(interaction.guildId!); + const member = await guild.members.fetch(interaction.user.id); + const reportedMessageChannel = await guild.channels.fetch(channelId) as GuildTextBasedChannel; + const reportedMessage = await reportedMessageChannel?.messages.fetch(messageId); + + const additionalInfo = interaction.fields.getTextInputValue('additional-info'); + const createPrivateHelpTicket = interaction.fields.getStringSelectValues('create-private-help-ticket')[0] == 'yes'; + + if (!reportedMessageChannel || !reportedMessage) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to submit report. Please report this to a staff member.' }); + + const guildSettings = await Database.getGuildSettings(interaction.guildId!); + + if (!guildSettings || !guildSettings.moderator_channel_id) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' }); + + const reportsChannel = await interaction.guild!.channels.fetch(guildSettings.moderator_channel_id); + + if (!reportsChannel || !reportsChannel.isSendable()) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' }); + + const embed = new EmbedBuilder() + .setTitle('New Message Report!') + .setColor(0xFF0000) + .addFields( + { + name: 'Message', + value: reportedMessage.url, + inline: false + }, + { + name: 'Message Author', + value: reportedMessage.author.toString(), + inline: false + }, + { + name: 'Reporter', + value: member.toString(), + inline: false + }); + + if (additionalInfo) { + embed.addFields( + { + name: 'Additional Information', + value: additionalInfo, + inline: false + } + ); + } + + let replyContent = "Thanks for making a report! I've notified the moderators who can take further action."; + + const wantsTicketButCantOpen = createPrivateHelpTicket && !await canOpenPrivateHelpTicket(member.id); + + if (wantsTicketButCantOpen) { + embed.addFields( + { + name: 'User Requested Private Ticket', + value: 'The user requested a private ticket be opened, but already has an open ticket. If additional information is needed, press the button below to open a ticket with the user.', + inline: false + } + ); + + replyContent += " Could not open private help ticket since you already have one open. Staff have been notified that you'd like to have a ticket opened, and can make one for you if they deem it necessary."; + } + + const openTicketButton = new ButtonBuilder() + .setCustomId('open-ticket-for-reported-message') + .setLabel('Open Private Ticket') + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder().addComponents(openTicketButton); + + const reportMessage = await reportsChannel.send({ + embeds: [embed], + components: createPrivateHelpTicket && !wantsTicketButCantOpen ? [] : [row] + }); + + await reportsChannel.send({ files: [new AttachmentBuilder(Buffer.from(reportedMessage.content), { name: 'message-content.txt' })] }); + + if (createPrivateHelpTicket && !wantsTicketButCantOpen) { + if (!guildSettings.private_help_channel_id) { + replyContent += ' Could not open private help ticket. Private help channel not set. Please report this to a staff member.'; + } else { + const thread = await createPrivateHelpTicketThread(client, guild, member, `Ticket created with message report (${reportMessage.url}). ${member}, use this channel to talk with staff privately about the reported message (${reportedMessage.url}). ${additionalInfo ? `\n\nAdditional information provided in report:\n${additionalInfo.split('\n').map(c => `> ${c}`).join('\n')}` : ''}`); + if (thread) { + replyContent += ` Private help ticket created: ${thread}.`; + embed.addFields({ + name: 'Private Help Ticket', + value: thread.url, + inline: false + }); + + await reportMessage.edit({ embeds: [embed] }); + + } else { + replyContent += ' There was an issue opening a private help ticket, please report this to a staff member.'; + await reportMessage.edit({ + embeds: [embed], + components: [row] + }); + } + } + } + + await interaction.editReply(replyContent); + } +}; \ No newline at end of file diff --git a/src/shared/Database.ts b/src/shared/Database.ts index d8eb176..0672ce2 100644 --- a/src/shared/Database.ts +++ b/src/shared/Database.ts @@ -29,7 +29,8 @@ const DB_SCHEMA = ` safe_channels TEXT, link_skip_channels TEXT, github_release_channel TEXT, - moderator_channel_id TEXT + moderator_channel_id TEXT, + private_help_channel_id TEXT ); CREATE TABLE IF NOT EXISTS messages ( @@ -231,6 +232,10 @@ export class Database { await Database.db.run('UPDATE settings SET github_release_channel = ? WHERE guild_id = ?', id, guildId); } + static async setPrivateHelpChannel(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET private_help_channel_id = ? WHERE guild_id = ?', id, guildId); + } + // Since "setting" has guaranteed values and is never set by the user, this shouldn't cause any security issues. // But it does allow me to skip rewriting this a bunch. static async getGuildArraySetting(setting: GuildArraySetting, guildId: string): Promise { diff --git a/src/types/database-types.d.ts b/src/types/database-types.d.ts index f92d1e4..310d36c 100644 --- a/src/types/database-types.d.ts +++ b/src/types/database-types.d.ts @@ -25,6 +25,7 @@ export type GuildSettings = { link_skip_channels?: string github_release_channel?: string moderator_channel_id?: string + private_help_channel_id?: string } export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels'; diff --git a/src/utils/private-help-utils.ts b/src/utils/private-help-utils.ts index e6b66e0..54b01e8 100644 --- a/src/utils/private-help-utils.ts +++ b/src/utils/private-help-utils.ts @@ -1,4 +1,4 @@ -import { Client, ThreadChannel } from 'discord.js'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, Client, Guild, GuildMember, PrivateThreadChannel, TextChannel, ThreadAutoArchiveDuration, ThreadChannel } from 'discord.js'; import { Database } from '../shared/Database'; export async function closeOldTickets(client: Client) { @@ -21,4 +21,65 @@ export async function closeOldTickets(client: Client) { console.error(e); } } +} + +export async function createPrivateHelpTicketThread(client: Client, guild: Guild, creator: GuildMember | null, reason: string, customTitle: string = '', additionalMembersToAdd: string[] = []): Promise { + const guildSettings = await Database.getGuildSettings(guild.id); + + if (!guildSettings || !guildSettings.private_help_channel_id || !guildSettings.private_help_role_id) return null; + + const channel = await client.channels.fetch(guildSettings.private_help_channel_id) as TextChannel; + + const thread = await channel.threads.create({ + name: customTitle ? customTitle : (creator ? `${creator.displayName}'s Ticket` : 'Mod Ticket'), + autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, + invitable: false, + type: ChannelType.PrivateThread + }) as PrivateThreadChannel; + + if (creator) await Database.createPrivateHelpTicket(creator.id, thread.id); + + if (creator) { + const closeButton = new ButtonBuilder() + .setCustomId('close-ticket') + .setLabel('Click here if you no longer need help') + .setStyle(ButtonStyle.Danger); + + const claimButton = new ButtonBuilder() + .setCustomId('claim-ticket') + .setLabel('Claim ticket') + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder().addComponents(closeButton, claimButton); + + await thread.send({ + content: `${creator} feel free to direct your questions at any <@&${guildSettings.private_help_role_id}>. Only you and staff members can see this channel.\n\n**Reason for contact:**\n${reason}\n\n-# Tickets will automatically close after 5 days of inactivity.`, + components: [row], + allowedMentions: { + users: [creator.id], + roles: [guildSettings.private_help_role_id] + } + }); + } else { + const closeButton = new ButtonBuilder() + .setCustomId('close-mod-ticket') + .setLabel('Close Mod Ticket') + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents(closeButton); + + await thread.send({ + content: reason, + components: [row], + allowedMentions: { + users: Array.from(new Set(additionalMembersToAdd)) + } + }); + } + + for (const id of additionalMembersToAdd) { + await thread.members.add(id); + } + + return thread; } \ No newline at end of file