Repository: python-discord/bot

Issue:
Error for DMing a user is uncaught in verification
Sentry Issue: [BOT-8Z](https://sentry.io/organizations/python-discord/issues/1901783817/?referrer=github_integration)

As can be seen below, Discord returns a 400 with a specific code rather than a 403 forbidden. Currently, verification attempts to catch this by suppressing a 403 rather than a 400.

```
HTTPException: 400 Bad Request (error code: 50007): Cannot send messages to this user
(1 additional frame(s) were not displayed)
...
  File "bot/cogs/verification.py", line 503, in on_member_join
    await member.send(ON_JOIN_MESSAGE)
  File "discord/abc.py", line 850, in send
    channel = await self._get_channel()
  File "discord/member.py", line 243, in _get_channel
    ch = await self.create_dm()
  File "discord/user.py", line 715, in create_dm
    data = await state.http.start_private_message(self.id)
  File "discord/http.py", line 245, in request
    raise HTTPException(r, data)

Unhandled exception in on_member_join.
```


Discussion:
Hmm, I guess let's just suppress `discord.HTTPException` then if you're cool with that.
That feels too broad. You could catch it and only suppress if it has that specific error code.

Resolving patch:
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -319,7 +319,7 @@ async def process_event(self, reaction: str, incident: discord.Message, member:
         try:
             await confirmation_task
         except asyncio.TimeoutError:
-            log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!")
+            log.info(f"Did not receive incident deletion confirmation within {timeout} seconds!")
         else:
             log.trace("Deletion was confirmed")
 
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -109,6 +109,25 @@ def is_verified(member: discord.Member) -> bool:
     return len(set(member.roles) - unverified_roles) > 0
 
 
+async def safe_dm(coro: t.Coroutine) -> None:
+    """
+    Execute `coro` ignoring disabled DM warnings.
+
+    The 50_0007 error code indicates that the target user does not accept DMs.
+    As it turns out, this error code can appear on both 400 and 403 statuses,
+    we therefore catch any Discord exception.
+
+    If the request fails on any other error code, the exception propagates,
+    and must be handled by the caller.
+    """
+    try:
+        await coro
+    except discord.HTTPException as discord_exc:
+        log.trace(f"DM dispatch failed on status {discord_exc.status} with code: {discord_exc.code}")
+        if discord_exc.code != 50_007:  # If any reason other than disabled DMs
+            raise
+
+
 class Verification(Cog):
     """
     User verification and role management.
@@ -330,11 +349,9 @@ async def _kick_members(self, members: t.Collection[discord.Member]) -> int:
         async def kick_request(member: discord.Member) -> None:
             """Send `KICKED_MESSAGE` to `member` and kick them from the guild."""
             try:
-                await member.send(KICKED_MESSAGE)
-            except discord.Forbidden as exc_403:
-                log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}")
-                if exc_403.code != 50_007:  # 403 raised for any other reason than disabled DMs
-                    raise StopExecution(reason=exc_403)
+                await safe_dm(member.send(KICKED_MESSAGE))  # Suppress disabled DMs
+            except discord.HTTPException as suspicious_exception:
+                raise StopExecution(reason=suspicious_exception)
             await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days")
 
         n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1))
@@ -503,8 +520,10 @@ async def on_member_join(self, member: discord.Member) -> None:
             return  # Only listen for PyDis events
 
         log.trace(f"Sending on join message to new member: {member.id}")
-        with suppress(discord.Forbidden):
-            await member.send(ON_JOIN_MESSAGE)
+        try:
+            await safe_dm(member.send(ON_JOIN_MESSAGE))
+        except discord.HTTPException:
+            log.exception("DM dispatch failed on unexpected error code")
 
     @Cog.listener()
     async def on_message(self, message: discord.Message) -> None:
@@ -671,9 +690,9 @@ async def accept_command(self, ctx: Context, *_) -> None:  # We don't actually c
             await ctx.author.remove_roles(discord.Object(constants.Roles.unverified))
 
         try:
-            await ctx.author.send(VERIFIED_MESSAGE)
-        except discord.Forbidden:
-            log.info(f"Sending welcome message failed for {ctx.author}.")
+            await safe_dm(ctx.author.send(VERIFIED_MESSAGE))
+        except discord.HTTPException:
+            log.exception(f"Sending welcome message failed for {ctx.author}.")
         finally:
             log.trace(f"Deleting accept message by {ctx.author}.")
             with suppress(discord.NotFound):


Test patch: