Repository: Cog-Creators/Red-DiscordBot

Issue:
Allow `[p]ban` to hackban and rename `[p]hackban` to `[p]massban`
# Enhancement request

#### Select the target of enhancement you are requesting:

- [ ] Cog
- [x] Command
- [ ] API functionality

#### Describe your requested feature

`[p]ban` should allow to ban users that aren't in the server (when user mention/user id was used of course as we can't otherwise ban them).
`[p]hackban` should be renamed to `[p]massban` or `[p]multiban`. The command should still allow only user mentions and IDs to avoid ambiguity.


Discussion:
To add, `[p]hackban` alias should probably stay for some time.

Resolving patch:
diff --git a/redbot/cogs/mod/kickban.py b/redbot/cogs/mod/kickban.py
--- a/redbot/cogs/mod/kickban.py
+++ b/redbot/cogs/mod/kickban.py
@@ -2,7 +2,7 @@
 import contextlib
 import logging
 from datetime import datetime, timedelta, timezone
-from typing import Optional, Union
+from typing import Optional, Tuple, Union
 
 import discord
 from redbot.core import commands, i18n, checks, modlog
@@ -62,67 +62,109 @@ async def get_invite_for_reinvite(ctx: commands.Context, max_age: int = 86400):
 
     async def ban_user(
         self,
-        user: discord.Member,
+        user: Union[discord.Member, discord.User, discord.Object],
         ctx: commands.Context,
         days: int = 0,
         reason: str = None,
         create_modlog_case=False,
-    ) -> Union[str, bool]:
+    ) -> Tuple[bool, str]:
         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.config, 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 (0 <= days <= 7):
-            return _("Invalid days. Must be between 0 and 7.")
+        removed_temp = False
 
