diff options
| -rw-r--r-- | Dockerfile | 22 | ||||
| -rw-r--r-- | bot/exts/help_channels/_channel.py | 3 | ||||
| -rw-r--r-- | bot/exts/info/help.py | 8 | ||||
| -rw-r--r-- | bot/exts/info/pypi.py | 5 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_scheduler.py | 55 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/infractions.py | 41 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/superstarify.py | 11 | ||||
| -rw-r--r-- | bot/exts/utils/reminders.py | 64 | ||||
| -rw-r--r-- | bot/pagination.py | 2 | ||||
| -rw-r--r-- | pyproject.toml | 6 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/infraction/test_infractions.py | 38 | ||||
| -rw-r--r-- | tests/helpers.py | 2 | 
12 files changed, 160 insertions, 97 deletions
| diff --git a/Dockerfile b/Dockerfile index 9cf9c7b27..205b66209 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,13 @@ -FROM --platform=linux/amd64 python:3.10-slim +FROM --platform=linux/amd64 ghcr.io/chrislovering/python-poetry-base:3.10-slim  # Define Git SHA build argument for sentry  ARG git_sha="development" - -ENV POETRY_VERSION=1.2.0 \ -  POETRY_HOME="/opt/poetry" \ -  POETRY_NO_INTERACTION=1 \ -  APP_DIR="/bot" \ -  GIT_SHA=$git_sha - -ENV PATH="$POETRY_HOME/bin:$PATH" - -RUN apt-get update \ -  && apt-get -y upgrade \ -  && apt-get install --no-install-recommends -y curl \ -  && apt-get clean && rm -rf /var/lib/apt/lists/* - -RUN curl -sSL https://install.python-poetry.org | python +ENV GIT_SHA=$git_sha  # Install project dependencies -WORKDIR $APP_DIR +WORKDIR /bot  COPY pyproject.toml poetry.lock ./ -RUN poetry install --no-dev +RUN poetry install --without dev  # Copy the source code in last to optimize rebuilding the image  COPY . . diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index d9cebf215..cfe774f4c 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -183,7 +183,8 @@ async def ensure_cached_claimant(channel: discord.TextChannel) -> None:                  log.info("Hit the dormant message embed before finding a claimant in %s (%d).", channel, channel.id)                  break              # Only set the claimant if the first embed matches the claimed channel embed regex -            if match := CLAIMED_BY_RE.match(message.embeds[0].description): +            description = message.embeds[0].description +            if (description is not None) and (match := CLAIMED_BY_RE.match(description)):                  await _caches.claimants.set(channel.id, int(match.group("user_id")))                  return diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 282f8c97a..48f840e51 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -307,7 +307,7 @@ class CustomHelpCommand(HelpCommand):          # Remove line breaks from docstrings, if not used to separate paragraphs.          # Allow overriding this behaviour via putting \u2003 at the start of a line.          formatted_doc = re.sub("(?<!\n)\n(?![\n\u2003])", " ", command.help) -        command_details += f"*{formatted_doc or 'No details provided.'}*\n" +        command_details += f"{formatted_doc or 'No details provided.'}\n"          embed.description = command_details          # If the help is invoked in the context of an error, don't show subcommand navigation. @@ -331,7 +331,7 @@ class CustomHelpCommand(HelpCommand):          for command in commands_:              signature = f" {command.signature}" if command.signature else ""              details.append( -                f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*" +                f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n{command.short_doc or 'No details provided'}"              )          if return_as_list:              return details @@ -372,7 +372,7 @@ class CustomHelpCommand(HelpCommand):          embed = Embed()          embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) -        embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" +        embed.description = f"**{cog.qualified_name}**\n{cog.description}"          command_details = self.get_commands_brief_details(commands_)          if command_details: @@ -412,7 +412,7 @@ class CustomHelpCommand(HelpCommand):          filtered_commands = await self.filter_commands(all_commands, sort=True)          command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True) -        description = f"**{category.name}**\n*{category.description}*" +        description = f"**{category.name}**\n{category.description}"          if command_detail_lines:              description += "\n\n**Commands:**" diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 2d387df3d..bac7d2389 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -54,11 +54,12 @@ class PyPi(Cog):                      embed.url = info["package_url"]                      embed.colour = next(PYPI_COLOURS) -                    summary = escape_markdown(info["summary"]) +                    # Summary can be None if not provided by the package +                    summary: str | None = info["summary"]                      # Summary could be completely empty, or just whitespace.                      if summary and not summary.isspace(): -                        embed.description = summary +                        embed.description = escape_markdown(summary)                      else:                          embed.description = "No summary provided." diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 655290559..4c275a1f0 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -1,6 +1,7 @@  import textwrap  import typing as t  from abc import abstractmethod +from collections.abc import Awaitable, Callable  from gettext import ngettext  import arrow @@ -79,9 +80,14 @@ class InfractionScheduler:      async def reapply_infraction(          self,          infraction: _utils.Infraction, -        apply_coro: t.Optional[t.Awaitable] +        action: t.Optional[Callable[[], Awaitable[None]]]      ) -> None: -        """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" +        """ +        Reapply an infraction if it's still active or deactivate it if less than 60 sec left. + +        Note: The `action` provided is an async function rather than a coroutine +        to prevent getting a RuntimeWarning if it is not used (e.g. in mocked tests). +        """          if infraction["expires_at"] is not None:              # Calculate the time remaining, in seconds, for the mute.              expiry = dateutil.parser.isoparse(infraction["expires_at"]) @@ -101,7 +107,7 @@ class InfractionScheduler:          # Allowing mod log since this is a passive action that should be logged.          try: -            await apply_coro +            await action()          except discord.HTTPException as e:              # When user joined and then right after this left again before action completed, this can't apply roles              if e.code == 10007 or e.status == 404: @@ -111,7 +117,7 @@ class InfractionScheduler:              else:                  log.exception(                      f"Got unexpected HTTPException (HTTP {e.status}, Discord code {e.code})" -                    f"when awaiting {infraction['type']} coroutine for {infraction['user']}." +                    f"when running {infraction['type']} action for {infraction['user']}."                  )          else:              log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") @@ -121,17 +127,20 @@ class InfractionScheduler:          ctx: Context,          infraction: _utils.Infraction,          user: MemberOrUser, -        action_coro: t.Optional[t.Awaitable] = None, +        action: t.Optional[Callable[[], Awaitable[None]]] = None,          user_reason: t.Optional[str] = None,          additional_info: str = "",      ) -> bool:          """          Apply an infraction to the user, log the infraction, and optionally notify the user. -        `action_coro`, if not provided, will result in the infraction not getting scheduled for deletion. +        `action`, if not provided, will result in the infraction not getting scheduled for deletion.          `user_reason`, if provided, will be sent to the user in place of the infraction reason.          `additional_info` will be attached to the text field in the mod-log embed. +        Note: The `action` provided is an async function rather than just a coroutine +        to prevent getting a RuntimeWarning if it is not used (e.g. in mocked tests). +          Returns whether or not the infraction succeeded.          """          infr_type = infraction["type"] @@ -200,10 +209,10 @@ class InfractionScheduler:          purge = infraction.get("purge", "")          # Execute the necessary actions to apply the infraction on Discord. -        if action_coro: -            log.trace(f"Awaiting the infraction #{id_} application action coroutine.") +        if action: +            log.trace(f"Running the infraction #{id_} application action.")              try: -                await action_coro +                await action()                  if expiry:                      # Schedule the expiration of the infraction.                      self.schedule_expiration(infraction) @@ -278,6 +287,7 @@ class InfractionScheduler:              ctx: Context,              infr_type: str,              user: MemberOrUser, +            pardon_reason: t.Optional[str] = None,              *,              send_msg: bool = True,              notify: bool = True @@ -285,6 +295,9 @@ class InfractionScheduler:          """          Prematurely end an infraction for a user and log the action in the mod log. +        If `pardon_reason` is None, then the database will not receive +        appended text explaining why the infraction was pardoned. +          If `send_msg` is True, then a pardoning confirmation message will be sent to          the context channel. Otherwise, no such message will be sent. @@ -309,7 +322,7 @@ class InfractionScheduler:              return          # Deactivate the infraction and cancel its scheduled expiration task. -        log_text = await self.deactivate_infraction(response[0], send_log=False, notify=notify) +        log_text = await self.deactivate_infraction(response[0], pardon_reason, send_log=False, notify=notify)          log_text["Member"] = messages.format_user(user)          log_text["Actor"] = ctx.author.mention @@ -362,6 +375,7 @@ class InfractionScheduler:      async def deactivate_infraction(          self,          infraction: _utils.Infraction, +        pardon_reason: t.Optional[str] = None,          *,          send_log: bool = True,          notify: bool = True @@ -370,8 +384,12 @@ class InfractionScheduler:          Deactivate an active infraction and return a dictionary of lines to send in a mod log.          The infraction is removed from Discord, marked as inactive in the database, and has its -        expiration task cancelled. If `send_log` is True, a mod log is sent for the -        deactivation of the infraction. +        expiration task cancelled. + +        If `pardon_reason` is None, then the database will not receive +        appended text explaining why the infraction was pardoned. + +        If `send_log` is True, a mod log is sent for the deactivation of the infraction.          If `notify` is True, notify the user of the pardon via DM where applicable. @@ -441,9 +459,20 @@ class InfractionScheduler:          try:              # Mark infraction as inactive in the database.              log.trace(f"Marking infraction #{id_} as inactive in the database.") + +            data = {"active": False} + +            if pardon_reason is not None: +                data["reason"] = "" +                # Append pardon reason to infraction in database. +                if (punish_reason := infraction["reason"]) is not None: +                    data["reason"] = punish_reason + " | " + +                data["reason"] += f"Pardoned: {pardon_reason}" +              await self.bot.api_client.patch(                  f"bot/infractions/{id_}", -                json={"active": False} +                json=data              )          except ResponseCodeError as e:              log.exception(f"Failed to deactivate infraction #{id_} ({type_})") diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 05cc74a03..60b4428b7 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -54,8 +54,9 @@ class Infractions(InfractionScheduler, commands.Cog):          if active_mutes:              reason = f"Re-applying active mute: {active_mutes[0]['id']}" -            action = member.add_roles(self._muted_role, reason=reason) +            async def action() -> None: +                await member.add_roles(self._muted_role, reason=reason)              await self.reapply_infraction(active_mutes[0], action)      # region: Permanent infractions @@ -125,8 +126,6 @@ class Infractions(InfractionScheduler, commands.Cog):          infraction = await self.apply_ban(ctx, user, reason, duration_or_expiry=duration)          if not infraction or not infraction.get("id"):              # Ban was unsuccessful, quit early. -            await ctx.send(":x: Failed to apply ban.") -            log.error("Failed to apply ban to user %d", user.id)              return          # Calling commands directly skips discord.py's convertors, so we need to convert args manually. @@ -339,14 +338,20 @@ class Infractions(InfractionScheduler, commands.Cog):      # region: Remove infractions (un- commands)      @command() -    async def unmute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: +    async def unmute( +        self, +        ctx: Context, +        user: UnambiguousMemberOrUser, +        *, +        pardon_reason: t.Optional[str] = None +    ) -> None:          """Prematurely end the active mute infraction for the user.""" -        await self.pardon_infraction(ctx, "mute", user) +        await self.pardon_infraction(ctx, "mute", user, pardon_reason)      @command() -    async def unban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: +    async def unban(self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: str) -> None:          """Prematurely end the active ban infraction for the user.""" -        await self.pardon_infraction(ctx, "ban", user) +        await self.pardon_infraction(ctx, "ban", user, pardon_reason)      @command(aliases=("uvban",))      async def unvoiceban(self, ctx: Context) -> None: @@ -358,9 +363,15 @@ class Infractions(InfractionScheduler, commands.Cog):          await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `unvoicemute`?")      @command(aliases=("uvmute",)) -    async def unvoicemute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: +    async def unvoicemute( +        self, +        ctx: Context, +        user: UnambiguousMemberOrUser, +        *, +        pardon_reason: t.Optional[str] = None +    ) -> None:          """Prematurely end the active voice mute infraction for the user.""" -        await self.pardon_infraction(ctx, "voice_mute", user) +        await self.pardon_infraction(ctx, "voice_mute", user, pardon_reason)      # endregion      # region: Base apply functions @@ -397,7 +408,7 @@ class Infractions(InfractionScheduler, commands.Cog):              log.trace(f"Attempting to kick {user} from voice because they've been muted.")              await user.move_to(None, reason=reason) -        await self.apply_infraction(ctx, infraction, user, action()) +        await self.apply_infraction(ctx, infraction, user, action)      @respect_role_hierarchy(member_arg=2)      async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: @@ -415,7 +426,9 @@ class Infractions(InfractionScheduler, commands.Cog):          if reason:              reason = textwrap.shorten(reason, width=512, placeholder="...") -        action = user.kick(reason=reason) +        async def action() -> None: +            await user.kick(reason=reason) +          await self.apply_infraction(ctx, infraction, user, action)      @respect_role_hierarchy(member_arg=2) @@ -464,7 +477,9 @@ class Infractions(InfractionScheduler, commands.Cog):          if reason:              reason = textwrap.shorten(reason, width=512, placeholder="...") -        action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days) +        async def action() -> None: +            await ctx.guild.ban(user, reason=reason, delete_message_days=purge_days) +          await self.apply_infraction(ctx, infraction, user, action)          bb_cog: t.Optional[BigBrother] = self.bot.get_cog("Big Brother") @@ -502,7 +517,7 @@ class Infractions(InfractionScheduler, commands.Cog):              await user.move_to(None, reason="Disconnected from voice to apply voice mute.")              await user.remove_roles(self._voice_verified_role, reason=reason) -        await self.apply_infraction(ctx, infraction, user, action()) +        await self.apply_infraction(ctx, infraction, user, action)      # endregion      # region: Base pardon functions diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index f2aab7a92..6cb2c3354 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -96,11 +96,12 @@ class Superstarify(InfractionScheduler, Cog):          if active_superstarifies:              infraction = active_superstarifies[0] -            action = member.edit( -                nick=self.get_nick(infraction["id"], member.id), -                reason=f"Superstarified member tried to escape the prison: {infraction['id']}" -            ) +            async def action() -> None: +                await member.edit( +                    nick=self.get_nick(infraction["id"], member.id), +                    reason=f"Superstarified member tried to escape the prison: {infraction['id']}" +                )              await self.reapply_infraction(infraction, action)      @command(name="superstarify", aliases=("force_nick", "star", "starify", "superstar")) @@ -175,7 +176,7 @@ class Superstarify(InfractionScheduler, Cog):          ).format          successful = await self.apply_infraction( -            ctx, infraction, member, action(), +            ctx, infraction, member, action,              user_reason=user_message(reason=f'**Additional details:** {reason}\n\n' if reason else ''),              additional_info=nickname_info          ) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 45cddd7a2..c65785314 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -209,6 +209,29 @@ class Reminders(Cog):          log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).")          await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}") +    @staticmethod +    async def try_get_content_from_reply(ctx: Context) -> t.Optional[str]: +        """ +        Attempts to get content from the referenced message, if applicable. + +        Differs from botcore.utils.commands.clean_text_or_reply as allows for messages with no content. +        """ +        content = None +        if reference := ctx.message.reference: +            if isinstance((resolved_message := reference.resolved), discord.Message): +                content = resolved_message.content + +        # If we weren't able to get the content of a replied message +        if content is None: +            await send_denial(ctx, "Your reminder must have a content and/or reply to a message.") +            return + +        # If the replied message has no content (e.g. only attachments/embeds) +        if content == "": +            content = "*See referenced message.*" + +        return content +      @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)      async def remind_group(          self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None @@ -282,18 +305,11 @@ class Reminders(Cog):          # If `content` isn't provided then we try to get message content of a replied message          if not content: -            if reference := ctx.message.reference: -                if isinstance((resolved_message := reference.resolved), discord.Message): -                    content = resolved_message.content -            # If we weren't able to get the content of a replied message -            if content is None: -                await send_denial(ctx, "Your reminder must have a content and/or reply to a message.") +            content = await self.try_get_content_from_reply(ctx) +            if not content: +                # Couldn't get content from reply                  return -            # If the replied message has no content (e.g. only attachments/embeds) -            if content == "": -                content = "See referenced message." -          # Now we can attempt to actually set the reminder.          reminder = await self.bot.api_client.post(              'bot/reminders', @@ -382,20 +398,7 @@ class Reminders(Cog):      @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True)      async def edit_reminder_group(self, ctx: Context) -> None: -        """ -        Commands for modifying your current reminders. - -        The `expiration` duration supports the following symbols for each unit of time: -        - years: `Y`, `y`, `year`, `years` -        - months: `m`, `month`, `months` -        - weeks: `w`, `W`, `week`, `weeks` -        - days: `d`, `D`, `day`, `days` -        - hours: `H`, `h`, `hour`, `hours` -        - minutes: `M`, `minute`, `minutes` -        - seconds: `S`, `s`, `second`, `seconds` - -        For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. -        """ +        """Commands for modifying your current reminders."""          await ctx.send_help(ctx.command)      @edit_reminder_group.command(name="duration", aliases=("time",)) @@ -417,8 +420,17 @@ class Reminders(Cog):          await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()})      @edit_reminder_group.command(name="content", aliases=("reason",)) -    async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: -        """Edit one of your reminder's content.""" +    async def edit_reminder_content(self, ctx: Context, id_: int, *, content: t.Optional[str] = None) -> None: +        """ +        Edit one of your reminder's content. + +        You can either supply the new content yourself, or reply to a message to use its content. +        """ +        if not content: +            content = await self.try_get_content_from_reply(ctx) +            if not content: +                # Message doesn't have a reply to get content from +                return          await self.edit_reminder(ctx, id_, {"content": content})      @edit_reminder_group.command(name="mentions", aliases=("pings",)) diff --git a/bot/pagination.py b/bot/pagination.py index 8f4353eb1..10bef1c9f 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -236,7 +236,7 @@ class LinePaginator(Paginator):                  raise EmptyPaginatorEmbedError("No lines to paginate")              log.debug("No lines to add to paginator, adding '(nothing to display)' message") -            lines.append("(nothing to display)") +            lines.append("*(nothing to display)*")          for line in lines:              try: diff --git a/pyproject.toml b/pyproject.toml index 43eb799b6..36c3b5392 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,3 +85,9 @@ case_sensitive = true  combine_as_imports = true  line_length = 120  atomic = true + +[tool.pytest.ini_options] +# We don't use nose style tests so disable them in pytest. +# This stops pytest from running functions named `setup` in test files. +# See https://github.com/python-discord/bot/pull/2229#issuecomment-1204436420 +addopts = "-p no:nose" diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index a18a4d23b..b78328137 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -35,17 +35,20 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):          self.cog.apply_infraction = AsyncMock()          self.bot.get_cog.return_value = AsyncMock()          self.cog.mod_log.ignore = Mock() -        self.ctx.guild.ban = Mock() +        self.ctx.guild.ban = AsyncMock()          await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000) -        self.ctx.guild.ban.assert_called_once_with( +        self.cog.apply_infraction.assert_awaited_once_with( +            self.ctx, {"foo": "bar", "purge": ""}, self.target, ANY +        ) + +        action = self.cog.apply_infraction.call_args.args[-1] +        await action() +        self.ctx.guild.ban.assert_awaited_once_with(              self.target,              reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."),              delete_message_days=0          ) -        self.cog.apply_infraction.assert_awaited_once_with( -            self.ctx, {"foo": "bar", "purge": ""}, self.target, self.ctx.guild.ban.return_value -        )      @patch("bot.exts.moderation.infraction._utils.post_infraction")      async def test_apply_kick_reason_truncation(self, post_infraction_mock): @@ -54,14 +57,17 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):          self.cog.apply_infraction = AsyncMock()          self.cog.mod_log.ignore = Mock() -        self.target.kick = Mock() +        self.target.kick = AsyncMock()          await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000) -        self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."))          self.cog.apply_infraction.assert_awaited_once_with( -            self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value +            self.ctx, {"foo": "bar"}, self.target, ANY          ) +        action = self.cog.apply_infraction.call_args.args[-1] +        await action() +        self.target.kick.assert_awaited_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) +  @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456)  class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): @@ -90,8 +96,14 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase):      async def test_voice_unmute(self):          """Should call infraction pardoning function."""          self.cog.pardon_infraction = AsyncMock() +        self.assertIsNone(await self.cog.unvoicemute(self.cog, self.ctx, self.user, pardon_reason="foobar")) +        self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user, "foobar") + +    async def test_voice_unmute_reasonless(self): +        """Should call infraction pardoning function without a pardon reason.""" +        self.cog.pardon_infraction = AsyncMock()          self.assertIsNone(await self.cog.unvoicemute(self.cog, self.ctx, self.user)) -        self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user) +        self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user, None)      @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")      @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") @@ -141,8 +153,8 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase):      async def action_tester(self, action, reason: str) -> None:          """Helper method to test voice mute action.""" -        self.assertTrue(inspect.iscoroutine(action)) -        await action +        self.assertTrue(inspect.iscoroutinefunction(action)) +        await action()          self.user.move_to.assert_called_once_with(None, reason=ANY)          self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason=reason) @@ -195,8 +207,8 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase):          # Test action          action = self.cog.apply_infraction.call_args[0][-1] -        self.assertTrue(inspect.iscoroutine(action)) -        await action +        self.assertTrue(inspect.iscoroutinefunction(action)) +        await action()      async def test_voice_unmute_user_not_found(self):          """Should include info to return dict when user was not found from guild.""" diff --git a/tests/helpers.py b/tests/helpers.py index 687e15b96..a4b919dcb 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -317,7 +317,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock):          guild_id=1,          intents=discord.Intents.all(),      ) -    additional_spec_asyncs = ("wait_for", "redis_ready") +    additional_spec_asyncs = ("wait_for",)      def __init__(self, **kwargs) -> None:          super().__init__(**kwargs) | 
