From 5c3479268d489d99f1959be2632db930b990f7f5 Mon Sep 17 00:00:00 2001 From: Lea Date: Wed, 15 Mar 2023 20:21:28 +0100 Subject: [PATCH] Confirmation prompt when kicking/banning via reply --- .vscode/settings.json | 2 +- bot/src/bot/commands/moderation/ban.ts | 369 +++++++++++++------ bot/src/bot/commands/moderation/kick.ts | 232 +++++++++--- bot/src/bot/util.ts | 471 +++++++++++++++++------- 4 files changed, 783 insertions(+), 291 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 446739d..b8cebed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "editor.formatOnSave": true, + "editor.formatOnSave": false, "editor.formatOnSaveMode": "modifications", "prettier.tabWidth": 4 } diff --git a/bot/src/bot/commands/moderation/ban.ts b/bot/src/bot/commands/moderation/ban.ts index a3c38ce..f6a1a76 100644 --- a/bot/src/bot/commands/moderation/ban.ts +++ b/bot/src/bot/commands/moderation/ban.ts @@ -5,9 +5,22 @@ import InfractionType from "automod/dist/types/antispam/InfractionType"; import SimpleCommand from "../../../struct/commands/SimpleCommand"; import { fetchUsername, logModAction } from "../../modules/mod_logs"; import { storeTempBan } from "../../modules/tempbans"; -import { dedupeArray, embed, EmbedColor, generateInfractionDMEmbed, getDmChannel, isModerator, NO_MANAGER_MSG, parseUserOrId, sanitizeMessageContent, storeInfraction } from "../../util"; -import Day from 'dayjs'; -import RelativeTime from 'dayjs/plugin/relativeTime'; +import { + dedupeArray, + embed, + EmbedColor, + generateInfractionDMEmbed, + getDmChannel, + getMembers, + isModerator, + NO_MANAGER_MSG, + parseUserOrId, + sanitizeMessageContent, + storeInfraction, + yesNoMessage, +} from "../../util"; +import Day from "dayjs"; +import RelativeTime from "dayjs/plugin/relativeTime"; import CommandCategory from "../../../struct/commands/CommandCategory"; import { SendableEmbed } from "revolt-api"; import { User } from "@janderedev/revolt.js"; @@ -16,30 +29,40 @@ import logger from "../../logger"; Day.extend(RelativeTime); export default { - name: 'ban', - aliases: [ 'eject' ], - description: 'Ban a member from the server', - syntax: '/ban @username [10m|1h|...?] [reason?]', + name: "ban", + aliases: ["eject"], + description: "Ban a member from the server", + syntax: "/ban @username [10m|1h|...?] [reason?]", removeEmptyArgs: true, category: CommandCategory.Moderation, run: async (message, args, serverConfig) => { - if (!await isModerator(message)) - return message.reply(NO_MANAGER_MSG); - if (!message.serverContext.havePermission('BanMembers')) { - return await message.reply({ embeds: [ - embed(`Sorry, I do not have \`BanMembers\` permission.`, '', EmbedColor.SoftError) - ] }); + if (!(await isModerator(message))) return message.reply(NO_MANAGER_MSG); + if (!message.serverContext.havePermission("BanMembers")) { + return await message.reply({ + embeds: [ + embed( + `Sorry, I do not have \`BanMembers\` permission.`, + "", + EmbedColor.SoftError + ), + ], + }); } - const userInput = !message.reply_ids?.length ? args.shift() || '' : undefined; - if (!userInput && !message.reply_ids?.length) return message.reply({ embeds: [ - embed( - `Please specify one or more users by replying to their message while running this command or ` + - `by specifying a comma-separated list of usernames.`, - 'No target user specified', - EmbedColor.SoftError, - ), - ] }); + const userInput = !message.reply_ids?.length + ? args.shift() || "" + : undefined; + if (!userInput && !message.reply_ids?.length) + return message.reply({ + embeds: [ + embed( + `Please specify one or more users by replying to their message while running this command or ` + + `by specifying a comma-separated list of usernames.`, + "No target user specified", + EmbedColor.SoftError + ), + ], + }); let banDuration = 0; let durationStr = args.shift(); @@ -49,48 +72,84 @@ export default { // Being able to specify the same letter multiple times // (e.g. 1s1s) and having their values stack is a feature for (const piece of pieces) { - let [ num, letter ] = [ Number(piece.slice(0, piece.length - 1)), piece.slice(piece.length - 1) ]; + let [num, letter] = [ + Number(piece.slice(0, piece.length - 1)), + piece.slice(piece.length - 1), + ]; let multiplier = 0; - switch(letter) { - case 's': multiplier = 1000; break; - case 'm': multiplier = 1000 * 60; break; - case 'h': multiplier = 1000 * 60 * 60; break; - case 'd': multiplier = 1000 * 60 * 60 * 24; break; - case 'w': multiplier = 1000 * 60 * 60 * 24 * 7; break; - case 'y': multiplier = 1000 * 60 * 60 * 24 * 365; break; + switch (letter) { + case "s": + multiplier = 1000; + break; + case "m": + multiplier = 1000 * 60; + break; + case "h": + multiplier = 1000 * 60 * 60; + break; + case "d": + multiplier = 1000 * 60 * 60 * 24; + break; + case "w": + multiplier = 1000 * 60 * 60 * 24 * 7; + break; + case "y": + multiplier = 1000 * 60 * 60 * 24 * 365; + break; } banDuration += num * multiplier; } } else if (durationStr) args.unshift(durationStr); - let reason = args.join(' ') - ?.replace(new RegExp('`', 'g'), '\'') - ?.replace(new RegExp('\n', 'g'), ' '); + let reason = args + .join(" ") + ?.replace(new RegExp("`", "g"), "'") + ?.replace(new RegExp("\n", "g"), " "); - if (reason.length > 500) return message.reply({ - embeds: [ embed('Ban reason may not be longer than 500 characters.', null, EmbedColor.SoftError) ] - }); + if (reason.length > 500) + return message.reply({ + embeds: [ + embed( + "Ban reason may not be longer than 500 characters.", + null, + EmbedColor.SoftError + ), + ], + }); const embeds: SendableEmbed[] = []; const handledUsers: string[] = []; - const targetUsers: User|{ _id: string }[] = []; + const targetUsers: User | { _id: string }[] = []; const targetInput = dedupeArray( message.reply_ids?.length - ? (await Promise.allSettled( - message.reply_ids.map(msg => message.channel?.fetchMessage(msg)) - )) - .filter(m => m.status == 'fulfilled').map(m => (m as any).value.author_id) - : userInput!.split(','), + ? ( + await Promise.allSettled( + message.reply_ids.map((msg) => + message.channel?.fetchMessage(msg) + ) + ) + ) + .filter((m) => m.status == "fulfilled") + .map((m) => (m as any).value.author_id) + : userInput!.split(",") ); for (const userStr of targetInput) { try { let user = await parseUserOrId(userStr); if (!user) { - embeds.push(embed(`I can't resolve \`${sanitizeMessageContent(userStr).trim()}\` to a user.`, null, EmbedColor.SoftError)); + embeds.push( + embed( + `I can't resolve \`${sanitizeMessageContent( + userStr + ).trim()}\` to a user.`, + null, + EmbedColor.SoftError + ) + ); continue; } @@ -99,27 +158,57 @@ export default { handledUsers.push(user._id); if (user._id == message.author_id) { - embeds.push(embed('I recommend against banning yourself :yeahokayyy:', null, EmbedColor.Warning)); + embeds.push( + embed( + "I recommend against banning yourself :yeahokayyy:", + null, + EmbedColor.Warning + ) + ); continue; } if (user._id == client.user!._id) { - embeds.push(embed('I\'m not going to ban myself :flushee:', null, EmbedColor.Warning)); + embeds.push( + embed( + "I'm not going to ban myself :flushee:", + null, + EmbedColor.Warning + ) + ); continue; } targetUsers.push(user); - } catch(e) { + } catch (e) { console.error(e); - embeds.push(embed( - `Failed to ban target \`${sanitizeMessageContent(userStr).trim()}\`: ${e}`, - `Failed to ban: An error has occurred`, - EmbedColor.Error, - )); + embeds.push( + embed( + `Failed to ban target \`${sanitizeMessageContent( + userStr + ).trim()}\`: ${e}`, + `Failed to ban: An error has occurred`, + EmbedColor.Error + ) + ); } } - const members = await message.serverContext.fetchMembers(); + if (message.reply_ids?.length && targetUsers.length) { + let res = await yesNoMessage( + message.channel!, + message.author_id, + `This will ban the author${targetUsers.length > 1 ? 's' : ''} ` + + `of the message${message.reply_ids.length > 1 ? 's' : ''} you replied to.\n` + + `The following user${targetUsers.length > 1 ? 's' : ''} will be affected: ` + + `${targetUsers.map(u => `<@${u._id}>`).join(', ')}.\n` + + `Are you sure?`, + 'Confirm action' + ); + if (!res) return; + } + + const members = getMembers(message.serverContext._id); for (const user of targetUsers) { try { @@ -129,64 +218,106 @@ export default { _id: infId, createdBy: message.author_id, date: Date.now(), - reason: reason || 'No reason provided', + reason: reason || "No reason provided", server: message.serverContext._id, type: InfractionType.Manual, user: user._id, - actionType: 'ban', + actionType: "ban", expires: Infinity, - } + }; const { userWarnCount } = await storeInfraction(infraction); - const member = members.members.find(m => m._id.user == user._id); + const member = members.find((m) => m._id.user == user._id); - if (member && message.member && !member.inferiorTo(message.member)) { - embeds.push(embed( - `\`${member.user?.username}\` has an equally or higher ranked role than you; refusing to ban.`, - 'Missing permission', - EmbedColor.SoftError - )); + if ( + member && + message.member && + !member.inferiorTo(message.member) + ) { + embeds.push( + embed( + `\`${member.user?.username}\` has an equally or higher ranked role than you; refusing to ban.`, + "Missing permission", + EmbedColor.SoftError + ) + ); continue; } if (member && !member.bannable) { - embeds.push(embed( - `I don't have permission to ban \`${member?.user?.username || user._id}\`.`, - null, - EmbedColor.SoftError - )); + embeds.push( + embed( + `I don't have permission to ban \`${ + member?.user?.username || user._id + }\`.`, + null, + EmbedColor.SoftError + ) + ); continue; } if (serverConfig?.dmOnKick) { try { - const embed = generateInfractionDMEmbed(message.serverContext, serverConfig, infraction, message); + const embed = generateInfractionDMEmbed( + message.serverContext, + serverConfig, + infraction, + message + ); const dmChannel = await getDmChannel(user); - - if (dmChannel.havePermission('SendMessage') || dmChannel.havePermission('SendEmbeds')) { - await dmChannel.sendMessage({ embeds: [ embed ] }); - } - else logger.warn('Missing permission to DM user.'); - } catch(e) { + + if ( + dmChannel.havePermission("SendMessage") || + dmChannel.havePermission("SendEmbeds") + ) { + await dmChannel.sendMessage({ + embeds: [embed], + }); + } else + logger.warn("Missing permission to DM user."); + } catch (e) { console.error(e); } } await message.serverContext.banUser(user._id, { - reason: reason + ` (by ${await fetchUsername(message.author_id)} ${message.author_id})` + reason: + reason + + ` (by ${await fetchUsername(message.author_id)} ${ + message.author_id + })`, }); - await logModAction('ban', message.serverContext, message.member!, user._id, reason, infraction._id, `Ban duration: **Permanent**`); + await logModAction( + "ban", + message.serverContext, + message.member!, + user._id, + reason, + infraction._id, + `Ban duration: **Permanent**` + ); embeds.push({ - title: `User ${Math.random() > 0.8 ? 'ejected' : 'banned'}`, - icon_url: user instanceof User ? user.generateAvatarURL() : undefined, + title: `User ${ + Math.random() > 0.8 ? "ejected" : "banned" + }`, + icon_url: + user instanceof User + ? user.generateAvatarURL() + : undefined, colour: EmbedColor.Success, - description: `This is ${userWarnCount == 1 ? '**the first infraction**' : `infraction number **${userWarnCount}**`}` + + description: + `This is ${ + userWarnCount == 1 + ? "**the first infraction**" + : `infraction number **${userWarnCount}**` + }` + ` for ${await fetchUsername(user._id)}.\n` + `**User ID:** \`${user._id}\`\n` + `**Infraction ID:** \`${infraction._id}\`\n` + - `**Reason:** \`${infraction.reason}\`` + `**Reason:** \`${infraction.reason}\``, }); } else { const banUntil = Date.now() + banDuration; @@ -196,31 +327,47 @@ export default { _id: infId, createdBy: message.author_id, date: Date.now(), - reason: (reason || 'No reason provided') + ` (${durationStr})`, + reason: + (reason || "No reason provided") + + ` (${durationStr})`, server: message.serverContext._id, type: InfractionType.Manual, user: user._id, - actionType: 'ban', + actionType: "ban", expires: banUntil, - } + }; const { userWarnCount } = await storeInfraction(infraction); if (serverConfig?.dmOnKick) { try { - const embed = generateInfractionDMEmbed(message.serverContext, serverConfig, infraction, message); + const embed = generateInfractionDMEmbed( + message.serverContext, + serverConfig, + infraction, + message + ); const dmChannel = await getDmChannel(user); - - if (dmChannel.havePermission('SendMessage') || dmChannel.havePermission('SendEmbeds')) { - await dmChannel.sendMessage({ embeds: [ embed ] }); - } - else logger.warn('Missing permission to DM user.'); - } catch(e) { + + if ( + dmChannel.havePermission("SendMessage") || + dmChannel.havePermission("SendEmbeds") + ) { + await dmChannel.sendMessage({ + embeds: [embed], + }); + } else + logger.warn("Missing permission to DM user."); + } catch (e) { console.error(e); } } await message.serverContext.banUser(user._id, { - reason: reason + ` (by ${await fetchUsername(message.author_id)} ${message.author_id}) (${durationStr})` + reason: + reason + + ` (by ${await fetchUsername(message.author_id)} ${ + message.author_id + }) (${durationStr})`, }); await Promise.all([ @@ -231,7 +378,7 @@ export default { until: banUntil, }), logModAction( - 'ban', + "ban", message.serverContext, message.member!, user._id, @@ -243,23 +390,36 @@ export default { embeds.push({ title: `User temporarily banned`, - icon_url: user instanceof User ? user.generateAvatarURL() : undefined, + icon_url: + user instanceof User + ? user.generateAvatarURL() + : undefined, colour: EmbedColor.Success, - description: `This is ${userWarnCount == 1 ? '**the first infraction**' : `infraction number **${userWarnCount}**`}` + + description: + `This is ${ + userWarnCount == 1 + ? "**the first infraction**" + : `infraction number **${userWarnCount}**` + }` + ` for ${await fetchUsername(user._id)}.\n` + `**Ban duration:** ${banDurationFancy}\n` + `**User ID:** \`${user._id}\`\n` + `**Infraction ID:** \`${infraction._id}\`\n` + - `**Reason:** \`${infraction.reason}\`` + `**Reason:** \`${infraction.reason}\``, }); } - } catch(e) { + } catch (e) { console.error(e); - embeds.push(embed( - `Failed to ban target \`${await fetchUsername(user._id, user._id)}\`: ${e}`, - 'Failed to ban: An error has occurred', - EmbedColor.Error, - )); + embeds.push( + embed( + `Failed to ban target \`${await fetchUsername( + user._id, + user._id + )}\`: ${e}`, + "Failed to ban: An error has occurred", + EmbedColor.Error + ) + ); } } @@ -268,11 +428,14 @@ export default { const targetEmbeds = embeds.splice(0, 10); if (firstMsg) { - await message.reply({ embeds: targetEmbeds, content: 'Operation completed.' }, false); + await message.reply( + { embeds: targetEmbeds, content: "Operation completed." }, + false + ); } else { await message.channel?.sendMessage({ embeds: targetEmbeds }); } firstMsg = false; } - } + }, } as SimpleCommand; diff --git a/bot/src/bot/commands/moderation/kick.ts b/bot/src/bot/commands/moderation/kick.ts index 11be47e..7b754d4 100644 --- a/bot/src/bot/commands/moderation/kick.ts +++ b/bot/src/bot/commands/moderation/kick.ts @@ -8,58 +8,99 @@ import CommandCategory from "../../../struct/commands/CommandCategory"; import SimpleCommand from "../../../struct/commands/SimpleCommand"; import logger from "../../logger"; import { fetchUsername, logModAction } from "../../modules/mod_logs"; -import { dedupeArray, embed, EmbedColor, generateInfractionDMEmbed, getDmChannel, isModerator, NO_MANAGER_MSG, parseUser, parseUserOrId, sanitizeMessageContent, storeInfraction } from "../../util"; +import { + dedupeArray, + embed, + EmbedColor, + generateInfractionDMEmbed, + getDmChannel, + getMembers, + isModerator, + NO_MANAGER_MSG, + parseUser, + parseUserOrId, + sanitizeMessageContent, + storeInfraction, + yesNoMessage, +} from "../../util"; export default { - name: 'kick', - aliases: [ 'yeet', 'vent' ], - description: 'Kick a member from the server', - syntax: '/kick @username [reason?]', + name: "kick", + aliases: ["yeet", "vent"], + description: "Kick a member from the server", + syntax: "/kick @username [reason?]", removeEmptyArgs: true, category: CommandCategory.Moderation, run: async (message, args, serverConfig) => { - if (!await isModerator(message)) - return message.reply(NO_MANAGER_MSG); - if (!message.serverContext.havePermission('KickMembers')) { - return await message.reply(`Sorry, I do not have \`KickMembers\` permission.`); + if (!(await isModerator(message))) return message.reply(NO_MANAGER_MSG); + if (!message.serverContext.havePermission("KickMembers")) { + return await message.reply( + `Sorry, I do not have \`KickMembers\` permission.` + ); } - const userInput = !message.reply_ids?.length ? args.shift() || '' : undefined; - if (!userInput && !message.reply_ids?.length) return message.reply({ embeds: [ - embed( - `Please specify one or more users by replying to their message while running this command or ` + - `by specifying a comma-separated list of usernames.`, - 'No target user specified', - EmbedColor.SoftError, - ), - ] }); - - let reason = args.join(' ') - ?.replace(new RegExp('`', 'g'), '\'') - ?.replace(new RegExp('\n', 'g'), ' '); + const userInput = !message.reply_ids?.length + ? args.shift() || "" + : undefined; + if (!userInput && !message.reply_ids?.length) + return message.reply({ + embeds: [ + embed( + `Please specify one or more users by replying to their message while running this command or ` + + `by specifying a comma-separated list of usernames.`, + "No target user specified", + EmbedColor.SoftError + ), + ], + }); - if (reason.length > 500) return message.reply({ - embeds: [ embed('Kick reason may not be longer than 500 characters.', null, EmbedColor.SoftError) ] - }); + let reason = args + .join(" ") + ?.replace(new RegExp("`", "g"), "'") + ?.replace(new RegExp("\n", "g"), " "); + + if (reason.length > 500) + return message.reply({ + embeds: [ + embed( + "Kick reason may not be longer than 500 characters.", + null, + EmbedColor.SoftError + ), + ], + }); const embeds: SendableEmbed[] = []; const handledUsers: string[] = []; - const targetUsers: User|{ _id: string }[] = []; + const targetUsers: User | { _id: string }[] = []; const targetInput = dedupeArray( message.reply_ids?.length - ? (await Promise.allSettled( - message.reply_ids.map(msg => message.channel?.fetchMessage(msg)) - )) - .filter(m => m.status == 'fulfilled').map(m => (m as any).value.author_id) - : userInput!.split(','), + ? ( + await Promise.allSettled( + message.reply_ids.map((msg) => + message.channel?.fetchMessage(msg) + ) + ) + ) + .filter((m) => m.status == "fulfilled") + .map((m) => (m as any).value.author_id) + : userInput!.split(",") ); for (const userStr of targetInput) { try { let user = await parseUserOrId(userStr); if (!user) { - embeds.push(embed(`I can't resolve \`${sanitizeMessageContent(userStr).trim()}\` to a user.`, null, EmbedColor.SoftError)); + embeds.push( + embed( + `I can't resolve \`${sanitizeMessageContent( + userStr + ).trim()}\` to a user.`, + null, + EmbedColor.SoftError + ) + ); continue; } @@ -68,33 +109,69 @@ export default { handledUsers.push(user._id); if (user._id == message.author_id) { - embeds.push(embed('You might want to avoid kicking yourself...', null, EmbedColor.Warning)); + embeds.push( + embed( + "You might want to avoid kicking yourself...", + null, + EmbedColor.Warning + ) + ); continue; } if (user._id == client.user!._id) { - embeds.push(embed('I won\'t allow you to get rid of me this easily :trol:', null, EmbedColor.Warning)); + embeds.push( + embed( + "I won't allow you to get rid of me this easily :trol:", + null, + EmbedColor.Warning + ) + ); continue; } targetUsers.push(user); - } catch(e) { + } catch (e) { console.error(e); - embeds.push(embed( - `Failed to kick target \`${sanitizeMessageContent(userStr).trim()}\`: ${e}`, - `Failed to kick: An error has occurred`, - EmbedColor.Error, - )); + embeds.push( + embed( + `Failed to kick target \`${sanitizeMessageContent( + userStr + ).trim()}\`: ${e}`, + `Failed to kick: An error has occurred`, + EmbedColor.Error + ) + ); } } - const members = await message.serverContext.fetchMembers(); + if (message.reply_ids?.length && targetUsers.length) { + let res = await yesNoMessage( + message.channel!, + message.author_id, + `This will kick the author${targetUsers.length > 1 ? 's' : ''} ` + + `of the message${message.reply_ids.length > 1 ? 's' : ''} you replied to.\n` + + `The following user${targetUsers.length > 1 ? 's' : ''} will be affected: ` + + `${targetUsers.map(u => `<@${u._id}>`).join(', ')}.\n` + + `Are you sure?`, + 'Confirm action' + ); + if (!res) return; + } + + const members = getMembers(message.serverContext._id); for (const user of targetUsers) { try { - const member = members.members.find(m => m._id.user == user._id); + const member = members.find((m) => m._id.user == user._id); if (!member) { - embeds.push(embed('')); + embeds.push( + embed( + `\`${await fetchUsername( + user._id + )}\` is not a member of this server.` + ) + ); continue; } @@ -103,45 +180,75 @@ export default { _id: infId, createdBy: message.author_id, date: Date.now(), - reason: reason || 'No reason provided', + reason: reason || "No reason provided", server: message.serverContext._id, type: InfractionType.Manual, user: user._id, - actionType: 'kick', - } + actionType: "kick", + }; if (serverConfig?.dmOnKick) { try { - const embed = generateInfractionDMEmbed(message.serverContext, serverConfig, infraction, message); + const embed = generateInfractionDMEmbed( + message.serverContext, + serverConfig, + infraction, + message + ); const dmChannel = await getDmChannel(user); - if (dmChannel.havePermission('SendMessage') || dmChannel.havePermission('SendEmbeds')) { - await dmChannel.sendMessage({ embeds: [ embed ] }); - } - else logger.warn('Missing permission to DM user.'); - } catch(e) { + if ( + dmChannel.havePermission("SendMessage") || + dmChannel.havePermission("SendEmbeds") + ) { + await dmChannel.sendMessage({ embeds: [embed] }); + } else logger.warn("Missing permission to DM user."); + } catch (e) { console.error(e); } } - let [ { userWarnCount } ] = await Promise.all([ + let [{ userWarnCount }] = await Promise.all([ storeInfraction(infraction), - logModAction('kick', message.serverContext, message.member!, user._id, reason, infraction._id), + logModAction( + "kick", + message.serverContext, + message.member!, + user._id, + reason, + infraction._id + ), member.kick(), ]); embeds.push({ title: `User kicked`, - icon_url: user instanceof User ? user.generateAvatarURL() : undefined, + icon_url: + user instanceof User + ? user.generateAvatarURL() + : undefined, colour: EmbedColor.Success, - description: `This is ${userWarnCount == 1 ? '**the first infraction**' : `infraction number **${userWarnCount}**`}` + + description: + `This is ${ + userWarnCount == 1 + ? "**the first infraction**" + : `infraction number **${userWarnCount}**` + }` + ` for ${await fetchUsername(user._id)}.\n` + `**User ID:** \`${user._id}\`\n` + `**Infraction ID:** \`${infraction._id}\`\n` + - `**Reason:** \`${infraction.reason}\`` + `**Reason:** \`${infraction.reason}\``, }); - } catch(e) { - embeds.push(embed(`Failed to kick user ${await fetchUsername(user._id)}: ${e}`, 'Failed to kick user', EmbedColor.Error)); + } catch (e) { + embeds.push( + embed( + `Failed to kick user ${await fetchUsername( + user._id + )}: ${e}`, + "Failed to kick user", + EmbedColor.Error + ) + ); } } @@ -150,11 +257,14 @@ export default { const targetEmbeds = embeds.splice(0, 10); if (firstMsg) { - await message.reply({ embeds: targetEmbeds, content: 'Operation completed.' }, false); + await message.reply( + { embeds: targetEmbeds, content: "Operation completed." }, + false + ); } else { await message.channel?.sendMessage({ embeds: targetEmbeds }); } firstMsg = false; } - } + }, } as SimpleCommand; diff --git a/bot/src/bot/util.ts b/bot/src/bot/util.ts index 3841796..03a1469 100644 --- a/bot/src/bot/util.ts +++ b/bot/src/bot/util.ts @@ -17,21 +17,25 @@ import { isSudo } from "./commands/admin/botadm"; import { SendableEmbed } from "revolt-api"; import MessageCommandContext from "../struct/MessageCommandContext"; import ServerConfig from "automod/dist/types/ServerConfig"; +import { ClientboundNotification } from "@janderedev/revolt.js"; -const NO_MANAGER_MSG = '🔒 Missing permission'; +const NO_MANAGER_MSG = "🔒 Missing permission"; const ULID_REGEX = /^[0-9A-HJ-KM-NP-TV-Z]{26}$/i; const USER_MENTION_REGEX = /^<@[0-9A-HJ-KM-NP-TV-Z]{26}>$/i; const CHANNEL_MENTION_REGEX = /^<#[0-9A-HJ-KM-NP-TV-Z]{26}>$/i; const RE_HTTP_URI = /^http(s?):\/\//g; const RE_MAILTO_URI = /^mailto:/g; -let autumn_url: string|null = null; -let apiConfig: any = axios.get(client.apiURL).then(res => { +let autumn_url: string | null = null; +let apiConfig: any = axios.get(client.apiURL).then((res) => { autumn_url = (res.data as any).features.autumn.url; }); async function getAutumnURL() { - return autumn_url || ((await axios.get(client.apiURL)).data as any).features.autumn.url; + return ( + autumn_url || + ((await axios.get(client.apiURL)).data as any).features.autumn.url + ); } /** @@ -40,20 +44,20 @@ async function getAutumnURL() { * @param text * @returns null if not found, otherwise user object */ -async function parseUser(text: string): Promise { +async function parseUser(text: string): Promise { if (!text) return null; - let uid: string|null = null; + let uid: string | null = null; if (USER_MENTION_REGEX.test(text)) { - uid = text.replace(/<@|>/g, '').toUpperCase(); + uid = text.replace(/<@|>/g, "").toUpperCase(); } else if (/^[0-9A-HJ-KM-NP-TV-Z]{26}$/gi.test(text)) { uid = text.toUpperCase(); } else { - if (text.startsWith('@')) text = text.substr(1); + if (text.startsWith("@")) text = text.substr(1); // Why is there no .find() or .filter() - let user: User|null = null; - client.users.forEach(u => { + let user: User | null = null; + client.users.forEach((u) => { if (u.username?.toLowerCase() == text.toLowerCase()) { user = u; } @@ -63,16 +67,20 @@ async function parseUser(text: string): Promise { } try { - if (uid) return await client.users.fetch(uid) || null; + if (uid) return (await client.users.fetch(uid)) || null; else return null; - } catch(e) { return null; } + } catch (e) { + return null; + } } /** * Does the exact same as `parseUser`, but returns only `_id` instead * of null if the user was not found and the input is also an ID */ -async function parseUserOrId(text: string): Promise { +async function parseUserOrId( + text: string +): Promise { let parsed = await parseUser(text); if (parsed) return parsed; if (ULID_REGEX.test(text)) return { _id: text.toUpperCase() }; @@ -80,63 +88,87 @@ async function parseUserOrId(text: string): Promise { } async function isModerator(message: Message, announceSudo?: boolean) { - let member = message.member!, server = message.channel!.server!; + let member = message.member!, + server = message.channel!.server!; - if (hasPerm(member, 'KickMembers')) return true; + if (hasPerm(member, "KickMembers")) return true; - const [ isManager, mods, isSudo ] = await Promise.all([ + const [isManager, mods, isSudo] = await Promise.all([ isBotManager(message), dbs.SERVERS.findOne({ id: server._id }), checkSudoPermission(message, announceSudo), ]); - return isManager - || (mods?.moderators?.indexOf(member.user?._id!) ?? -1) > -1 - || isSudo; + return ( + isManager || + (mods?.moderators?.indexOf(member.user?._id!) ?? -1) > -1 || + isSudo + ); } async function isBotManager(message: Message, announceSudo?: boolean) { - let member = message.member!, server = message.channel!.server!; + let member = message.member!, + server = message.channel!.server!; - if (hasPerm(member, 'ManageServer')) return true; + if (hasPerm(member, "ManageServer")) return true; - const [ managers, isSudo ] = await Promise.all([ + const [managers, isSudo] = await Promise.all([ dbs.SERVERS.findOne({ id: server._id }), checkSudoPermission(message, announceSudo), ]); - return (managers?.botManagers?.indexOf(member.user?._id!) ?? -1) > -1 - || isSudo; + return ( + (managers?.botManagers?.indexOf(member.user?._id!) ?? -1) > -1 || isSudo + ); } -async function checkSudoPermission(message: Message, announce?: boolean): Promise { +async function checkSudoPermission( + message: Message, + announce?: boolean +): Promise { const hasPerm = isSudo(message.author!); if (!hasPerm) return false; else { if (announce !== false) { - await message.reply(`# :unlock: Bypassed permission check\n` - + `Sudo mode is enabled for @${message.author!.username}.\n`); + await message.reply( + `# :unlock: Bypassed permission check\n` + + `Sudo mode is enabled for @${message.author!.username}.\n` + ); } return true; } } -async function getPermissionLevel(user: User|Member, server: Server): Promise<0|1|2|3> { - if (isSudo(user instanceof User ? user : (user.user || await client.users.fetch(user._id.user)))) return 3; +async function getPermissionLevel( + user: User | Member, + server: Server +): Promise<0 | 1 | 2 | 3> { + if ( + isSudo( + user instanceof User + ? user + : user.user || (await client.users.fetch(user._id.user)) + ) + ) + return 3; const member = user instanceof User ? await server.fetchMember(user) : user; if (user instanceof Member) user = user.user!; - if (hasPerm(member, 'ManageServer')) return 3; + if (hasPerm(member, "ManageServer")) return 3; const config = await dbs.SERVERS.findOne({ id: server._id }); if (config?.botManagers?.includes(user._id)) return 2; - if (config?.moderators?.includes(user._id) || hasPerm(member, 'KickMembers')) return 1; + if ( + config?.moderators?.includes(user._id) || + hasPerm(member, "KickMembers") + ) + return 1; return 0; } -function getPermissionBasedOnRole(member: Member): 0|1|2|3 { - if (hasPerm(member, 'ManageServer')) return 3; - if (hasPerm(member, 'KickMembers')) return 1; +function getPermissionBasedOnRole(member: Member): 0 | 1 | 2 | 3 { + if (hasPerm(member, "ManageServer")) return 3; + if (hasPerm(member, "KickMembers")) return 1; return 0; } @@ -153,85 +185,117 @@ function hasPerm(member: Member, perm: keyof typeof Permission): boolean { /** * @deprecated Unnecessary */ -function hasPermForChannel(member: Member, channel: Channel, perm: keyof typeof Permission): boolean { - if (!member.server) throw 'hasPermForChannel(): Server is undefined'; +function hasPermForChannel( + member: Member, + channel: Channel, + perm: keyof typeof Permission +): boolean { + if (!member.server) throw "hasPermForChannel(): Server is undefined"; return member.hasPermission(channel, perm); } async function getOwnMemberInServer(server: Server): Promise { - return client.members.getKey({ server: server._id, user: client.user!._id }) - || await server.fetchMember(client.user!._id); + return ( + client.members.getKey({ server: server._id, user: client.user!._id }) || + (await server.fetchMember(client.user!._id)) + ); } -async function storeInfraction(infraction: Infraction): Promise<{ userWarnCount: number }> { +async function storeInfraction( + infraction: Infraction +): Promise<{ userWarnCount: number }> { let r = await Promise.all([ dbs.INFRACTIONS.insert(infraction, { castIds: false }), dbs.INFRACTIONS.find({ server: infraction.server, user: infraction.user, - _id: { $not: { $eq: infraction._id } } }, - ), + _id: { $not: { $eq: infraction._id } }, + }), ]); - return { userWarnCount: (r[1].length ?? 0) + 1 } + return { userWarnCount: (r[1].length ?? 0) + 1 }; } async function uploadFile(file: any, filename: string): Promise { let data = new FormData(); data.append("file", file, { filename: filename }); - let req = await axios.post(await getAutumnURL() + '/attachments', data, { headers: data.getHeaders() }); - return (req.data as any)['id'] as string; + let req = await axios.post((await getAutumnURL()) + "/attachments", data, { + headers: data.getHeaders(), + }); + return (req.data as any)["id"] as string; } async function sendLogMessage(config: LogConfig, content: LogMessage) { if (config.discord?.webhookUrl) { - let c = { ...content, ...content.overrides?.discord } + let c = { ...content, ...content.overrides?.discord }; const embed = new MessageEmbed(); if (c.title) embed.setTitle(content.title); if (c.description) embed.setDescription(c.description); - if (c.color?.match(/^#[0-9a-fA-F]+$/)) embed.setColor(c.color as ColorResolvable); + if (c.color?.match(/^#[0-9a-fA-F]+$/)) + embed.setColor(c.color as ColorResolvable); if (c.fields?.length) { for (const field of c.fields) { - embed.addField(field.title, field.content.trim() || "\u200b", field.inline); + embed.addField( + field.title, + field.content.trim() || "\u200b", + field.inline + ); } } if (content.image) { - if (content.image.type == 'THUMBNAIL') embed.setThumbnail(content.image.url); - else if (content.image.type == 'BIG') embed.setImage(content.image.url); + if (content.image.type == "THUMBNAIL") + embed.setThumbnail(content.image.url); + else if (content.image.type == "BIG") + embed.setImage(content.image.url); } if (content.attachments?.length) { - embed.setFooter(`Attachments: ${content.attachments.map(a => a.name).join(', ')}`); + embed.setFooter( + `Attachments: ${content.attachments + .map((a) => a.name) + .join(", ")}` + ); } let data = new FormData(); - content.attachments?.forEach(a => { + content.attachments?.forEach((a) => { data.append(`files[${ulid()}]`, a.content, { filename: a.name }); }); - data.append("payload_json", JSON.stringify({ embeds: [ embed.toJSON() ] }), { contentType: 'application/json' }); + data.append( + "payload_json", + JSON.stringify({ embeds: [embed.toJSON()] }), + { contentType: "application/json" } + ); - axios.post(config.discord.webhookUrl, data, {headers: data.getHeaders() }) - .catch(e => logger.error(`Failed to send log message (discord): ${e}`)); + axios + .post(config.discord.webhookUrl, data, { + headers: data.getHeaders(), + }) + .catch((e) => + logger.error(`Failed to send log message (discord): ${e}`) + ); } if (config.revolt?.channel) { let c = { ...content, ...content.overrides?.revolt }; try { - const channel = client.channels.get(config.revolt.channel) || await client.channels.fetch(config.revolt.channel); + const channel = + client.channels.get(config.revolt.channel) || + (await client.channels.fetch(config.revolt.channel)); - let message = ''; - let embed: SendableEmbed|undefined = undefined; - switch(config.revolt.type) { - case 'EMBED': + let message = ""; + let embed: SendableEmbed | undefined = undefined; + switch (config.revolt.type) { + case "EMBED": c = { ...c, ...content.overrides?.revoltEmbed }; embed = { title: c.title, description: c.description, colour: c.color, - } + }; if (c.fields?.length) { for (const field of c.fields) { @@ -241,35 +305,55 @@ async function sendLogMessage(config: LogConfig, content: LogMessage) { break; default: // QUOTEBLOCK, PLAIN or unspecified - + // Wrap entire message in quotes // please disregard this mess c = { ...c, ...content.overrides?.revoltQuoteblock }; - const quote = config.revolt.type == 'PLAIN' ? '' : '>'; + const quote = config.revolt.type == "PLAIN" ? "" : ">"; if (c.title) message += `## ${c.title}\n`; if (c.description) message += `${c.description}\n`; if (c.fields?.length) { for (const field of c.fields) { - message += `${quote ? '\u200b\n' : ''}${quote}### ${field.title}\n` + - `${quote}${field.content.trim().split('\n').join('\n' + quote)}\n${quote ? '\n' : ''}`; + message += + `${quote ? "\u200b\n" : ""}${quote}### ${ + field.title + }\n` + + `${quote}${field.content + .trim() + .split("\n") + .join("\n" + quote)}\n${quote ? "\n" : ""}`; } } - message = message.trim().split('\n').join('\n' + quote); // Wrap entire message in quotes - if (c.image?.url) message += `\n[Attachment](${c.image.url})`; + message = message + .trim() + .split("\n") + .join("\n" + quote); + if (c.image?.url) + message += `\n[Attachment](${c.image.url})`; break; } - channel.sendMessage({ - content: message, - embeds: embed ? [ embed ] : undefined, - attachments: content.attachments ? - await Promise.all(content.attachments?.map(a => uploadFile(a.content, a.name))) : - undefined - }).catch(e => logger.error(`Failed to send log message (revolt): ${e}`)); - } catch(e) { - logger.error(`Failed to send log message in ${config.revolt.channel}: ${e}`); + channel + .sendMessage({ + content: message, + embeds: embed ? [embed] : undefined, + attachments: content.attachments + ? await Promise.all( + content.attachments?.map((a) => + uploadFile(a.content, a.name) + ) + ) + : undefined, + }) + .catch((e) => + logger.error(`Failed to send log message (revolt): ${e}`) + ); + } catch (e) { + logger.error( + `Failed to send log message in ${config.revolt.channel}: ${e}` + ); } } } @@ -278,17 +362,17 @@ async function sendLogMessage(config: LogConfig, content: LogMessage) { * Attempts to escape a message's markdown content (qoutes, headers, **bold** / *italic*, etc) */ function sanitizeMessageContent(msg: string): string { - let str = ''; - for (let line of msg.split('\n')) { - + let str = ""; + for (let line of msg.split("\n")) { line = line.trim(); - if (line.startsWith('#') || // headers - line.startsWith('>') || // quotes - line.startsWith('|') || // tables - line.startsWith('*') || // unordered lists - line.startsWith('-') || // ^ - line.startsWith('+') // ^ + if ( + line.startsWith("#") || // headers + line.startsWith(">") || // quotes + line.startsWith("|") || // tables + line.startsWith("*") || // unordered lists + line.startsWith("-") || // ^ + line.startsWith("+") // ^ ) { line = `\\${line}`; } @@ -299,14 +383,17 @@ function sanitizeMessageContent(msg: string): string { line = `\u200b${line}`; } - for (const char of ['_', '!!', '~', '`', '*', '^', '$']) { - line = line.replace(new RegExp(`(?(...arrays: T[][]): T[] { @@ -342,65 +433,191 @@ function dedupeArray(...arrays: T[][]): T[] { function getMutualServers(user: User) { const servers: Server[] = []; for (const member of client.members) { - if (member[1]._id.user == user._id && member[1].server) servers.push(member[1].server); + if (member[1]._id.user == user._id && member[1].server) + servers.push(member[1].server); } return servers; } -const awaitClient = () => new Promise(async resolve => { - if (!client.user) client.once('ready', () => resolve()); - else resolve(); -}); +const awaitClient = () => + new Promise(async (resolve) => { + if (!client.user) client.once("ready", () => resolve()); + else resolve(); + }); -const getDmChannel = async (user: string|{_id: string}|User) => { - if (typeof user == 'string') user = client.users.get(user) || await client.users.fetch(user); - if (!(user instanceof User)) user = client.users.get(user._id) || await client.users.fetch(user._id); +const getDmChannel = async (user: string | { _id: string } | User) => { + if (typeof user == "string") + user = client.users.get(user) || (await client.users.fetch(user)); + if (!(user instanceof User)) + user = + client.users.get(user._id) || (await client.users.fetch(user._id)); - return Array.from(client.channels).find( - c => c[1].channel_type == 'DirectMessage' && c[1].recipient?._id == (user as User)._id - )?.[1] || await (user as User).openDM(); -} + return ( + Array.from(client.channels).find( + (c) => + c[1].channel_type == "DirectMessage" && + c[1].recipient?._id == (user as User)._id + )?.[1] || (await (user as User).openDM()) + ); +}; -const generateInfractionDMEmbed = (server: Server, serverConfig: ServerConfig, infraction: Infraction, message: MessageCommandContext) => { +const generateInfractionDMEmbed = ( + server: Server, + serverConfig: ServerConfig, + infraction: Infraction, + message: MessageCommandContext +) => { const embed: SendableEmbed = { title: message.serverContext.name, icon_url: message.serverContext.generateIconURL({ max_side: 128 }), - colour: '#ff9e2f', + colour: "#ff9e2f", url: message.url, - description: 'You have been ' + + description: + "You have been " + (infraction.actionType - ? `**${infraction.actionType == 'ban' ? 'banned' : 'kicked'}** from ` + ? `**${ + infraction.actionType == "ban" ? "banned" : "kicked" + }** from ` : `**warned** in `) + - `'${sanitizeMessageContent(message.serverContext.name).trim()}' .\n` + + `'${sanitizeMessageContent( + message.serverContext.name + ).trim()}' .\n` + `**Reason:** ${infraction.reason}\n` + - `**Moderator:** [@${sanitizeMessageContent(message.author?.username || 'Unknown')}](/@${message.author_id})\n` + + `**Moderator:** [@${sanitizeMessageContent( + message.author?.username || "Unknown" + )}](/@${message.author_id})\n` + `**Infraction ID:** \`${infraction._id}\`` + - (infraction.actionType == 'ban' && infraction.expires - ? (infraction.expires == Infinity - ? '\n**Ban duration:** Permanent' - : `\n**Ban expires** `) - : '') + - (infraction.actionType == 'ban' - ? '\n\n**Reminder:** Circumventing this ban by using another account is a violation of the Revolt [Terms of Service]() ' + - 'and may result in your accounts getting suspended from the platform.' - : '') - } + (infraction.actionType == "ban" && infraction.expires + ? infraction.expires == Infinity + ? "\n**Ban duration:** Permanent" + : `\n**Ban expires** ` + : "") + + (infraction.actionType == "ban" + ? "\n\n**Reminder:** Circumventing this ban by using another account is a violation of the Revolt [Terms of Service]() " + + "and may result in your accounts getting suspended from the platform." + : ""), + }; if (serverConfig.contact) { if (RE_MAILTO_URI.test(serverConfig.contact)) { - embed.description += `\n\nIf you wish to appeal this decision, you may contact the server's moderation team at ` + - `[${serverConfig.contact.replace(RE_MAILTO_URI, '')}](${serverConfig.contact}).` - } - else if (RE_HTTP_URI.test(serverConfig.contact)) { - embed.description += `\n\nIf you wish to appeal this decision, you may do so [here](${serverConfig.contact}).` - } - else { + embed.description += + `\n\nIf you wish to appeal this decision, you may contact the server's moderation team at ` + + `[${serverConfig.contact.replace(RE_MAILTO_URI, "")}](${ + serverConfig.contact + }).`; + } else if (RE_HTTP_URI.test(serverConfig.contact)) { + embed.description += `\n\nIf you wish to appeal this decision, you may do so [here](${serverConfig.contact}).`; + } else { embed.description += `\n\n${serverConfig.contact}`; } } return embed; -} +}; + +// Copied from https://github.com/janderedev/feeds-bot/blob/master/src/util.ts +const yesNoMessage = ( + channel: Channel, + allowedUser: string, + message: string, + title?: string, + messageYes?: string, + messageNo?: string +): Promise => + new Promise(async (resolve, reject) => { + const EMOJI_YES = "✅", + EMOJI_NO = "❌"; + try { + const msg = await channel.sendMessage({ + embeds: [ + { + colour: "var(--status-streaming)", + title: title, + description: message, + }, + ], + interactions: { + reactions: [EMOJI_YES, EMOJI_NO], + restrict_reactions: true, + }, + }); + + let destroyed = false; + const cb = async (packet: ClientboundNotification) => { + if (packet.type != "MessageReact") return; + if (packet.id != msg._id) return; + if (packet.user_id != allowedUser) return; + + switch (packet.emoji_id) { + case EMOJI_YES: + channel.client.removeListener("packet", cb); + destroyed = true; + resolve(true); + msg.edit({ + embeds: [ + { + colour: "var(--success)", + title: title, + description: `${EMOJI_YES} ${ + messageYes ?? "Confirmed!" + }`, + }, + ], + }).catch((e) => console.error(e)); + break; + + case EMOJI_NO: + channel.client.removeListener("packet", cb); + destroyed = true; + resolve(false); + msg.edit({ + embeds: [ + { + colour: "var(--error)", + title: title, + description: `${EMOJI_NO} ${ + messageNo ?? "Cancelled." + }`, + }, + ], + }).catch((e) => console.error(e)); + break; + + default: + logger.warn( + "Received unexpected reaction: " + packet.emoji_id + ); + } + }; + channel.client.on("packet", cb); + + setTimeout(() => { + if (!destroyed) { + resolve(false); + channel.client.removeListener("packet", cb); + msg.edit({ + embeds: [ + { + colour: "var(--error)", + title: title, + description: `${EMOJI_NO} Timed out`, + }, + ], + }).catch((e) => console.error(e)); + } + }, 30000); + } catch (e) { + reject(e); + } + }); + +// Get all cached members of a server. Whoever put STRINGIFIED JSON as map keys is now on my hit list. +const getMembers = (id: string) => + Array.from(client.members.entries()) + .filter((item) => item[0].includes(`"${id}"`)) + .map((entry) => entry[1]); export { getAutumnURL, @@ -423,9 +640,11 @@ export { getMutualServers, getDmChannel, generateInfractionDMEmbed, + yesNoMessage, + getMembers, EmbedColor, NO_MANAGER_MSG, ULID_REGEX, USER_MENTION_REGEX, CHANNEL_MENTION_REGEX, -} +};