diff --git a/commands/minesweeper.py b/commands/minesweeper.py index c1fb114..0a71b93 100644 --- a/commands/minesweeper.py +++ b/commands/minesweeper.py @@ -4,6 +4,7 @@ from discord import app_commands import random import asyncio import re +from datetime import datetime, timedelta class MinesweeperGame: def __init__(self, rows: int = 13, cols: int = 13, mines: int = 20): @@ -197,10 +198,64 @@ class MinesweeperGame: class MinesweeperView(discord.ui.View): def __init__(self, player: discord.Member, game: MinesweeperGame): - super().__init__(timeout=600) + super().__init__(timeout=None) # Disable built-in timeout, we'll manage it ourselves self.player = player self.game = game self.message: discord.Message = None + self.timeout_seconds = 1800 # 30 minutes + self.timeout_task = None + self.timed_out = False + + async def start_timeout(self): + """Start or restart the timeout timer""" + # Cancel existing timeout task if any + if self.timeout_task and not self.timeout_task.done(): + self.timeout_task.cancel() + + # Create new timeout task + self.timeout_task = asyncio.create_task(self._timeout_handler()) + + async def _timeout_handler(self): + """Handle the timeout after waiting""" + try: + await asyncio.sleep(self.timeout_seconds) + # If we reach here, the timeout expired + await self.handle_timeout() + except asyncio.CancelledError: + # Timeout was reset, this is expected + pass + + async def handle_timeout(self): + """Handle when the game times out""" + if self.timed_out or self.game.game_over: + return + + self.timed_out = True + self.game.game_over = True + + for child in self.children: + child.disabled = True + + self.stop() + + if self.message: + embed = self.create_embed() + embed.color = discord.Color.orange() + embed.title = "⏱️ Game Timed Out" + embed.description = f"{self.player.mention}'s game has ended due to inactivity (30 minutes)." + embed.set_footer(text="The game has been automatically closed.") + try: + await self.message.edit(embed=embed, view=self) + except discord.errors.NotFound: + # Message was deleted + pass + except Exception as e: + print(f"Error updating timed out game: {e}") + + def reset_timeout(self): + """Reset the timeout timer - called when a move is made""" + if not self.game.game_over and not self.timed_out: + asyncio.create_task(self.start_timeout()) @discord.ui.button(label="Forfeit", style=discord.ButtonStyle.danger, emoji="🏳️") async def forfeit(self, interaction: discord.Interaction, button: discord.ui.Button): @@ -209,6 +264,11 @@ class MinesweeperView(discord.ui.View): return self.game.game_over = True + + # Cancel timeout task + if self.timeout_task and not self.timeout_task.done(): + self.timeout_task.cancel() + for child in self.children: child.disabled = True self.stop() @@ -295,23 +355,9 @@ class MinesweeperView(discord.ui.View): embed.add_field(name="Stats", value=stats, inline=False) if not self.game.game_over: - embed.set_footer(text="Send your move in chat: [column number] [row letter] [flag]") + embed.set_footer(text="Send your move in chat: [column number] [row letter] [flag] | Timeout resets with each move (30min)") return embed - - async def on_timeout(self): - self.game.game_over = True - for child in self.children: - child.disabled = True - - if self.message: - embed = self.create_embed() - embed.color = discord.Color.greyple() - embed.title = "⏱️ Game Timed Out" - try: - await self.message.edit(embed=embed, view=self) - except: - pass class Minesweeper(commands.Cog): @@ -351,6 +397,9 @@ class Minesweeper(commands.Cog): message = await interaction.followup.send(embed=embed, view=view) view.message = message + # Start the timeout timer + await view.start_timeout() + self.active_games[interaction.channel_id] = (interaction.user.id, view) # Wait for the game to finish @@ -401,8 +450,15 @@ class Minesweeper(commands.Cog): else: view.game.reveal(row, col) + # Reset the timeout timer since a valid move was made + view.reset_timeout() + # Update the embed if view.game.game_over: + # Cancel timeout task since game is over + if view.timeout_task and not view.timeout_task.done(): + view.timeout_task.cancel() + for child in view.children: child.disabled = True view.stop() diff --git a/commands/roletrack.py b/commands/roletrack.py index 0a8ca11..781aafe 100644 --- a/commands/roletrack.py +++ b/commands/roletrack.py @@ -13,113 +13,193 @@ class RoleTrack(commands.Cog): async def init_db(self): """Initialize the database tables if they don't exist""" async with aiosqlite.connect(self.db_path) as db: - # Table for tracking opt-in status - await db.execute(''' - CREATE TABLE IF NOT EXISTS role_tracking_users ( - user_id INTEGER, - guild_id INTEGER, - opted_in INTEGER DEFAULT 1, - PRIMARY KEY (user_id, guild_id) - ) - ''') - - # Table for storing role data + # Table for storing role data - one row per user per guild + # role_ids stored as comma-separated string await db.execute(''' CREATE TABLE IF NOT EXISTS tracked_roles ( user_id INTEGER, guild_id INTEGER, - role_id INTEGER, - role_name TEXT, + role_ids TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_id, guild_id, role_id) + PRIMARY KEY (user_id, guild_id) ) ''') await db.commit() - async def is_opted_in(self, user_id: int, guild_id: int) -> bool: - """Check if a user is opted into role tracking""" - async with aiosqlite.connect(self.db_path) as db: - async with db.execute(''' - SELECT opted_in FROM role_tracking_users - WHERE user_id = ? AND guild_id = ? - ''', (user_id, guild_id)) as cursor: - result = await cursor.fetchone() - return result[0] == 1 if result else False - async def save_user_roles(self, member: discord.Member): """Save all roles for a user (excluding @everyone)""" - if not await self.is_opted_in(member.id, member.guild.id): - return + # Get all role IDs except @everyone + role_ids = [str(role.id) for role in member.roles if role.id != member.guild.id] + role_ids_str = ",".join(role_ids) if role_ids else "" async with aiosqlite.connect(self.db_path) as db: - # Clear existing roles for this user in this guild + # Insert or replace the user's roles await db.execute(''' - DELETE FROM tracked_roles - WHERE user_id = ? AND guild_id = ? - ''', (member.id, member.guild.id)) - - # Save current roles (excluding @everyone) - for role in member.roles: - if role.id != member.guild.id: # Skip @everyone role - await db.execute(''' - INSERT INTO tracked_roles (user_id, guild_id, role_id, role_name) - VALUES (?, ?, ?, ?) - ''', (member.id, member.guild.id, role.id, role.name)) + INSERT OR REPLACE INTO tracked_roles (user_id, guild_id, role_ids, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ''', (member.id, member.guild.id, role_ids_str)) await db.commit() - @app_commands.command(name="roletrack", description="Manage role tracking opt-in status") - @app_commands.describe( - setting="Choose to opt in or opt out of role tracking" - ) - @app_commands.choices(setting=[ - app_commands.Choice(name="opt_in", value="opt_in"), - app_commands.Choice(name="opt_out", value="opt_out") - ]) - async def roletrack(self, interaction: discord.Interaction, setting: app_commands.Choice[str]): - """Command to opt in or opt out of role tracking""" + async def get_saved_roles(self, user_id: int, guild_id: int): + """Get saved role IDs for a user""" async with aiosqlite.connect(self.db_path) as db: - if setting.value == "opt_in": - # Opt the user in - await db.execute(''' - INSERT OR REPLACE INTO role_tracking_users (user_id, guild_id, opted_in) - VALUES (?, ?, 1) - ''', (interaction.user.id, interaction.guild.id)) - - await db.commit() - - # Save current roles (outside the db context) - await self.save_user_roles(interaction.user) + async with db.execute(''' + SELECT role_ids FROM tracked_roles + WHERE user_id = ? AND guild_id = ? + ''', (user_id, guild_id)) as cursor: + result = await cursor.fetchone() + if result and result[0]: + # Convert comma-separated string back to list of ints + return [int(role_id) for role_id in result[0].split(",")] + return [] + + @app_commands.command(name="syncroles", description="Manually sync your current roles to the role tracking system") + async def syncroles(self, interaction: discord.Interaction): + """Command to manually sync user's current roles""" + await interaction.response.defer(ephemeral=True) + + try: + await self.save_user_roles(interaction.user) + + # Count how many roles were saved (excluding @everyone) + role_count = len([r for r in interaction.user.roles if r.id != interaction.guild.id]) + + embed = discord.Embed( + title="✅ Roles Synced", + description=f"Successfully saved {role_count} role(s) to the tracking system.", + color=discord.Color.green() + ) + embed.add_field( + name="What does this do?", + value="If you leave and rejoin this server, your roles will be automatically restored.", + inline=False + ) + + # Show current roles + if role_count > 0: + roles_list = ", ".join([r.name for r in interaction.user.roles if r.id != interaction.guild.id]) + embed.add_field( + name="Your Current Roles", + value=roles_list, + inline=False + ) + + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + embed = discord.Embed( + title="❌ Sync Failed", + description=f"An error occurred while syncing your roles: {str(e)}", + color=discord.Color.red() + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + @app_commands.command(name="checkroles", description="Check what roles are saved in the database for you") + async def checkroles(self, interaction: discord.Interaction): + """Debug command to check saved roles""" + await interaction.response.defer(ephemeral=True) + + try: + saved_role_ids = await self.get_saved_roles(interaction.user.id, interaction.guild.id) + + if not saved_role_ids: + embed = discord.Embed( + title="📋 Saved Roles", + description="No roles are currently saved for you in the database.", + color=discord.Color.orange() + ) + else: + # Get role names + roles = [] + for role_id in saved_role_ids: + role = interaction.guild.get_role(role_id) + if role: + roles.append(f"{role.name} (ID: {role_id})") + else: + roles.append(f"Deleted Role (ID: {role_id})") embed = discord.Embed( - title="✅ Role Tracking Enabled", - description="You've been opted into role tracking. If you leave and rejoin this server, your roles will be automatically restored.", - color=discord.Color.green() + title="📋 Saved Roles", + description=f"Found {len(saved_role_ids)} role(s) in the database:", + color=discord.Color.blue() ) - await interaction.response.send_message(embed=embed, ephemeral=True) - - elif setting.value == "opt_out": - # Opt the user out - await db.execute(''' - INSERT OR REPLACE INTO role_tracking_users (user_id, guild_id, opted_in) - VALUES (?, ?, 0) - ''', (interaction.user.id, interaction.guild.id)) - - # Delete their tracked roles - await db.execute(''' - DELETE FROM tracked_roles - WHERE user_id = ? AND guild_id = ? - ''', (interaction.user.id, interaction.guild.id)) - - await db.commit() - + embed.add_field( + name="Roles", + value="\n".join(roles) if roles else "None", + inline=False + ) + + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + embed = discord.Embed( + title="❌ Check Failed", + description=f"An error occurred: {str(e)}", + color=discord.Color.red() + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member): + """Save roles when a member leaves""" + await self.save_user_roles(member) + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + """Restore roles when a member rejoins, and save them for new members""" + saved_role_ids = await self.get_saved_roles(member.id, member.guild.id) + + if not saved_role_ids: + # New member - just save their initial roles + await self.save_user_roles(member) + return + + # Returning member - restore their roles + # Get the roles that still exist in the guild + roles_to_add = [] + for role_id in saved_role_ids: + role = member.guild.get_role(role_id) + if role: + roles_to_add.append(role) + + if not roles_to_add: + return # None of the saved roles exist anymore + + try: + # Add the roles back + await member.add_roles(*roles_to_add, reason="Role tracking: Restoring previous roles") + + # Send a DM to the user notifying them + try: embed = discord.Embed( - title="❌ Role Tracking Disabled", - description="You've been opted out of role tracking. Your saved roles have been deleted.", - color=discord.Color.red() + title="🎭 Roles Restored", + description=f"Welcome back to **{member.guild.name}**! Your roles have been automatically restored.", + color=discord.Color.blue() ) - await interaction.response.send_message(embed=embed, ephemeral=True) + embed.add_field( + name="Restored Roles", + value=", ".join([role.name for role in roles_to_add]), + inline=False + ) + await member.send(embed=embed) + except: + # User has DMs disabled, that's fine + pass + + except discord.Forbidden: + # Bot doesn't have permission to add roles + pass + except Exception as e: + print(f"Error restoring roles for {member}: {e}") + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + """Track role changes in real-time""" + # Check if roles changed + if before.roles != after.roles: + await self.save_user_roles(after) async def setup(bot): await bot.add_cog(RoleTrack(bot)) \ No newline at end of file