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.

Changed files:
bot/exts/filters/antispam.py

diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index af8528a6..7555e25a 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -3,7 +3,7 @@ import logging
 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.constants import (
 )
 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 @@ class AntiSpam(Cog):
 
         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 @@ class AntiSpam(Cog):
                 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 @@ class AntiSpam(Cog):
                 )
 
                 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):