Repository: python-discord/bot

Issue:
Voice ban bug for non-members
Trying to voice ban someone who left the server leads to:
![image](https://user-images.githubusercontent.com/18114431/100516805-1fe33380-318f-11eb-8d0e-7cab58417777.png)

The voice ban still registers, but the exception should be caught.
Gracefully handle user not being present in voice ban
When a user is voice banned without being a member of the server the bot still tries to move them to a different voice channel. We should gracefully handle the user not being in voice or not being in the server (the infraction still applies fine) by doing a membership check before attempting to move the user.

Sentry Issue: [BOT-KT](https://sentry.io/organizations/python-discord/issues/2159677697/?referrer=github_integration)

```
AttributeError: 'User' object has no attribute 'move_to'
  File "discord/ext/commands/core.py", line 85, in wrapped
    ret = await coro(*args, **kwargs)
  File "bot/exts/moderation/infraction/infractions.py", line 95, in voiceban
    await self.apply_voice_ban(ctx, user, reason)
  File "bot/decorators.py", line 135, in wrapper
    await func(*args, **kwargs)
  File "bot/exts/moderation/infraction/infractions.py", line 354, in apply_voice_ban
    await user.move_to(None, reason="Disconnected from voice to apply voiceban.")

Error executing command invoked by joe#6000: !voiceban [REDACTED] [REDACTED]
```


Discussion:
I can work on a patch for that.


Resolving patch:
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -102,6 +102,7 @@ async def apply_infraction(
         """
         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.
         `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.
 
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -257,6 +257,10 @@ async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str],
         self.mod_log.ignore(Event.member_update, user.id)
 
         async def action() -> None:
+            # Skip members that left the server
+            if not isinstance(user, Member):
+                return
+
             await user.add_roles(self._muted_role, reason=reason)
 
             log.trace(f"Attempting to kick {user} from voice because they've been muted.")
@@ -351,10 +355,15 @@ async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Opt
         if reason:
             reason = textwrap.shorten(reason, width=512, placeholder="...")
 
-        await user.move_to(None, reason="Disconnected from voice to apply voiceban.")
+        async def action() -> None:
+            # Skip members that left the server
+            if not isinstance(user, Member):
+                return
 
-        action = user.remove_roles(self._voice_verified_role, reason=reason)
-        await self.apply_infraction(ctx, infraction, user, action)
+            await user.move_to(None, reason="Disconnected from voice to apply voiceban.")
+            await user.remove_roles(self._voice_verified_role, reason=reason)
+
+        await self.apply_infraction(ctx, infraction, user, action())
 
     # endregion
     # region: Base pardon functions


Test patch:
diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
--- a/tests/bot/exts/moderation/infraction/test_infractions.py
+++ b/tests/bot/exts/moderation/infraction/test_infractions.py
@@ -1,10 +1,12 @@
+import inspect
 import textwrap
 import unittest
-from unittest.mock import AsyncMock, MagicMock, Mock, patch
+from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
 
 from bot.constants import Event
+from bot.exts.moderation.infraction import _utils
 from bot.exts.moderation.infraction.infractions import Infractions
-from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole
+from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser, autospec
 
 
 class TruncationTests(unittest.IsolatedAsyncioTestCase):
@@ -132,20 +134,29 @@ async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infrac
         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)
 
+    async def action_tester(self, action, reason: str) -> None:
+        """Helper method to test voice ban action."""
+        self.assertTrue(inspect.iscoroutine(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)
+
     @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")
+        reason = "foobar"
+        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, reason))
+        self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY)
+
+        await self.action_tester(self.cog.apply_infraction.call_args[0][-1], reason)
 
     @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
     @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
@@ -153,16 +164,33 @@ async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infra
         """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")
+        self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY)
+
+        # Test action
+        action = self.cog.apply_infraction.call_args[0][-1]
+        await self.action_tester(action, textwrap.shorten("foobar" * 3000, 512, placeholder="..."))
+
+    @autospec(_utils, "post_infraction", "get_active_infraction", return_value=None)
+    @autospec(Infractions, "apply_infraction")
+    async def test_voice_ban_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _):
+        """Should voice ban user that left the guild without throwing an error."""
+        infraction = {"foo": "bar"}
+        post_infraction_mock.return_value = {"foo": "bar"}
+
+        user = MockUser()
+        await self.cog.voiceban(self.cog, self.ctx, user, reason=None)
+        post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True)
+        apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY)
+
+        # Test action
+        action = self.cog.apply_infraction.call_args[0][-1]
+        self.assertTrue(inspect.iscoroutine(action))
+        await action
 
     async def test_voice_unban_user_not_found(self):
         """Should include info to return dict when user was not found from guild."""