Skip to content

Commit 0d60d27

Browse files
authored
Merge pull request TheCodeVerseHub#39 from TheCodeVerseHub/yc45
Fixed issue TheCodeVerseHub#38
2 parents a22251b + 3707f83 commit 0d60d27

2 files changed

Lines changed: 163 additions & 37 deletions

File tree

cogs/counting.py

Lines changed: 120 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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:

utils/config.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
from typing import Any, Optional
67

78
from pydantic import Field, field_validator, model_validator
@@ -31,6 +32,42 @@ class Config(BaseSettings):
3132
extra='ignore' # Ignore extra fields from .env
3233
)
3334

35+
@field_validator('guild_id', mode='before')
36+
@classmethod
37+
def _parse_guild_id(cls, v: Any) -> Optional[int]:
38+
"""Parse legacy single guild id.
39+
40+
Some deployments mistakenly set `GUILD_ID` as a CSV / JSON list.
41+
To stay backwards compatible and avoid startup failures, we accept:
42+
- int
43+
- numeric string
44+
- CSV: "1,2,3" (uses the first value)
45+
- JSON list: "[1,2]" (uses the first value)
46+
"""
47+
if v is None:
48+
return None
49+
if isinstance(v, int):
50+
return v
51+
if isinstance(v, str):
52+
s = v.strip()
53+
if not s:
54+
return None
55+
if s.startswith('[') and s.endswith(']'):
56+
try:
57+
import json
58+
59+
parsed = json.loads(s)
60+
if isinstance(parsed, list) and parsed:
61+
return int(parsed[0])
62+
except Exception:
63+
# Fall back to CSV/int parsing
64+
pass
65+
if ',' in s:
66+
first = s.split(',', 1)[0].strip()
67+
return int(first) if first else None
68+
return int(s)
69+
return int(v)
70+
3471
@field_validator('guild_ids', mode='before')
3572
@classmethod
3673
def _parse_guild_ids(cls, v: Any) -> list[int]:
@@ -42,7 +79,12 @@ def _parse_guild_ids(cls, v: Any) -> list[int]:
4279
- CSV: "1,2,3"
4380
"""
4481
if v is None:
45-
return []
82+
# If GUILD_IDS isn't set, allow legacy GUILD_ID to behave like a list.
83+
legacy = os.getenv('GUILD_ID')
84+
if legacy and str(legacy).strip():
85+
v = legacy
86+
else:
87+
return []
4688
if isinstance(v, list):
4789
return [int(x) for x in v if str(x).strip()]
4890
if isinstance(v, (int, str)):

0 commit comments

Comments
 (0)