diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 76e0a7e83..eeb62588b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,12 +27,14 @@ type HubRating { rating Int @default(0) } +enum HubModeratorPosition { + network_mod + manager +} + type HubModerator { userId String - position String @default("network_mod") // network, manager - // network -> just basic actions like warning/muting - // manager -> perm blacklisting, managing hub etc. - // honestly... I might as well make permissions lol + position HubModeratorPosition @default(network_mod) } type HubLogChannels { diff --git a/src/RandomComponents.ts b/src/RandomComponents.ts index 57f8bd7f4..f3a3dd144 100644 --- a/src/RandomComponents.ts +++ b/src/RandomComponents.ts @@ -1,7 +1,10 @@ /* eslint-disable complexity */ import { RegisterInteractionHandler } from '#main/decorators/Interaction.js'; +import HubSettingsManager from '#main/modules/HubSettingsManager.js'; +import { InfoEmbed } from '#main/utils/EmbedUtils.js'; import { addReaction, removeReaction, updateReactions } from '#main/utils/reaction/actions.js'; import { checkBlacklists } from '#main/utils/reaction/helpers.js'; +import sortReactions from '#main/utils/reaction/sortReactions.js'; import { stripIndents } from 'common-tags'; import { ActionRowBuilder, @@ -18,9 +21,7 @@ import { fetchConnection, updateConnection } from './utils/ConnectedListUtils.js import { CustomID } from './utils/CustomID.js'; import db from './utils/Db.js'; import { t } from './utils/Locale.js'; -import { getEmojiId, simpleEmbed, sortReactions } from './utils/Utils.js'; -import HubSettingsManager from '#main/modules/HubSettingsManager.js'; -import { InfoEmbed } from '#main/utils/EmbedUtils.js'; +import { getEmojiId } from './utils/Utils.js'; export class RandomComponents { /** Listens for a reaction button or select menu interaction and updates the reactions accordingly. */ @@ -98,7 +99,7 @@ export class RandomComponents { .setPlaceholder('Add a reaction'), ); - const { hub } = networkMessage?.originalMsg; + const { hub } = networkMessage.originalMsg; const hubSettings = new HubSettingsManager(hub.id, hub.settings); if (!hubSettings.getSetting('Reactions')) reactionMenu.components[0].setDisabled(true); @@ -214,13 +215,12 @@ export class RandomComponents { await updateConnection({ channelId }, { connected: true }); - await interaction.update({ - embeds: [ - simpleEmbed( - `### ${emojis.tick} Connection Resumed\nConnection has been resumed. Have fun chatting!`, - ), - ], - components: [], - }); + const embed = new InfoEmbed() + .removeTitle() + .setDescription( + `### ${emojis.tick} Connection Resumed\nConnection has been resumed. Have fun chatting!`, + ); + + await interaction.update({ embeds: [embed], components: [] }); } } diff --git a/src/commands/context-menu/blacklist.ts b/src/commands/context-menu/blacklist.ts deleted file mode 100644 index 321c86915..000000000 --- a/src/commands/context-menu/blacklist.ts +++ /dev/null @@ -1,422 +0,0 @@ -import BaseCommand from '#main/core/BaseCommand.js'; -import { RegisterInteractionHandler } from '#main/decorators/Interaction.js'; -import ServerBlacklisManager from '#main/modules/ServerBlacklistManager.js'; -import UserDbManager from '#main/modules/UserDbManager.js'; -import { deleteConnections } from '#main/utils/ConnectedListUtils.js'; -import Constants, { emojis } from '#main/config/Constants.js'; -import { CustomID } from '#main/utils/CustomID.js'; -import db from '#main/utils/Db.js'; -import { logBlacklist } from '#main/utils/HubLogger/ModLogs.js'; -import { t, type supportedLocaleCodes } from '#main/utils/Locale.js'; -import Logger from '#main/utils/Logger.js'; -import { broadcastedMessages, hubs, originalMessages } from '@prisma/client'; -import { stripIndents } from 'common-tags'; -import { - ActionRowBuilder, - ApplicationCommandType, - ButtonBuilder, - ButtonStyle, - EmbedBuilder, - Interaction, - ModalBuilder, - Snowflake, - TextInputBuilder, - TextInputStyle, - time, - type MessageComponentInteraction, - type MessageContextMenuCommandInteraction, - type ModalSubmitInteraction, - type RESTPostAPIApplicationCommandsJSONBody, -} from 'discord.js'; -import parse from 'parse-duration'; -import { isStaffOrHubMod } from '#main/utils/hub/utils.js'; - -type DbMessageT = originalMessages & { hub: hubs | null; broadcastMsgs: broadcastedMessages[] }; - -export default class Blacklist extends BaseCommand { - readonly data: RESTPostAPIApplicationCommandsJSONBody = { - type: ApplicationCommandType.Message, - name: 'Blacklist', - dm_permission: false, - }; - - async execute(interaction: MessageContextMenuCommandInteraction) { - await interaction.deferReply({ ephemeral: true }); - - const { userManager } = interaction.client; - const userData = await userManager.getUser(interaction.user.id); - const locale = await userManager.getUserLocale(userData); - - const originalMsg = await this.fetchMessageFromDb(interaction.targetId, { - hub: true, - broadcastMsgs: true, - }); - - if (!originalMsg?.hub || !isStaffOrHubMod(interaction.user.id, originalMsg.hub)) { - await this.replyEmbed( - interaction, - t({ phrase: 'errors.messageNotSentOrExpired', locale }, { emoji: emojis.info }), - { ephemeral: true, edit: true }, - ); - - return; - } - - if (originalMsg.authorId === interaction.user.id) { - await interaction.editReply( - ' Nuh uh! You can\'t blacklist yourself.', - ); - return; - } - - const user = await interaction.client.users.fetch(originalMsg.authorId); - const server = await interaction.client.fetchGuild(originalMsg.serverId); - - const isUserBlacklisted = await this.isBlacklisted( - originalMsg.authorId, - originalMsg.hub.id, - interaction.client.userManager, - ); - const isServerBlacklisted = await this.isBlacklisted( - originalMsg.serverId, - originalMsg.hub.id, - interaction.client.serverBlacklists, - ); - - const userEmbedDesc = t( - { - phrase: isUserBlacklisted - ? 'blacklist.embed.userAlreadyBlacklisted' - : 'blacklist.embed.userValue', - locale, - }, - { user: user.username }, - ); - const serverEmbedDesc = t( - { - phrase: isServerBlacklisted - ? 'blacklist.embed.serverAlreadyBlacklisted' - : 'blacklist.embed.serverValue', - locale, - }, - { server: `${server?.name}` }, - ); - - const embed = new EmbedBuilder() - .setColor(Constants.Colors.invisible) - .setFooter({ text: t({ phrase: 'blacklist.embed.footer', locale }) }) - .setDescription(stripIndents` - ### ${emojis.timeout_icon} Create A Blacklist - **${t({ phrase: 'blacklist.embed.user', locale }, { emoji: emojis.user_icon })}:** ${userEmbedDesc} - **${t({ phrase: 'blacklist.embed.server', locale }, { emoji: emojis.globe_icon })}:** ${serverEmbedDesc} - `); - - const buttons = this.buildButtons( - interaction, - originalMsg.messageId, - isUserBlacklisted, - isServerBlacklisted, - ); - - await interaction.editReply({ embeds: [embed], components: [buttons] }); - } - - @RegisterInteractionHandler('blacklist') - async handleButtons(interaction: MessageComponentInteraction): Promise { - const customId = CustomID.parseCustomId(interaction.customId); - if (customId.prefix !== 'blacklist') return; - const [userId, originalMsgId] = customId.args; - - const locale = await interaction.client.userManager.getUserLocale(interaction.user.id); - - if (interaction.user.id !== userId) { - await this.replyEmbed( - interaction, - t({ phrase: 'errors.notYourAction', locale }, { emoji: emojis.no }), - { ephemeral: true }, - ); - return; - } - - const modal = new ModalBuilder() - .setTitle('Blacklist') - .setCustomId( - new CustomID() - .setIdentifier('blacklist_modal', customId.suffix) - .addArgs(originalMsgId) - .toString(), - ) - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('reason') - .setLabel(t({ phrase: 'blacklist.modal.reason.label', locale })) - .setPlaceholder(t({ phrase: 'blacklist.modal.reason.placeholder', locale })) - .setStyle(TextInputStyle.Paragraph) - .setMaxLength(500), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('duration') - .setLabel(t({ phrase: 'blacklist.modal.duration.label', locale })) - .setPlaceholder(t({ phrase: 'blacklist.modal.duration.placeholder', locale })) - .setStyle(TextInputStyle.Short) - .setMinLength(2) - .setRequired(false), - ), - ); - - await interaction.showModal(modal); - } - - @RegisterInteractionHandler('blacklist_modal') - override async handleModals(interaction: ModalSubmitInteraction): Promise { - await interaction.deferUpdate(); - - const customId = CustomID.parseCustomId(interaction.customId); - const [messageId] = customId.args; - const originalMsg = await this.fetchMessageFromDb(messageId); - - const locale = await interaction.client.userManager.getUserLocale(interaction.user.id); - - if (!originalMsg?.hubId) { - await interaction.editReply( - t({ phrase: 'errors.unknownNetworkMessage', locale }, { emoji: emojis.no }), - ); - return; - } - - const blacklistType = customId.suffix as 'user' | 'server'; - const idToBlacklist = blacklistType === 'user' ? originalMsg.authorId : originalMsg.serverId; - const manager = - blacklistType === 'user' - ? interaction.client.userManager - : interaction.client.serverBlacklists; - - if (await this.isBlacklisted(idToBlacklist, originalMsg.hubId, manager)) { - await this.replyEmbed( - interaction, - t( - { phrase: `blacklist.${blacklistType}.alreadyBlacklisted`, locale }, - { emoji: emojis.no }, - ), - { ephemeral: true }, - ); - return; - } - - if (customId.suffix === 'user') { - await this.handleUserBlacklist(interaction, originalMsg, locale); - } - else { - await this.handleServerBlacklist(interaction, originalMsg, locale); - } - } - - private async handleUserBlacklist( - interaction: ModalSubmitInteraction, - originalMsg: DbMessageT, - locale: supportedLocaleCodes, - ) { - const user = await interaction.client.users.fetch(originalMsg.authorId).catch(() => null); - - if (!user) { - await this.replyEmbed( - interaction, - `${emojis.neutral} Unable to fetch user. They may have deleted their account?`, - { ephemeral: true }, - ); - return; - } - - if (!originalMsg.hubId) { - await this.replyEmbed( - interaction, - t({ phrase: 'hub.notFound_mod', locale }, { emoji: emojis.no }), - { ephemeral: true }, - ); - return; - } - - const { reason, expires } = this.getModalData(interaction); - const { userManager } = interaction.client; - const successEmbed = this.buildSuccessEmbed(reason, expires, locale).setDescription( - t( - { phrase: 'blacklist.user.success', locale }, - { username: user?.username ?? 'Unknown User', emoji: emojis.tick }, - ), - ); - - await userManager.addBlacklist({ id: user.id, name: user.username }, originalMsg.hubId, { - reason, - moderatorId: interaction.user.id, - expires, - }); - - if (user) { - userManager - .sendNotification({ target: user, hubId: originalMsg.hubId, expires, reason }) - .catch(() => null); - - await logBlacklist(originalMsg.hubId, interaction.client, { - target: user, - mod: interaction.user, - reason, - expires, - }); - } - - Logger.info( - `User ${user?.username} blacklisted by ${interaction.user.username} in ${originalMsg.hub?.name}`, - ); - - await interaction.editReply({ embeds: [successEmbed], components: [] }); - } - - private async handleServerBlacklist( - interaction: ModalSubmitInteraction, - originalMsg: originalMessages & { hub: hubs | null; broadcastMsgs: broadcastedMessages[] }, - locale: supportedLocaleCodes, - ) { - if (!originalMsg.hubId) { - await this.replyEmbed( - interaction, - t({ phrase: 'hub.notFound_mod', locale }, { emoji: emojis.no }), - { ephemeral: true }, - ); - return; - } - - const server = await interaction.client.fetchGuild(originalMsg.serverId); - if (!server) { - await this.replyEmbed( - interaction, - t({ phrase: 'errors.unknownServer', locale }, { emoji: emojis.no }), - { ephemeral: true }, - ); - return; - } - - const { serverBlacklists } = interaction.client; - const { reason, expires } = this.getModalData(interaction); - - await serverBlacklists.addBlacklist( - { name: server?.name ?? 'Unknown Server', id: originalMsg.serverId }, - originalMsg.hubId, - { - reason, - moderatorId: interaction.user.id, - expires, - }, - ); - - // Notify server of blacklist - await serverBlacklists.sendNotification({ - target: { id: originalMsg.serverId }, - hubId: originalMsg.hubId, - expires, - reason, - }); - - await deleteConnections({ serverId: originalMsg.serverId, hubId: originalMsg.hubId }); - - if (server) { - await logBlacklist(originalMsg.hubId, interaction.client, { - target: server.id, - mod: interaction.user, - reason, - expires, - }).catch(() => null); - } - - const successEmbed = this.buildSuccessEmbed(reason, expires, locale).setDescription( - t( - { phrase: 'blacklist.server.success', locale }, - { server: server?.name ?? 'Unknown Server', emoji: emojis.tick }, - ), - ); - await interaction.editReply({ embeds: [successEmbed], components: [] }); - } - - // utils - - private async isBlacklisted( - id: Snowflake, - hubId: string, - manager: UserDbManager | ServerBlacklisManager, - ) { - const isBlacklisted = await manager.fetchBlacklist(hubId, id); - return Boolean(isBlacklisted); - } - - private getModalData(interaction: ModalSubmitInteraction) { - const reason = interaction.fields.getTextInputValue('reason'); - const duration = parse(interaction.fields.getTextInputValue('duration')); - const expires = duration ? new Date(Date.now() + duration) : null; - - return { reason, expires }; - } - - private buildButtons( - interaction: Interaction, - messageId: Snowflake, - isUserBlacklisted: boolean, - isServerBlacklisted: boolean, - ) { - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId( - new CustomID('blacklist:user', [interaction.user.id, messageId]) - .setExpiry(new Date(Date.now() + 60_000)) - .toString(), - ) - .setStyle(ButtonStyle.Secondary) - .setEmoji(emojis.user_icon) - .setDisabled(isUserBlacklisted), - new ButtonBuilder() - .setCustomId( - new CustomID('blacklist:server', [interaction.user.id, messageId]) - .setExpiry(new Date(Date.now() + 60_000)) - .toString(), - ) - .setStyle(ButtonStyle.Secondary) - .setEmoji(emojis.globe_icon) - .setDisabled(isServerBlacklisted), - ); - } - - private buildSuccessEmbed(reason: string, expires: Date | null, locale: supportedLocaleCodes) { - return new EmbedBuilder().setColor('Green').addFields( - { - name: 'Reason', - value: reason ? reason : t({ phrase: 'misc.noReason', locale }), - inline: true, - }, - { - name: 'Expires', - value: expires ? `${time(Math.round(expires.getTime() / 1000), 'R')}` : 'Never.', - inline: true, - }, - ); - } - private async fetchMessageFromDb( - messageId: string, - include: { hub: boolean; broadcastMsgs: boolean } = { hub: false, broadcastMsgs: false }, - ) { - let messageInDb = await db.originalMessages.findFirst({ - where: { messageId }, - include, - }); - - if (!messageInDb) { - const broadcastedMsg = await db.broadcastedMessages.findFirst({ - where: { messageId }, - include: { originalMsg: { include } }, - }); - - messageInDb = broadcastedMsg?.originalMsg ?? null; - } - - return messageInDb; - } -} diff --git a/src/commands/context-menu/deleteMsg.ts b/src/commands/context-menu/deleteMsg.ts index 9995703dc..a624105a5 100644 --- a/src/commands/context-menu/deleteMsg.ts +++ b/src/commands/context-menu/deleteMsg.ts @@ -1,5 +1,4 @@ import BaseCommand from '#main/core/BaseCommand.js'; -import { getHubConnections } from '#main/utils/ConnectedListUtils.js'; import Constants, { emojis } from '#main/config/Constants.js'; import db from '#main/utils/Db.js'; import { logMsgDelete } from '#main/utils/HubLogger/ModLogs.js'; @@ -10,6 +9,10 @@ import { RESTPostAPIApplicationCommandsJSONBody, } from 'discord.js'; import { isStaffOrHubMod } from '#main/utils/hub/utils.js'; +import { deleteMessageFromHub, isDeleteInProgress } from '#main/utils/moderation/deleteMessage.js'; +import { originalMessages, hubs, broadcastedMessages } from '@prisma/client'; + +type OriginalMsgT = originalMessages & { hub: hubs; broadcastMsgs: broadcastedMessages[] }; export default class DeleteMessage extends BaseCommand { readonly data: RESTPostAPIApplicationCommandsJSONBody = { @@ -21,74 +24,49 @@ export default class DeleteMessage extends BaseCommand { readonly cooldown = 10_000; async execute(interaction: MessageContextMenuCommandInteraction): Promise { - const isOnCooldown = await this.checkOrSetCooldown(interaction); - if (isOnCooldown || !interaction.inCachedGuild()) return; + if ((await this.checkOrSetCooldown(interaction)) || !interaction.inCachedGuild()) return; await interaction.deferReply({ ephemeral: true }); - let originalMsg = await db.originalMessages.findFirst({ - where: { messageId: interaction.targetId }, + const originalMsg = await this.getOriginalMessage(interaction.targetId); + if (!(await this.validateMessage(interaction, originalMsg))) return; + + await this.processMessageDeletion(interaction, originalMsg as OriginalMsgT); + } + + private async getOriginalMessage(messageId: string) { + const originalMsg = await db.originalMessages.findFirst({ + where: { messageId }, include: { hub: true, broadcastMsgs: true }, }); - if (!originalMsg) { - const broadcastedMsg = await db.broadcastedMessages.findFirst({ - where: { messageId: interaction.targetId }, - include: { originalMsg: { include: { hub: true, broadcastMsgs: true } } }, - }); + if (originalMsg) return originalMsg; - originalMsg = broadcastedMsg?.originalMsg ?? null; - } + const broadcastedMsg = await db.broadcastedMessages.findFirst({ + where: { messageId }, + include: { originalMsg: { include: { hub: true, broadcastMsgs: true } } }, + }); - const { userManager } = interaction.client; - const locale = await userManager.getUserLocale(interaction.user.id); - if (!originalMsg?.hub) { - await interaction.editReply( - t({ phrase: 'errors.unknownNetworkMessage', locale }, { emoji: emojis.no }), - ); - return; - } + return broadcastedMsg?.originalMsg ?? null; + } + private async processMessageDeletion( + interaction: MessageContextMenuCommandInteraction, + originalMsg: OriginalMsgT, + ): Promise { const { hub } = originalMsg; - - if ( - interaction.user.id !== originalMsg.authorId && - !isStaffOrHubMod(interaction.user.id, hub) - ) { - await interaction.editReply( - t({ phrase: 'errors.notMessageAuthor', locale }, { emoji: emojis.no }), - ); - return; - } + const { userManager } = interaction.client; + const locale = await userManager.getUserLocale(interaction.user.id); await interaction.editReply( `${emojis.yes} Your request has been queued. Messages will be deleted shortly...`, ); - let passed = 0; - - const allConnections = await getHubConnections(hub.id); - - for await (const dbMsg of originalMsg.broadcastMsgs) { - const connection = allConnections?.find( - (c) => c.connected && c.channelId === dbMsg.channelId, - ); - - if (!connection) break; - - const webhookURL = connection.webhookURL.split('/'); - const webhook = await interaction.client - .fetchWebhook(webhookURL[webhookURL.length - 2]) - ?.catch(() => null); - - if (webhook?.owner?.id !== interaction.client.user?.id) break; - - // finally, delete the message - await webhook - ?.deleteMessage(dbMsg.messageId, connection.parentId ? connection.channelId : undefined) - .catch(() => null); - passed++; - } + const { deletedCount } = await deleteMessageFromHub( + hub.id, + originalMsg.messageId, + originalMsg.broadcastMsgs, + ); await interaction .editReply( @@ -97,29 +75,76 @@ export default class DeleteMessage extends BaseCommand { { emoji: emojis.yes, user: `<@${originalMsg.authorId}>`, - deleted: passed.toString(), - total: originalMsg.broadcastMsgs.length.toString(), + deleted: `${deletedCount}`, + total: `${originalMsg.broadcastMsgs.length}`, }, ), ) .catch(() => null); - const { targetMessage } = interaction; + await this.logDeletion(interaction, hub, originalMsg); + } + + private async validateMessage( + interaction: MessageContextMenuCommandInteraction, + originalMsg: + | (originalMessages & { hub: hubs | null; broadcastMsgs: broadcastedMessages[] }) + | null, + ): Promise { + const { userManager } = interaction.client; + const locale = await userManager.getUserLocale(interaction.user.id); + + if (!originalMsg?.hub) { + await interaction.editReply( + t({ phrase: 'errors.unknownNetworkMessage', locale }, { emoji: emojis.no }), + ); + return false; + } + if (await isDeleteInProgress(originalMsg.messageId)) { + await this.replyEmbed( + interaction, + `${emojis.neutral} This message is already deleted or is being deleted by another moderator.`, + { ephemeral: true, edit: true }, + ); + return false; + } + + if ( + interaction.user.id !== originalMsg.authorId && + !isStaffOrHubMod(interaction.user.id, originalMsg.hub) + ) { + await interaction.editReply( + t({ phrase: 'errors.notMessageAuthor', locale }, { emoji: emojis.no }), + ); + return false; + } + + return true; + } + + private async logDeletion( + interaction: MessageContextMenuCommandInteraction, + hub: hubs, + originalMsg: OriginalMsgT, + ): Promise { + if (!isStaffOrHubMod(interaction.user.id, hub)) return; + + const { targetMessage } = interaction; const messageContent = targetMessage.cleanContent ?? targetMessage.embeds.at(0)?.description?.replaceAll('`', '\\`'); + if (!messageContent) return; + const imageUrl = targetMessage.embeds.at(0)?.image?.url ?? - targetMessage.content.match(Constants.Regex.ImageURL)?.at(0); - - if (isStaffOrHubMod(interaction.user.id, hub) && messageContent) { - await logMsgDelete(interaction.client, messageContent, hub, { - userId: originalMsg.authorId, - serverId: originalMsg.serverId, - modName: interaction.user.username, - imageUrl, - }); - } + targetMessage.content.match(Constants.Regex.ImageURL)?.[0]; + + await logMsgDelete(interaction.client, messageContent, hub, { + userId: originalMsg.authorId, + serverId: originalMsg.serverId, + modName: interaction.user.username, + imageUrl, + }); } } diff --git a/src/commands/context-menu/messageInfo.ts b/src/commands/context-menu/messageInfo.ts index 14de608a2..77934eed7 100644 --- a/src/commands/context-menu/messageInfo.ts +++ b/src/commands/context-menu/messageInfo.ts @@ -6,10 +6,12 @@ import { getHubConnections } from '#main/utils/ConnectedListUtils.js'; import { CustomID } from '#main/utils/CustomID.js'; import db from '#main/utils/Db.js'; import { InfoEmbed } from '#main/utils/EmbedUtils.js'; +import { isStaffOrHubMod } from '#main/utils/hub/utils.js'; import { sendHubReport } from '#main/utils/HubLogger/Report.js'; import { supportedLocaleCodes, t } from '#main/utils/Locale.js'; +import modActionsPanel from '#main/utils/moderation/modActions/modActionsPanel.js'; import type { RemoveMethods } from '#types/index.d.ts'; -import { connectedList, hubs } from '@prisma/client'; +import { connectedList, hubs, originalMessages } from '@prisma/client'; import { ActionRow, ActionRowBuilder, @@ -42,10 +44,8 @@ type MsgInfo = { messageId: string }; type UserInfoOpts = LocaleInfo & AuthorInfo; type MsgInfoOpts = AuthorInfo & ServerInfo & LocaleInfo & HubInfo & MsgInfo; type ReportOpts = LocaleInfo & HubInfo & MsgInfo; -type ServerInfoOpts = LocaleInfo & - ServerInfo & { - guildConnected: connectedList | undefined; - }; +type ModActionsOpts = { originalMsg: originalMessages }; +type ServerInfoOpts = LocaleInfo & ServerInfo & { connection: connectedList | undefined }; export default class MessageInfo extends BaseCommand { readonly data: RESTPostAPIApplicationCommandsJSONBody = { @@ -84,21 +84,14 @@ export default class MessageInfo extends BaseCommand { .setThumbnail(author.displayAvatarURL()) .setColor(Constants.Colors.invisible); - const expiry = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes - const components = this.buildButtons(expiry, locale); - const guildConnected = (await getHubConnections(originalMsg.hub.id))?.find( + const connection = (await getHubConnections(originalMsg.hub.id))?.find( (c) => c.connected && c.serverId === originalMsg.serverId, ); - - if (guildConnected?.invite) { - components[1].addComponents( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setURL(`https://discord.gg/${guildConnected?.invite}`) - .setEmoji(emojis.join) - .setLabel('Join Server'), - ); - } + const expiry = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes + const components = this.buildButtons(expiry, locale, { + buildModActions: isStaffOrHubMod(interaction.user.id, originalMsg.hub), + inviteButtonUrl: connection?.invite, + }); const reply = await interaction.followUp({ embeds: [embed], @@ -129,7 +122,7 @@ export default class MessageInfo extends BaseCommand { // button responses switch (customId.suffix) { case 'serverInfo': - this.handleServerInfoButton(i, newComponents, { server, locale, guildConnected }); + this.handleServerInfoButton(i, newComponents, { server, locale, connection }); break; case 'userInfo': @@ -150,6 +143,10 @@ export default class MessageInfo extends BaseCommand { this.handleReportButton(i, { hub: originalMsg.hub, locale, messageId: target.id }); break; + case 'modActions': + this.handleModActionsButton(i, { originalMsg }); + break; + default: break; } @@ -204,7 +201,7 @@ export default class MessageInfo extends BaseCommand { private async handleServerInfoButton( interaction: ButtonInteraction, components: ActionRowBuilder[], - { server, locale, guildConnected }: ServerInfoOpts, + { server, locale, connection }: ServerInfoOpts, ) { if (!server) { await interaction.update({ @@ -217,6 +214,9 @@ export default class MessageInfo extends BaseCommand { const owner = await interaction.client.users.fetch(server.ownerId); const createdAt = Math.round(server.createdTimestamp / 1000); + const ownerName = `${owner.username}#${ + owner.discriminator !== '0' ? `#${owner.discriminator}` : '' + }`; const iconUrl = server.icon ? `https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png` @@ -224,29 +224,20 @@ export default class MessageInfo extends BaseCommand { const bannerUrL = server.icon ? `https://cdn.discordapp.com/icons/${server.id}/${server.banner}.png` : null; - const inviteString = guildConnected?.invite ? `${guildConnected.invite}` : 'Not Set.'; + const inviteString = connection?.invite ? `${connection.invite}` : 'Not Set.'; const serverEmbed = new EmbedBuilder() - .setColor(Constants.Colors.invisible) + .setDescription(`### ${emojis.info} ${server.name}`) + .addFields([ + { name: 'Owner', value: codeBlock(ownerName), inline: true }, + { name: 'Member Count', value: codeBlock(String(server.memberCount)), inline: true }, + { name: 'Server ID', value: codeBlock(server.id), inline: true }, + { name: 'Invite', value: inviteString, inline: true }, + { name: 'Created At', value: time(createdAt, 'R'), inline: true }, + ]) .setThumbnail(iconUrl) .setImage(bannerUrL) - .setDescription( - t( - { phrase: 'msgInfo.server.description', locale }, - { - server: server.name, - description: server.description || t({ phrase: 'misc.noDesc', locale }), - owner: `${owner.username}#${ - owner.discriminator !== '0' ? `#${owner.discriminator}` : '' - }`, - createdAt: time(createdAt, 'R'), - createdAtFull: time(createdAt, 'd'), - memberCount: `${server.memberCount}`, - invite: `${inviteString}`, - }, - ), - ) - .setFooter({ text: `ID: ${server.id}` }); + .setColor(Constants.Colors.invisible); // disable the server info button greyOutButton(components[0], 1); @@ -257,29 +248,28 @@ export default class MessageInfo extends BaseCommand { private async handleUserInfoButton( interaction: ButtonInteraction, components: ActionRowBuilder[], - { author, locale }: UserInfoOpts, + { author }: UserInfoOpts, ) { await interaction.deferUpdate(); const createdAt = Math.round(author.createdTimestamp / 1000); + const hubsOwned = await db.hubs.count({ where: { ownerId: author.id } }); + const displayName = author.globalName || 'Not Set.'; const userEmbed = new EmbedBuilder() + .setDescription(`### ${emojis.info} ${author.username}`) + .addFields([ + { name: 'Display Name', value: codeBlock(displayName), inline: true }, + { name: 'User ID', value: codeBlock(author.id), inline: true }, + { name: 'Hubs Owned', value: codeBlock(`${hubsOwned}`), inline: true }, + { + name: 'Created At', + value: `${time(createdAt, 'd')} (${time(createdAt, 'R')})`, + inline: true, + }, + ]) .setThumbnail(author.displayAvatarURL()) - .setColor('Random') .setImage(author.bannerURL() ?? null) - .setDescription( - t( - { phrase: 'msgInfo.user.description', locale }, - { - username: author.discriminator !== '0' ? author.tag : author.username, - id: author.id, - createdAt: time(createdAt, 'R'), - createdAtFull: time(createdAt, 'd'), - globalName: author.globalName || 'Not Set.', - hubsOwned: `${await db.hubs.count({ where: { ownerId: author.id } })}`, - }, - ), - ) - .setTimestamp(); + .setColor(Constants.Colors.invisible); // disable the user info button greyOutButton(components[0], 2); @@ -294,7 +284,7 @@ export default class MessageInfo extends BaseCommand { ) { const message = await interaction.channel?.messages.fetch(messageId).catch(() => null); - if (!message) { + if (!message || !hub) { await interaction.update({ content: t({ phrase: 'errors.unknownNetworkMessage', locale }, { emoji: emojis.no }), embeds: [], @@ -304,29 +294,30 @@ export default class MessageInfo extends BaseCommand { } const embed = new EmbedBuilder() - .setDescription( - t( - { phrase: 'msgInfo.message.description', locale }, - { - emoji: emojis.clipart, - author: author.discriminator !== '0' ? author.tag : author.username, - server: `${server?.name}`, - messageId: message.id, - hub: `${hub?.name}`, - createdAt: time(Math.floor(message.createdTimestamp / 1000), 'R'), - }, - ), - ) - .setThumbnail( - server?.icon ? `https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png` : null, - ) - .setColor('Random'); + .setDescription(`### ${emojis.info} Message Info`) + .addFields([ + { name: 'Sender', value: codeBlock(author.username), inline: true }, + { name: 'From Server', value: codeBlock(`${server?.name}`), inline: true }, + { name: 'Which Hub?', value: codeBlock(hub.name), inline: true }, + { name: 'Message ID', value: codeBlock(messageId), inline: true }, + { name: 'Sent At', value: time(message.createdAt, 't'), inline: true }, + ]) + .setThumbnail(author.displayAvatarURL()) + .setColor(Constants.Colors.invisible); greyOutButton(components[0], 0); await interaction.update({ embeds: [embed], components, files: [] }); } + private async handleModActionsButton( + interaction: ButtonInteraction, + { originalMsg }: ModActionsOpts, + ) { + const { buttons, embed } = await modActionsPanel.buildMessage(interaction, originalMsg); + await interaction.reply({ embeds: [embed], components: [buttons], ephemeral: true }); + } + private async handleReportButton( interaction: ButtonInteraction, { hub, locale, messageId }: ReportOpts, @@ -387,7 +378,41 @@ export default class MessageInfo extends BaseCommand { return { originalMsg, locale, messageId }; } - private buildButtons(expiry: Date, locale: supportedLocaleCodes = 'en') { + private buildButtons( + expiry: Date, + locale: supportedLocaleCodes = 'en', + opts?: { buildModActions?: boolean; inviteButtonUrl?: string | null }, + ) { + const extras = [ + new ButtonBuilder() + .setLabel(t({ phrase: 'msgInfo.buttons.report', locale })) + .setStyle(ButtonStyle.Danger) + .setCustomId( + new CustomID().setIdentifier('msgInfo', 'report').setExpiry(expiry).toString(), + ), + ]; + + if (opts?.buildModActions) { + extras.push( + new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setEmoji('🛠️') + .setLabel('Mod Actions') + .setCustomId( + new CustomID().setIdentifier('msgInfo', 'modActions').setExpiry(expiry).toString(), + ), + ); + } + if (opts?.inviteButtonUrl) { + extras.push( + new ButtonBuilder() + .setLabel('Join Server') + .setStyle(ButtonStyle.Link) + .setURL(opts.inviteButtonUrl) + .setDisabled(false), + ); + } + return [ new ActionRowBuilder().addComponents( new ButtonBuilder() @@ -395,7 +420,7 @@ export default class MessageInfo extends BaseCommand { .setStyle(ButtonStyle.Secondary) .setDisabled(true) .setCustomId( - new CustomID().setIdentifier('msgInfo', 'info').setExpiry(expiry).toString(), + new CustomID().setIdentifier('msgInfo', 'msgInfo').setExpiry(expiry).toString(), ), new ButtonBuilder() .setLabel(t({ phrase: 'msgInfo.buttons.server', locale })) @@ -410,14 +435,7 @@ export default class MessageInfo extends BaseCommand { new CustomID().setIdentifier('msgInfo', 'userInfo').setExpiry(expiry).toString(), ), ), - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setLabel(t({ phrase: 'msgInfo.buttons.report', locale })) - .setStyle(ButtonStyle.Danger) - .setCustomId( - new CustomID().setIdentifier('msgInfo', 'report').setExpiry(expiry).toString(), - ), - ), + new ActionRowBuilder({ components: extras }), ]; } } diff --git a/src/commands/context-menu/modActions.ts b/src/commands/context-menu/modActions.ts new file mode 100644 index 000000000..faf319650 --- /dev/null +++ b/src/commands/context-menu/modActions.ts @@ -0,0 +1,131 @@ +import { emojis } from '#main/config/Constants.js'; +import BaseCommand from '#main/core/BaseCommand.js'; +import { RegisterInteractionHandler } from '#main/decorators/Interaction.js'; +import { CustomID } from '#main/utils/CustomID.js'; +import { isStaffOrHubMod } from '#main/utils/hub/utils.js'; +import { t, type supportedLocaleCodes } from '#main/utils/Locale.js'; +import { + BlacklistServerHandler, + BlacklistUserHandler, +} from '#main/utils/moderation/modActions/handlers/blacklistHandler.js'; +import DeleteMessageHandler from '#main/utils/moderation/modActions/handlers/deleteMsgHandler.js'; +import RemoveReactionsHandler from '#main/utils/moderation/modActions/handlers/RemoveReactionsHandler.js'; +import UserBanHandler from '#main/utils/moderation/modActions/handlers/userBanHandler.js'; +import modActionsPanel from '#main/utils/moderation/modActions/modActionsPanel.js'; +import { + fetchMessageFromDb, + ModAction, + ModActionsDbMsgT, +} from '#main/utils/moderation/modActions/utils.js'; +import { + ApplicationCommandType, + ButtonInteraction, + RepliableInteraction, + type MessageContextMenuCommandInteraction, + type ModalSubmitInteraction, + type RESTPostAPIApplicationCommandsJSONBody, +} from 'discord.js'; + +export default class Blacklist extends BaseCommand { + readonly data: RESTPostAPIApplicationCommandsJSONBody = { + type: ApplicationCommandType.Message, + name: 'Moderation Actions', + dm_permission: false, + }; + + private modActionHandlers: Record; + + constructor() { + super(); + this.modActionHandlers = { + deleteMsg: new DeleteMessageHandler(), + banUser: new UserBanHandler(), + blacklistUser: new BlacklistUserHandler(), + blacklistServer: new BlacklistServerHandler(), + removeAllReactions: new RemoveReactionsHandler(), + }; + } + + async execute(interaction: MessageContextMenuCommandInteraction) { + await interaction.deferReply({ ephemeral: true }); + + const { userManager } = interaction.client; + const dbUser = await userManager.getUser(interaction.user.id); + const locale = await userManager.getUserLocale(dbUser); + + const originalMsg = await fetchMessageFromDb(interaction.targetId, { + hub: true, + broadcastMsgs: true, + }); + + if (!(await this.validateMessage(interaction, originalMsg, locale))) return; + + const { embed, buttons } = await modActionsPanel.buildMessage(interaction, originalMsg!); + await interaction.editReply({ embeds: [embed], components: [buttons] }); + } + + @RegisterInteractionHandler('modMessage') + async handleButtons(interaction: ButtonInteraction): Promise { + const customId = CustomID.parseCustomId(interaction.customId); + const [userId, originalMsgId] = customId.args; + const locale = await interaction.client.userManager.getUserLocale(interaction.user.id); + + if (!(await this.validateUser(interaction, userId, locale))) return; + + const handler = this.modActionHandlers[customId.suffix]; + if (handler) { + await handler.handle(interaction, originalMsgId, locale); + } + } + + @RegisterInteractionHandler('blacklist_modal') + async handleBlacklistModal(interaction: ModalSubmitInteraction): Promise { + await interaction.deferUpdate(); + + const customId = CustomID.parseCustomId(interaction.customId); + const [originalMsgId] = customId.args; + const originalMsg = await fetchMessageFromDb(originalMsgId, { hub: true }); + const locale = await interaction.client.userManager.getUserLocale(interaction.user.id); + + if (!(await this.validateMessage(interaction, originalMsg, locale))) return; + + const handlerId = customId.suffix === 'user' ? 'blacklistUser' : 'blacklistServer'; + const handler = this.modActionHandlers[handlerId]; + if (handler?.handleModal) { + await handler.handleModal(interaction, originalMsg!, locale); + } + } + + private async validateMessage( + interaction: RepliableInteraction, + originalMsg: ModActionsDbMsgT | null, + locale: supportedLocaleCodes, + ) { + if (!originalMsg?.hub || !isStaffOrHubMod(interaction.user.id, originalMsg.hub)) { + await this.replyEmbed( + interaction, + t({ phrase: 'errors.messageNotSentOrExpired', locale }, { emoji: emojis.info }), + { ephemeral: true, edit: true }, + ); + return false; + } + + return true; + } + + private async validateUser( + interaction: RepliableInteraction, + userId: string, + locale: supportedLocaleCodes, + ) { + if (interaction.user.id !== userId) { + await this.replyEmbed( + interaction, + t({ phrase: 'errors.notYourAction', locale }, { emoji: emojis.no }), + { ephemeral: true }, + ); + return false; + } + return true; + } +} diff --git a/src/commands/slash/Main/blacklist/server.ts b/src/commands/slash/Main/blacklist/server.ts index 9d27eb91d..e9e25e6e1 100644 --- a/src/commands/slash/Main/blacklist/server.ts +++ b/src/commands/slash/Main/blacklist/server.ts @@ -1,5 +1,5 @@ -import { deleteConnections } from '#main/utils/ConnectedListUtils.js'; import { emojis } from '#main/config/Constants.js'; +import { deleteConnections } from '#main/utils/ConnectedListUtils.js'; import { logBlacklist, logServerUnblacklist } from '#main/utils/HubLogger/ModLogs.js'; import { t } from '#main/utils/Locale.js'; import { type ChatInputCommandInteraction, type Snowflake } from 'discord.js'; @@ -48,10 +48,7 @@ export default class extends BlacklistCommand { await this.sendSuccessResponse( interaction, - t( - { phrase: 'blacklist.server.success', locale }, - { server: server.name, emoji: emojis.tick }, - ), + t({ phrase: 'blacklist.success', locale }, { name: server.name, emoji: emojis.tick }), { reason, expires }, ); @@ -80,8 +77,8 @@ export default class extends BlacklistCommand { await this.replyEmbed( interaction, t( - { phrase: 'blacklist.server.removed', locale }, - { emoji: emojis.delete, server: result.serverName }, + { phrase: 'blacklist.removed', locale }, + { emoji: emojis.delete, name: result.serverName }, ), ); diff --git a/src/commands/slash/Main/blacklist/user.ts b/src/commands/slash/Main/blacklist/user.ts index e8efcb383..ad8c77f41 100644 --- a/src/commands/slash/Main/blacklist/user.ts +++ b/src/commands/slash/Main/blacklist/user.ts @@ -34,10 +34,7 @@ export default class extends BlacklistCommand { await this.addUserBlacklist(interaction, user, { expires, hubId: hub.id, reason }); await this.sendSuccessResponse( interaction, - t( - { phrase: 'blacklist.user.success', locale }, - { username: user.username, emoji: emojis.tick }, - ), + t({ phrase: 'blacklist.success', locale }, { name: user.username, emoji: emojis.tick }), { reason, expires }, ); @@ -64,8 +61,8 @@ export default class extends BlacklistCommand { await interaction.followUp( t( - { phrase: 'blacklist.user.removed', locale }, - { emoji: emojis.delete, username: `${result.username}` }, + { phrase: 'blacklist.removed', locale }, + { emoji: emojis.delete, name: `${result.username}` }, ), ); diff --git a/src/commands/slash/Main/hub/join.ts b/src/commands/slash/Main/hub/join.ts index ac285d6a6..0184c0d25 100644 --- a/src/commands/slash/Main/hub/join.ts +++ b/src/commands/slash/Main/hub/join.ts @@ -11,10 +11,7 @@ import { ChannelType, ChatInputCommandInteraction, GuildTextBasedChannel, - NewsChannel, Snowflake, - TextChannel, - ThreadChannel, } from 'discord.js'; import Hub from './index.js'; import { sendToHub } from '#main/utils/hub/utils.js'; @@ -224,7 +221,7 @@ export default class JoinSubCommand extends Hub { private async createWebhook( interaction: ChatInputCommandInteraction, - channel: NewsChannel | TextChannel | ThreadChannel, + channel: GuildTextBasedChannel, locale: supportedLocaleCodes, ) { const webhook = await getOrCreateWebhook(channel); diff --git a/src/commands/slash/Main/hub/joined.ts b/src/commands/slash/Main/hub/joined.ts index 914942433..1607db14e 100644 --- a/src/commands/slash/Main/hub/joined.ts +++ b/src/commands/slash/Main/hub/joined.ts @@ -3,7 +3,8 @@ import Constants, { emojis } from '#main/config/Constants.js'; import db from '#main/utils/Db.js'; import { t } from '#main/utils/Locale.js'; import { simpleEmbed } from '#main/utils/Utils.js'; -import { ChatInputCommandInteraction, EmbedBuilder } from 'discord.js'; +import { connectedList, hubs } from '@prisma/client'; +import { ChatInputCommandInteraction, EmbedBuilder, EmbedField } from 'discord.js'; import Hub from './index.js'; export default class Joined extends Hub { @@ -24,40 +25,51 @@ export default class Joined extends Hub { return; } - const allFields = connections.map((con) => ({ - name: `${con.hub?.name}`, - value: `<#${con.channelId}>`, - inline: true, - })); - const description = t( { phrase: 'hub.joined.joinedHubs', locale }, - { total: `${allFields.length}` }, + { total: `${connections.length}` }, ); - if (allFields.length < 25) { - const embed = new EmbedBuilder() - .setFields(allFields) - .setColor(Constants.Colors.interchatBlue) - .setDescription(description); - + if (connections.length <= 25) { + const embed = this.getEmbed(connections.map(this.getField), description); await interaction.reply({ embeds: [embed] }); + return; } - // Split the fields into multiple embeds - const paginator = new Pagination(); - allFields.forEach((field, index) => { - // Start a new embed - if (index % 25 === 0) { - const embed = new EmbedBuilder() - .addFields(field) - .setColor(Constants.Colors.interchatBlue) - .setDescription(description); - - paginator.addPage({ embeds: [embed] }); - } + const pages = this.createPaginatedEmbeds(connections, description); + + new Pagination().addPages(pages).run(interaction); + } + + private createPaginatedEmbeds( + connections: (connectedList & { hub: hubs | null })[], + description: string, + fieldsPerPage = 25, + ) { + const totalPages = Math.ceil(connections.length / fieldsPerPage); + + const pages = Array.from({ length: totalPages }, (_, pageIndex) => { + const startIndex = pageIndex * fieldsPerPage; + const fields = connections.slice(startIndex, startIndex + fieldsPerPage).map(this.getField); + + return { embeds: [this.getEmbed(fields, description)] }; }); - await paginator.run(interaction); + return pages; + } + + private getField(connection: connectedList & { hub: hubs | null }) { + return { + name: `${connection.hub?.name}`, + value: `<#${connection.channelId}>`, + inline: true, + }; + } + + private getEmbed(fields: EmbedField[], description: string) { + return new EmbedBuilder() + .setColor(Constants.Colors.interchatBlue) + .setDescription(description) + .addFields(fields); } } diff --git a/src/commands/slash/Main/hub/manage.ts b/src/commands/slash/Main/hub/manage.ts index 3a64fa6a3..a34211cdc 100644 --- a/src/commands/slash/Main/hub/manage.ts +++ b/src/commands/slash/Main/hub/manage.ts @@ -175,7 +175,7 @@ export default class Manage extends Hub { } private async fetchHubFromDb(userId: string, hubName: string) { - return db.hubs.findFirst({ + return await db.hubs.findFirst({ where: { name: hubName, OR: [{ ownerId: userId }, { moderators: { some: { userId, position: 'manager' } } }], diff --git a/src/commands/slash/Main/hub/moderator.ts b/src/commands/slash/Main/hub/moderator.ts index 9f3552bb3..0c2d25f31 100644 --- a/src/commands/slash/Main/hub/moderator.ts +++ b/src/commands/slash/Main/hub/moderator.ts @@ -1,7 +1,7 @@ import { emojis } from '#main/config/Constants.js'; import db from '#main/utils/Db.js'; import { supportedLocaleCodes, t } from '#main/utils/Locale.js'; -import { hubs } from '@prisma/client'; +import { HubModeratorPosition, hubs } from '@prisma/client'; import { ChatInputCommandInteraction, EmbedBuilder } from 'discord.js'; import Hub from './index.js'; @@ -65,14 +65,15 @@ export default class Moderator extends Hub { } const mod = hub.moderators.find((m) => m.userId === user.id); - const userIsManager = mod?.position === 'manager'; + + const isRestrictedAction = mod?.position === 'manager' || user.id === interaction.user.id; const isExecutorOwner = hub.ownerId === interaction.user.id; /* executor needs to be owner to: - change position of other managers - change their own position */ - if (!isExecutorOwner && (userIsManager || user.id === interaction.user.id)) { + if (!isExecutorOwner && isRestrictedAction) { await this.replyEmbed( interaction, t({ phrase: 'hub.moderator.remove.notOwner', locale }, { emoji: emojis.no }), @@ -103,7 +104,7 @@ export default class Moderator extends Hub { locale: supportedLocaleCodes, ) { const user = interaction.options.getUser('user', true); - const position = interaction.options.getString('position', true); + const position = interaction.options.getString('position', true) as HubModeratorPosition; const isUserMod = hub.moderators.find((mod) => mod.userId === user.id); const isExecutorMod = hub.moderators.find( (mod) => @@ -202,7 +203,8 @@ export default class Moderator extends Hub { return; } - const position = interaction.options.getString('position') ?? 'network_mod'; + const position = (interaction.options.getString('position') ?? + 'network_mod') as HubModeratorPosition; await db.hubs.update({ where: { id: hub.id }, data: { moderators: { push: { userId: user.id, position } } }, diff --git a/src/commands/slash/Staff/ban.ts b/src/commands/slash/Staff/ban.ts index a02019e64..54c3c0a60 100644 --- a/src/commands/slash/Staff/ban.ts +++ b/src/commands/slash/Staff/ban.ts @@ -1,7 +1,5 @@ import BaseCommand from '#main/core/BaseCommand.js'; -import { emojis } from '#main/config/Constants.js'; -import db from '#main/utils/Db.js'; -import Logger from '#main/utils/Logger.js'; +import handleBan from '#main/utils/banUtls/handleBan.js'; import { type ChatInputCommandInteraction, type RESTPostAPIChatInputApplicationCommandsJSONBody, @@ -31,40 +29,6 @@ export default class Ban extends BaseCommand { override async execute(interaction: ChatInputCommandInteraction) { const user = interaction.options.getUser('user', true); const reason = interaction.options.getString('reason', true); - - if (user.id === interaction.user.id) { - await this.replyEmbed(interaction, `Let's not go there. ${emojis.bruhcat}`, { - ephemeral: true, - }); - return; - } - - const dbUser = await interaction.client.userManager.getUser(user.id); - if (dbUser?.banMeta) { - await this.replyEmbed( - interaction, - `${emojis.slash} User **${user.username}** is already banned.`, - ); - return; - } - - await db.userData.upsert({ - where: { id: user.id }, - create: { - id: user.id, - username: user.username, - viewedNetworkWelcome: false, - voteCount: 0, - banMeta: { reason }, - }, - update: { banMeta: { reason } }, - }); - - Logger.info(`User ${user.username} (${user.id}) banned by ${interaction.user.username}.`); - - await this.replyEmbed( - interaction, - `${emojis.tick} Successfully banned \`${user.username}\`. They can no longer use the bot.`, - ); + await handleBan(interaction, user.id, user, reason); } } diff --git a/src/commands/slash/Staff/unban.ts b/src/commands/slash/Staff/unban.ts index 31c9a6705..6b7cf7c90 100644 --- a/src/commands/slash/Staff/unban.ts +++ b/src/commands/slash/Staff/unban.ts @@ -1,6 +1,5 @@ -import BaseCommand, { type CmdData } from '#main/core/BaseCommand.js'; import { emojis } from '#main/config/Constants.js'; -import db from '#main/utils/Db.js'; +import BaseCommand, { type CmdData } from '#main/core/BaseCommand.js'; import { simpleEmbed } from '#main/utils/Utils.js'; import { type ChatInputCommandInteraction, ApplicationCommandOptionType } from 'discord.js'; @@ -20,7 +19,9 @@ export default class Unban extends BaseCommand { }; override async execute(interaction: ChatInputCommandInteraction): Promise { const user = interaction.options.getUser('user', true); - const alreadyBanned = await interaction.client.userManager.getUser(user.id); + + const { userManager } = interaction.client; + const alreadyBanned = await userManager.getUser(user.id); if (!alreadyBanned?.banMeta?.reason) { await interaction.reply({ @@ -29,17 +30,7 @@ export default class Unban extends BaseCommand { return; } - await db.userData.upsert({ - where: { id: user.id }, - create: { - id: user.id, - username: user.username, - viewedNetworkWelcome: false, - voteCount: 0, - banMeta: { set: null }, - }, - update: { banMeta: { set: null } }, - }); + await userManager.unban(user.id, user.username); await interaction.reply({ embeds: [ diff --git a/src/config/Constants.ts b/src/config/Constants.ts index c5687dd21..fae1d10e2 100644 --- a/src/config/Constants.ts +++ b/src/config/Constants.ts @@ -22,6 +22,7 @@ export const enum RedisKeys { blacklistedServers = 'blacklistedServers', channelQueue = 'channelQueue', commandUsesLeft = 'commandUsesLeft', + msgDeleteInProgress = 'msgDeleteInProgress', } export const enum ConnectionMode { diff --git a/src/events/ready.ts b/src/events/ready.ts index f64bd3bec..a6ad7192f 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -5,6 +5,6 @@ import { Client } from 'discord.js'; export default class Ready extends BaseEventListener<'ready'> { readonly name = 'ready'; public execute(client: Client) { - Logger.info(`Logged in as ${client.user?.tag}!`); + Logger.info(`Logged in as ${client.user.tag}!`); } } diff --git a/src/modules/UserDbManager.ts b/src/modules/UserDbManager.ts index cd678d4d9..68ea33f79 100644 --- a/src/modules/UserDbManager.ts +++ b/src/modules/UserDbManager.ts @@ -144,4 +144,32 @@ export default class UserDbManager extends BaseBlacklistManager { await opts.target.send({ embeds: [embed] }).catch(() => null); } + + async ban(id: string, reason: string, username?: string) { + return await db.userData.upsert({ + where: { id }, + create: { + id, + username, + viewedNetworkWelcome: false, + voteCount: 0, + banMeta: { reason }, + }, + update: { banMeta: { reason }, username }, + }); + } + + async unban(id: string, username?: string) { + return await db.userData.upsert({ + where: { id }, + create: { + id, + username, + viewedNetworkWelcome: false, + voteCount: 0, + banMeta: { set: null }, + }, + update: { banMeta: { set: null }, username }, + }); + } } diff --git a/src/tasks/pauseIdleConnections.ts b/src/tasks/pauseIdleConnections.ts index 2c1b9347d..469bcce47 100644 --- a/src/tasks/pauseIdleConnections.ts +++ b/src/tasks/pauseIdleConnections.ts @@ -27,7 +27,7 @@ export default async (manager: ClusterManager) => { // Loop through the data connections.forEach(async ({ channelId, lastActive }) => { Logger.info( - `[InterChat]: Channel ${channelId} is older than 24 hours: ${lastActive?.toLocaleString()} - ${new Date().toLocaleString()}`, + `[InterChat]: Pausing inactive connection ${channelId} due to inactivity since ${lastActive?.toLocaleString()} - ${new Date().toLocaleString()}`, ); // Create the button @@ -46,7 +46,9 @@ export default async (manager: ClusterManager) => { .setDescription( stripIndents` ### ${emojis.timeout} Paused Due to Inactivity - Connection to this hub has been stopped to save resources because no messages were sent for past day. **Click the button** below to resume chatting (or alternatively, \`/connection\`). + Connection to this hub has been stopped to save resources because no messages were sent to this channel in the past day. + + -# **Click the button** below or use \`/connection unpause\` anytime to resume chatting. `, ) .toJSON(); diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 5d56df493..0d440625e 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,16 +1,25 @@ -import Scheduler from '#main/modules/SchedulerService.js'; import BaseCommand from '#main/core/BaseCommand.js'; -import UserDbManager from '#main/modules/UserDbManager.js'; -import ServerBlacklistManager from '#main/modules/ServerBlacklistManager.js'; -import { ClusterClient } from 'discord-hybrid-sharding'; import { InteractionFunction } from '#main/decorators/Interaction.ts'; -import { Collection, Snowflake, Channel } from 'discord.js'; import CooldownService from '#main/modules/CooldownService.js'; +import Scheduler from '#main/modules/SchedulerService.js'; +import ServerBlacklistManager from '#main/modules/ServerBlacklistManager.js'; +import UserDbManager from '#main/modules/UserDbManager.js'; +import { ClusterClient } from 'discord-hybrid-sharding'; +import { + Collection, + ForumChannel, + MediaChannel, + NewsChannel, + Snowflake, + TextChannel, +} from 'discord.js'; -type RemoveMethods = { +export type RemoveMethods = { [K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? never : RemoveMethods; }; +export type ThreadParentChannel = NewsChannel | TextChannel | ForumChannel | MediaChannel; + declare module 'discord.js' { export interface Client { readonly version: string; diff --git a/src/types/locale.d.ts b/src/types/locale.d.ts index 97e6fc7c4..a2e7637cd 100644 --- a/src/types/locale.d.ts +++ b/src/types/locale.d.ts @@ -11,30 +11,18 @@ export type TranslationKeys = { 'network.deleteSuccess': 'emoji' | 'user' | 'deleted' | 'total'; 'network.editSuccess': 'emoji' | 'user' | 'edited' | 'total'; 'network.welcome': 'emoji' | 'user' | 'hub' | 'totalServers' | 'channel' | 'rules_command'; - 'network.nsfw.title': never; - 'network.nsfw.description': 'rules_command'; - 'network.nsfw.footer': never; 'network.onboarding.embed.title': 'hubName'; 'network.onboarding.embed.description': 'docs_link'; 'network.onboarding.embed.footer': 'version'; 'network.onboarding.inProgress': 'emoji' | 'channel'; - 'blacklist.embed.user': 'emoji'; - 'blacklist.embed.userValue': 'user'; - 'blacklist.embed.userAlreadyBlacklisted': 'user'; - 'blacklist.embed.server': 'emoji'; - 'blacklist.embed.serverValue': 'server'; - 'blacklist.embed.serverAlreadyBlacklisted': 'server'; - 'blacklist.embed.footer': never; + 'blacklist.success': 'emoji' | 'name'; + 'blacklist.removed': 'emoji' | 'name'; 'blacklist.modal.reason.label': never; 'blacklist.modal.reason.placeholder': never; 'blacklist.modal.duration.label': never; 'blacklist.modal.duration.placeholder': never; - 'blacklist.user.success': 'emoji' | 'username'; - 'blacklist.user.removed': 'emoji' | 'username'; 'blacklist.user.alreadyBlacklisted': 'emoji'; 'blacklist.user.easterEggs.blacklistBot': never; - 'blacklist.server.success': 'emoji' | 'server'; - 'blacklist.server.removed': 'emoji' | 'server'; 'blacklist.server.alreadyBlacklisted': 'emoji'; 'blacklist.server.unknownError': 'server'; 'blacklist.list.user': 'id' | 'moderator' | 'reason' | 'expires'; diff --git a/src/types/network.d.ts b/src/types/network.d.ts new file mode 100644 index 000000000..6c82f649c --- /dev/null +++ b/src/types/network.d.ts @@ -0,0 +1 @@ +export type ReactionArray = { [key: string]: Snowflake[] }; diff --git a/src/utils/ConnectedListUtils.ts b/src/utils/ConnectedListUtils.ts index 6ab0c7060..c25ba44ac 100644 --- a/src/utils/ConnectedListUtils.ts +++ b/src/utils/ConnectedListUtils.ts @@ -95,7 +95,7 @@ export const fetchConnection = async (channelId: string) => { if (!connection) return null; cacheConnectionHubId(connection); - if (connection.connected) syncHubConnCache(connection, 'modify'); + syncHubConnCache(connection, 'modify'); return connection; }; @@ -148,7 +148,7 @@ export const updateConnection = async (where: whereUniuqeInput, data: dataInput) // Update cache await cacheConnectionHubId(connection); - await syncHubConnCache(connection, connection.connected ? 'modify' : 'delete'); + await syncHubConnCache(connection, 'modify'); return connection; }; @@ -160,7 +160,7 @@ export const updateConnections = async (where: whereInput, data: dataInput) => { db.connectedList.findMany({ where }).then((connections) => { connections.forEach(async (connection) => { await cacheConnectionHubId(connection); - await syncHubConnCache(connection, connection.connected ? 'modify' : 'delete'); + await syncHubConnCache(connection, 'modify'); }); }); diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 3dfa59e07..d8c3c114e 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -1,26 +1,22 @@ import Constants from '#main/config/Constants.js'; -import type { RemoveMethods } from '#types/index.d.ts'; import { CustomID } from '#main/utils/CustomID.js'; import db from '#main/utils/Db.js'; import { ErrorEmbed } from '#main/utils/EmbedUtils.js'; import Logger from '#main/utils/Logger.js'; +import type { RemoveMethods, ThreadParentChannel } from '#types/index.d.ts'; import { captureException } from '@sentry/node'; import type { ClusterManager } from 'discord-hybrid-sharding'; import { - ChannelType, - ColorResolvable, EmbedBuilder, - NewsChannel, + type ColorResolvable, type CommandInteraction, - type ForumChannel, + type GuildTextBasedChannel, type Interaction, - type MediaChannel, type Message, type MessageComponentInteraction, type RepliableInteraction, type Snowflake, - type TextChannel, - type ThreadChannel, + type VoiceBasedChannel, } from 'discord.js'; import startCase from 'lodash/startCase.js'; import toLower from 'lodash/toLower.js'; @@ -49,21 +45,6 @@ export const msToReadable = (milliseconds: number) => { export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -/** - * Sort the array based on the reaction counts. - * - * **Before:** - * ```ts - * { '👍': ['10201930193'], '👎': ['10201930193', '10201930194'] } - * ``` - * **After:** - * ```ts - * [ [ '👎', ['10201930193', '10201930194'] ], [ '👍', ['10201930193'] ] ] - * ``` - * */ -export const sortReactions = (reactions: { [key: string]: string[] }): [string, string[]][] => - Object.entries(reactions).sort((a, b) => b[1].length - a[1].length); - export const yesOrNoEmoji = (option: unknown, yesEmoji: string, noEmoji: string) => option ? yesEmoji : noEmoji; @@ -74,10 +55,12 @@ export const disableComponents = (message: Message) => return jsonRow; }); -const createWebhook = async ( - channel: NewsChannel | TextChannel | ForumChannel | MediaChannel, - avatar: string, -) => +const findExistingWebhook = async (channel: ThreadParentChannel | VoiceBasedChannel) => { + const webhooks = await channel?.fetchWebhooks().catch(() => null); + return webhooks?.find((w) => w.owner?.id === channel.client.user?.id); +}; + +const createWebhook = async (channel: ThreadParentChannel | VoiceBasedChannel, avatar: string) => await channel ?.createWebhook({ name: 'InterChat Network', @@ -85,22 +68,11 @@ const createWebhook = async ( }) .catch(() => undefined); -const findExistingWebhook = async ( - channel: NewsChannel | TextChannel | ForumChannel | MediaChannel, -) => { - const webhooks = await channel?.fetchWebhooks().catch(() => null); - return webhooks?.find((w) => w.owner?.id === channel.client.user?.id); -}; - export const getOrCreateWebhook = async ( - channel: NewsChannel | TextChannel | ThreadChannel, + channel: GuildTextBasedChannel, avatar = Constants.Links.EasterAvatar, ) => { - const channelOrParent = - channel.type === ChannelType.GuildText || channel.type === ChannelType.GuildAnnouncement - ? channel - : channel.parent; - + const channelOrParent = channel.isThread() ? channel.parent : channel; if (!channelOrParent) return null; const existingWebhook = await findExistingWebhook(channelOrParent); diff --git a/src/utils/banUtls/handleBan.ts b/src/utils/banUtls/handleBan.ts new file mode 100644 index 000000000..c41cb751c --- /dev/null +++ b/src/utils/banUtls/handleBan.ts @@ -0,0 +1,35 @@ +import { emojis } from '#main/config/Constants.js'; +import Logger from '#main/utils/Logger.js'; +import type { RepliableInteraction, User } from 'discord.js'; + +export default async ( + interaction: RepliableInteraction, + targetId: string, + target: User | null, + reason: string, +) => { + if (targetId === interaction.user.id) { + await interaction.reply({ content: `Let's not go there. ${emojis.bruhcat}`, ephemeral: true }); + return; + } + + const { userManager } = interaction.client; + const dbUser = await userManager.getUser(targetId); + + if (dbUser?.banMeta) { + await interaction.reply({ + content: `${emojis.slash} This user is already banned.`, + ephemeral: true, + }); + return; + } + + const targetUsername = target?.username; + await userManager.ban(targetId, reason, targetUsername); + + Logger.info(`User ${targetUsername} (${targetId}) banned by ${interaction.user.username}.`); + + await interaction.reply( + `${emojis.tick} Successfully banned \`${targetUsername}\`. They can no longer use the bot.`, + ); +}; diff --git a/src/utils/cache/cacheUtils.ts b/src/utils/cache/cacheUtils.ts index 4586d3ffe..96af625be 100644 --- a/src/utils/cache/cacheUtils.ts +++ b/src/utils/cache/cacheUtils.ts @@ -80,7 +80,7 @@ export const getAllDocuments = async (match: string) => { export const getCachedData = async ( key: `${RedisKeys}:${string}`, - fetchFunction: (() => Awaitable) | null, + fetchFunction?: (() => Awaitable) | null, expiry?: number, ): Promise<{ data: ConvertDatesToString | null; fromCache: boolean }> => { // Check cache first diff --git a/src/utils/moderation/blacklistUtils.ts b/src/utils/moderation/blacklistUtils.ts new file mode 100644 index 000000000..c20a251af --- /dev/null +++ b/src/utils/moderation/blacklistUtils.ts @@ -0,0 +1,14 @@ +import ServerBlacklisManager from '#main/modules/ServerBlacklistManager.js'; +import UserDbManager from '#main/modules/UserDbManager.js'; +import { blacklistedServers, userData } from '@prisma/client'; +import { Snowflake } from 'discord.js'; + +export const isBlacklisted = async ( + userOrServer: Snowflake | userData | blacklistedServers, + hubId: string, + manager: UserDbManager | ServerBlacklisManager, +) => { + const blacklist = + typeof userOrServer === 'string' ? await manager.fetchBlacklist(hubId, userOrServer) : userOrServer; + return Boolean(blacklist?.blacklistedFrom.some((b) => b.hubId === hubId)); +}; diff --git a/src/utils/moderation/deleteMessage.ts b/src/utils/moderation/deleteMessage.ts new file mode 100644 index 000000000..819fa9b20 --- /dev/null +++ b/src/utils/moderation/deleteMessage.ts @@ -0,0 +1,42 @@ +import { RedisKeys } from '#main/config/Constants.js'; +import cacheClient from '#main/utils/cache/cacheClient.js'; +import { cacheData, getCachedData } from '#main/utils/cache/cacheUtils.js'; +import { getHubConnections } from '#main/utils/ConnectedListUtils.js'; +import { broadcastedMessages } from '@prisma/client'; +import { Snowflake, WebhookClient } from 'discord.js'; + +export const setDeleteLock = async (messageId: string) => { + const key = `${RedisKeys.msgDeleteInProgress}:${messageId}` as const; + const alreadyLocked = await getCachedData(key); + if (!alreadyLocked.data) await cacheData(key, 't', 900); // 15 mins +}; + +export const deleteMessageFromHub = async ( + hubId: string, + originalMsgId: string, + dbMessagesToDelete: broadcastedMessages[], +) => { + await setDeleteLock(originalMsgId); + + let deletedCount = 0; + const hubConnections = await getHubConnections(hubId); + const hubConnectionsMap = new Map(hubConnections?.map((c) => [c.channelId, c])); + + for await (const dbMsg of dbMessagesToDelete) { + const connection = hubConnectionsMap.get(dbMsg.channelId); + if (!connection) continue; + + const webhook = new WebhookClient({ url: connection.webhookURL }); + const threadId = connection.parentId ? connection.channelId : undefined; + await webhook.deleteMessage(dbMsg.messageId, threadId).catch(() => null); + deletedCount++; + } + + await cacheClient.del(`${RedisKeys.msgDeleteInProgress}:${originalMsgId}`); + return { deletedCount }; +}; + +export const isDeleteInProgress = async (originalMsgId: Snowflake) => { + const res = await getCachedData(`${RedisKeys.msgDeleteInProgress}:${originalMsgId}`); + return res.data === 't'; +}; diff --git a/src/utils/moderation/modActions/handlers/RemoveReactionsHandler.ts b/src/utils/moderation/modActions/handlers/RemoveReactionsHandler.ts new file mode 100644 index 000000000..d269a94f0 --- /dev/null +++ b/src/utils/moderation/modActions/handlers/RemoveReactionsHandler.ts @@ -0,0 +1,34 @@ +import { emojis } from '#main/config/Constants.js'; +import { ReactionArray } from '#main/types/network.js'; +import { fetchMessageFromDb, ModAction } from '#main/utils/moderation/modActions/utils.js'; +import { updateReactions } from '#main/utils/reaction/actions.js'; +import sortReactions from '#main/utils/reaction/sortReactions.js'; +import { ButtonInteraction, Snowflake } from 'discord.js'; + +export default class RemoveReactionsHandler implements ModAction { + async handle(interaction: ButtonInteraction, originalMsgId: Snowflake): Promise { + await interaction.deferReply({ ephemeral: true }); + + const originalMsg = await fetchMessageFromDb(originalMsgId, { + broadcastMsgs: true, + }); + + if ( + !originalMsg?.broadcastMsgs?.length || + !sortReactions((originalMsg.reactions as ReactionArray) ?? {}).length + ) { + await interaction.followUp({ + content: `${emojis.slash} No reactions to remove.`, + ephemeral: true, + }); + return; + } + + await updateReactions(originalMsg?.broadcastMsgs, {}); + + await interaction.followUp({ + content: `${emojis.yes} Reactions removed.`, + ephemeral: true, + }); + } +} diff --git a/src/utils/moderation/modActions/handlers/blacklistHandler.ts b/src/utils/moderation/modActions/handlers/blacklistHandler.ts new file mode 100644 index 000000000..d4c403b18 --- /dev/null +++ b/src/utils/moderation/modActions/handlers/blacklistHandler.ts @@ -0,0 +1,243 @@ +import { emojis } from '#main/config/Constants.js'; +import { deleteConnections } from '#main/utils/ConnectedListUtils.js'; +import { CustomID } from '#main/utils/CustomID.js'; +import { logBlacklist } from '#main/utils/HubLogger/ModLogs.js'; +import { supportedLocaleCodes, t } from '#main/utils/Locale.js'; +import Logger from '#main/utils/Logger.js'; +import modActionsPanel from '#main/utils/moderation/modActions/modActionsPanel.js'; +import { ModAction, ModActionsDbMsgT } from '#main/utils/moderation/modActions/utils.js'; +import { + ActionRowBuilder, + ButtonInteraction, + EmbedBuilder, + ModalBuilder, + ModalSubmitInteraction, + Snowflake, + TextInputBuilder, + TextInputStyle, + time, +} from 'discord.js'; +import parse from 'parse-duration'; + +abstract class BaseBlacklistHandler implements ModAction { + abstract handle( + interaction: ButtonInteraction, + originalMsgId: Snowflake, + locale: supportedLocaleCodes, + ): Promise; + + abstract handleModal( + interaction: ModalSubmitInteraction, + originalMsg: ModActionsDbMsgT, + locale: supportedLocaleCodes, + ): Promise; + + buildModal( + title: string, + type: 'user' | 'server', + originalMsgId: Snowflake, + locale: supportedLocaleCodes, + ) { + return new ModalBuilder() + .setTitle(title) + .setCustomId( + new CustomID().setIdentifier('blacklist_modal', type).addArgs(originalMsgId).toString(), + ) + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('reason') + .setLabel(t({ phrase: 'blacklist.modal.reason.label', locale })) + .setPlaceholder(t({ phrase: 'blacklist.modal.reason.placeholder', locale })) + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(500), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('duration') + .setLabel(t({ phrase: 'blacklist.modal.duration.label', locale })) + .setPlaceholder(t({ phrase: 'blacklist.modal.duration.placeholder', locale })) + .setStyle(TextInputStyle.Short) + .setMinLength(2) + .setRequired(false), + ), + ); + } + + protected getModalData(interaction: ModalSubmitInteraction) { + const reason = interaction.fields.getTextInputValue('reason'); + const duration = parse(interaction.fields.getTextInputValue('duration')); + const expires = duration ? new Date(Date.now() + duration) : null; + + return { reason, expires }; + } + + protected buildSuccessEmbed( + name: string, + reason: string, + expires: Date | null, + locale: supportedLocaleCodes, + ) { + return new EmbedBuilder() + .setColor('Green') + .setDescription(t({ phrase: 'blacklist.success', locale }, { name, emoji: emojis.tick })) + .addFields( + { + name: 'Reason', + value: reason ?? t({ phrase: 'misc.noReason', locale }), + inline: true, + }, + { + name: 'Expires', + value: expires ? `${time(Math.round(expires.getTime() / 1000), 'R')}` : 'Never.', + inline: true, + }, + ); + } +} + +export class BlacklistUserHandler extends BaseBlacklistHandler { + async handle( + interaction: ButtonInteraction, + originalMsgId: Snowflake, + locale: supportedLocaleCodes, + ) { + await interaction.showModal(this.buildModal('Blacklist User', 'user', originalMsgId, locale)); + } + + async handleModal( + interaction: ModalSubmitInteraction, + originalMsg: ModActionsDbMsgT, + locale: supportedLocaleCodes, + ) { + const user = await interaction.client.users.fetch(originalMsg.authorId).catch(() => null); + + if (!user) { + await interaction.reply({ + content: `${emojis.neutral} Unable to fetch user. They may have deleted their account?`, + ephemeral: true, + }); + return; + } + + if (!originalMsg.hubId) { + await interaction.reply({ + content: t({ phrase: 'hub.notFound_mod', locale }, { emoji: emojis.no }), + ephemeral: true, + }); + return; + } + + if (originalMsg.authorId === interaction.user.id) { + await interaction.editReply( + ' Nuh uh! You can\'t moderate your own messages.', + ); + return; + } + + const { userManager } = interaction.client; + const { reason, expires } = this.getModalData(interaction); + + await userManager.addBlacklist({ id: user.id, name: user.username }, originalMsg.hubId, { + reason, + moderatorId: interaction.user.id, + expires, + }); + + if (user) { + userManager + .sendNotification({ target: user, hubId: originalMsg.hubId, expires, reason }) + .catch(() => null); + + await logBlacklist(originalMsg.hubId, interaction.client, { + target: user, + mod: interaction.user, + reason, + expires, + }); + } + + Logger.info( + `User ${user?.username} blacklisted by ${interaction.user.username} in ${originalMsg.hub?.name}`, + ); + + const { embed, buttons } = await modActionsPanel.buildMessage(interaction, originalMsg); + await interaction.editReply({ embeds: [embed], components: [buttons] }); + + const successEmbed = this.buildSuccessEmbed(user.username, reason, expires, locale); + await interaction.followUp({ embeds: [successEmbed], components: [], ephemeral: true }); + } +} + +export class BlacklistServerHandler extends BaseBlacklistHandler { + async handle( + interaction: ButtonInteraction, + originalMsgId: Snowflake, + locale: supportedLocaleCodes, + ) { + await interaction.showModal( + this.buildModal('Blacklist Server', 'server', originalMsgId, locale), + ); + } + + async handleModal( + interaction: ModalSubmitInteraction, + originalMsg: ModActionsDbMsgT, + locale: supportedLocaleCodes, + ) { + if (!originalMsg.hubId) { + await interaction.reply({ + content: t({ phrase: 'hub.notFound_mod', locale }, { emoji: emojis.no }), + ephemeral: true, + }); + return; + } + + const server = await interaction.client.fetchGuild(originalMsg.serverId); + if (!server) { + await interaction.reply({ + content: t({ phrase: 'errors.unknownServer', locale }, { emoji: emojis.no }), + ephemeral: true, + }); + return; + } + + const { serverBlacklists } = interaction.client; + const { reason, expires } = this.getModalData(interaction); + + await serverBlacklists.addBlacklist( + { name: server?.name ?? 'Unknown Server', id: originalMsg.serverId }, + originalMsg.hubId, + { + reason, + moderatorId: interaction.user.id, + expires, + }, + ); + + // Notify server of blacklist + await serverBlacklists.sendNotification({ + target: { id: originalMsg.serverId }, + hubId: originalMsg.hubId, + expires, + reason, + }); + + await deleteConnections({ serverId: originalMsg.serverId, hubId: originalMsg.hubId }); + + if (server) { + await logBlacklist(originalMsg.hubId, interaction.client, { + target: server.id, + mod: interaction.user, + reason, + expires, + }).catch(() => null); + } + + const successEmbed = this.buildSuccessEmbed(server.name, reason, expires, locale); + + const { embed, buttons } = await modActionsPanel.buildMessage(interaction, originalMsg); + await interaction.editReply({ embeds: [embed], components: [buttons] }); + await interaction.followUp({ embeds: [successEmbed], components: [], ephemeral: true }); + } +} diff --git a/src/utils/moderation/modActions/handlers/deleteMsgHandler.ts b/src/utils/moderation/modActions/handlers/deleteMsgHandler.ts new file mode 100644 index 000000000..64610fab9 --- /dev/null +++ b/src/utils/moderation/modActions/handlers/deleteMsgHandler.ts @@ -0,0 +1,69 @@ +import { emojis } from '#main/config/Constants.js'; +import { type supportedLocaleCodes, t } from '#main/utils/Locale.js'; +import { isDeleteInProgress, deleteMessageFromHub } from '#main/utils/moderation/deleteMessage.js'; +import modActionsPanel from '#main/utils/moderation/modActions/modActionsPanel.js'; +import { + type ModAction, + fetchMessageFromDb, + replyWithUnknownMessage, +} from '#main/utils/moderation/modActions/utils.js'; +import { simpleEmbed } from '#main/utils/Utils.js'; +import { type ButtonInteraction, type Snowflake } from 'discord.js'; + +export default class DeleteMessageHandler implements ModAction { + async handle( + interaction: ButtonInteraction, + originalMsgId: Snowflake, + locale: supportedLocaleCodes, + ) { + const originalMsg = await fetchMessageFromDb(originalMsgId, { + broadcastMsgs: true, + }); + + if (!originalMsg?.hubId || !originalMsg.broadcastMsgs) { + await replyWithUnknownMessage(interaction, locale); + return; + } + + const deleteInProgress = await isDeleteInProgress(originalMsg.messageId); + if (deleteInProgress) { + const { embed, buttons } = await modActionsPanel.buildMessage(interaction, originalMsg); + await interaction.update({ embeds: [embed], components: [buttons] }); + + const errorEmbed = simpleEmbed( + `${emojis.neutral} This message is already deleted or is being deleted by another moderator.`, + ); + + await interaction.followUp({ ephemeral: true, embeds: [errorEmbed] }); + return; + } + + await interaction.reply({ + content: `${emojis.loading} Deleting messages... This may take a minute or so.`, + ephemeral: true, + }); + + const { deletedCount } = await deleteMessageFromHub( + originalMsg.hubId, + originalMsg.messageId, + originalMsg.broadcastMsgs, + ); + + await interaction + .editReply( + t( + { + phrase: 'network.deleteSuccess', + locale: await interaction.client.userManager.getUserLocale(interaction.user.id), + }, + { + emoji: emojis.yes, + user: `<@${originalMsg.authorId}>`, + deleted: `${deletedCount}`, + total: `${originalMsg.broadcastMsgs.length}`, + }, + ), + ) + .catch(() => null); + } +} diff --git a/src/utils/moderation/modActions/handlers/userBanHandler.ts b/src/utils/moderation/modActions/handlers/userBanHandler.ts new file mode 100644 index 000000000..500625f62 --- /dev/null +++ b/src/utils/moderation/modActions/handlers/userBanHandler.ts @@ -0,0 +1,62 @@ +import { RegisterInteractionHandler } from '#main/decorators/Interaction.js'; +import handleBan from '#main/utils/banUtls/handleBan.js'; +import { CustomID } from '#main/utils/CustomID.js'; +import type { supportedLocaleCodes } from '#main/utils/Locale.js'; +import { + type ModAction, + fetchMessageFromDb, + replyWithUnknownMessage, +} from '#main/utils/moderation/modActions/utils.js'; +import { + type ButtonInteraction, + type ModalSubmitInteraction, + type Snowflake, + ActionRowBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; + +export default class UserBanHandler implements ModAction { + async handle( + interaction: ButtonInteraction, + originalMsgId: Snowflake, + locale: supportedLocaleCodes, + ) { + const originalMsg = await fetchMessageFromDb(originalMsgId); + + if (!originalMsg) { + await replyWithUnknownMessage(interaction, locale); + return; + } + + const modal = new ModalBuilder() + .setTitle('Ban User') + .setCustomId( + new CustomID().setIdentifier('userBanModal').addArgs(originalMsg.authorId).toString(), + ) + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('reason') + .setLabel('reason') + .setPlaceholder('Breaking rules...') + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(500), + ), + ); + + await interaction.showModal(modal); + } + + @RegisterInteractionHandler('userBanModal') + async handleModal(interaction: ModalSubmitInteraction): Promise { + const customId = CustomID.parseCustomId(interaction.customId); + const [userId] = customId.args; + + const user = await interaction.client.users.fetch(userId).catch(() => null); + const reason = interaction.fields.getTextInputValue('reason'); + + await handleBan(interaction, userId, user, reason); + } +} diff --git a/src/utils/moderation/modActions/modActionsPanel.ts b/src/utils/moderation/modActions/modActionsPanel.ts new file mode 100644 index 000000000..80e5b48e6 --- /dev/null +++ b/src/utils/moderation/modActions/modActionsPanel.ts @@ -0,0 +1,135 @@ +import Constants, { emojis } from '#main/config/Constants.js'; +import { CustomID } from '#main/utils/CustomID.js'; +import { isBlacklisted } from '#main/utils/moderation/blacklistUtils.js'; +import { isDeleteInProgress } from '#main/utils/moderation/deleteMessage.js'; +import { ModActionsDbMsgT } from '#main/utils/moderation/modActions/utils.js'; +import { checkIfStaff } from '#main/utils/Utils.js'; +import { stripIndents } from 'common-tags'; +import { + type Interaction, + type Snowflake, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, +} from 'discord.js'; + +type BuilderOpts = { + isUserBlacklisted: boolean; + isServerBlacklisted: boolean; + isDeleteInProgress: boolean; + isBanned: boolean; +}; + +const buildButtons = (interaction: Interaction, messageId: Snowflake, opts: BuilderOpts) => { + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId( + new CustomID('modMessage:blacklistUser', [interaction.user.id, messageId]).toString(), + ) + .setStyle(ButtonStyle.Secondary) + .setEmoji(emojis.user_icon) + .setDisabled(opts.isUserBlacklisted), + new ButtonBuilder() + .setCustomId( + new CustomID('modMessage:blacklistServer', [interaction.user.id, messageId]).toString(), + ) + .setStyle(ButtonStyle.Secondary) + .setEmoji(emojis.globe_icon) + .setDisabled(opts.isServerBlacklisted), + new ButtonBuilder() + .setCustomId( + new CustomID('modMessage:removeAllReactions', [interaction.user.id, messageId]).toString(), + ) + .setStyle(ButtonStyle.Secondary) + .setEmoji(emojis.add_icon), + new ButtonBuilder() + .setCustomId( + new CustomID('modMessage:deleteMsg', [interaction.user.id, messageId]).toString(), + ) + .setStyle(ButtonStyle.Secondary) + .setEmoji(emojis.deleteDanger_icon) + .setDisabled(opts.isDeleteInProgress), + ); + + if (checkIfStaff(interaction.user.id)) { + buttons.addComponents( + new ButtonBuilder() + .setCustomId( + new CustomID('modMessage:banUser', [interaction.user.id, messageId]).toString(), + ) + .setStyle(ButtonStyle.Secondary) + .setEmoji(emojis.blobFastBan) + .setDisabled(opts.isBanned), + ); + } + + return buttons; +}; + +const buildInfoEmbed = (username: string, servername: string, opts: BuilderOpts) => { + const userEmbedDesc = opts.isUserBlacklisted + ? `~~User **${username}** is already blacklisted.~~` + : `Blacklist user **${username}** from this hub.`; + + const serverEmbedDesc = opts.isServerBlacklisted + ? `~~Server **${servername}** is already blacklisted.~~` + : `Blacklist server **${servername}** from this hub.`; + + const deleteDesc = opts.isDeleteInProgress + ? '~~Message is already deleted or is being deleted.~~' + : 'Delete this message from all connections.'; + + const banUserDesc = opts.isBanned + ? '~~This user is already banned.~~' + : 'Ban this user from the entire bot.'; + + return new EmbedBuilder().setColor(Constants.Colors.invisible).setFooter({ + text: 'Target will be notified of the blacklist. Use /blacklist list to view all blacklists.', + }).setDescription(stripIndents` + ### ${emojis.timeout_icon} Moderation Actions + **${emojis.user_icon} Blacklist User**: ${userEmbedDesc} + **${emojis.globe_icon} Blacklist Server**: ${serverEmbedDesc} + **${emojis.add_icon} Remove Reactions**: Remove all reactions from this message. + **${emojis.deleteDanger_icon} Delete Message**: ${deleteDesc} + **${emojis.blobFastBan} Ban User**: ${banUserDesc} + `); +}; + +const buildMessage = async (interaction: Interaction, originalMsg: ModActionsDbMsgT) => { + const user = await interaction.client.users.fetch(originalMsg.authorId); + const server = await interaction.client.fetchGuild(originalMsg.serverId); + const deleteInProgress = await isDeleteInProgress(originalMsg.messageId); + + const { userManager } = interaction.client; + const dbUserTarget = await userManager.getUser(user.id); + + const isUserBlacklisted = await isBlacklisted( + dbUserTarget ?? user.id, + `${originalMsg.hubId}`, + userManager, + ); + const isServerBlacklisted = await isBlacklisted( + originalMsg.serverId, + `${originalMsg.hubId}`, + interaction.client.serverBlacklists, + ); + + const embed = buildInfoEmbed(user.username, server?.name ?? 'Unknown Server', { + isUserBlacklisted, + isServerBlacklisted, + isBanned: Boolean(dbUserTarget?.banMeta?.reason), + isDeleteInProgress: deleteInProgress, + }); + + const buttons = buildButtons(interaction, originalMsg.messageId, { + isUserBlacklisted, + isServerBlacklisted, + isBanned: Boolean(dbUserTarget?.banMeta?.reason), + isDeleteInProgress: deleteInProgress, + }); + + return { embed, buttons }; +}; + +export default { buildButtons, buildInfoEmbed, buildMessage }; diff --git a/src/utils/moderation/modActions/utils.ts b/src/utils/moderation/modActions/utils.ts new file mode 100644 index 000000000..1cfb4d162 --- /dev/null +++ b/src/utils/moderation/modActions/utils.ts @@ -0,0 +1,56 @@ +import { emojis } from '#main/config/Constants.js'; +import db from '#main/utils/Db.js'; +import { type supportedLocaleCodes, t } from '#main/utils/Locale.js'; +import { simpleEmbed } from '#main/utils/Utils.js'; +import type { broadcastedMessages, hubs, originalMessages, Prisma } from '@prisma/client'; +import type { ButtonInteraction, ModalSubmitInteraction, RepliableInteraction, Snowflake } from 'discord.js'; + +export type ModActionsDbMsgT = originalMessages & { + hub?: hubs | null; + broadcastMsgs?: broadcastedMessages[]; +}; + +export interface ModAction { + handle( + interaction: ButtonInteraction, + originalMsgId: Snowflake, + locale: supportedLocaleCodes, + ): Promise; + handleModal?( + interaction: ModalSubmitInteraction, + originalMsg: ModActionsDbMsgT, + locale: supportedLocaleCodes, + ): Promise; +} + + +export const fetchMessageFromDb = async ( + messageId: string, + include: Prisma.originalMessagesInclude = { hub: false, broadcastMsgs: false }, +): Promise => { + let messageInDb = await db.originalMessages.findFirst({ where: { messageId }, include }); + + if (!messageInDb) { + const broadcastedMsg = await db.broadcastedMessages.findFirst({ + where: { messageId }, + include: { originalMsg: { include } }, + }); + + messageInDb = broadcastedMsg?.originalMsg ?? null; + } + + return messageInDb; +}; + +export async function replyWithUnknownMessage( + interaction: RepliableInteraction, + locale: supportedLocaleCodes, + edit = false, +) { + const embed = simpleEmbed( + t({ phrase: 'errors.unknownNetworkMessage', locale }, { emoji: emojis.no }), + ); + + if (edit) await interaction.editReply({ embeds: [embed] }); + else await interaction.reply({ embeds: [embed] }); +} diff --git a/src/utils/network/messageFormatters.ts b/src/utils/network/messageFormatters.ts index f1e7c88a2..eab045b13 100644 --- a/src/utils/network/messageFormatters.ts +++ b/src/utils/network/messageFormatters.ts @@ -2,7 +2,7 @@ import type { BroadcastOpts, CompactFormatOpts, EmbedFormatOpts, -} from '#main/utils/network/Types.d.ts'; +} from './Types.d.ts'; import Constants from '#main/config/Constants.js'; import { censor } from '#main/utils/ProfanityUtils.js'; import type { connectedList, hubs, userData } from '@prisma/client'; diff --git a/src/utils/reaction/actions.ts b/src/utils/reaction/actions.ts index 066cb4e42..40bf414b2 100644 --- a/src/utils/reaction/actions.ts +++ b/src/utils/reaction/actions.ts @@ -1,79 +1,46 @@ +import { CustomID } from '#main/utils/CustomID.js'; +import db from '#main/utils/Db.js'; +import { getEmojiId } from '#main/utils/Utils.js'; import { broadcastedMessages } from '@prisma/client'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, - WebhookClient, ComponentType, - Snowflake, + WebhookClient, } from 'discord.js'; -import { CustomID } from '#main/utils/CustomID.js'; -import db from '#main/utils/Db.js'; -import { getEmojiId } from '#main/utils/Utils.js'; import sortReactions from './sortReactions.js'; +import { ReactionArray } from '#main/types/network.js.js'; -/** - * Adds a user ID to the array of user IDs for a given emoji in the reactionArr object. - * @param reactionArr - The object containing arrays of user IDs for each emoji. - * @param userId - The ID of the user to add to the array. - * @param emoji - The emoji to add the user ID to. - */ -export const addReaction = ( - reactionArr: { [key: string]: Snowflake[] }, - userId: string, - emoji: string, -) => { +export const addReaction = (reactionArr: ReactionArray, userId: string, emoji: string): void => { + reactionArr[emoji] = reactionArr[emoji] || []; reactionArr[emoji].push(userId); }; -/** - * Removes a user's reaction from the reaction array. - * @param reactionArr - The reaction array to remove the user's reaction from. - * @param userId - The ID of the user whose reaction is to be removed. - * @param emoji - The emoji of the reaction to be removed. - * @returns The updated reaction array after removing the user's reaction. - */ export const removeReaction = ( - reactionArr: { [key: string]: Snowflake[] }, + reactionArr: ReactionArray, userId: string, emoji: string, -) => { - // if (reactionArr[emoji].length <= 1) { - // delete reactionArr[emoji]; - // return; - // } - - const userIndex = reactionArr[emoji].indexOf(userId); - reactionArr[emoji].splice(userIndex, 1); +): ReactionArray => { + if (reactionArr[emoji]) { + reactionArr[emoji] = reactionArr[emoji].filter((id) => id !== userId); + if (reactionArr[emoji].length === 0) { + delete reactionArr[emoji]; + } + } return reactionArr; }; -/** - * Updates reactions on messages in multiple channels. - * @param channelAndMessageIds An array of objects containing channel and message IDs. - * @param reactions An object containing the reactions data. - */ -export const updateReactions = async ( - channelAndMessageIds: broadcastedMessages[], - reactions: { [key: string]: string[] }, -) => { - const connections = await db.connectedList.findMany({ - where: { - channelId: { in: channelAndMessageIds.map((c) => c.channelId) }, - connected: true, - }, - }); +const createReactionButtons = ( + sortedReactions: [string, string[]][], +): ActionRowBuilder | null => { + if (sortedReactions.length === 0) return null; - // reactions data example: { '👍': ['userId1', 'userId2'], '👎': ['userId1', 'userId2', 'userId3'] } - // sortedReactions[0] = array of [emoji, users[]] - // sortedReactions[x] = emojiIds - // sortedReactions[x][y] = arr of users - const sortedReactions = sortReactions(reactions); - const reactionCount = sortedReactions[0][1].length; - const mostReaction = sortedReactions[0][0]; + const [mostReaction, users] = sortedReactions[0]; + const reactionCount = users.length; const mostReactionEmoji = getEmojiId(mostReaction); - if (!mostReactionEmoji) return; + if (!mostReactionEmoji) return null; const reactionBtn = new ActionRowBuilder().addComponents( new ButtonBuilder() @@ -83,54 +50,70 @@ export const updateReactions = async ( .setLabel(`${reactionCount}`), ); - if (sortedReactions.length > 1) { - const allReactionCount = sortedReactions.filter( - (e) => e[0] !== mostReaction && e[1].length > 0, + const additionalReactionsCount = sortedReactions + .slice(1) + .filter(([, usrs]) => usrs.length > 0).length; + if (additionalReactionsCount > 0) { + reactionBtn.addComponents( + new ButtonBuilder() + .setCustomId(new CustomID().setIdentifier('reaction_', 'view_all').toString()) + .setStyle(ButtonStyle.Secondary) + .setLabel(`+ ${additionalReactionsCount}`), ); - if (allReactionCount.length > 0) { - reactionBtn.addComponents( - new ButtonBuilder() - .setCustomId(new CustomID().setIdentifier('reaction_', 'view_all').toString()) - .setStyle(ButtonStyle.Secondary) - .setLabel(`+ ${allReactionCount.length}`), - ); - } } - connections.forEach(async (connection) => { - const dbMsg = channelAndMessageIds.find((e) => e.channelId === connection.channelId); - if (!dbMsg) return; + return reactionBtn; +}; - const webhook = new WebhookClient({ url: connection.webhookURL }); - const message = await webhook - .fetchMessage(dbMsg.messageId, { - threadId: connection.parentId ? connection.channelId : undefined, - }) - .catch(() => null); - - const components = message?.components?.filter((row) => { - // filter all buttons that are not reaction buttons +const updateMessageComponents = async ( + webhook: WebhookClient, + messageId: string, + threadId: string | undefined, + reactionBtn: ActionRowBuilder | null, +): Promise => { + const message = await webhook.fetchMessage(messageId, { threadId }).catch(() => null); + if (!message) return; + + const components = + message.components?.filter((row) => { row.components = row.components.filter((component) => { - const isButton = component.type === ComponentType.Button; - if (isButton && component.style === ButtonStyle.Secondary) { - const custom_id = CustomID.parseCustomId(component.custom_id); - return custom_id.prefix !== 'reaction_' && custom_id.suffix !== 'view_all'; - } - return true; + if (component.type !== ComponentType.Button) return true; + if (component.style !== ButtonStyle.Secondary) return true; + const custom_id = CustomID.parseCustomId(component.custom_id); + return custom_id.prefix !== 'reaction_' && custom_id.suffix !== 'view_all'; }); - - // if the filtered row has components, that means it has components other than reaction buttons - // so we return true to keep the row return row.components.length > 0; - }); + }) || []; + + if (reactionBtn) components.push(reactionBtn.toJSON()); - if (reactionCount > 0) components?.push(reactionBtn.toJSON()); + await webhook.editMessage(messageId, { components, threadId }).catch(() => null); +}; - webhook - .editMessage(dbMsg.messageId, { - components, - threadId: connection.parentId ? connection.channelId : undefined, - }) - .catch(() => null); +export const updateReactions = async ( + channelAndMessageIds: broadcastedMessages[], + reactions: { [key: string]: string[] }, +): Promise => { + const connections = await db.connectedList.findMany({ + where: { + channelId: { in: channelAndMessageIds.map((c) => c.channelId) }, + connected: true, + }, }); + + const sortedReactions = sortReactions(reactions); + const reactionBtn = createReactionButtons(sortedReactions); + + for (const connection of connections) { + const dbMsg = channelAndMessageIds.find((e) => e.channelId === connection.channelId); + if (!dbMsg) continue; + + const webhook = new WebhookClient({ url: connection.webhookURL }); + await updateMessageComponents( + webhook, + dbMsg.messageId, + connection.parentId ? connection.channelId : undefined, + reactionBtn, + ); + } }; diff --git a/src/utils/reaction/sortReactions.ts b/src/utils/reaction/sortReactions.ts index 5365f56c5..c62a24b86 100644 --- a/src/utils/reaction/sortReactions.ts +++ b/src/utils/reaction/sortReactions.ts @@ -5,10 +5,17 @@ * The array is sorted in descending order based on the length of the reaction arrays. * Each element of the array is a tuple containing the reaction and its corresponding array of user IDs. * - * ### Example: - * ```js - * [ [ '👎', ['1020193019332334'] ], [ '👍', ['1020193019332334'] ] ] + * **Before:** + * ```ts + * { '👍': ['10201930193'], '👎': ['10201930193', '10201930194'] } * ``` - */ -export default (reactions: { [key: string]: string[] }) => - Object.entries(reactions).sort((a, b) => b[1].length - a[1].length); + * **After:** + * ```ts + * [ [ '👎', ['10201930193', '10201930194'] ], [ '👍', ['10201930193'] ] ] + * ``` + * */ +export default (reactions: { [key: string]: string[] }): [string, string[]][] => { + const idk = Object.entries(reactions).sort((a, b) => b[1].length - a[1].length); + console.log(idk); + return idk; +};