@@ -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
@@ -140,27 +178,23 @@ async def _mark_highscore_message(
140178 new_count : int ,
141179 previous_high_score : int ,
142180 ) -> None :
143- """Add ✅+🏆 to the message and keep only one active highscore marker per guild."""
181+ """Add ✅+🏆 to the message.
182+
183+ Note: Reactions, once added by the bot, should never be removed.
184+ """
144185 if not message .guild or not isinstance (message .channel , discord .TextChannel ):
145186 return
146187
147188 guild_id = message .guild .id
148189 channel = message .channel
149190
150- previous_marker = await self ._get_active_highscore_message_id (guild_id )
151- if previous_marker and previous_marker != message .id :
152- await self ._remove_bot_reactions (channel , previous_marker )
153-
154- # Ensure reactions exist (✅ may already be there)
155- try :
156- await message .add_reaction ("✅" )
157- except Exception :
158- pass
159- try :
160- await message .add_reaction ("🏆" )
161- except Exception :
162- pass
191+ # Only add the trophy here.
192+ # The ✅ reaction is added for all valid counts in the main handler;
193+ # adding it again here causes extra API calls and rate limits.
194+ self ._enqueue_reaction (message , "🏆" )
163195
196+ # Track the latest highscore/tie message ID for bookkeeping.
197+ # (We no longer remove reactions from older messages.)
164198 await self ._set_active_highscore_message_id (guild_id , message .id )
165199
166200 # Record history only if it is a NEW record
@@ -178,24 +212,48 @@ async def _mark_highscore_message(
178212 async def _clear_highscore_marker_if_any (self , guild_id : int , channel : discord .TextChannel ) -> None :
179213 marker_id = await self ._get_active_highscore_message_id (guild_id )
180214 if marker_id :
181- await self ._remove_bot_reactions (channel , marker_id )
182215 await self ._set_active_highscore_message_id (guild_id , None )
183216
184217 @app_commands .command (name = "setcountingchannel" , description = "Set the channel for the counting game" )
185218 @app_commands .checks .has_permissions (administrator = True )
186219 async def setcountingchannel (self , interaction : discord .Interaction , channel : discord .TextChannel ):
187- async with aiosqlite .connect (DB_PATH , timeout = 30.0 ) as db :
188- await db .execute ("""
189- INSERT INTO counting_config (guild_id, channel_id)
190- VALUES (?, ?)
191- ON CONFLICT(guild_id) DO UPDATE SET channel_id = excluded.channel_id
192- """ , (interaction .guild_id , channel .id ))
193- await db .commit ()
194-
220+ # Slash command interactions must be acknowledged quickly.
221+ # DB operations can take >3s (locks, slow disks), so defer immediately.
222+ if interaction .response .is_done ():
223+ # Extremely defensive; normally false here.
224+ pass
225+ else :
226+ await interaction .response .defer (ephemeral = True )
227+
228+ if interaction .guild_id is None :
229+ await interaction .followup .send ("This command can only be used in a server." , ephemeral = True )
230+ return
231+
232+ retries = 3
233+ while retries > 0 :
234+ try :
235+ async with aiosqlite .connect (DB_PATH , timeout = 30.0 ) as db :
236+ await db .execute (
237+ """
238+ INSERT INTO counting_config (guild_id, channel_id)
239+ VALUES (?, ?)
240+ ON CONFLICT(guild_id) DO UPDATE SET channel_id = excluded.channel_id
241+ """ ,
242+ (interaction .guild_id , channel .id ),
243+ )
244+ await db .commit ()
245+ break
246+ except aiosqlite .OperationalError as e :
247+ if "locked" in str (e ).lower ():
248+ retries -= 1
249+ await asyncio .sleep (0.5 )
250+ continue
251+ raise
252+
195253 # Update cache
196254 self .counting_channels [interaction .guild_id ] = channel .id
197-
198- await interaction .response . send_message (f"Counting channel set to { channel .mention } " , ephemeral = True )
255+
256+ await interaction .followup . send (f"Counting channel set to { channel .mention } " , ephemeral = True )
199257
200258 def safe_eval (self , expr ):
201259 operators = {
@@ -293,6 +351,17 @@ async def on_message(self, message):
293351 if message .channel .id != self .counting_channels [message .guild .id ]:
294352 return
295353
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+
296365 # 2. Process the message logic
297366 # Wrap DB operations in retry loop for robustness
298367 retries = 3
@@ -327,19 +396,29 @@ async def on_message(self, message):
327396
328397 if message .author .id == last_user_id :
329398 # Warn instead of instant ruin. 3 warnings ruins the count.
330- warnings = await self ._get_warning_count (message .guild .id , message .author .id )
331- warnings += 1
332- await self ._set_warning_count (message .guild .id , message .author .id , warnings )
333-
334- try :
335- await message .add_reaction ("⚠️" )
336- except Exception :
337- 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 ()
338415
339416 if warnings >= 3 :
340417 await self .fail_count (message , current_count , "Too many warnings (counted twice in a row 3 times)!" )
341418 return
342419
420+ self ._enqueue_reaction (message , "⚠️" )
421+
343422 await message .channel .send (
344423 f"You can't count twice in a row, { message .author .mention } . "
345424 f"You have **{ warnings } /3** warnings." ,
@@ -348,7 +427,6 @@ async def on_message(self, message):
348427 return
349428
350429 # Valid count - Update DB
351- await message .add_reaction ("✅" )
352430 new_high_score = max (high_score , next_count )
353431
354432 # Update configuration tables
@@ -364,11 +442,17 @@ async def on_message(self, message):
364442 VALUES (?, ?, 1, 0)
365443 ON CONFLICT(user_id, guild_id) DO UPDATE SET total_counts = total_counts + 1
366444 """ , (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+ )
367451
368452 await db .commit ()
369453
370- # Reset warnings for this user on a valid count
371- 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 , "✅" )
372456
373457 # Highscore marker: react ✅+🏆 when reaching/topping the record
374458 if next_count >= high_score :
0 commit comments