Repository: python-discord/bot

Issue:
Only punish users once to prevent a race condition in AntiSpam
If a user manages to take part of multiple filter violations, the cog will attempt to `punish()` them for each of those violations. While `punish()` does check for the muted role before applying the infraction, the asynchronous nature of the code can mean a second `punish()` is called and passes the role check before the first `punish()` has gotten a chance to apply the mute. This would result in an attempt to apply a second mute infraction, which will ultimately fail and display a message stating the user already has such infraction.

I believe the fix is to keep track of which members were already punished so that they can be skipped when processing members for the rule violation. One has to consider this globally, not just for the current deletion context. Maybe using locks for each member would just be easier.


Discussion:
I'm going to take a stab at this. It led to some serious issues last week, and I think we should tackle this relatively soon-ish.

I'm not quite sure about what the best implementation for this would be, yet. If we go with a global lock and making sure the "deletion context"-specific infractions only happen once, the infractions from other contexts will still go through after the lock is released. Granted, this should already reduce to a single fail/success message per context, but I don't actually think we need "failure" messages showing up in the context. 

My proposal would be to implement the "single infraction per user per context" and hiding failures for existing infractions for automated anti-spam infractions. If infractions fail for another reason, which probably means that something is happening but the bot can't deal with it, I think it's best to send those automated failures to mod-alerts using a webhook (to avoid rate limits in raid events). (I don't think I've seen those "other" failures yet, though, but silencing them completely sounds bad.)

Obviously, this involves some refactoring and redesigning of some of the moving parts, as we have no support for the things I've mentioned.
Sentry issue: [BOT-52](https://sentry.io/organizations/python-discord/issues/1683118148/?referrer=github_integration)

Resolving patch:
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -3,7 +3,7 @@
 from collections.abc import Mapping
 from dataclasses import dataclass, field
 from datetime import datetime, timedelta
-from operator import itemgetter
+from operator import attrgetter, itemgetter
 from typing import Dict, Iterable, List, Set
 
 from discord import Colour, Member, Message, NotFound, Object, TextChannel
@@ -18,6 +18,7 @@
 )
 from bot.converters import Duration
 from bot.exts.moderation.modlog import ModLog
+from bot.utils import lock, scheduling
 from bot.utils.messages import format_user, send_attachments
 
 
@@ -114,7 +115,7 @@ def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None:
 
         self.message_deletion_queue = dict()
 
-        self.bot.loop.create_task(self.alert_on_validation_error())
+        self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error")
 
     @property
     def mod_log(self) -> ModLog:
@@ -191,7 +192,10 @@ async def on_message(self, message: Message) -> None:
                 if channel.id not in self.message_deletion_queue:
                     log.trace(f"Creating queue for channel `{channel.id}`")
                     self.message_deletion_queue[message.channel.id] = DeletionContext(channel)
-                    self.bot.loop.create_task(self._process_deletion_context(message.channel.id))
+                    scheduling.create_task(
+                        self._process_deletion_context(message.channel.id),
+                        name=f"AntiSpam._process_deletion_context({message.channel.id})"
+                    )
 
                 # Add the relevant of this trigger to the Deletion Context
                 await self.message_deletion_queue[message.channel.id].add(
@@ -201,16 +205,15 @@ async def on_message(self, message: Message) -> None:
                 )
 
                 for member in members:
-
-                    # Fire it off as a background task to ensure
-                    # that the sleep doesn't block further tasks
-                    self.bot.loop.create_task(
-                        self.punish(message, member, full_reason)
+                    scheduling.create_task(
+                        self.punish(message, member, full_reason),
+                        name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})"
                     )
 
                 await self.maybe_delete_messages(channel, relevant_messages)
                 break
 
+    @lock.lock_arg("antispam.punish", "member", attrgetter("id"))
     async def punish(self, msg: Message, member: Member, reason: str) -> None:
         """Punishes the given member for triggering an antispam rule."""
         if not any(role.id == self.muted_role.id for role in member.roles):


Test patch: