@@ -15,9 +15,47 @@ def __init__(self, bot):
1515 self .bot = bot
1616 # Cache for counting channels: guild_id -> channel_id
1717 self .counting_channels = {}
18+ # Protect against occasional duplicate MESSAGE_CREATE dispatches or accidental double-processing.
19+ # Key: message_id, Value: monotonic timestamp
20+ self ._recent_message_ids : dict [int , float ] = {}
21+ # Throttle reaction API calls to avoid Discord rate limits in fast counting channels.
22+ self ._reaction_queue : asyncio .Queue [tuple [discord .Message , str ]] = asyncio .Queue ()
23+ self ._pending_reactions : set [tuple [int , str ]] = set ()
24+ self ._reaction_worker_task : Optional [asyncio .Task [None ]] = None
25+
26+ async def cog_unload (self ) -> None :
27+ if self ._reaction_worker_task and not self ._reaction_worker_task .done ():
28+ self ._reaction_worker_task .cancel ()
29+
30+ async def _reaction_worker (self ) -> None :
31+ # A small delay between reaction requests keeps us under the common reaction route limits.
32+ # Reactions may appear slightly delayed, but they will still be added.
33+ while True :
34+ message , emoji = await self ._reaction_queue .get ()
35+ try :
36+ try :
37+ await message .add_reaction (emoji )
38+ except Exception :
39+ pass
40+ await asyncio .sleep (0.35 )
41+ finally :
42+ self ._pending_reactions .discard ((message .id , emoji ))
43+ self ._reaction_queue .task_done ()
44+
45+ def _enqueue_reaction (self , message : discord .Message , emoji : str ) -> None :
46+ key = (message .id , emoji )
47+ if key in self ._pending_reactions :
48+ return
49+ self ._pending_reactions .add (key )
50+ try :
51+ self ._reaction_queue .put_nowait ((message , emoji ))
52+ except Exception :
53+ self ._pending_reactions .discard (key )
1854
1955 async def cog_load (self ):
2056 """Load counting channels into memory on startup"""
57+ if self ._reaction_worker_task is None or self ._reaction_worker_task .done ():
58+ self ._reaction_worker_task = asyncio .create_task (self ._reaction_worker ())
2159 try :
2260 async with aiosqlite .connect (DB_PATH , timeout = 30.0 ) as db :
2361 # Ensure auxiliary tables exist
@@ -153,10 +191,7 @@ async def _mark_highscore_message(
153191 # Only add the trophy here.
154192 # The ✅ reaction is added for all valid counts in the main handler;
155193 # adding it again here causes extra API calls and rate limits.
156- try :
157- await message .add_reaction ("🏆" )
158- except Exception :
159- pass
194+ self ._enqueue_reaction (message , "🏆" )
160195
161196 # Track the latest highscore/tie message ID for bookkeeping.
162197 # (We no longer remove reactions from older messages.)
@@ -316,6 +351,17 @@ async def on_message(self, message):
316351 if message .channel .id != self .counting_channels [message .guild .id ]:
317352 return
318353
354+ # Deduplicate processing of the same message ID within this process.
355+ # This prevents duplicate warnings/messages if Discord or the bot dispatches the event twice.
356+ now = time .monotonic ()
357+ last_seen = self ._recent_message_ids .get (message .id )
358+ if last_seen is not None and (now - last_seen ) < 30 :
359+ return
360+ self ._recent_message_ids [message .id ] = now
361+ if len (self ._recent_message_ids ) > 5000 :
362+ cutoff = now - 120
363+ self ._recent_message_ids = {mid : ts for mid , ts in self ._recent_message_ids .items () if ts >= cutoff }
364+
319365 # 2. Process the message logic
320366 # Wrap DB operations in retry loop for robustness
321367 retries = 3
@@ -350,19 +396,29 @@ async def on_message(self, message):
350396
351397 if message .author .id == last_user_id :
352398 # Warn instead of instant ruin. 3 warnings ruins the count.
353- warnings = await self ._get_warning_count (message .guild .id , message .author .id )
354- warnings += 1
355- await self ._set_warning_count (message .guild .id , message .author .id , warnings )
356-
357- try :
358- await message .add_reaction ("⚠️" )
359- except Exception :
360- pass
399+ # Use an atomic increment in the SAME connection to avoid races and DB-lock retries.
400+ await db .execute (
401+ """
402+ INSERT INTO counting_warnings (guild_id, user_id, warnings)
403+ VALUES (?, ?, 1)
404+ ON CONFLICT(guild_id, user_id) DO UPDATE SET warnings = warnings + 1
405+ """ ,
406+ (message .guild .id , message .author .id ),
407+ )
408+ async with db .execute (
409+ "SELECT warnings FROM counting_warnings WHERE guild_id = ? AND user_id = ?" ,
410+ (message .guild .id , message .author .id ),
411+ ) as cursor :
412+ row = await cursor .fetchone ()
413+ warnings = int (row [0 ]) if row else 1
414+ await db .commit ()
361415
362416 if warnings >= 3 :
363417 await self .fail_count (message , current_count , "Too many warnings (counted twice in a row 3 times)!" )
364418 return
365419
420+ self ._enqueue_reaction (message , "⚠️" )
421+
366422 await message .channel .send (
367423 f"You can't count twice in a row, { message .author .mention } . "
368424 f"You have **{ warnings } /3** warnings." ,
@@ -371,7 +427,6 @@ async def on_message(self, message):
371427 return
372428
373429 # Valid count - Update DB
374- await message .add_reaction ("✅" )
375430 new_high_score = max (high_score , next_count )
376431
377432 # Update configuration tables
@@ -387,11 +442,17 @@ async def on_message(self, message):
387442 VALUES (?, ?, 1, 0)
388443 ON CONFLICT(user_id, guild_id) DO UPDATE SET total_counts = total_counts + 1
389444 """ , (message .author .id , message .guild .id ))
445+
446+ # Reset warnings for this user on a valid count (in the same transaction).
447+ await db .execute (
448+ "DELETE FROM counting_warnings WHERE guild_id = ? AND user_id = ?" ,
449+ (message .guild .id , message .author .id ),
450+ )
390451
391452 await db .commit ()
392453
393- # Reset warnings for this user on a valid count
394- await self ._set_warning_count (message . guild . id , message . author . id , 0 )
454+ # Side effects after commit to avoid duplicate reactions on retries.
455+ self ._enqueue_reaction (message , "✅" )
395456
396457 # Highscore marker: react ✅+🏆 when reaching/topping the record
397458 if next_count >= high_score :
0 commit comments