diff options
| -rw-r--r-- | bot/constants.py | 10 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_scheduler.py | 12 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_utils.py | 5 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/infractions.py | 78 | ||||
| -rw-r--r-- | bot/exts/moderation/voice_gate.py | 154 | ||||
| -rw-r--r-- | config-default.yml | 8 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/infraction/test_infractions.py | 148 | 
7 files changed, 406 insertions, 9 deletions
| diff --git a/bot/constants.py b/bot/constants.py index 99584ab6c..c79de012c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -425,6 +425,7 @@ class Channels(metaclass=YAMLGetter):      user_event_announcements: int      user_log: int      verification: int +    voice_gate: int      voice_log: int @@ -461,6 +462,7 @@ class Roles(metaclass=YAMLGetter):      team_leaders: int      unverified: int      verified: int  # This is the Developers role on PyDis, here named verified for readability reasons. +    voice_verified: int  class Guild(metaclass=YAMLGetter): @@ -589,6 +591,14 @@ class Verification(metaclass=YAMLGetter):      kick_confirmation_threshold: float +class VoiceGate(metaclass=YAMLGetter): +    section = "voice_gate" + +    minimum_days_verified: int +    minimum_messages: int +    bot_message_delete_delay: int + +  class Event(Enum):      """      Event names. This does not include every event (for example, raw diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index dba3f1513..bba80afaf 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -125,7 +125,7 @@ class InfractionScheduler:                  log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})")              else:                  # Accordingly display whether the user was successfully notified via DM. -                if await _utils.notify_infraction(user, infr_type, expiry, reason, icon): +                if await _utils.notify_infraction(user, " ".join(infr_type.split("_")).title(), expiry, reason, icon):                      dm_result = ":incoming_envelope: "                      dm_log_text = "\nDM: Sent" @@ -166,7 +166,7 @@ class InfractionScheduler:                  log_content = ctx.author.mention                  log_title = "failed to apply" -                log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" +                log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}"                  if isinstance(e, discord.Forbidden):                      log.warning(f"{log_msg}: bot lacks permissions.")                  else: @@ -183,7 +183,7 @@ class InfractionScheduler:                  log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.")              infr_message = ""          else: -            infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" +            infr_message = f" **{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}"          # Send a confirmation message to the invoking context.          log.trace(f"Sending infraction #{id_} confirmation message.") @@ -195,7 +195,7 @@ class InfractionScheduler:          await self.mod_log.send_log_message(              icon_url=icon,              colour=Colours.soft_red, -            title=f"Infraction {log_title}: {infr_type}", +            title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}",              thumbnail=user.avatar_url_as(static_format="png"),              text=textwrap.dedent(f"""                  Member: {messages.format_user(user)} @@ -272,7 +272,7 @@ class InfractionScheduler:          if send_msg:              log.trace(f"Sending infraction #{id_} pardon confirmation message.")              await ctx.send( -                f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " +                f"{dm_emoji}{confirm_msg} infraction **{' '.join(infr_type.split('_'))}** for {user.mention}. "                  f"{log_text.get('Failure', '')}"              ) @@ -283,7 +283,7 @@ class InfractionScheduler:          await self.mod_log.send_log_message(              icon_url=_utils.INFRACTION_ICONS[infr_type][1],              colour=Colours.soft_green, -            title=f"Infraction {log_title}: {infr_type}", +            title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}",              thumbnail=user.avatar_url_as(static_format="png"),              text="\n".join(f"{k}: {v}" for k, v in log_text.items()),              footer=footer, diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 1d91964f1..d0dc3f0a1 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -18,9 +18,10 @@ INFRACTION_ICONS = {      "note": (Icons.user_warn, None),      "superstar": (Icons.superstarify, Icons.unsuperstarify),      "warning": (Icons.user_warn, None), +    "voice_ban": (Icons.voice_state_red, Icons.voice_state_green),  }  RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") +APPEALABLE_INFRACTIONS = ("ban", "mute", "voice_ban")  # Type aliases  UserObject = t.Union[discord.Member, discord.User] @@ -154,7 +155,7 @@ async def notify_infraction(      log.trace(f"Sending {user} a DM about their {infr_type} infraction.")      text = INFRACTION_DESCRIPTION_TEMPLATE.format( -        type=infr_type.capitalize(), +        type=infr_type.title(),          expires=expires_at or "N/A",          reason=reason or "No reason provided."      ) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 7cf7075e6..71d873667 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -31,6 +31,7 @@ class Infractions(InfractionScheduler, commands.Cog):          self.category = "Moderation"          self._muted_role = discord.Object(constants.Roles.muted) +        self._voice_verified_role = discord.Object(constants.Roles.voice_verified)      @commands.Cog.listener()      async def on_member_join(self, member: Member) -> None: @@ -88,6 +89,11 @@ class Infractions(InfractionScheduler, commands.Cog):          """          await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) +    @command(aliases=('vban',)) +    async def voiceban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: +        """Permanently ban user from using voice channels.""" +        await self.apply_voice_ban(ctx, user, reason) +      # endregion      # region: Temporary infractions @@ -136,6 +142,32 @@ class Infractions(InfractionScheduler, commands.Cog):          """          await self.apply_ban(ctx, user, reason, expires_at=duration) +    @command(aliases=("tempvban", "tvban")) +    async def tempvoiceban( +            self, +            ctx: Context, +            user: FetchedMember, +            duration: Expiry, +            *, +            reason: t.Optional[str] +    ) -> None: +        """ +        Temporarily voice ban a user for the given reason and duration. + +        A unit of time should be appended to the duration. +        Units (∗case-sensitive): +        \u2003`y` - years +        \u2003`m` - months∗ +        \u2003`w` - weeks +        \u2003`d` - days +        \u2003`h` - hours +        \u2003`M` - minutes∗ +        \u2003`s` - seconds + +        Alternatively, an ISO 8601 timestamp can be provided for the duration. +        """ +        await self.apply_voice_ban(ctx, user, reason, expires_at=duration) +      # endregion      # region: Permanent shadow infractions @@ -225,6 +257,11 @@ class Infractions(InfractionScheduler, commands.Cog):          """Prematurely end the active ban infraction for the user."""          await self.pardon_infraction(ctx, "ban", user) +    @command(aliases=("uvban",)) +    async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None: +        """Prematurely end the active voice ban infraction for the user.""" +        await self.pardon_infraction(ctx, "voice_ban", user) +      # endregion      # region: Base apply functions @@ -319,6 +356,24 @@ class Infractions(InfractionScheduler, commands.Cog):          bb_reason = "User has been permanently banned from the server. Automatically removed."          await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) +    @respect_role_hierarchy(member_arg=2) +    async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: +        """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" +        if await _utils.get_active_infraction(ctx, user, "voice_ban"): +            return + +        infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) +        if infraction is None: +            return + +        self.mod_log.ignore(Event.member_update, user.id) + +        if reason: +            reason = textwrap.shorten(reason, width=512, placeholder="...") + +        action = user.remove_roles(self._voice_verified_role, reason=reason) +        await self.apply_infraction(ctx, infraction, user, action) +      # endregion      # region: Base pardon functions @@ -363,6 +418,27 @@ class Infractions(InfractionScheduler, commands.Cog):          return log_text +    async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: +        """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" +        user = guild.get_member(user_id) +        log_text = {} + +        if user: +            # DM user about infraction expiration +            notified = await _utils.notify_pardon( +                user=user, +                title="Voice ban ended", +                content="You have been unbanned and can verify yourself again in the server.", +                icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] +            ) + +            log_text["Member"] = format_user(user) +            log_text["DM"] = "Sent" if notified else "**Failed**" +        else: +            log_text["Info"] = "User was not found in the guild." + +        return log_text +      async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]:          """          Execute deactivation steps specific to the infraction's type and return a log dict. @@ -377,6 +453,8 @@ class Infractions(InfractionScheduler, commands.Cog):              return await self.pardon_mute(user_id, guild, reason)          elif infraction["type"] == "ban":              return await self.pardon_ban(user_id, guild, reason) +        elif infraction["type"] == "voice_ban": +            return await self.pardon_voice_ban(user_id, guild, reason)      # endregion diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py new file mode 100644 index 000000000..7cadca153 --- /dev/null +++ b/bot/exts/moderation/voice_gate.py @@ -0,0 +1,154 @@ +import logging +from contextlib import suppress +from datetime import datetime, timedelta + +import discord +from dateutil import parser +from discord import Colour +from discord.ext.commands import Cog, Context, command + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf +from bot.decorators import has_no_roles, in_whitelist +from bot.exts.moderation.modlog import ModLog +from bot.utils.checks import InWhitelistCheckFailure + +log = logging.getLogger(__name__) + +FAILED_MESSAGE = ( +    """You are not currently eligible to use voice inside Python Discord for the following reasons:\n\n{reasons}""" +) + +MESSAGE_FIELD_MAP = { +    "verified_at": f"have been verified for less {GateConf.minimum_days_verified} days", +    "voice_banned": "have an active voice ban infraction", +    "total_messages": f"have sent less than {GateConf.minimum_messages} messages", +} + + +class VoiceGate(Cog): +    """Voice channels verification management.""" + +    def __init__(self, bot: Bot): +        self.bot = bot + +    @property +    def mod_log(self) -> ModLog: +        """Get the currently loaded ModLog cog instance.""" +        return self.bot.get_cog("ModLog") + +    @command(aliases=('voiceverify',)) +    @has_no_roles(Roles.voice_verified) +    @in_whitelist(channels=(Channels.voice_gate,), redirect=None) +    async def voice_verify(self, ctx: Context, *_) -> None: +        """ +        Apply to be able to use voice within the Discord server. + +        In order to use voice you must meet all three of the following criteria: +        - You must have over a certain number of messages within the Discord server +        - You must have accepted our rules over a certain number of days ago +        - You must not be actively banned from using our voice channels +        """ +        # Send this as first thing in order to return after sending DM +        await ctx.send(f"{ctx.author.mention}, check your DMs.") + +        try: +            data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") +        except ResponseCodeError as e: +            if e.status == 404: +                embed = discord.Embed( +                    title="Not found", +                    description=( +                        "We were unable to find user data for you. " +                        "Please try again shortly, " +                        "if this problem persists please contact the server staff through Modmail.", +                    ), +                    color=Colour.red() +                ) +                log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") +            else: +                embed = discord.Embed( +                    title="Unexpected response", +                    description=( +                        "We encountered an error while attempting to find data for your user. " +                        "Please try again and let us know if the problem persists." +                    ), +                    color=Colour.red() +                ) +                log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") + +            await ctx.author.send(embed=embed) +            return + +        # Pre-parse this for better code style +        if data["verified_at"] is not None: +            data["verified_at"] = parser.isoparse(data["verified_at"]) +        else: +            data["verified_at"] = datetime.utcnow() - timedelta(days=3) + +        checks = { +            "verified_at": data["verified_at"] > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), +            "total_messages": data["total_messages"] < GateConf.minimum_messages, +            "voice_banned": data["voice_banned"] +        } +        failed = any(checks.values()) +        failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] +        [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] + +        if failed: +            embed = discord.Embed( +                title="Voice Gate failed", +                description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), +                color=Colour.red() +            ) +            await ctx.author.send(embed=embed) +            return + +        self.mod_log.ignore(Event.member_update, ctx.author.id) +        await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") +        embed = discord.Embed( +            title="Voice gate passed", +            description="You have been granted permission to use voice channels in Python Discord.", +            color=Colour.green() +        ) +        await ctx.author.send(embed=embed) +        self.bot.stats.incr("voice_gate.passed") + +    @Cog.listener() +    async def on_message(self, message: discord.Message) -> None: +        """Delete all non-staff messages from voice gate channel that don't invoke voice verify command.""" +        # Check is channel voice gate +        if message.channel.id != Channels.voice_gate: +            return + +        ctx = await self.bot.get_context(message) +        is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify" + +        # When it's bot sent message, delete it after some time +        if message.author.bot: +            with suppress(discord.NotFound): +                await message.delete(delay=GateConf.bot_message_delete_delay) +                return + +        # Then check is member moderator+, because we don't want to delete their messages. +        if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command is False: +            log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") +            return + +        # Ignore deleted voice verification messages +        if ctx.command is not None and ctx.command.name == "voice_verify": +            self.mod_log.ignore(Event.message_delete, message.id) + +        with suppress(discord.NotFound): +            await message.delete() + +    async def cog_command_error(self, ctx: Context, error: Exception) -> None: +        """Check for & ignore any InWhitelistCheckFailure.""" +        if isinstance(error, InWhitelistCheckFailure): +            error.handled = True + + +def setup(bot: Bot) -> None: +    """Loads the VoiceGate cog.""" +    bot.add_cog(VoiceGate(bot)) diff --git a/config-default.yml b/config-default.yml index fd96ff2c6..c712d1eb7 100644 --- a/config-default.yml +++ b/config-default.yml @@ -170,6 +170,7 @@ guild:          bot_commands:       &BOT_CMD        267659945086812160          esoteric:                           470884583684964352          verification:                       352442727016693763 +        voice_gate:                         764802555427029012          # Staff          admins:             &ADMINS         365960823622991872 @@ -231,6 +232,7 @@ guild:          unverified:                             739794855945044069          verified:                               352427296948486144  # @Developers on PyDis +        voice_verified:                         764802720779337729          # Staff          admins:             &ADMINS_ROLE    267628507062992896 @@ -508,5 +510,11 @@ verification:      kick_confirmation_threshold: 0.01  # 1% +voice_gate: +    minimum_days_verified: 3  # How many days the user must have been verified for +    minimum_messages: 50  # How many messages a user must have to be eligible for voice +    bot_message_delete_delay: 10  # Seconds before deleting bot's response in Voice Gate + +  config:      required_keys: ['bot.token'] diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index be1b649e1..bf557a484 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,7 +1,8 @@  import textwrap  import unittest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from bot.constants import Event  from bot.exts.moderation.infraction.infractions import Infractions  from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole @@ -53,3 +54,148 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):          self.cog.apply_infraction.assert_awaited_once_with(              self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value          ) + + +@patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) +class VoiceBanTests(unittest.IsolatedAsyncioTestCase): +    """Tests for voice ban related functions and commands.""" + +    def setUp(self): +        self.bot = MockBot() +        self.mod = MockMember(top_role=10) +        self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) +        self.guild = MockGuild() +        self.ctx = MockContext(bot=self.bot, author=self.mod) +        self.cog = Infractions(self.bot) + +    async def test_permanent_voice_ban(self): +        """Should call voice ban applying function without expiry.""" +        self.cog.apply_voice_ban = AsyncMock() +        self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar")) +        self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") + +    async def test_temporary_voice_ban(self): +        """Should call voice ban applying function with expiry.""" +        self.cog.apply_voice_ban = AsyncMock() +        self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) +        self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") + +    async def test_voice_unban(self): +        """Should call infraction pardoning function.""" +        self.cog.pardon_infraction = AsyncMock() +        self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) +        self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): +        """Should return early when user already have Voice Ban infraction.""" +        get_active_infraction.return_value = {"foo": "bar"} +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) +        get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban") +        post_infraction_mock.assert_not_awaited() + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock): +        """Should return early when posting infraction fails.""" +        self.cog.mod_log.ignore = MagicMock() +        get_active_infraction.return_value = None +        post_infraction_mock.return_value = None +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) +        post_infraction_mock.assert_awaited_once() +        self.cog.mod_log.ignore.assert_not_called() + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): +        """Should pass all kwargs passed to apply_voice_ban to post_infraction.""" +        get_active_infraction.return_value = None +        # We don't want that this continue yet +        post_infraction_mock.return_value = None +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23)) +        post_infraction_mock.assert_awaited_once_with( +            self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 +        ) + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock): +        """Should ignore Voice Verified role removing.""" +        self.cog.mod_log.ignore = MagicMock() +        self.cog.apply_infraction = AsyncMock() +        self.user.remove_roles = MagicMock(return_value="my_return_value") + +        get_active_infraction.return_value = None +        post_infraction_mock.return_value = {"foo": "bar"} + +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) +        self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): +        """Should ignore Voice Verified role removing.""" +        self.cog.mod_log.ignore = MagicMock() +        self.cog.apply_infraction = AsyncMock() +        self.user.remove_roles = MagicMock(return_value="my_return_value") + +        get_active_infraction.return_value = None +        post_infraction_mock.return_value = {"foo": "bar"} + +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) +        self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") +        self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock): +        """Should truncate reason for voice ban.""" +        self.cog.mod_log.ignore = MagicMock() +        self.cog.apply_infraction = AsyncMock() +        self.user.remove_roles = MagicMock(return_value="my_return_value") + +        get_active_infraction.return_value = None +        post_infraction_mock.return_value = {"foo": "bar"} + +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) +        self.user.remove_roles.assert_called_once_with( +            self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") +        ) +        self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + +    async def test_voice_unban_user_not_found(self): +        """Should include info to return dict when user was not found from guild.""" +        self.guild.get_member.return_value = None +        result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") +        self.assertEqual(result, {"Info": "User was not found in the guild."}) + +    @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") +    @patch("bot.exts.moderation.infraction.infractions.format_user") +    async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): +        """Should add role back with ignoring, notify user and return log dictionary..""" +        self.guild.get_member.return_value = self.user +        notify_pardon_mock.return_value = True +        format_user_mock.return_value = "my-user" + +        result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") +        self.assertEqual(result, { +            "Member": "my-user", +            "DM": "Sent" +        }) +        notify_pardon_mock.assert_awaited_once() + +    @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") +    @patch("bot.exts.moderation.infraction.infractions.format_user") +    async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock): +        """Should add role back with ignoring, notify user and return log dictionary..""" +        self.guild.get_member.return_value = self.user +        notify_pardon_mock.return_value = False +        format_user_mock.return_value = "my-user" + +        result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") +        self.assertEqual(result, { +            "Member": "my-user", +            "DM": "**Failed**" +        }) +        notify_pardon_mock.assert_awaited_once() | 
