diff options
| -rw-r--r-- | bot/constants.py | 2 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_scheduler.py | 22 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_utils.py | 51 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/management.py | 29 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/superstarify.py | 22 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/infraction/test_utils.py | 36 | 
6 files changed, 97 insertions, 65 deletions
| diff --git a/bot/constants.py b/bot/constants.py index b775848fb..4531b547d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -429,8 +429,6 @@ class Channels(metaclass=YAMLGetter):      off_topic_1: int      off_topic_2: int -    black_formatter: int -      bot_commands: int      discord_bots: int      esoteric: int diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index d51009358..8107b502a 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -166,15 +166,12 @@ class InfractionScheduler:          # apply kick/ban infractions first, this would mean that we'd make it          # impossible for us to deliver a DM. See python-discord/bot#982.          if not infraction["hidden"] and infr_type in {"ban", "kick"}: -            dm_result = f"{constants.Emojis.failmail} " -            dm_log_text = "\nDM: **Failed**" - -            # Accordingly update whether the user was successfully notified via DM. -            if await _utils.notify_infraction( -                    self.bot, user, infraction["id"], infr_type.replace("_", " ").title(), expiry, user_reason, icon -            ): +            if await _utils.notify_infraction(infraction, user, user_reason):                  dm_result = ":incoming_envelope: "                  dm_log_text = "\nDM: Sent" +            else: +                dm_result = f"{constants.Emojis.failmail} " +                dm_log_text = "\nDM: **Failed**"          end_msg = ""          if is_mod_channel(ctx.channel): @@ -236,15 +233,12 @@ class InfractionScheduler:              # If we need to DM and haven't already tried to              if not infraction["hidden"] and infr_type not in {"ban", "kick"}: -                dm_result = f"{constants.Emojis.failmail} " -                dm_log_text = "\nDM: **Failed**" - -                # Accordingly update whether the user was successfully notified via DM. -                if await _utils.notify_infraction( -                        self.bot, user, infraction["id"], infr_type.replace("_", " ").title(), expiry, user_reason, icon -                ): +                if await _utils.notify_infraction(infraction, user, user_reason):                      dm_result = ":incoming_envelope: "                      dm_log_text = "\nDM: Sent" +                else: +                    dm_result = f"{constants.Emojis.failmail} " +                    dm_log_text = "\nDM: **Failed**"          # Send a confirmation message to the invoking context.          log.trace(f"Sending infraction #{id_} confirmation message.") diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index a464f7c87..e319f9d71 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,15 +1,17 @@  import typing as t  from datetime import datetime +import arrow  import disnake  from disnake.ext.commands import Context +import bot  from bot.api import ResponseCodeError -from bot.bot import Bot  from bot.constants import Colours, Icons  from bot.converters import MemberOrUser  from bot.errors import InvalidInfractedUserError  from bot.log import get_logger +from bot.utils import time  log = get_logger(__name__) @@ -43,6 +45,7 @@ LONGEST_EXTRAS = max(len(INFRACTION_APPEAL_SERVER_FOOTER), len(INFRACTION_APPEAL  INFRACTION_DESCRIPTION_TEMPLATE = (      "**Type:** {type}\n"      "**Expires:** {expires}\n" +    "**Duration:** {duration}\n"      "**Reason:** {reason}\n"  ) @@ -159,20 +162,46 @@ async def send_active_infraction_message(ctx: Context, infraction: Infraction) -  async def notify_infraction( -        bot: Bot, +        infraction: Infraction,          user: MemberOrUser, -        infr_id: id, -        infr_type: str, -        expires_at: t.Optional[str] = None, -        reason: t.Optional[str] = None, -        icon_url: str = Icons.token_removed +        reason: t.Optional[str] = None  ) -> bool: -    """DM a user about their new infraction and return True if the DM is successful.""" +    """ +    DM a user about their new infraction and return True if the DM is successful. + +    `reason` can be used to override what is in `infraction`. Otherwise, this data will +    be retrieved from `infraction`. +    """ +    infr_id = infraction["id"] +    infr_type = infraction["type"].replace("_", " ").title() +    icon_url = INFRACTION_ICONS[infraction["type"]][0] + +    if infraction["expires_at"] is None: +        expires_at = "Never" +        duration = "Permanent" +    else: +        expiry = arrow.get(infraction["expires_at"]) +        now = arrow.utcnow() + +        expires_at = time.format_relative(expiry) +        duration = time.humanize_delta(infraction["inserted_at"], expiry, max_units=2) + +        if expiry < now: +            expires_at += " (Expired)" +        else: +            remaining = time.humanize_delta(expiry, now, max_units=2) +            if duration != remaining: +                duration += f" ({remaining} remaining)" +      log.trace(f"Sending {user} a DM about their {infr_type} infraction.") +    if reason is None: +        reason = infraction["reason"] +      text = INFRACTION_DESCRIPTION_TEMPLATE.format(          type=infr_type.title(), -        expires=expires_at or "N/A", +        expires=expires_at, +        duration=duration,          reason=reason or "No reason provided."      ) @@ -180,7 +209,7 @@ async def notify_infraction(      if len(text) > 4096 - LONGEST_EXTRAS:          text = f"{text[:4093-LONGEST_EXTRAS]}..." -    text += INFRACTION_APPEAL_SERVER_FOOTER if infr_type.lower() == 'ban' else INFRACTION_APPEAL_MODMAIL_FOOTER +    text += INFRACTION_APPEAL_SERVER_FOOTER if infraction["type"] == 'ban' else INFRACTION_APPEAL_MODMAIL_FOOTER      embed = disnake.Embed(          description=text, @@ -193,7 +222,7 @@ async def notify_infraction(      dm_sent = await send_private_embed(user, embed)      if dm_sent: -        await bot.api_client.patch( +        await bot.instance.api_client.patch(              f"bot/infractions/{infr_id}",              json={"dm_sent": True}          ) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 875e8ef34..25420cd7a 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -10,6 +10,7 @@ from bot import constants  from bot.bot import Bot  from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings  from bot.errors import InvalidInfraction +from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction.infractions import Infractions  from bot.exts.moderation.modlog import ModLog  from bot.log import get_logger @@ -39,12 +40,10 @@ class ModManagement(commands.Cog):          """Get currently loaded Infractions cog instance."""          return self.bot.get_cog("Infractions") -    # region: Edit infraction commands -      @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True)      async def infraction_group(self, ctx: Context, infraction: Infraction = None) -> None:          """ -        Infraction manipulation commands. +        Infraction management commands.          If `infraction` is passed then this command fetches that infraction. The `Infraction` converter          supports 'l', 'last' and 'recent' to get the most recent infraction made by `ctx.author`. @@ -59,6 +58,30 @@ class ModManagement(commands.Cog):          )          await self.send_infraction_list(ctx, embed, [infraction]) +    @infraction_group.command(name="resend", aliases=("send", "rs", "dm")) +    async def infraction_resend(self, ctx: Context, infraction: Infraction) -> None: +        """Resend a DM to a user about a given infraction of theirs.""" +        if infraction["hidden"]: +            await ctx.send(f"{constants.Emojis.failmail} You may not resend hidden infractions.") +            return + +        member_id = infraction["user"]["id"] +        member = await get_or_fetch_member(ctx.guild, member_id) +        if not member: +            await ctx.send(f"{constants.Emojis.failmail} Cannot find member `{member_id}` in the guild.") +            return + +        id_ = infraction["id"] +        reason = infraction["reason"] or "No reason provided." +        reason += "\n\n**This is a re-sent message for a previously applied infraction which may have been edited.**" + +        if await _utils.notify_infraction(infraction, member, reason): +            await ctx.send(f":incoming_envelope: Resent DM for infraction `{id_}`.") +        else: +            await ctx.send(f"{constants.Emojis.failmail} Failed to resend DM for infraction `{id_}`.") + +    # region: Edit infraction commands +      @infraction_group.command(name="append", aliases=("amend", "add", "a"))      async def infraction_append(          self, diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 1d357d441..41ba52580 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -63,6 +63,12 @@ class Superstarify(InfractionScheduler, Cog):          if after.display_name == forced_nick:              return  # Nick change was triggered by this event. Ignore. +        reason = ( +            "You have tried to change your nickname on the **Python Discord** server " +            f"from **{before.display_name}** to **{after.display_name}**, but as you " +            "are currently in superstar-prison, you do not have permission to do so." +        ) +          log.info(              f"{after.display_name} ({after.id}) tried to escape superstar prison. "              f"Changing the nick back to {before.display_name}." @@ -72,21 +78,7 @@ class Superstarify(InfractionScheduler, Cog):              reason=f"Superstarified member tried to escape the prison: {infr_id}"          ) -        notified = await _utils.notify_infraction( -            bot=self.bot, -            user=after, -            infr_id=infr_id, -            infr_type="Superstarify", -            expires_at=time.discord_timestamp(infraction["expires_at"]), -            reason=( -                "You have tried to change your nickname on the **Python Discord** server " -                f"from **{before.display_name}** to **{after.display_name}**, but as you " -                "are currently in superstar-prison, you do not have permission to do so." -            ), -            icon_url=_utils.INFRACTION_ICONS["superstar"][0] -        ) - -        if not notified: +        if not await _utils.notify_infraction(infraction, after, reason):              log.info("Failed to DM user about why they cannot change their nickname.")      @Cog.listener() diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 6601b9d25..eaa0e701e 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -15,7 +15,10 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):      """Tests Moderation utils."""      def setUp(self): -        self.bot = MockBot() +        patcher = patch("bot.instance", new=MockBot()) +        self.bot = patcher.start() +        self.addCleanup(patcher.stop) +          self.member = MockMember(id=1234)          self.user = MockUser(id=1234)          self.ctx = MockContext(bot=self.bot, author=self.member) @@ -123,8 +126,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  else:                      self.ctx.send.assert_not_awaited() +    @unittest.skip("Current time needs to be patched so infraction duration is correct.")      @patch("bot.exts.moderation.infraction._utils.send_private_embed") -    async def test_notify_infraction(self, send_private_embed_mock): +    async def test_send_infraction_embed(self, send_private_embed_mock):          """          Should send an embed of a certain format as a DM and return `True` if DM successful. @@ -132,7 +136,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):          """          test_cases = [              { -                "args": (self.bot, self.user, 0, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), +                "args": (dict(id=0, type="ban", reason=None, expires_at=datetime(2020, 2, 26, 9, 20)), self.user),                  "expected_output": Embed(                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -145,12 +149,12 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL, -                    icon_url=Icons.token_removed +                    icon_url=Icons.user_ban                  ),                  "send_result": True              },              { -                "args": (self.bot, self.user, 0, "warning", None, "Test reason."), +                "args": (dict(id=0, type="warning", reason="Test reason.", expires_at=None), self.user),                  "expected_output": Embed(                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -163,14 +167,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL, -                    icon_url=Icons.token_removed +                    icon_url=Icons.user_warn                  ),                  "send_result": False              },              # Note that this test case asserts that the DM that *would* get sent to the user is formatted              # correctly, even though that message is deliberately never sent.              { -                "args": (self.bot, self.user, 0, "note", None, None, Icons.defcon_denied), +                "args": (dict(id=0, type="note", reason=None, expires_at=None), self.user),                  "expected_output": Embed(                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -183,20 +187,12 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL, -                    icon_url=Icons.defcon_denied +                    icon_url=Icons.user_warn                  ),                  "send_result": False              },              { -                "args": ( -                    self.bot, -                    self.user, -                    0, -                    "mute", -                    "2020-02-26 09:20 (23 hours and 59 minutes)", -                    "Test", -                    Icons.defcon_denied -                ), +                "args": (dict(id=0, type="mute", reason="Test", expires_at=datetime(2020, 2, 26, 9, 20)), self.user),                  "expected_output": Embed(                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -209,12 +205,12 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL, -                    icon_url=Icons.defcon_denied +                    icon_url=Icons.user_mute                  ),                  "send_result": False              },              { -                "args": (self.bot, self.user, 0, "mute", None, "foo bar" * 4000, Icons.defcon_denied), +                "args": (dict(id=0, type="mute", reason="foo bar" * 4000, expires_at=None), self.user),                  "expected_output": Embed(                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -227,7 +223,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL, -                    icon_url=Icons.defcon_denied +                    icon_url=Icons.user_mute                  ),                  "send_result": True              } | 
