Conversation
youngcoder45
left a comment
There was a problem hiding this comment.
Looks good but will think before merging
| async with aiosqlite.connect(DB_PATH) as db: | ||
| cursor = await db.execute( | ||
| "SELECT event_id, name, description, start_at, end_at, status, announcement_channel_id " | ||
| "FROM events WHERE guild_id = ? AND status = 'active'", |
There was a problem hiding this comment.
Pull request overview
Adds a new seasonal events / live-ops system to the Discord bot, backed by SQLite tables and loaded as a new cog at startup.
Changes:
- Adds new
events,event_participants, andevent_actionstables to the main SQLite DB initialization. - Introduces
cogs/events.pyimplementing/eventcommands (create/join/checkin/leaderboard/admin controls) plus an automatic status transition task loop. - Wires the new cog into
bot.pyextension loading.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| utils/codebuddy_database.py | Creates SQLite tables needed for events/live-ops. |
| cogs/events.py | New seasonal events cog with commands, leaderboard, and automatic start/end announcements. |
| bot.py | Loads the new cogs.events extension during startup. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async with aiosqlite.connect(DB_PATH) as db: | ||
| cursor = await db.execute( | ||
| "SELECT last_checkin FROM event_participants WHERE event_id = ? AND user_id = ?", | ||
| (event_id, ctx.author.id) | ||
| ) | ||
| row = await cursor.fetchone() | ||
| if row and row[0] == today: | ||
| return await ctx.reply("You already checked in today. Try again tomorrow.") | ||
| await db.execute( | ||
| "UPDATE event_participants SET last_checkin = ?, last_activity = ? WHERE event_id = ? AND user_id = ?", | ||
| (today, _utcnow().isoformat(), event_id, ctx.author.id) | ||
| ) | ||
| await db.commit() | ||
|
|
||
| await self._add_points(event_id, ctx.author.id, 1, "daily_checkin") | ||
| await ctx.reply("Check-in recorded. +1 point.") |
There was a problem hiding this comment.
/event checkin updates last_checkin in one DB connection/transaction, then calls _add_points() which opens a second connection and performs separate writes. If the second step fails (e.g., DB locked), users can end up with a check-in recorded but no point awarded. Consider doing the check-in update + points increment + action insert in a single transaction/connection for atomicity.
| import discord | ||
| from discord import app_commands | ||
| from discord.ext import commands, tasks | ||
| from datetime import datetime, timezone, timedelta |
There was a problem hiding this comment.
timedelta is imported from datetime but never used in this module. Removing unused imports helps keep the file tidy and avoids confusion about intended time calculations.
| from datetime import datetime, timezone, timedelta | |
| from datetime import datetime, timezone |
|
|
||
| DB_PATH = "botdata.db" | ||
|
|
||
|
|
There was a problem hiding this comment.
This cog defines its own DB_PATH = "botdata.db" instead of reusing the shared DB path constant. Other cogs that use the main SQLite DB import DB_PATH from utils.codebuddy_database (e.g., cogs/counting.py:5, cogs/tod.py:6). To avoid divergence if the DB location changes, import and use the shared DB_PATH here too (and drop the local constant).
| DB_PATH = "botdata.db" | |
| from utils.codebuddy_database import DB_PATH |
| @tasks.loop(minutes=1) | ||
| async def event_tick(self): | ||
| try: | ||
| rows = await self._fetch_events() | ||
| guild_ids = sorted({row[1] for row in rows}) | ||
| for guild_id in guild_ids: | ||
| await self._sync_guild(guild_id) | ||
| except Exception: | ||
| return | ||
|
|
There was a problem hiding this comment.
event_tick swallows all exceptions and returns without logging. This can silently break automatic transitions (and make DB lock/parse issues impossible to diagnose). Log the exception with traceback (and ideally continue per-guild rather than aborting the whole tick) instead of silently returning.
There was a problem hiding this comment.
I was going to raise this as well, but since nothing else in this bot logs anyway, I decided not to comment on it.
| async def _fetch_events(self, guild_id: Optional[int] = None) -> List[tuple]: | ||
| query = "SELECT event_id, guild_id, name, description, start_at, end_at, status, announcement_channel_id FROM events" | ||
| params: tuple = () | ||
| if guild_id is not None: | ||
| query += " WHERE guild_id = ?" | ||
| params = (guild_id,) | ||
| async with aiosqlite.connect(DB_PATH) as db: | ||
| cursor = await db.execute(query, params) | ||
| return await cursor.fetchall() |
There was a problem hiding this comment.
All DB accesses here use the default SQLite timeout and no retry/backoff. In this repo, at least cogs/counting.py explicitly uses aiosqlite.connect(DB_PATH, timeout=30.0) and retries on database is locked, which suggests the main DB sees concurrent access. Using the default timeout here can cause frequent OperationalError: database is locked during commands/ticks. Consider using the same timeout (and possibly a small retry) for these connections.
| async def _fetch_events(self, guild_id: Optional[int] = None) -> List[tuple]: | ||
| query = "SELECT event_id, guild_id, name, description, start_at, end_at, status, announcement_channel_id FROM events" | ||
| params: tuple = () | ||
| if guild_id is not None: | ||
| query += " WHERE guild_id = ?" | ||
| params = (guild_id,) | ||
| async with aiosqlite.connect(DB_PATH) as db: | ||
| cursor = await db.execute(query, params) | ||
| return await cursor.fetchall() | ||
|
|
There was a problem hiding this comment.
_fetch_events() selects all rows from events (including ended / cancelled) and event_tick calls it every minute, then _sync_guild re-fetches per guild. As the table grows, this becomes unnecessary work each tick. Restrict the query to statuses that can transition (e.g., scheduled/active) and/or fetch once per tick and group in-memory to avoid the extra per-guild queries.
| "FROM events WHERE guild_id = ? AND status = 'scheduled'", | ||
| (guild_id,) | ||
| ) | ||
| rows = await cursor.fetchall() | ||
| if not rows: | ||
| return None | ||
| rows_sorted = sorted(rows, key=lambda r: datetime.fromisoformat(r[3])) | ||
| return rows_sorted[0] |
There was a problem hiding this comment.
_get_next_event() loads all scheduled events and sorts them in Python. This is less efficient than letting SQLite do it (ORDER BY start_at LIMIT 1), and it also risks raising ValueError in the sort key if any row has a non-ISO start_at string (the schema doesn't enforce format). Prefer an ordered query that returns only the next row, which removes both issues.
| "FROM events WHERE guild_id = ? AND status = 'scheduled'", | |
| (guild_id,) | |
| ) | |
| rows = await cursor.fetchall() | |
| if not rows: | |
| return None | |
| rows_sorted = sorted(rows, key=lambda r: datetime.fromisoformat(r[3])) | |
| return rows_sorted[0] | |
| "FROM events WHERE guild_id = ? AND status = 'scheduled' " | |
| "ORDER BY start_at ASC LIMIT 1", | |
| (guild_id,) | |
| ) | |
| row = await cursor.fetchone() | |
| return row |
| end_at TEXT NOT NULL, | ||
| status TEXT NOT NULL DEFAULT 'scheduled', | ||
| announcement_channel_id INTEGER, | ||
| created_by INTEGER, |
There was a problem hiding this comment.
Why is created_by nullable?
As far as I know, it shouldn't be possible for an event to create itself autonomously like some sort of cosmic deity.
There was a problem hiding this comment.
created_by is currently nullable mainly as a safeguard for edge cases where an event might be inserted programmatically (for example during migrations, imports, or automated maintenance tasks) where a user context might not exist.
|
|
||
| def __init__(self, bot: commands.Bot): | ||
| self.bot = bot | ||
| self.event_tick.start() |
There was a problem hiding this comment.
Nit: Is there a reason why this is in the constructor? Why not do this in cog_load?
|
|
||
| @event_group.command(name="award", description="Award points to a participant.") | ||
| @app_commands.describe(user="User to award", points="Points to add", reason="Reason for the award") | ||
| @commands.has_permissions(manage_guild=True) |
There was a problem hiding this comment.
Is manage_guild the right permission here? Guild settings aren't modified here either.
I'd again consider using manage_events, since the points aren't used outside of this cog. (Correct me if I am wrong.)
There was a problem hiding this comment.
manage_events would be the more appropriate permission here since the command only affects event-related data and doesn't modify broader guild settings. I'll switch the checks to manage_events.
There was a problem hiding this comment.
Sounds good. Also check whether the tick task initialization couldn't be moved to cog_load.
I'll approve changes after you push.
| """) | ||
|
|
||
| await db.execute(""" | ||
| CREATE TABLE IF NOT EXISTS event_actions ( |
There was a problem hiding this comment.
I'd consider renaming this table, as it is not immediately apparent that it logs point additions.
Summary
Testing
Notes
YYYY-MM-DD HH:MMor ISO 8601; timezone defaults to UTC if omitted