-        toggle = await self.config.guild(guild).dm_on_kickban()
-        if toggle:
-            with contextlib.suppress(discord.HTTPException):
-                em = discord.Embed(
-                    title=bold(_("You have been banned from {guild}.").format(guild=guild))
+        if not (0 <= days <= 7):
+            return False, _("Invalid days. Must be between 0 and 7.")
+
+        if isinstance(user, discord.Member):
+            if author == user:
+                return (
+                    False,
+                    _("I cannot let you do that. Self-harm is bad {}").format("\N{PENSIVE FACE}"),
                 )
-                em.add_field(
-                    name=_("**Reason**"),
-                    value=reason if reason is not None else _("No reason was given."),
-                    inline=False,
+            elif not await is_allowed_by_hierarchy(self.bot, self.config, guild, author, user):
+                return (
+                    False,
+                    _(
+                        "I cannot let you do that. You are "
+                        "not higher than the user in the role "
+                        "hierarchy."
+                    ),
                 )
-                await user.send(embed=em)
+            elif guild.me.top_role <= user.top_role or user == guild.owner:
+                return False, _("I cannot do that due to Discord hierarchy rules.")
+
+            toggle = await self.config.guild(guild).dm_on_kickban()
+            if toggle:
+                with contextlib.suppress(discord.HTTPException):
+                    em = discord.Embed(
+                        title=bold(_("You have been banned from {guild}.").format(guild=guild))
+                    )
+                    em.add_field(
+                        name=_("**Reason**"),
+                        value=reason if reason is not None else _("No reason was given."),
+                        inline=False,
+                    )
+                    await user.send(embed=em)
+
+            ban_type = "ban"
+        else:
+            tempbans = await self.config.guild(guild).current_tempbans()
+
+            ban_list = [ban.user.id for ban in await guild.bans()]
+            if user.id in ban_list:
+                if user.id in tempbans:
+                    async with self.config.guild(guild).current_tempbans() as tempbans:
+                        tempbans.remove(user.id)
+                    removed_temp = True
+                else:
+                    return (
+                        False,
+                        _("User with ID {user_id} is already banned.").format(user_id=user.id),
+                    )
+
+            ban_type = "hackban"
 
         audit_reason = get_audit_reason(author, reason)
 
         queue_entry = (guild.id, user.id)
-        try:
-            await guild.ban(user, reason=audit_reason, delete_message_days=days)
+        if removed_temp:
             log.info(
-                "{}({}) banned {}({}), deleting {} days worth of messages.".format(
-                    author.name, author.id, user.name, user.id, str(days)
+                "{}({}) upgraded the tempban for {} to a permaban.".format(
+                    author.name, author.id, user.id
                 )
             )
-        except discord.Forbidden:
-            return _("I'm not allowed to do that.")
-        except Exception as e:
-            log.exception(
-                "{}({}) attempted to kick {}({}), but an error occurred.".format(
-                    author.name, author.id, user.name, user.id
+            success_message = _(
+                "User with ID {user_id} was upgraded from a temporary to a permanent ban."
+            ).format(user_id=user.id)
+        else:
+            username = user.name if hasattr(user, "name") else "Unknown"
+            try:
+                await guild.ban(user, reason=audit_reason, delete_message_days=days)
+                log.info(
+                    "{}({}) {}ned {}({}), deleting {} days worth of messages.".format(
+                        author.name, author.id, ban_type, username, user.id, str(days)
+                    )
                 )
-            )
-            return _("An unexpected error occurred.")
+                success_message = _("Done. That felt good.")
+            except discord.Forbidden:
+                return False, _("I'm not allowed to do that.")
+            except discord.NotFound:
+                return False, _("User with ID {user_id} not found").format(user_id=user.id)
+            except Exception as e:
+                log.exception(
+                    "{}({}) attempted to {} {}({}), but an error occurred.".format(
+                        author.name, author.id, ban_type, username, user.id
+                    )
+                )
+                return False, _("An unexpected error occurred.")
 
         if create_modlog_case:
             await modlog.create_case(
                 self.bot,
                 guild,
                 ctx.message.created_at.replace(tzinfo=timezone.utc),
-                "ban",
+                ban_type,
                 user,
                 author,
                 reason,
@@ -130,7 +172,7 @@ async def ban_user(
                 channel=None,
             )
 
-        return True
+        return True, success_message
 
     async def check_tempban_expirations(self):
         while self == self.bot.get_cog("Mod"):
@@ -247,13 +289,15 @@ async def kick(self, ctx: commands.Context, user: discord.Member, *, reason: str
     async def ban(
         self,
         ctx: commands.Context,
-        user: discord.Member,
+        user: Union[discord.Member, RawUserIds],
         days: Optional[int] = None,
         *,
         reason: str = None,
     ):
         """Ban a user from this server and optionally delete days of messages.
 
+        A user ID should be provided if the user is not a member of this server.
+
         If days is not a number, it's treated as the first word of the reason.
 
         Minimum 0 days, maximum 7. If not specified, defaultdays setting will be used instead."""
@@ -261,21 +305,20 @@ async def ban(
         guild = ctx.guild
         if days is None:
             days = await self.config.guild(guild).default_days()
+        if isinstance(user, int):
+            user = self.bot.get_user(user) or discord.Object(id=user)
 
-        result = await self.ban_user(
+        success_, message = await self.ban_user(
             user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True
         )
 
-        if result is True:
-            await ctx.send(_("Done. It was about time."))
-        elif isinstance(result, str):
-            await ctx.send(result)
+        await ctx.send(message)
 
-    @commands.command()
+    @commands.command(aliases=["hackban"])
     @commands.guild_only()
     @commands.bot_has_permissions(ban_members=True)
     @checks.admin_or_permissions(ban_members=True)
-    async def hackban(
+    async def massban(
         self,
         ctx: commands.Context,
         user_ids: commands.Greedy[RawUserIds],
@@ -283,7 +326,7 @@ async def hackban(
         *,
         reason: str = None,
     ):
-        """Preemptively bans user(s) from the server.
+        """Mass bans user(s) from the server.
 
         User IDs need to be provided in order to ban
         using this command."""
@@ -339,7 +382,7 @@ def remove_processed(ids):
                         # We need to check if a user is tempbanned here because otherwise they won't be processed later on.
                         continue
                     else:
-                        errors[user_id] = _("User {user_id} is already banned.").format(
+                        errors[user_id] = _("User with ID {user_id} is already banned.").format(
                             user_id=user_id
                         )
 
@@ -358,14 +401,14 @@ def remove_processed(ids):
                 else:
                     # Instead of replicating all that handling... gets attr from decorator
                     try:
-                        result = await self.ban_user(
+                        success, reason = await self.ban_user(
                             user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True
                         )
-                        if result is True:
+                        if success:
                             banned.append(user_id)
                         else:
                             errors[user_id] = _("Failed to ban user {user_id}: {reason}").format(
-                                user_id=user_id, reason=result
+                                user_id=user_id, reason=reason
                             )
                     except Exception as e:
                         errors[user_id] = _("Failed to ban user {user_id}: {reason}").format(
@@ -397,13 +440,13 @@ def remove_processed(ids):
                         await guild.ban(user, reason=audit_reason, delete_message_days=days)
                         log.info("{}({}) hackbanned {}".format(author.name, author.id, user_id))
                     except discord.NotFound:
-                        errors[user_id] = _("User {user_id} does not exist.").format(
+                        errors[user_id] = _("User with ID {user_id} not found").format(
                             user_id=user_id
                         )
                         continue
                     except discord.Forbidden:
                         errors[user_id] = _(
-                            "Could not ban {user_id}: missing permissions."
+                            "Could not ban user with ID {user_id}: missing permissions."
                         ).format(user_id=user_id)
                         continue
                     else:


Test patch: