diff --git a/bun.lock b/bun.lock index 5df40f6a..4fa15c1c 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "djs-slash-helper": "git+https://github.com/bristermitten/djs-slash-helper.git#97726a356be094f4d187637734b116e4e1c89be5", "expiry-map": "^2.0.0", "fluent-ffmpeg": "^2.1.3", + "handlebars": "^4.7.8", "lru-cache": "^11.1.0", "mariadb": "^3.4.5", "node-fetch": "^3.3.2", @@ -36,6 +37,7 @@ "@sentry/types": "^10.5.0", "@sinonjs/fake-timers": "^14.0.0", "@types/bun": "^1.2.19", + "@types/handlebars": "^4.1.0", "@types/node": "^24.3.0", "@types/node-schedule": "^2.1.8", "@types/opentype.js": "^1.3.8", @@ -273,6 +275,8 @@ "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/handlebars": ["@types/handlebars@4.1.0", "", { "dependencies": { "handlebars": "*" } }, "sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], @@ -681,6 +685,8 @@ "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -919,6 +925,8 @@ "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "node-abi": ["node-abi@3.75.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], @@ -1159,6 +1167,8 @@ "sorted-array-functions": ["sorted-array-functions@1.3.0", "", {}, "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "sqlite3": ["sqlite3@5.1.7", "", { "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.1", "tar": "^6.1.11" }, "optionalDependencies": { "node-gyp": "8.x" } }, "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog=="], @@ -1259,6 +1269,8 @@ "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], @@ -1309,6 +1321,8 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "wrap-ansi": ["wrap-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], diff --git a/package.json b/package.json index 016a69ca..59c07fd1 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "djs-slash-helper": "git+https://github.com/bristermitten/djs-slash-helper.git#97726a356be094f4d187637734b116e4e1c89be5", "expiry-map": "^2.0.0", "fluent-ffmpeg": "^2.1.3", + "handlebars": "^4.7.8", "lru-cache": "^11.1.0", "mariadb": "^3.4.5", "node-fetch": "^3.3.2", @@ -69,6 +70,7 @@ "@sentry/types": "^10.5.0", "@sinonjs/fake-timers": "^14.0.0", "@types/bun": "^1.2.19", + "@types/handlebars": "^4.1.0", "@types/node": "^24.3.0", "@types/node-schedule": "^2.1.8", "@types/opentype.js": "^1.3.8", diff --git a/src/Config.prod.ts b/src/Config.prod.ts index 1bb9f731..9b226246 100644 --- a/src/Config.prod.ts +++ b/src/Config.prod.ts @@ -61,6 +61,11 @@ export const config: Config = { yesEmojiId: "thumbsup", noEmojiId: "thumbsdown", }, + modmail: { + pingRole: "", + archiveChannel: "", + channel: "", + }, pastebin: { url: "https://paste.developerden.org", threshold: 20, diff --git a/src/Config.ts b/src/Config.ts index 31bf599f..28bc5e7a 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -49,6 +49,11 @@ export const config: Config = { yesEmojiId: "šŸ‘", noEmojiId: "šŸ‘Ž", }, + modmail: { + pingRole: "1412470653050818724", + archiveChannel: "1412470199495561338", + channel: "1412470223004766268", + }, pastebin: prodConfig.pastebin, branding: { color: "#ffffff", diff --git a/src/config.type.ts b/src/config.type.ts index 7de4d4e6..43b4b7d4 100644 --- a/src/config.type.ts +++ b/src/config.type.ts @@ -44,6 +44,11 @@ export interface Config { noPing: Snowflake; bumpNotifications?: Snowflake; }; + modmail: { + channel: string; + archiveChannel: string; + pingRole?: Snowflake; + }; branding: BrandingConfig; informationMessage?: InformationMessage; } diff --git a/src/index.ts b/src/index.ts index cbaccbeb..dc650954 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import JoinLeaveMessageModule from "./modules/joinLeaveMessage.module.js"; import { LanguageStatusModule } from "./modules/languageStatus.module.js"; import { LearningModule } from "./modules/learning/learning.module.js"; import { ModerationModule } from "./modules/moderation/moderation.module.js"; +import { ModmailModule } from "./modules/modmail/modmail.module.js"; import ModuleManager from "./modules/moduleManager.js"; import PastifyModule from "./modules/pastify/pastify.module.js"; import { RolesModule } from "./modules/roles/roles.module.js"; @@ -36,6 +37,7 @@ const client = new Client({ GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMembers, + GatewayIntentBits.DirectMessages, ], partials: [Partials.Message, Partials.Channel, Partials.Reaction], }); @@ -62,6 +64,7 @@ export const moduleManager = new ModuleManager( SuggestModule, ModerationModule, StarboardModule, + ModmailModule, ], ); diff --git a/src/modules/modmail/modmail.command.ts b/src/modules/modmail/modmail.command.ts new file mode 100644 index 00000000..1407111b --- /dev/null +++ b/src/modules/modmail/modmail.command.ts @@ -0,0 +1,321 @@ +import { + type ActionRowBuilder, + ApplicationCommandOptionType, + ApplicationCommandType, + type ButtonBuilder, + type EmbedBuilder, + PermissionFlagsBits, +} from "discord.js"; +import type { Command, ExecutableSubcommand } from "djs-slash-helper"; +import { config } from "../../Config.js"; +import { logger } from "../../logging.js"; +import { getMemberFromInteraction } from "../../util/member.js"; +import { safelyFetchUser } from "../../util/users.js"; +import { + closeModMailTicketByModMail, + createModMailDetails, + getActiveModMailByChannel, + getActiveModMailByUser, +} from "./modmail.js"; + +const DetailsSubCommand: ExecutableSubcommand = { + type: ApplicationCommandOptionType.Subcommand, + name: "details", + description: "Get details about the current thread.", + async handle(interaction) { + await interaction.deferReply({ + flags: ["Ephemeral"], + }); + + // Handle DM usage + if (!interaction.inGuild()) { + const modMail = await getActiveModMailByUser(BigInt(interaction.user.id)); + if (!modMail) { + await interaction.followUp({ + content: "You don't have an active ticket.", + flags: ["Ephemeral"], + }); + return; + } + + const ticketDetails = createModMailDetails( + modMail, + interaction.user, + false, + true, + ) as { + embed: EmbedBuilder; + row: ActionRowBuilder; + }; + + await interaction.followUp({ + embeds: [ticketDetails.embed], + components: [ticketDetails.row], + flags: ["Ephemeral"], + }); + return; + } + + // Handle guild usage (existing logic) + if (!interaction.channel?.isThread()) { + await interaction.followUp({ + content: "This command can only be used in a modmail thread", + flags: ["Ephemeral"], + }); + return; + } + + const modMail = await getActiveModMailByChannel( + BigInt(interaction.channelId), + ); + + if (!modMail) { + await interaction.followUp({ + content: "This command can only be used in a modmail thread", + flags: ["Ephemeral"], + }); + return; + } + const user = await safelyFetchUser( + interaction.client, + modMail.creatorId.toString(), + ); + const ticketDetails = createModMailDetails( + modMail, + user ?? modMail.creatorId.toString(), + true, + ) as { + embed: EmbedBuilder; + row: ActionRowBuilder; + }; + + await interaction.followUp({ + embeds: [ticketDetails.embed], + components: [ticketDetails.row], + }); + }, +}; + +const CloseSubCommand: ExecutableSubcommand = { + type: ApplicationCommandOptionType.Subcommand, + name: "close", + description: "Close your ticket or the current modmail thread.", + async handle(interaction) { + await interaction.deferReply({ + flags: ["Ephemeral"], + }); + + // Handle DM usage - user closing their own ticket + if (!interaction.inGuild()) { + const modMail = await getActiveModMailByUser(BigInt(interaction.user.id)); + if (!modMail) { + await interaction.followUp({ + content: "You don't have an active ticket.", + flags: ["Ephemeral"], + }); + return; + } + + // Close the ticket + await closeModMailTicketByModMail(modMail); + + // Notify the thread if it exists + try { + if (modMail.threadId) { + const guild = await interaction.client.guilds.fetch(config.guildId); + const thread = await guild.channels.fetch( + modMail.threadId.toString(), + ); + + if (thread?.isThread() && thread.isSendable()) { + await thread.send({ + content: `šŸ”’ Ticket has been closed by the user (${interaction.user.displayName}).`, + }); + } + } + } catch (error) { + logger.warn( + "Failed to notify thread about user closing ticket:", + error, + ); + } + + await interaction.followUp({ + content: "āœ… Your ticket has been closed. Thank you for contacting us!", + flags: ["Ephemeral"], + }); + return; + } + + // Handle guild usage - moderator closing ticket + const member = await getMemberFromInteraction(interaction); + if (!member?.permissions.has(PermissionFlagsBits.ManageMessages)) { + await interaction.followUp({ + content: "You don't have permission to close tickets", + flags: ["Ephemeral"], + }); + return; + } + + if (!interaction.channel?.isThread()) { + await interaction.followUp({ + content: "This command can only be used in a modmail thread", + flags: ["Ephemeral"], + }); + return; + } + + const modMail = await getActiveModMailByChannel( + BigInt(interaction.channelId), + ); + + if (!modMail) { + await interaction.followUp({ + content: "This command can only be used in a modmail thread", + flags: ["Ephemeral"], + }); + return; + } + + // Close the ticket + await closeModMailTicketByModMail(modMail); + + // Notify the user via DM + try { + const ticketCreator = await safelyFetchUser( + interaction.client, + modMail.creatorId.toString(), + ); + if (ticketCreator) { + const dmChannel = await ticketCreator.createDM(); + if (dmChannel?.isSendable()) { + await dmChannel.send({ + content: `Your support ticket (#${modMail.id}) has been closed by ${interaction.user.displayName}. Thank you for contacting us!`, + }); + } + } + } catch { + // Silently fail DM notification - not critical + } + + // Send notification in the channel + await interaction.channel.send({ + content: `šŸ”’ Ticket has been closed by ${interaction.user}`, + }); + + await interaction.followUp({ + content: `āœ… Successfully closed ticket`, + flags: ["Ephemeral"], + }); + }, +}; + +const AssignSubCommand: ExecutableSubcommand = { + type: ApplicationCommandOptionType.Subcommand, + name: "assign", + description: "Assign the current ticket to a moderator.", + options: [ + { + type: ApplicationCommandOptionType.User, + name: "moderator", + description: "The moderator to assign the ticket to", + required: true, + }, + ], + async handle(interaction) { + await interaction.deferReply({ + flags: ["Ephemeral"], + }); + + if (!interaction.inGuild()) { + await interaction.followUp({ + content: "This command can only be used in a guild", + flags: ["Ephemeral"], + }); + return; + } + + // Check if user has moderator permissions + const member = await getMemberFromInteraction(interaction); + if (!member?.permissions.has(PermissionFlagsBits.ManageMessages)) { + await interaction.followUp({ + content: "You don't have permission to assign tickets", + flags: ["Ephemeral"], + }); + return; + } + + if (!interaction.channel?.isThread()) { + await interaction.followUp({ + content: "This command can only be used in a modmail thread", + flags: ["Ephemeral"], + }); + return; + } + + const modMail = await getActiveModMailByChannel( + BigInt(interaction.channelId), + ); + + if (!modMail) { + await interaction.followUp({ + content: "This command can only be used in a modmail thread", + flags: ["Ephemeral"], + }); + return; + } + + const targetUser = interaction.options.getUser("moderator", true); + const targetMember = await interaction.guild?.members.fetch(targetUser.id); + + // Check if the target user has moderator permissions + if (!targetMember?.permissions.has(PermissionFlagsBits.ManageMessages)) { + await interaction.followUp({ + content: "The selected user doesn't have moderator permissions", + flags: ["Ephemeral"], + }); + return; + } + + // Update the ticket assignment + await modMail.update({ + assignedUserId: BigInt(targetUser.id), + }); + + // Send notification in the channel + await interaction.channel.send({ + content: `šŸŽÆ Ticket has been assigned to ${targetUser} by ${interaction.user}`, + }); + + // Notify the user via DM + try { + const ticketCreator = await safelyFetchUser( + interaction.client, + modMail.creatorId.toString(), + ); + if (ticketCreator) { + const dmChannel = await ticketCreator.createDM(); + if (dmChannel?.isSendable()) { + await dmChannel.send({ + content: `Your support ticket (#${modMail.id}) has been assigned to ${targetUser.displayName}. They will assist you shortly.`, + }); + } + } + } catch { + // Silently fail DM notification - not critical + } + + await interaction.followUp({ + content: `āœ… Successfully assigned ticket to ${targetUser.displayName}`, + flags: ["Ephemeral"], + }); + }, +}; + +export const ModmailCommand: Command = { + name: "ticket", + description: "Manage Tickets", + type: ApplicationCommandType.ChatInput, + options: [DetailsSubCommand, AssignSubCommand, CloseSubCommand], + handle() {}, +}; diff --git a/src/modules/modmail/modmail.listener.ts b/src/modules/modmail/modmail.listener.ts new file mode 100644 index 00000000..3e12ab81 --- /dev/null +++ b/src/modules/modmail/modmail.listener.ts @@ -0,0 +1,606 @@ +import { clearTimeout } from "node:timers"; +import { + type ActionRowBuilder, + type ButtonBuilder, + type ButtonInteraction, + ChannelType, + type Client, + DiscordAPIError, + type EmbedBuilder, + type Interaction, + type Message, + type OmitPartialGroupDMChannel, + PermissionFlagsBits, + type StringSelectMenuInteraction, + type UserSelectMenuInteraction, +} from "discord.js"; +import { config } from "../../Config.js"; +import { logger } from "../../logging.js"; +import { + ModMailTicket, + ModMailTicketCategory, +} from "../../store/models/ModMailTicket.js"; +import { mentionRoleById } from "../../util/role.js"; +import { safelyFetchUser } from "../../util/users.js"; +import type { EventListener } from "../module.js"; +import { + closeModMailTicketByModMail, + createModMailDetails, + createModMailInitializationEmbed, + createModMailTicket, + extractEmbedAndFilesFromMessageModMail, + getActiveModMailByChannel, + getActiveModMailByUser, + handleModmailArchive, + handleModmailAssign, + handleModmailUserClose, + handleModmailUserDetails, + hasActiveModMailByUser, + MODMAIL_ARCHIVE_ID, + MODMAIL_ASSIGN_ID, + MODMAIL_CATEGORY_SELECT_ID, + MODMAIL_SUBMIT_ID, + MODMAIL_USER_CLOSE_ID, + MODMAIL_USER_DETAILS_ID, +} from "./modmail.js"; + +interface PendingModmailSelection { + category: ModMailTicketCategory; + timeout: NodeJS.Timeout; +} + +const SELECTION_TIMEOUT_MS = 600 * 1000; // 10 minutes +const pendingModmailSelections = new Map(); + +// Utility functions for better code organization +const isValidMessage = (message: Message) => { + return message?.author && !message.author.bot && !message.author.system; +}; + +const isCommandMessage = (content: string) => { + return content.startsWith("!") || content.startsWith("/"); +}; + +const cleanupPendingSelection = (userId: string) => { + const pending = pendingModmailSelections.get(userId); + if (pending?.timeout) { + clearTimeout(pending.timeout); + } + pendingModmailSelections.delete(userId); +}; + +const handleDMMessage = async ( + client: Client, + message: OmitPartialGroupDMChannel, +) => { + const modMail = await getActiveModMailByUser(BigInt(message.author.id)); + + if (!modMail) { + const initializationMessage = createModMailInitializationEmbed( + message.author, + ); + if (message.channel.isSendable()) + await message.channel.send({ + embeds: [initializationMessage.embed], + components: initializationMessage.components, + }); + return; + } + + if (!modMail.threadId) { + await closeModMailTicketByModMail(modMail); + return; + } + + try { + const guild = await client.guilds.fetch(config.guildId); + const threadChannel = await guild.channels.fetch(config.modmail.channel); + if (!threadChannel || threadChannel.type !== ChannelType.GuildText) { + logger.warn(`Modmail channel ${config.modmail.channel} not found`); + return; + } + const thread = await threadChannel.threads.fetch( + modMail.threadId.toString(), + ); + + // Check if thread exists, is accessible, and is not archived + if (!thread?.isThread()) { + logger.warn( + `Thread ${modMail.threadId} no longer exists or is not a thread`, + ); + await closeModMailTicketByModMail(modMail); + if (message.channel.isSendable()) { + await message.channel.send({ + content: + "Your ticket has been closed because the associated thread channel is no longer available.", + }); + } + return; + } + + if (thread.archived) { + logger.warn(`Thread ${modMail.threadId} is archived`); + await closeModMailTicketByModMail(modMail); + if (message.channel.isSendable()) { + await message.channel.send({ + content: + "Your ticket has been closed because the associated thread has been archived.", + }); + } + return; + } + + if (!thread.isSendable()) { + logger.warn(`Thread ${modMail.threadId} is not sendable`); + await closeModMailTicketByModMail(modMail); + if (message.channel.isSendable()) { + await message.channel.send({ + content: + "Your ticket has been closed because messages cannot be sent to the associated thread.", + }); + } + return; + } + + const parsedMessage = extractEmbedAndFilesFromMessageModMail( + message, + message.author, + ); + await thread.send({ + embeds: [parsedMessage.embed], + files: parsedMessage.files, + }); + } catch (error: unknown) { + if (error instanceof DiscordAPIError) { + // Check if the error is related to thread not existing or being inaccessible + if ( + error.code === 10003 || + error.code === 50001 || + error.code === 50013 + ) { + logger.warn( + `Thread ${modMail.threadId} is deleted or inaccessible, closing ticket`, + ); + await closeModMailTicketByModMail(modMail); + if (message.channel.isSendable()) { + await message.channel.send({ + content: + "Your ticket has been closed because the associated thread is no longer accessible.", + }); + } + } else { + logger.error( + `Error handling DM message from ${message.author.id}:`, + error, + ); + } + return; + } + + if (message.channel.isSendable()) + message.channel + .send({ + content: + "Sorry, there was an error processing your message. Please contact a member of staff manually.", + }) + .catch(() => { + logger.error( + `Failed to send error message to user ${message.author.id}`, + ); + }); + } +}; + +const handleThreadMessage = async (client: Client, message: Message) => { + try { + const modMail = await getActiveModMailByChannel(BigInt(message.channelId)); + if (!modMail) return; + + // Check if the current thread is archived before processing + if (message.channel.isThread() && message.channel.archived) { + logger.warn(`Thread ${message.channelId} is archived, closing ticket`); + await closeModMailTicketByModMail(modMail); + // Can't send messages to archived threads, so just log and return + return; + } + + const user = await client.users.fetch(modMail.creatorId.toString()); + const dmChannel = await user.createDM(); + + if (!dmChannel?.isSendable()) { + await closeModMailTicketByModMail(modMail); + if (message.channel.isSendable()) { + await message.channel.send({ + content: + "This ticket was closed because the DM channel is no longer accessible.", + }); + } + return; + } + + const parsedMessage = extractEmbedAndFilesFromMessageModMail( + message, + message.author, + ); + + // Send to thread first to show it was processed (only if thread is sendable) + if (message.channel.isSendable()) { + await message.channel.send({ + embeds: [parsedMessage.embed], + }); + } + + // Then send to DM + await dmChannel.send({ + embeds: [parsedMessage.embed], + files: parsedMessage.files, + }); + + // Delete original message to keep thread clean + await message.delete().catch(() => { + logger.warn(`Failed to delete message ${message.id} in thread`); + }); + } catch (error: unknown) { + if (error instanceof DiscordAPIError) { + logger.error(`Discord API Error: ${error.code} - ${error.message}`); + // Check if the error is related to thread being deleted or archived + if ( + error.code === 10003 || + error.code === 50001 || + error.code === 50013 + ) { + logger.warn( + `Thread ${message.channelId} is deleted or inaccessible, closing ticket`, + ); + const modMail = await getActiveModMailByChannel( + BigInt(message.channelId), + ); + if (modMail) { + await closeModMailTicketByModMail(modMail); + } + } else { + logger.error(`Error handling thread message ${message.id}:`, error); + } + + return; + } + + try { + if (message.channel.isSendable()) { + await message.channel.send({ + content: `An error occurred while processing the message. Message link: ${message.url}`, + }); + } + } catch (innerError) { + logger.error(`Failed to send error message in thread:`, innerError); + } + } +}; + +const handleModmailAssignSelect = async ( + interaction: UserSelectMenuInteraction, +) => { + await interaction.deferReply({ flags: ["Ephemeral"] }); + + try { + if (!interaction.inGuild()) { + await interaction.followUp({ + content: "This command can only be used in a guild.", + flags: ["Ephemeral"], + }); + return; + } + + // Check if the current thread is still accessible + if (interaction.channel?.isThread()) { + if (interaction.channel.archived) { + await interaction.followUp({ + content: + "This thread has been archived and the ticket is no longer active.", + flags: ["Ephemeral"], + }); + return; + } + } + + // Extract ticket ID from custom ID + const ticketId = interaction.customId.split("-").pop(); + if (!ticketId) { + await interaction.followUp({ + content: "Invalid ticket ID.", + flags: ["Ephemeral"], + }); + return; + } + const modMail = await ModMailTicket.findOne({ + where: { + id: ticketId, + }, + }); + + if (!modMail) { + await interaction.followUp({ + content: "Ticket not found.", + flags: ["Ephemeral"], + }); + return; + } + + const targetUserId = interaction.values[0]; + const targetMember = await interaction.guild?.members.fetch(targetUserId); + + // Check if the target user has moderator permissions + if (!targetMember?.permissions.has(PermissionFlagsBits.ManageMessages)) { + await interaction.followUp({ + content: "The selected user doesn't have moderator permissions.", + flags: ["Ephemeral"], + }); + return; + } + + // Update the ticket assignment + await modMail.update({ + assignedUserId: BigInt(targetUserId), + }); + + // Send notification in the channel (with error handling for deleted/archived threads) + if (interaction.channel?.isSendable()) { + try { + await interaction.channel.send({ + content: `šŸŽÆ Ticket has been assigned to <@${targetUserId}> by ${interaction.user}`, + }); + } catch (error) { + logger.warn("Failed to send assignment notification to thread:", error); + // Thread might be deleted or archived, but assignment was successful + } + } + + // Notify the user via DM + try { + const ticketCreator = await safelyFetchUser( + interaction.client, + modMail.creatorId.toString(), + ); + if (ticketCreator) { + const dmChannel = await ticketCreator.createDM(); + if (dmChannel?.isSendable()) { + await dmChannel.send({ + content: `Your support ticket (#${modMail.id}) has been assigned to ${targetMember.displayName}. They will assist you shortly.`, + }); + } + } + } catch (error) { + logger.warn("Failed to send DM notification to ticket creator:", error); + } + + await interaction.followUp({ + content: `āœ… Successfully assigned ticket to ${targetMember.displayName}`, + flags: ["Ephemeral"], + }); + } catch (error) { + logger.error("Failed to assign ticket:", error); + await interaction.followUp({ + content: "An error occurred while assigning the ticket.", + flags: ["Ephemeral"], + }); + } +}; + +const handleModmailSubmit = async ( + client: Client, + interaction: ButtonInteraction, +) => { + await interaction.deferUpdate(); + + const userId = interaction.user.id; + + if (await hasActiveModMailByUser(BigInt(userId))) { + await interaction.message.delete().catch(() => {}); + await interaction.followUp({ + content: "You already have an open ticket", + flags: ["Ephemeral"], + }); + return; + } + + const userConfig = pendingModmailSelections.get(userId); + const category = userConfig?.category ?? ModMailTicketCategory.QUESTION; + + // Clean up pending selection + cleanupPendingSelection(userId); + + try { + const guild = await client.guilds.fetch(config.guildId); + const channel = await guild.channels.fetch(config.modmail.channel); + + if (!channel) { + await interaction.followUp({ + content: "Modmail channel not found. Please contact an administrator.", + flags: ["Ephemeral"], + }); + logger.error("Modmail channel not found"); + return; + } + + if (!channel.isTextBased() || channel.type !== ChannelType.GuildText) { + await interaction.followUp({ + content: + "Modmail channel configuration error. Please contact an administrator.", + flags: ["Ephemeral"], + }); + logger.error("Modmail channel is not a text channel"); + return; + } + + const thread = await channel.threads.create({ + name: `${category} - ${interaction.user.tag}`, + reason: `Modmail thread created by ${interaction.user.tag}`, + type: ChannelType.PublicThread, + }); + if (!thread.joined) await thread.join(); + logger.debug( + "Created modmail thread for user %s with id %s", + interaction.user.id, + thread.id, + ); + + const ticket = await createModMailTicket( + BigInt(userId), + BigInt(thread.id), + category, + ); + + const ticketDetails = createModMailDetails( + ticket, + interaction.user, + true, + ) as { + embed: EmbedBuilder; + row: ActionRowBuilder; + }; + + await thread.send({ + content: `A new ticket has been created! ${config.modmail.pingRole ? mentionRoleById(config.modmail.pingRole) : ""}`, + embeds: [ticketDetails.embed], + components: [ticketDetails.row], + }); + + if (interaction.channel?.isSendable()) { + const userTicketDetails = createModMailDetails( + ticket, + interaction.user, + false, + true, + ) as { + embed: EmbedBuilder; + row: ActionRowBuilder; + }; + + await interaction.channel.send({ + content: + "Your ticket has been created successfully! A member of staff will follow up soon.", + embeds: [userTicketDetails.embed], + components: [userTicketDetails.row], + }); + } + + await interaction.message.delete().catch(() => { + logger.warn(`Failed to delete initialization message for user ${userId}`); + }); + } catch (error) { + logger.error(`Error creating modmail ticket for user ${userId}:`, error); + await interaction.followUp({ + content: + "An error occurred while creating your ticket. Please try again or contact an administrator.", + flags: ["Ephemeral"], + }); + } +}; + +const handleCategorySelect = async ( + interaction: StringSelectMenuInteraction, +) => { + const category = interaction.values[0] as ModMailTicketCategory; + const userId = interaction.user.id; + + // Clean up existing timeout if any + cleanupPendingSelection(userId); + + const timeout = setTimeout(() => { + pendingModmailSelections.delete(userId); + }, SELECTION_TIMEOUT_MS); + + pendingModmailSelections.set(userId, { + category, + timeout, + }); + + await interaction.deferUpdate(); +}; + +export const ModMailListener: EventListener[] = [ + { + async messageCreate(client, message) { + if (!isValidMessage(message)) return; + + if (message.channel.isDMBased()) { + await handleDMMessage(client, message); + } else if ( + message.inGuild() && + message.channel.isThread() && + !isCommandMessage(message.content) + ) { + await handleThreadMessage(client, message); + } + }, + + async interactionCreate(client, interaction: Interaction) { + if ( + !interaction.isButton() && + !interaction.isStringSelectMenu() && + !interaction.isUserSelectMenu() + ) + return; + + try { + if ( + interaction.customId === MODMAIL_SUBMIT_ID && + interaction.isButton() + ) { + await handleModmailSubmit(client, interaction); + } else if ( + interaction.isStringSelectMenu() && + interaction.customId === MODMAIL_CATEGORY_SELECT_ID + ) { + await handleCategorySelect(interaction); + } else if ( + interaction.isButton() && + interaction.customId === MODMAIL_ARCHIVE_ID + ) { + await handleModmailArchive(interaction); + } else if ( + interaction.isButton() && + interaction.customId === MODMAIL_ASSIGN_ID + ) { + await handleModmailAssign(interaction); + } else if ( + interaction.isUserSelectMenu() && + interaction.customId.startsWith("modmail-assign-select-") + ) { + await handleModmailAssignSelect(interaction); + } else if ( + interaction.customId === MODMAIL_USER_DETAILS_ID && + interaction.isButton() + ) { + await handleModmailUserDetails(interaction); + } else if ( + interaction.customId === MODMAIL_USER_CLOSE_ID && + interaction.isButton() + ) { + await handleModmailUserClose(interaction); + } + } catch (error) { + logger.error(`Error handling interaction ${interaction.id}:`, error); + + const errorMessage = + "An unexpected error occurred. Please try again or contact support."; + + if (interaction.deferred || interaction.replied) { + await interaction + .followUp({ + content: errorMessage, + flags: ["Ephemeral"], + }) + .catch(() => {}); + } else { + await interaction + .reply({ + content: errorMessage, + flags: ["Ephemeral"], + }) + .catch(() => {}); + } + } + }, + }, +]; diff --git a/src/modules/modmail/modmail.module.ts b/src/modules/modmail/modmail.module.ts new file mode 100644 index 00000000..45142f4c --- /dev/null +++ b/src/modules/modmail/modmail.module.ts @@ -0,0 +1,9 @@ +import type Module from "../module.js"; +import { ModmailCommand } from "./modmail.command.js"; +import { ModMailListener } from "./modmail.listener.js"; + +export const ModmailModule: Module = { + name: "modMail", + globalCommands: [ModmailCommand], + listeners: [...ModMailListener], +}; diff --git a/src/modules/modmail/modmail.ts b/src/modules/modmail/modmail.ts new file mode 100644 index 00000000..1fd83476 --- /dev/null +++ b/src/modules/modmail/modmail.ts @@ -0,0 +1,807 @@ +import { readFileSync } from "node:fs"; +import type { MessageActionRowComponentBuilder } from "@discordjs/builders"; +import { + ActionRowBuilder, + type AnyThreadChannel, + AttachmentBuilder, + type AttachmentPayload, + ButtonBuilder, + type ButtonInteraction, + ButtonStyle, + type EmbedAuthorData, + type EmbedBuilder, + type JSONEncodable, + type Message, + PermissionFlagsBits, + type SelectMenuComponentOptionData, + StringSelectMenuBuilder, + type User, + UserSelectMenuBuilder, +} from "discord.js"; +import Handlebars from "handlebars"; +import { config } from "../../Config.js"; +import { logger } from "../../logging.js"; +import { + ModMailTicket, + ModMailTicketCategory, + ModMailTicketStatus, +} from "../../store/models/ModMailTicket.js"; +import { createStandardEmbed } from "../../util/embeds.js"; +import { getMemberFromInteraction } from "../../util/member.js"; +import { fetchAllMessagesWithRetry } from "../../util/message.js"; +import { actualMentionById } from "../../util/users.js"; + +export const MODMAIL_SUBMIT_ID = "modmail-submit"; +export const MODMAIL_CATEGORY_SELECT_ID = "modmail-category-select"; +export const MODMAIL_ARCHIVE_ID = "modmail-archive"; +export const MODMAIL_ASSIGN_ID = "modmail-assign"; +export const MODMAIL_USER_DETAILS_ID = "modmail-user-details"; +export const MODMAIL_USER_CLOSE_ID = "modmail-user-close"; + +const modMailCategorySelections: SelectMenuComponentOptionData[] = [ + { + label: "Question", + value: ModMailTicketCategory.QUESTION, + default: true, + description: "Questions about the server or a project", + emoji: "ā“", + }, + { + label: "Bug Report", + value: ModMailTicketCategory.BUG, + description: "Report a bug in the server or a project", + emoji: "šŸ›", + }, + { + label: "Suggestion", + value: ModMailTicketCategory.SUGGESTION, + description: "Make a suggestion for the server or a project", + emoji: "šŸ’”", + }, + { + label: "Other", + value: ModMailTicketCategory.OTHER, + description: "Any other category", + emoji: "šŸ—Øļø", + }, +]; + +export async function getActiveModMailByUser(userId: bigint) { + return await ModMailTicket.findOne({ + where: { + creatorId: userId, + status: ModMailTicketStatus.OPEN, + }, + }); +} + +export async function hasActiveModMailByUser(userId: bigint) { + return ( + (await ModMailTicket.count({ + where: { + creatorId: userId, + status: ModMailTicketStatus.OPEN, + }, + })) > 0 + ); +} + +export async function getActiveModMailByChannel(threadId: bigint) { + return await ModMailTicket.findOne({ + where: { + threadId: threadId, + status: ModMailTicketStatus.OPEN, + }, + }); +} + +export async function createModMailTicket( + creatorId: bigint, + threadId: bigint, + category: ModMailTicketCategory = ModMailTicketCategory.QUESTION, +) { + return await ModMailTicket.create({ + creatorId: creatorId, + threadId: threadId, + category: category, + status: ModMailTicketStatus.OPEN, + }); +} + +export async function createArchiveFromThread( + thread: AnyThreadChannel, + modMailTicket: ModMailTicket, +) { + try { + const assignedModerator = !modMailTicket.assignedUserId + ? null + : await thread.guild.members.fetch( + modMailTicket.assignedUserId.toString(), + ); + const messages = await fetchAllMessagesWithRetry( + thread, + 3, + 50000, + (message) => { + return ( + message.author.bot && + message.author.id === thread.client.user.id && + message.embeds.length === 1 && + !message.embeds[0].title + ); + }, + ); // Max 3 retries, up to 50k messages + + logger.info(`Processing ${messages.size} messages for archive...`); + + // Convert messages to template data + const messageData = await Promise.all( + messages.map(async (message) => { + const attachments = message.attachments.map((attachment) => ({ + name: attachment.name || "Unknown", + url: attachment.url, + size: attachment.size, + contentType: attachment.contentType || "unknown", + isImage: attachment.contentType?.startsWith("image/") || false, + })); + const embed = message.embeds[0]; + const messageContent = embed.description ?? "Unknown"; + + const author = embed.author as EmbedAuthorData; + + return { + id: message.id, + content: messageContent, + createdAt: message.createdAt.toLocaleString(), + author: { + url: author.url, + displayName: author.name, + avatarURL: author.iconURL, + }, + attachments: attachments, + }; + }), + ); + + // Sort messages by creation time (oldest first) + messageData.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + logger.debug(`Sorted ${messageData.length} messages chronologically`); + + // Prepare template data + const templateData = { + threadName: thread.name || "Unnamed Thread", + threadId: thread.id, + createdAt: thread.createdAt?.toLocaleString() || "Unknown", + archivedAt: new Date().toLocaleString(), + messageCount: messageData.length, + messages: messageData, + generatedAt: new Date().toLocaleString(), + ticket: modMailTicket, + assignedTo: !assignedModerator + ? "Unassigned" + : (assignedModerator.displayName ?? assignedModerator.user.username), + }; + + // Load and compile the template + const templatePath = `${process.cwd()}/src/modules/modmail/template/modmail-archive.html`; + const templateContent = readFileSync(templatePath, "utf-8"); + const template = Handlebars.compile(templateContent); + + // Generate HTML + logger.debug("Generating HTML archive..."); + const html = template(templateData, { + allowedProtoProperties: { + category: true, + avatarURL: true, + displayName: true, + url: true, + }, + }); + + logger.info( + `Successfully created archive with ${messageData.length} messages`, + ); + + return { + success: true, + content: html, + messageCount: messageData.length, + }; + } catch (error) { + logger.error("Failed to create thread archive:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + +export async function closeModMailTicketByThreadId(threadId: bigint) { + const ticket = await ModMailTicket.findOne({ + where: { + threadId: threadId, + }, + }); + if (ticket == null) { + return; + } + return await ticket.update({ + status: ModMailTicketStatus.ARCHIVED, + }); +} + +export async function closeModMailTicketByModMail(ticket: ModMailTicket) { + return await ticket.update({ + status: ModMailTicketStatus.ARCHIVED, + }); +} + +export function createModMailInitializationEmbed(user: User) { + const embed = createStandardEmbed(user) + .setTitle("Modmail") + .setDescription( + "Welcome to the Modmail system! Please select a category below to get started.", + ); + + const categorySelect = new StringSelectMenuBuilder() + .setCustomId(MODMAIL_CATEGORY_SELECT_ID) + .setPlaceholder("Select a category") + .setMaxValues(1) + .setMinValues(1) + .addOptions(modMailCategorySelections); + + const categorySelectRow = + new ActionRowBuilder().addComponents( + categorySelect, + ); + + const submitButton = new ButtonBuilder() + .setCustomId(MODMAIL_SUBMIT_ID) + .setLabel("Submit") + .setStyle(ButtonStyle.Primary); + + const actionRow = + new ActionRowBuilder().addComponents( + submitButton, + ); + + return { + embed: embed, + components: [categorySelectRow, actionRow], + }; +} +export function createModMailDetails( + modMailTicket: ModMailTicket, + user: User | string, + moderator: boolean | null = null, + forUser: boolean = false, +) { + const isString = typeof user === "string"; + const embed = isString ? createStandardEmbed() : createStandardEmbed(user); + embed.setTitle( + `Modmail Ticket #${modMailTicket.id} - ${isString ? user : user.displayName}`, + ); + embed.addFields([ + { + name: "Category", + value: modMailTicket.category, + }, + ]); + + if (!moderator && !forUser) { + return { embed: embed, row: null }; + } + + // Moderator buttons (existing functionality) + if (modMailTicket.assignedUserId) { + embed.addFields({ + name: "Assigned Moderator", + value: actualMentionById(modMailTicket.assignedUserId), + inline: true, + }); + } else { + embed.addFields({ + name: "Assigned Moderator", + value: "Unassigned", + inline: true, + }); + } + + embed.addFields({ + name: "Status", + value: modMailTicket.status, + inline: true, + }); + if (forUser) { + // Show user-specific buttons + const detailsButton = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setLabel("Show Details") + .setCustomId(MODMAIL_USER_DETAILS_ID); + + const closeButton = new ButtonBuilder() + .setStyle(ButtonStyle.Danger) + .setLabel("Close Ticket") + .setCustomId(MODMAIL_USER_CLOSE_ID); + + const actionRow = + new ActionRowBuilder().addComponents([ + detailsButton, + closeButton, + ]); + + return { embed: embed, row: actionRow }; + } + + const archiveButton = new ButtonBuilder() + .setStyle(ButtonStyle.Danger) + .setLabel("Archive") + .setCustomId(MODMAIL_ARCHIVE_ID); + + const assignButton = new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Assign") + .setCustomId(MODMAIL_ASSIGN_ID); + + const actionRow = + new ActionRowBuilder().addComponents([ + archiveButton, + assignButton, + ]); + return { embed: embed, row: actionRow }; +} + +export const extractEmbedAndFilesFromMessageModMail: ( + message: Message, + user: User, +) => { + embed: EmbedBuilder; + files?: AttachmentBuilder[]; +} = (message, user) => { + const embed = createStandardEmbed(user) + .setColor("Blurple") + .setAuthor({ + name: user.displayName, + iconURL: user.displayAvatarURL(), + url: `https://discord.com/users/${user.id}`, + }) + .setDescription(message.content.length > 0 ? message.content : null); + const files: AttachmentBuilder[] = message.attachments.map((attachment) => + AttachmentBuilder.from( + attachment.toJSON() as JSONEncodable, + ), + ); + + return { + embed: embed, + files: files.length > 0 ? files : undefined, + }; +}; +export const handleModmailArchive = async (interaction: ButtonInteraction) => { + await interaction.deferReply({ flags: ["Ephemeral"] }); + + try { + if (!interaction.channel?.isThread()) { + await interaction.followUp({ + content: "This command can only be used in a modmail thread.", + flags: ["Ephemeral"], + }); + return; + } + + const modMail = await getActiveModMailByChannel( + BigInt(interaction.channelId), + ); + if (!modMail) { + await interaction.followUp({ + content: "This is not an active modmail thread.", + flags: ["Ephemeral"], + }); + return; + } + + // Create archive using the filtered messages + const archiveResult = await createArchiveFromThread( + interaction.channel, + modMail, + ); + + if (!archiveResult.success || !archiveResult.content) { + await interaction.followUp({ + content: `Failed to create archive: ${archiveResult.error}`, + flags: ["Ephemeral"], + }); + return; + } + + // Create HTML file attachment + const htmlBuffer = Buffer.from(archiveResult.content, "utf-8"); + const fileName = `modmail-archive-${interaction.channel.name}-${Date.now()}.html`; + const attachment = new AttachmentBuilder(htmlBuffer, { name: fileName }); + + let dmSendSuccess = false; + let archiveChannelSendSuccess = false; + + try { + // Send to user's DM + const user = await interaction.client.users.fetch( + modMail.creatorId.toString(), + ); + const dmChannel = await user.createDM(); + + if (dmChannel.isSendable()) { + await dmChannel.send({ + content: + "Your modmail ticket has been archived. Here's a copy of the conversation:", + files: [attachment], + }); + dmSendSuccess = true; + logger.info( + `Successfully sent archive to user ${modMail.creatorId} via DM`, + ); + } else { + logger.warn( + `Could not send archive to user ${modMail.creatorId} - DM not accessible`, + ); + } + } catch (error) { + logger.error( + `Failed to send archive to user ${modMail.creatorId} via DM:`, + error, + ); + } + + try { + // Send to archive channel + const guild = await interaction.client.guilds.fetch(config.guildId); + const archiveChannel = await guild.channels.fetch( + config.modmail.archiveChannel, + ); + + if (archiveChannel?.isTextBased() && archiveChannel.isSendable()) { + // Create a new attachment for the archive channel (Discord requires separate instances) + const archiveAttachment = new AttachmentBuilder(htmlBuffer, { + name: fileName, + }); + + await archiveChannel.send({ + content: `Modmail ticket archived: ${interaction.channel.name}\nTicket ID: ${modMail.id}\nUser: <@${modMail.creatorId}>`, + files: [archiveAttachment], + }); + archiveChannelSendSuccess = true; + logger.info( + `Successfully sent archive to archive channel ${config.modmail.archiveChannel}`, + ); + } else { + logger.warn( + `Could not send archive to archive channel - channel not accessible`, + ); + } + } catch (error) { + logger.error(`Failed to send archive to archive channel:`, error); + } + + // Close the ticket + await closeModMailTicketByModMail(modMail); + + // Provide feedback to moderator if we reach here (thread wasn't deleted) + let statusMessage = `Archive created with ${archiveResult.messageCount} messages.`; + + if (dmSendSuccess && archiveChannelSendSuccess) { + statusMessage += + "\nāœ… Sent to user's DM and archive channel. Deleting Thread in 10 seconds..."; + } else if (dmSendSuccess) { + statusMessage += + "\nāœ… Sent to user's DM.\nāŒ Failed to send to archive channel."; + } else if (archiveChannelSendSuccess) { + statusMessage += + "\nāŒ Failed to send to user's DM.\nāœ… Sent to archive channel."; + } else { + statusMessage += + "\nāŒ Failed to send to both user's DM and archive channel."; + } + + await interaction.followUp({ + content: statusMessage, + flags: ["Ephemeral"], + }); + + setTimeout(async () => { + if (!interaction.channel) { + logger.warn( + `Modmail thread ${interaction.channelId} was deleted before timeout or something went wrong!`, + ); + return; + } + // Delete thread if both sends were successful + if (dmSendSuccess && archiveChannelSendSuccess) { + try { + await interaction.channel.delete( + "Modmail ticket archived and processed successfully", + ); + // Since thread is deleted, we can't send followUp, so log instead + logger.info( + `Successfully deleted modmail thread ${interaction.channelId} after archiving`, + ); + } catch (error) { + logger.error( + `Failed to delete thread ${interaction.channelId}:`, + error, + ); + } + } + }, 10 * 1000); + } catch (error) { + logger.error(`Error creating modmail archive:`, error); + await interaction.followUp({ + content: "An error occurred while creating the archive.", + flags: ["Ephemeral"], + }); + } +}; + +export const handleModmailAssign = async (interaction: ButtonInteraction) => { + await interaction.deferReply({ flags: ["Ephemeral"] }); + + try { + if (!interaction.inGuild()) { + await interaction.followUp({ + content: "This command can only be used in a guild.", + flags: ["Ephemeral"], + }); + return; + } + + // Check if user has moderator permissions + const member = await getMemberFromInteraction(interaction); + if (!member?.permissions.has(PermissionFlagsBits.ManageMessages)) { + await interaction.followUp({ + content: "You don't have permission to assign tickets.", + flags: ["Ephemeral"], + }); + return; + } + + if (!interaction.channel?.isThread()) { + await interaction.followUp({ + content: "This command can only be used in a modmail thread.", + flags: ["Ephemeral"], + }); + return; + } + + const modMail = await getActiveModMailByChannel( + BigInt(interaction.channelId), + ); + if (!modMail) { + await interaction.followUp({ + content: "This is not an active modmail thread.", + flags: ["Ephemeral"], + }); + return; + } + + // Create a user select menu for assignment + const userSelect = new UserSelectMenuBuilder() + .setCustomId(`modmail-assign-select-${modMail.id}`) + .setPlaceholder("Select a moderator to assign") + .setMaxValues(1) + .setMinValues(1); + + const selectRow = + new ActionRowBuilder().addComponents(userSelect); + + await interaction.followUp({ + content: "Please select a moderator to assign this ticket to:", + components: [selectRow], + flags: ["Ephemeral"], + }); + } catch (error) { + logger.error("Failed to create assign selection:", error); + await interaction.followUp({ + content: "An error occurred while creating the assignment selection.", + flags: ["Ephemeral"], + }); + } +}; +export const handleModmailUserDetails = async ( + interaction: ButtonInteraction, +) => { + await interaction.deferReply({ flags: ["Ephemeral"] }); + + try { + // Only allow this in DMs + if (interaction.inGuild()) { + await interaction.followUp({ + content: "This action can only be used in DMs.", + flags: ["Ephemeral"], + }); + return; + } + + const modMail = await getActiveModMailByUser(BigInt(interaction.user.id)); + if (!modMail) { + await interaction.followUp({ + content: "You don't have an active ticket.", + flags: ["Ephemeral"], + }); + return; + } + + const ticketDetails = createModMailDetails( + modMail, + interaction.user, + false, + true, + ) as { + embed: EmbedBuilder; + row: ActionRowBuilder; + }; + + // Add additional details for the user + ticketDetails.embed.addFields([ + { + name: "Status", + value: modMail.status, + inline: true, + }, + ]); + + if (modMail.assignedUserId) { + try { + const assignedUser = await interaction.client.users.fetch( + modMail.assignedUserId.toString(), + ); + ticketDetails.embed.addFields({ + name: "Assigned Moderator", + value: assignedUser.displayName, + inline: true, + }); + } catch { + ticketDetails.embed.addFields({ + name: "Assigned Moderator", + value: "Unknown", + inline: true, + }); + } + } else { + ticketDetails.embed.addFields({ + name: "Assigned Moderator", + value: "Unassigned", + inline: true, + }); + } + + await interaction.followUp({ + embeds: [ticketDetails.embed], + components: [ticketDetails.row], + flags: ["Ephemeral"], + }); + } catch (error) { + logger.error("Failed to show user ticket details:", error); + await interaction.followUp({ + content: "An error occurred while retrieving your ticket details.", + flags: ["Ephemeral"], + }); + } +}; +export const handleModmailUserClose = async ( + interaction: ButtonInteraction, +) => { + await interaction.deferReply({ flags: ["Ephemeral"] }); + + try { + // Only allow this in DMs + if (interaction.inGuild()) { + await interaction.followUp({ + content: "This action can only be used in DMs.", + flags: ["Ephemeral"], + }); + return; + } + + const modMail = await getActiveModMailByUser(BigInt(interaction.user.id)); + if (!modMail) { + await interaction.followUp({ + content: "You don't have an active ticket.", + flags: ["Ephemeral"], + }); + return; + } + + // Create archive before closing the ticket + let archiveCreated = false; + try { + if (modMail.threadId) { + const guild = await interaction.client.guilds.fetch(config.guildId); + const thread = await guild.channels.fetch(modMail.threadId.toString()); + + if (thread?.isThread()) { + // Create archive using the filtered messages + const archiveResult = await createArchiveFromThread(thread, modMail); + + if (archiveResult.success && archiveResult.content) { + // Create HTML file attachment + const htmlBuffer = Buffer.from(archiveResult.content, "utf-8"); + const fileName = `modmail-archive-${thread.name}-${Date.now()}.html`; + const attachment = new AttachmentBuilder(htmlBuffer, { + name: fileName, + }); + if (interaction.channel?.isSendable()) { + // Send archive to user's DM + await interaction.channel?.send({ + content: + "Your ticket has been closed and archived. Here's a copy of the conversation:", + files: [attachment], + }); + } + + // Send to archive channel + try { + const archiveChannel = await guild.channels.fetch( + config.modmail.archiveChannel, + ); + + if ( + archiveChannel?.isTextBased() && + archiveChannel.isSendable() + ) { + const archiveAttachment = new AttachmentBuilder(htmlBuffer, { + name: fileName, + }); + + await archiveChannel.send({ + content: `Modmail ticket closed by user: ${thread.name}\nTicket ID: ${modMail.id}\nUser: <@${modMail.creatorId}>`, + files: [archiveAttachment], + }); + } + } catch (error) { + logger.warn("Failed to send archive to archive channel:", error); + } + + archiveCreated = true; + logger.info( + `Successfully created archive for user-closed ticket ${modMail.id}`, + ); + } + } + } + } catch (error) { + logger.error("Failed to create archive for user-closed ticket:", error); + } + + // Close the ticket + await closeModMailTicketByModMail(modMail); + + // Notify the thread if it exists + try { + if (modMail.threadId) { + const guild = await interaction.client.guilds.fetch(config.guildId); + const thread = await guild.channels.fetch(modMail.threadId.toString()); + + if (thread?.isThread() && thread.isSendable()) { + await thread.send({ + content: `šŸ”’ Ticket has been closed by the user (${interaction.user.displayName}).${archiveCreated ? " Archive has been created." : ""}`, + }); + } + } + } catch (error) { + logger.warn("Failed to notify thread about user closing ticket:", error); + } + + await interaction.followUp({ + content: `āœ… Your ticket has been closed. Thank you for contacting us!${archiveCreated ? " An archive of your conversation has been sent above." : ""}`, + flags: ["Ephemeral"], + }); + } catch (error) { + logger.error("Failed to close user ticket:", error); + await interaction.followUp({ + content: "An error occurred while closing your ticket.", + flags: ["Ephemeral"], + }); + } +}; diff --git a/src/modules/modmail/template/modmail-archive.html b/src/modules/modmail/template/modmail-archive.html new file mode 100644 index 00000000..12fe89b5 --- /dev/null +++ b/src/modules/modmail/template/modmail-archive.html @@ -0,0 +1,236 @@ + + + + + + Discord Thread Archive - {{threadName}} + + + +
+
+

{{threadName}}

+
+

Thread ID: {{threadId}}

+

Assigned to: {{assignedTo}}

+

Categroy: {{ticket.category}}

+

Created: {{createdAt}}

+

Archived: {{archivedAt}}

+

Total Messages: {{messageCount}}

+
+
+ +
+ {{#if messages.length}} + {{#each messages}} +
+
+ {{author.username}} + +
+
+ {{#if content}} +
{{content}}
+ {{/if}} + + {{#if attachments.length}} +
+ Attachments: + {{#each attachments}} +
+ + {{name}} + + {{#if isImage}} + {{name}} + {{/if}} +
+ Size: {{size}} bytes | Type: {{contentType}} +
+
+ {{/each}} +
+ {{/if}} +
+
+ {{/each}} + {{else}} +
+ No messages found in this thread. +
+ {{/if}} +
+ + +
+ + \ No newline at end of file diff --git a/src/modules/module.ts b/src/modules/module.ts index cabf7c96..612515cf 100644 --- a/src/modules/module.ts +++ b/src/modules/module.ts @@ -18,6 +18,7 @@ export type EventListener = { export default interface Module { name: string; commands?: Array>; + globalCommands?: Array>; listeners?: EventListener[]; /** diff --git a/src/modules/moduleManager.ts b/src/modules/moduleManager.ts index 0754c3d1..aed2f4a6 100644 --- a/src/modules/moduleManager.ts +++ b/src/modules/moduleManager.ts @@ -5,7 +5,8 @@ import { logger } from "../logging.js"; import type Module from "./module.js"; export default class ModuleManager { - private readonly commandManager: CommandManager; + private readonly guildCommandManager: CommandManager; + private readonly globalCommandManager: CommandManager; private readonly originalEmit; constructor( @@ -16,16 +17,20 @@ export default class ModuleManager { ) { this.originalEmit = this.client.emit; client.emit = this.overrideEmit().bind(client); + for (const module of modules) { module.preInit?.(client)?.catch((e) => { Sentry.captureException(e); logger.error(`Error in preInit for module ${module.name}`, e); }); } - this.commandManager = new CommandManager( - modules.flatMap((it) => it.commands ?? []), - client, - ); + + // Separate guild and global commands + const guildCommands = modules.flatMap((it) => it.commands ?? []); + const globalCommands = modules.flatMap((it) => it.globalCommands ?? []); + + this.guildCommandManager = new CommandManager(guildCommands, client); + this.globalCommandManager = new CommandManager(globalCommands, client); } /** @@ -55,11 +60,17 @@ export default class ModuleManager { for (const module of this.modules) { module.onCommandInit?.(this.client)?.catch((e) => { Sentry.captureException(e); - logger.error(`Error in preInit for module ${module.name}`, e); + logger.error(`Error in onCommandInit for module ${module.name}`, e); }); } - await this.commandManager.setupForGuild(this.clientId, this.guildId); + // Set up guild-specific commands + await this.guildCommandManager.setupForGuild(this.clientId, this.guildId); + + // Set up global commands + if (this.globalCommandManager) { + await this.globalCommandManager.setupGlobally(this.clientId); + } } getModules() { diff --git a/src/modules/starboard/starboard.listener.ts b/src/modules/starboard/starboard.listener.ts index b4fc9ab1..03f9e63e 100644 --- a/src/modules/starboard/starboard.listener.ts +++ b/src/modules/starboard/starboard.listener.ts @@ -196,6 +196,7 @@ export const StarboardListener: EventListener = { member, count, ); + return; } const starboardMessageContent = await createStarboardMessageFromMessage( diff --git a/src/store/RealBigInt.ts b/src/store/RealBigInt.ts index cdf9695f..76cb6a45 100644 --- a/src/store/RealBigInt.ts +++ b/src/store/RealBigInt.ts @@ -1,10 +1,39 @@ import { DataTypes, ValidationErrorItem } from "@sequelize/core"; +/** + * A better bigint type than the one Sequelize provides + * It uses a native bigint where supported, otherwise it converts it to a string adding the suffix "n" to prevent + * the driver parsing as a number + */ export class RealBigInt extends DataTypes.ABSTRACT { toSql() { - return "BIGINT"; - // this is actually kind of bad, as it will use BIGINT on sqlite too, potentially losing data - // however since sqlite is only used for testing i basically don't care + if (this.nativeBigIntSupport()) { + return "BIGINT"; + } else { + return "STRING"; + } + } + + nativeBigIntSupport() { + return this._getDialect().supports.dataTypes.BIGINT; + } + + override toBindableValue(value: bigint): unknown { + if (this.nativeBigIntSupport()) { + return value; + } else { + return `${value.toString()}n`; + } + } + + override escape(value: unknown): string { + if (this.nativeBigIntSupport()) { + // For native bigint support, return the value as string + return value?.toString() ?? "0"; + } else { + // For string representation, escape as a string literal + return `'${value}'`; + } } override sanitize(value: unknown): unknown { @@ -29,7 +58,13 @@ export class RealBigInt extends DataTypes.ABSTRACT { override parseDatabaseValue(value: unknown) { if (typeof value === "bigint") return value; - if (typeof value === "string") return BigInt(value); + if (typeof value === "string") { + // stupid lol + if (value.endsWith("n")) { + return BigInt(value.slice(0, -1)); + } + return BigInt(value); + } if (typeof value === "number") return BigInt(value); if (typeof value === "boolean") return BigInt(value); diff --git a/src/store/models/FAQ.ts b/src/store/models/FAQ.ts index f7935d6e..41e94605 100644 --- a/src/store/models/FAQ.ts +++ b/src/store/models/FAQ.ts @@ -20,10 +20,10 @@ export class FAQ extends Model< InferAttributes, InferCreationAttributes > { - @Attribute(RealBigInt) + @Attribute(DataTypes.INTEGER) @PrimaryKey @AutoIncrement - public declare id: CreationOptional; + public declare id: CreationOptional; @Attribute(RealBigInt) @NotNull diff --git a/src/store/models/ModMailTicket.ts b/src/store/models/ModMailTicket.ts new file mode 100644 index 00000000..96ebd77f --- /dev/null +++ b/src/store/models/ModMailTicket.ts @@ -0,0 +1,70 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, +} from "@sequelize/core"; +import { + AllowNull, + Attribute, + Default, + NotNull, + PrimaryKey, + Table, +} from "@sequelize/core/decorators-legacy"; +import { RealBigInt } from "../RealBigInt.js"; + +export enum ModMailTicketStatus { + OPEN = "OPEN", + ARCHIVED = "ARCHIVED", +} + +export enum ModMailTicketCategory { + QUESTION = "QUESTION", + BUG = "BUG", + SUGGESTION = "SUGGESTION", + OTHER = "OTHER", +} + +@Table({ + tableName: "ModMailTicket", +}) +export class ModMailTicket extends Model< + InferAttributes, + InferCreationAttributes +> { + @Attribute(DataTypes.INTEGER) + @PrimaryKey + public declare id: CreationOptional; + + @Attribute(RealBigInt) + @NotNull + public declare creatorId: bigint; + + @Attribute(RealBigInt) + @AllowNull + public declare assignedUserId?: bigint; + + @Attribute(RealBigInt) + @AllowNull + public declare threadId?: bigint; + + @Attribute(RealBigInt) + @AllowNull + public declare channelId?: bigint; + + @Attribute(DataTypes.STRING) + @AllowNull + public declare archiveMessageId?: string; + + @Attribute(DataTypes.ENUM(ModMailTicketStatus)) + @Default(ModMailTicketStatus.OPEN) + @NotNull + public status: ModMailTicketStatus = ModMailTicketStatus.OPEN; + + @Attribute(DataTypes.ENUM(ModMailTicketCategory)) + @Default(ModMailTicketCategory.QUESTION) + @NotNull + public category: ModMailTicketCategory = ModMailTicketCategory.QUESTION; +} diff --git a/src/store/models/ModeratorActions.ts b/src/store/models/ModeratorActions.ts index aea3a136..b229652b 100644 --- a/src/store/models/ModeratorActions.ts +++ b/src/store/models/ModeratorActions.ts @@ -8,7 +8,6 @@ import { import { AllowNull, Attribute, - AutoIncrement, BelongsTo, ColumnName, Default, @@ -28,10 +27,9 @@ export class ModeratorActions extends Model< InferAttributes, InferCreationAttributes > { - @Attribute(RealBigInt) + @Attribute(DataTypes.INTEGER) @PrimaryKey - @AutoIncrement - public declare id: CreationOptional; + public declare id: CreationOptional; @Attribute(RealBigInt) @NotNull diff --git a/src/store/storage.ts b/src/store/storage.ts index 6407bff1..7e642ddd 100644 --- a/src/store/storage.ts +++ b/src/store/storage.ts @@ -11,6 +11,7 @@ import { ColourRoles } from "./models/ColourRoles.js"; import { DDUser } from "./models/DDUser.js"; import { FAQ } from "./models/FAQ.js"; import { ModeratorActions } from "./models/ModeratorActions.js"; +import { ModMailTicket } from "./models/ModMailTicket.js"; import { StarboardMessage } from "./models/StarboardMessage.js"; import { Suggestion } from "./models/Suggestion.js"; import { SuggestionVote } from "./models/SuggestionVote.js"; @@ -70,6 +71,7 @@ export async function initStorage() { ModeratorActions, Suggestion, SuggestionVote, + ModMailTicket, ]; sequelize.addModels(models); diff --git a/src/util/embeds.ts b/src/util/embeds.ts index 87c73441..99cc6eeb 100644 --- a/src/util/embeds.ts +++ b/src/util/embeds.ts @@ -4,16 +4,21 @@ import { type EmbedFooterOptions, type GuildMember, type PartialGuildMember, + type User, } from "discord.js"; import { branding } from "./branding.js"; export function createStandardEmbed( - user?: GuildMember | PartialGuildMember, + user?: GuildMember | PartialGuildMember | User, ): EmbedBuilder { const builder = new EmbedBuilder(); - builder.setColor( - user?.roles?.color?.hexColor ?? (branding.color as ColorResolvable), - ); + if (user && "roles" in user) { + builder.setColor( + user?.roles?.color?.hexColor ?? (branding.color as ColorResolvable), + ); + } else { + builder.setColor(branding.color as ColorResolvable); + } const options = standardFooter(); builder.setFooter(options); builder.setTimestamp(new Date()); diff --git a/src/util/member.ts b/src/util/member.ts index db2103ab..86db7e81 100644 --- a/src/util/member.ts +++ b/src/util/member.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/bun"; -import type { Message } from "discord.js"; +import type { GuildMember, Interaction, Message } from "discord.js"; import { logger } from "../logging.js"; export async function getMember(message: Message) { @@ -16,3 +16,20 @@ export async function getMember(message: Message) { Sentry.captureException(error); } } +export async function getMemberFromInteraction(interaction: Interaction) { + if (!interaction.inGuild()) return null; + + if (interaction.member) { + if (typeof interaction.member.permissions === "string") { + return await interaction.guild?.members.fetch(interaction.user.id); + } + return interaction.member as GuildMember; + } + + try { + return await interaction.guild?.members.fetch(interaction.user.id); + } catch (error) { + logger.error("Failed to fetch member:", error); + Sentry.captureException(error); + } +} diff --git a/src/util/message.ts b/src/util/message.ts new file mode 100644 index 00000000..310084c4 --- /dev/null +++ b/src/util/message.ts @@ -0,0 +1,129 @@ +import { Collection, type Message, type TextBasedChannel } from "discord.js"; +import { logger } from "../logging.js"; + +/** + * Fetches all messages from a channel with rate limit protection and pagination + * @param channel The channel to fetch messages from + * @param maxMessages Maximum number of messages to fetch (default: 10000, -1 = fetch all available) + * @param delayMs Delay between requests in milliseconds (default: 500ms) + * @param filter Optional filter function to apply to messages + * @returns Collection of all messages + */ +export async function fetchAllMessages( + channel: TextBasedChannel, + maxMessages: number = 10000, + delayMs: number = 500, + filter: (message: Message) => boolean = () => true, +): Promise> { + const allMessages = new Map(); + let lastMessageId: string | undefined; + let totalFetched = 0; + const fetchAll = maxMessages === -1; + + try { + while (fetchAll || totalFetched < maxMessages) { + // Calculate remaining messages to fetch + const remaining = fetchAll ? 100 : maxMessages - totalFetched; + const limit = Math.min(100, remaining); // Discord API limit is 100 per request + + logger.debug( + `Fetching ${limit} messages... (Total: ${totalFetched}${fetchAll ? "" : `/${maxMessages}`})`, + ); + + // Fetch messages with pagination + const fetchOptions: { limit: number; before?: string } = { limit }; + if (lastMessageId) { + fetchOptions.before = lastMessageId; + } + + const messages = await channel.messages.fetch(fetchOptions); + + // If no messages returned, we've reached the end + if (messages.size === 0) { + logger.info("No more messages to fetch"); + break; + } + // Add messages to our collection + for (const [id, message] of messages) { + if (!filter(message)) continue; + allMessages.set(id, message); + } + + totalFetched += messages.size; + logger.info(`Fetched ${messages.size} messages (Total: ${totalFetched})`); + + // Update lastMessageId for pagination + const messagesArray = Array.from(messages.values()); + lastMessageId = messagesArray[messagesArray.length - 1]?.id; + + // If we fetched less than requested, we've reached the end + if (messages.size < limit) { + console.info("Reached end of channel messages"); + break; + } + + // Rate limit protection - wait before next request + if ((fetchAll || totalFetched < maxMessages) && delayMs > 0) { + logger.info(`Waiting ${delayMs}ms before next request...`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + logger.info(`Successfully fetched ${allMessages.size} total messages`); + return new Collection(allMessages); + } catch (error) { + logger.error("Error fetching messages:", error); + + // Return what we have so far instead of failing completely + if (allMessages.size > 0) { + logger.error( + `Returning ${allMessages.size} messages fetched before error`, + ); + return new Collection(allMessages); + } + + throw error; + } +} + +/** + * Fetches all messages with exponential backoff retry logic + * @param channel The channel to fetch messages from + * @param maxRetries Maximum number of retry attempts (default: 3) + * @param maxMessages Maximum number of messages to fetch (default: 10000) + * @param filter Optional filter function to apply to messages + * @returns Collection of all messages + */ +export async function fetchAllMessagesWithRetry( + channel: TextBasedChannel, + maxRetries: number = 3, + maxMessages: number = 10000, + filter: (message: Message) => boolean = () => true, +): Promise> { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + logger.info(`Fetch attempt ${attempt}/${maxRetries}`); + + // Increase delay with each retry attempt + const delayMs = Math.min(500 * attempt, 2000); + + return await fetchAllMessages(channel, maxMessages, delayMs, filter); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + logger.error(`Attempt ${attempt} failed:`, lastError.message); + + if (attempt < maxRetries) { + // Exponential backoff: wait longer after each failed attempt + const backoffMs = Math.min(1000 * 2 ** (attempt - 1), 10000); + logger.error(`Retrying in ${backoffMs}ms...`); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + } + + throw new Error( + `Failed to fetch messages after ${maxRetries} attempts. Last error: ${lastError?.message}`, + ); +} diff --git a/src/util/role.ts b/src/util/role.ts new file mode 100644 index 00000000..2a22a9bb --- /dev/null +++ b/src/util/role.ts @@ -0,0 +1 @@ +export const mentionRoleById = (id: bigint | string) => `<@&${id}>`; diff --git a/src/util/users.ts b/src/util/users.ts index 99a110f5..dd8ed8e0 100644 --- a/src/util/users.ts +++ b/src/util/users.ts @@ -1,4 +1,5 @@ import { + type Client, type GuildMember, Message, type PartialGuildMember, @@ -68,3 +69,16 @@ export const isSpecialUser = (user: GuildMember): boolean => user.roles.cache.has(config.roles.staff) || user.roles.cache.has(config.roles.notable ?? "") || user.roles.cache.has("1185946490984738938"); + +export const safelyFetchUser = async ( + client: Client, + userId: string, +): Promise => { + try { + return await client.users.fetch(userId); + } catch (error) { + // Optionally log the error + console.warn(`Could not fetch user ${userId}:`, error); + return null; + } +};