diff --git a/redbot/cogs/mod/mod.py b/redbot/cogs/mod/mod.py index 592339098..f59e75d5e 100644 --- a/redbot/cogs/mod/mod.py +++ b/redbot/cogs/mod/mod.py @@ -2,14 +2,16 @@ import asyncio import contextlib from datetime import datetime, timedelta from collections import deque, defaultdict, namedtuple -from typing import cast +from typing import Optional, Union, cast import discord +from discord.ext.commands.converter import Converter, Greedy +from discord.ext.commands.errors import BadArgument from redbot.core import checks, Config, modlog, commands from redbot.core.bot import Red from redbot.core.i18n import Translator, cog_i18n -from redbot.core.utils.chat_formatting import box, escape +from redbot.core.utils.chat_formatting import box, escape, pagify from .checks import mod_or_voice_permissions, admin_or_voice_permissions, bot_has_voice_permissions from redbot.core.utils.mod import is_mod_or_superior, is_allowed_by_hierarchy, get_audit_reason from .log import log @@ -19,6 +21,18 @@ from redbot.core.utils.common_filters import filter_invites, filter_various_ment _ = T_ = Translator("Mod", __file__) +class RawUserIds(Converter): + async def convert(self, ctx, argument): + # This is for the hackban command, where we receive IDs that + # are most likely not in the guild. + # As long as it's numeric and long enough, it makes a good candidate + # to attempt a ban on + if argument.isnumeric() and len(argument) >= 17: + return int(argument) + + raise BadArgument("{} doesn't look like a valid user ID.".format(argument)) + + @cog_i18n(_) class Mod(commands.Cog): """Moderation tools.""" @@ -386,145 +400,144 @@ class Mod(commands.Cog): @commands.bot_has_permissions(ban_members=True) @checks.admin_or_permissions(ban_members=True) async def ban( - self, ctx: commands.Context, user: discord.Member, days: str = None, *, reason: str = None + self, + ctx: commands.Context, + user: discord.Member, + days: Optional[int] = 0, + *, + reason: str = None, ): """Ban a user from this server. - Deletes `` worth of messages. + If days is not a number, it's treated as the first word of the reason. + Minimum 0 days, maximum 7. Defaults to 0.""" + result = await self.ban_user( + user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True + ) - If `` is not a number, it's treated as the first word of - the reason. Minimum 0 days, maximum 7. Defaults to 0. - """ - author = ctx.author - guild = ctx.guild - - if author == user: - await ctx.send( - _("I cannot let you do that. Self-harm is bad {}").format("\N{PENSIVE FACE}") - ) - return - elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user): - await ctx.send( - _( - "I cannot let you do that. You are " - "not higher than the user in the role " - "hierarchy." - ) - ) - return - elif ctx.guild.me.top_role <= user.top_role or user == ctx.guild.owner: - await ctx.send(_("I cannot do that due to discord hierarchy rules")) - return - - if days: - if days.isdigit(): - days = int(days) - else: - if reason: - reason = "{} {}".format(days, reason) - else: - reason = days - days = 0 - else: - days = 0 - - audit_reason = get_audit_reason(author, reason) - - if days < 0 or days > 7: - await ctx.send(_("Invalid days. Must be between 0 and 7.")) - return - queue_entry = (guild.id, user.id) - self.ban_queue.append(queue_entry) - try: - await guild.ban(user, reason=audit_reason, delete_message_days=days) - log.info( - "{}({}) banned {}({}), deleting {} days worth of messages".format( - author.name, author.id, user.name, user.id, str(days) - ) - ) - except discord.Forbidden: - self.ban_queue.remove(queue_entry) - await ctx.send(_("I'm not allowed to do that.")) - except Exception as e: - self.ban_queue.remove(queue_entry) - print(e) - else: + if result is True: await ctx.send(_("Done. It was about time.")) - - try: - await modlog.create_case( - self.bot, - guild, - ctx.message.created_at, - "ban", - user, - author, - reason, - until=None, - channel=None, - ) - except RuntimeError as e: - await ctx.send(e) + elif isinstance(result, str): + await ctx.send(result) @commands.command() @commands.guild_only() @commands.bot_has_permissions(ban_members=True) @checks.admin_or_permissions(ban_members=True) - async def hackban(self, ctx: commands.Context, user_id: int, *, reason: str = None): - """Pre-emptively ban a user from this server. + async def hackban( + self, + ctx: commands.Context, + user_ids: Greedy[RawUserIds], + days: Optional[int] = 0, + *, + reason: str = None, + ): + """Preemptively bans user(s) from the server + + User IDs need to be provided in order to ban + using this command""" + + banned = [] + errors = {} + + async def show_results(): + text = _("Banned {} users from the server.".format(len(banned))) + if errors: + text += _("\nErrors:\n") + text += "\n".join(errors.values()) + + for p in pagify(text): + await ctx.send(p) + + def remove_processed(ids): + return [_id for _id in ids if _id not in banned and _id not in errors] + + user_ids = list(set(user_ids)) # No dupes - A user ID needs to be provided in order to ban - using this command. - """ author = ctx.author guild = ctx.guild - is_banned = False - ban_list = await guild.bans() - for entry in ban_list: - if entry.user.id == user_id: - is_banned = True - break - if is_banned: - await ctx.send(_("User is already banned.")) + if not user_ids: + await ctx.send_help() return - user = guild.get_member(user_id) - if user is not None: - # Instead of replicating all that handling... gets attr from decorator - return await ctx.invoke(self.ban, user, None, reason=reason) - user = discord.Object(id=user_id) # User not in the guild, but + if not (days >= 0 and days <= 7): + await ctx.send(_("Invalid days. Must be between 0 and 7.")) + return - audit_reason = get_audit_reason(author, reason) - queue_entry = (guild.id, user_id) - self.ban_queue.append(queue_entry) - try: - await guild.ban(user, reason=audit_reason) - log.info("{}({}) hackbanned {}".format(author.name, author.id, user_id)) - except discord.NotFound: - self.ban_queue.remove(queue_entry) - await ctx.send(_("User not found. Have you provided the correct user ID?")) - except discord.Forbidden: - self.ban_queue.remove(queue_entry) - await ctx.send(_("I lack the permissions to do this.")) - else: - await ctx.send(_("Done. The user will not be able to join this server.")) + if not guild.me.guild_permissions.ban_members: + return await ctx.send(_("I lack the permissions to do this.")) - user_info = await self.bot.get_user_info(user_id) - try: - await modlog.create_case( - self.bot, - guild, - ctx.message.created_at, - "hackban", - user_info, - author, - reason, - until=None, - channel=None, - ) - except RuntimeError as e: - await ctx.send(e) + ban_list = await guild.bans() + for entry in ban_list: + for user_id in user_ids: + if entry.user.id == user_id: + errors[user_id] = _("User {} is already banned.".format(user_id)) + + user_ids = remove_processed(user_ids) + + if not user_ids: + await show_results() + return + + for user_id in user_ids: + user = guild.get_member(user_id) + if user is not None: + # Instead of replicating all that handling... gets attr from decorator + try: + result = await self.ban_user( + user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True + ) + if result is True: + banned.append(user_id) + else: + errors[user_id] = _("Failed to ban user {}: {}".format(user_id, result)) + except Exception as e: + errors[user_id] = _("Failed to ban user {}: {}".format(user_id, e)) + + user_ids = remove_processed(user_ids) + + if not user_ids: + await show_results() + return + + for user_id in user_ids: + user = discord.Object(id=user_id) + audit_reason = get_audit_reason(author, reason) + queue_entry = (guild.id, user_id) + self.ban_queue.append(queue_entry) + try: + await guild.ban(user, reason=audit_reason, delete_message_days=days) + log.info("{}({}) hackbanned {}".format(author.name, author.id, user_id)) + except discord.NotFound: + self.ban_queue.remove(queue_entry) + errors[user_id] = _("User {} does not exist.".format(user_id)) + continue + except discord.Forbidden: + self.ban_queue.remove(queue_entry) + errors[user_id] = _("Could not ban {}: missing permissions.".format(user_id)) + continue + else: + banned.append(user_id) + + user_info = await self.bot.get_user_info(user_id) + + try: + await modlog.create_case( + self.bot, + guild, + ctx.message.created_at, + "hackban", + user_info, + author, + reason, + until=None, + channel=None, + ) + except RuntimeError as e: + errors["0"] = _("Failed to create modlog case: {}".format(e)) + + await show_results() @commands.command() @commands.guild_only() @@ -1423,6 +1436,69 @@ class Mod(commands.Cog): else: await ctx.send(_("That user doesn't have any recorded name or nickname change.")) + async def ban_user( + self, + user: discord.Member, + ctx: commands.Context, + days: Optional[int] = 0, + reason: str = None, + create_modlog_case=False, + ) -> Union[str, bool]: + author = ctx.author + guild = ctx.guild + + if author == user: + return _("I cannot let you do that. Self-harm is bad {}").format("\N{PENSIVE FACE}") + elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user): + return _( + "I cannot let you do that. You are " + "not higher than the user in the role " + "hierarchy." + ) + elif guild.me.top_role <= user.top_role or user == guild.owner: + return _("I cannot do that due to discord hierarchy rules") + elif not (days >= 0 and days <= 7): + return _("Invalid days. Must be between 0 and 7.") + + audit_reason = get_audit_reason(author, reason) + + queue_entry = (guild.id, user.id) + self.ban_queue.append(queue_entry) + try: + await guild.ban(user, reason=audit_reason, delete_message_days=days) + log.info( + "{}({}) banned {}({}), deleting {} days worth of messages".format( + author.name, author.id, user.name, user.id, str(days) + ) + ) + except discord.Forbidden: + self.ban_queue.remove(queue_entry) + return _("I'm not allowed to do that.") + except Exception as e: + self.ban_queue.remove(queue_entry) + return e + + if create_modlog_case: + try: + await modlog.create_case( + self.bot, + guild, + ctx.message.created_at, + "ban", + user, + author, + reason, + until=None, + channel=None, + ) + except RuntimeError as e: + return _( + "The user was banned but an error occurred when trying to " + "create the modlog entry: {}".format(e) + ) + + return True + async def get_names_and_nicks(self, user): names = await self.settings.user(user).past_names() nicks = await self.settings.member(user).past_nicks()