diff --git a/.gitignore b/.gitignore index 17c5437..d18a724 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ venv/ */fonts/ .txt */databases/ -*/old_backups/ \ No newline at end of file +*/old_backups/ +logs/ \ No newline at end of file diff --git a/bot.py b/bot.py index 8e918d5..45fa3ba 100644 --- a/bot.py +++ b/bot.py @@ -7,6 +7,7 @@ import glob import traceback from xp.database import get_db as get_xp_db from moderation.loader import ModerationBase +from aiohttp import web # Import sparkle DB to ensure it exists from sparkle.database import get_db as get_sparkle_db diff --git a/commands/role_color.py b/commands/role_color.py index cb56541..343a24e 100644 --- a/commands/role_color.py +++ b/commands/role_color.py @@ -15,7 +15,7 @@ COLOR_ROLE_NAMES = [ "Turquoise", "Green Sea", "Emerald", "Nephritis", "River", "Belize", "Amethyst", "Wisteria", "Linen", "Alizarin", "Pomegranate", "Tangerine", "Rose", "Carrot", "Orange", "Sun Flower", "Pumpkin", "Light Gray", - "Dark Air", "White" + "Dark Air", "White-ish" ] FONTS_PATH = os.path.join(os.path.dirname(__file__), "..", "fonts") diff --git a/events/status_alerts.py b/events/status_alerts.py new file mode 100644 index 0000000..4ad70e0 --- /dev/null +++ b/events/status_alerts.py @@ -0,0 +1,233 @@ +""" +Status Monitor Cog +Monitors the status_monitor database and sends DMs when services go down/recover +""" +import discord +from discord.ext import commands, tasks +import sqlite3 +import os +from pathlib import Path +from datetime import datetime +from zoneinfo import ZoneInfo + +class StatusMonitor(commands.Cog): + def __init__(self, bot): + self.bot = bot + # Path to the status monitor database + self.db_path = Path("/home/lilacrose/lilacrose.dev2.0/monitor.db") + # Your Discord user ID + self.admin_user_id = 252130669919076352 + # Track last known status to detect changes + self.last_status = {} + # Start the monitoring loop + self.check_status.start() + + def cog_unload(self): + """Stop the monitoring loop when cog is unloaded""" + self.check_status.cancel() + + @tasks.loop(minutes=1) + async def check_status(self): + """Check service status every minute""" + try: + # Check if database exists + if not self.db_path.exists(): + return + + # Connect to the status monitor database + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + # Get latest status for each service + cur.execute(""" + SELECT service_name, status, error_message, timestamp + FROM service_checks + WHERE id IN ( + SELECT MAX(id) + FROM service_checks + GROUP BY service_name + ) + """) + + current_statuses = {row['service_name']: row for row in cur.fetchall()} + conn.close() + + # Get the admin user + admin = await self.bot.fetch_user(self.admin_user_id) + if not admin: + return + + # Check each service for status changes + for service_name, row in current_statuses.items(): + current_status = row['status'] + error_msg = row['error_message'] + + # Get the service's display name + service_display_names = { + "website": "TERMINAL//FKLR-F23", + "game_tracker": "GAME_TRACKER//FKLR-F23", + "forms_server": "Lacie Bot Website", + "file_server": "File Server", + "discord_bot": "Lacie Bot" + } + display_name = service_display_names.get(service_name, service_name) + + # If we haven't seen this service before, just store it + if service_name not in self.last_status: + self.last_status[service_name] = current_status + continue + + previous_status = self.last_status[service_name] + + # Service went DOWN + if current_status == "down" and previous_status != "down": + embed = discord.Embed( + title="🔴 Service Down Alert", + description=f"**{display_name}** has gone offline", + color=discord.Color.red(), + timestamp=datetime.now(ZoneInfo("America/New_York")) + ) + if error_msg: + embed.add_field(name="Error", value=f"```{error_msg}```", inline=False) + embed.add_field(name="Service ID", value=f"`{service_name}`", inline=True) + embed.set_footer(text="Status Monitor Alert") + + try: + await admin.send(embed=embed) + except Exception as e: + print(f"Failed to send down alert for {service_name}: {e}") + + # Service went DEGRADED + elif current_status == "degraded" and previous_status == "up": + embed = discord.Embed( + title="⚠️ Service Degraded", + description=f"**{display_name}** is experiencing issues", + color=discord.Color.orange(), + timestamp=datetime.now(ZoneInfo("America/New_York")) + ) + if error_msg: + embed.add_field(name="Error", value=f"```{error_msg}```", inline=False) + embed.add_field(name="Service ID", value=f"`{service_name}`", inline=True) + embed.set_footer(text="Status Monitor Alert") + + try: + await admin.send(embed=embed) + except Exception as e: + print(f"Failed to send degraded alert for {service_name}: {e}") + + # Service RECOVERED + elif current_status == "up" and previous_status in ["down", "degraded"]: + # Calculate downtime + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + cur.execute(""" + SELECT started_at, ended_at, duration_seconds + FROM downtime_incidents + WHERE service_name = ? AND ended_at IS NOT NULL + ORDER BY id DESC LIMIT 1 + """, (service_name,)) + + incident = cur.fetchone() + conn.close() + + embed = discord.Embed( + title="✅ Service Recovered", + description=f"**{display_name}** is back online", + color=discord.Color.green(), + timestamp=datetime.now(ZoneInfo("America/New_York")) + ) + + if incident and incident['duration_seconds']: + duration = incident['duration_seconds'] + if duration < 60: + duration_str = f"{int(duration)} seconds" + elif duration < 3600: + duration_str = f"{int(duration / 60)} minutes" + else: + duration_str = f"{duration / 3600:.1f} hours" + embed.add_field(name="Downtime", value=duration_str, inline=True) + + embed.add_field(name="Service ID", value=f"`{service_name}`", inline=True) + embed.set_footer(text="Status Monitor Alert") + + try: + await admin.send(embed=embed) + except Exception as e: + print(f"Failed to send recovery alert for {service_name}: {e}") + + # Update last known status + self.last_status[service_name] = current_status + + except Exception as e: + print(f"[StatusMonitor] Error checking status: {e}") + + @check_status.before_loop + async def before_check_status(self): + """Wait for bot to be ready before starting the loop""" + await self.bot.wait_until_ready() + print("[StatusMonitor] Starting status monitoring loop...") + + # Initialize last_status with current state on startup + try: + if self.db_path.exists(): + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + cur.execute(""" + SELECT service_name, status + FROM service_checks + WHERE id IN ( + SELECT MAX(id) + FROM service_checks + GROUP BY service_name + ) + """) + + for row in cur.fetchall(): + self.last_status[row['service_name']] = row['status'] + + conn.close() + print(f"[StatusMonitor] Initialized with {len(self.last_status)} services") + except Exception as e: + print(f"[StatusMonitor] Failed to initialize: {e}") + + @commands.command(name="statustest") + @commands.is_owner() + async def status_test(self, ctx): + """Test the status monitoring alerts (Owner only)""" + embed = discord.Embed( + title="🔔 Status Monitor Test", + description="This is a test alert from the status monitoring system", + color=discord.Color.blue(), + timestamp=datetime.now(ZoneInfo("America/New_York")) + ) + embed.add_field(name="Database Path", value=f"`{self.db_path}`", inline=False) + embed.add_field(name="Database Exists", value="✅ Yes" if self.db_path.exists() else "❌ No", inline=True) + embed.add_field(name="Monitoring", value=f"{len(self.last_status)} services", inline=True) + embed.add_field(name="Admin User ID", value=f"`{self.admin_user_id}`", inline=True) + + if self.last_status: + status_list = "\n".join([f"• `{name}`: {status}" for name, status in self.last_status.items()]) + embed.add_field(name="Current Status", value=status_list, inline=False) + + await ctx.send(embed=embed) + + # Try to DM the admin + try: + admin = await self.bot.fetch_user(self.admin_user_id) + test_embed = discord.Embed( + title="✅ DM Test Successful", + description="If you're seeing this, status alerts will work!", + color=discord.Color.green() + ) + await admin.send(embed=test_embed) + await ctx.send("✅ Test DM sent successfully!") + except Exception as e: + await ctx.send(f"❌ Failed to send test DM: {e}") + +async def setup(bot): + await bot.add_cog(StatusMonitor(bot)) \ No newline at end of file diff --git a/media/colorimage.png b/media/colorimage.png index fa3f34d..c53723f 100644 Binary files a/media/colorimage.png and b/media/colorimage.png differ diff --git a/moderation/infraction.py b/moderation/infraction.py index e842a32..cc77baf 100644 --- a/moderation/infraction.py +++ b/moderation/infraction.py @@ -1,10 +1,202 @@ import discord -from discord.ext import commands +from discord.ext import commands, tasks from .loader import ModerationBase -from datetime import datetime +from datetime import datetime, timedelta class InfractionCommand(ModerationBase): + def __init__(self, bot: commands.Bot): + super().__init__(bot) + self.approval_channel_id = 1467901790333960377 + self.migrate_existing_infractions() + self.check_auto_removals.start() + + def cog_unload(self): + """Stop the background task and close DB when cog unloads.""" + self.check_auto_removals.cancel() + super().cog_unload() + + def migrate_existing_infractions(self): + """Add new columns to existing infractions table for auto-removal system.""" + try: + # Add removed column (0 = active, 1 = removed) + self.c.execute("ALTER TABLE infractions ADD COLUMN removed INTEGER DEFAULT 0") + except Exception: + pass # Column already exists + + try: + # Add removed_date column + self.c.execute("ALTER TABLE infractions ADD COLUMN removed_date TEXT") + except Exception: + pass + + try: + # Add removed_by column (moderator who approved removal) + self.c.execute("ALTER TABLE infractions ADD COLUMN removed_by INTEGER") + except Exception: + pass + + try: + # Add skip_auto_removal column (1 = staff chose to keep it, skip future checks) + self.c.execute("ALTER TABLE infractions ADD COLUMN skip_auto_removal INTEGER DEFAULT 0") + except Exception: + pass + + try: + # Add pending_approval column (1 = approval request already sent, waiting for staff decision) + self.c.execute("ALTER TABLE infractions ADD COLUMN pending_approval INTEGER DEFAULT 0") + except Exception: + pass + + self.conn.commit() + + @tasks.loop(hours=24) + async def check_auto_removals(self): + """Background task that runs every 24 hours to check for eligible infraction removals.""" + try: + # Get all guilds with active infractions + self.c.execute("SELECT DISTINCT guild_id FROM infractions WHERE removed=0 AND skip_auto_removal=0 AND pending_approval=0") + guilds = [row[0] for row in self.c.fetchall()] + + for guild_id in guilds: + # Get all users with active, non-skipped, non-pending infractions in this guild + self.c.execute(""" + SELECT DISTINCT user_id + FROM infractions + WHERE guild_id=? AND removed=0 AND skip_auto_removal=0 AND pending_approval=0 + """, (guild_id,)) + users = [row[0] for row in self.c.fetchall()] + + for user_id in users: + await self.check_user_eligibility(guild_id, user_id) + + except Exception as e: + print(f"Error in auto-removal check: {e}") + + @check_auto_removals.before_loop + async def before_check_auto_removals(self): + """Wait until the bot is ready before starting the loop.""" + await self.bot.wait_until_ready() + + async def check_user_eligibility(self, guild_id: int, user_id: int): + """Check if a user's most recent infraction is eligible for removal.""" + try: + # Get guild + guild = self.bot.get_guild(guild_id) + if not guild: + return + + # Check if user is banned + try: + ban = await guild.fetch_ban(discord.Object(id=user_id)) + if ban: + return # User is banned, skip + except discord.NotFound: + pass # User is not banned, continue + except Exception: + pass # Error checking ban, continue anyway + + # Check if user is in the server + member = guild.get_member(user_id) + if not member: + return # User not in server, skip + + # Get the most recent ACTIVE infraction for this user (not removed, not skipped, not pending) + self.c.execute(""" + SELECT id, timestamp, type, reason, moderator_id + FROM infractions + WHERE user_id=? AND guild_id=? AND removed=0 AND skip_auto_removal=0 AND pending_approval=0 + ORDER BY timestamp DESC + LIMIT 1 + """, (user_id, guild_id)) + + most_recent = self.c.fetchone() + if not most_recent: + return # No active infractions + + inf_id, timestamp_str, inf_type, reason, mod_id = most_recent + infraction_date = datetime.fromisoformat(timestamp_str) + + # Check if 4 months have passed + four_months_ago = datetime.utcnow() - timedelta(days=120) + if infraction_date > four_months_ago: + return # Not eligible yet + + # Check if user got ANY infractions after this one + self.c.execute(""" + SELECT COUNT(*) + FROM infractions + WHERE user_id=? AND guild_id=? AND timestamp > ? + """, (user_id, guild_id, timestamp_str)) + + newer_infractions = self.c.fetchone()[0] + + if newer_infractions > 0: + return # User got infractions after this one, not eligible + + # User is eligible! Send to staff for approval + await self.send_removal_approval(guild_id, user_id, inf_id, inf_type, reason, timestamp_str, mod_id) + + except Exception as e: + print(f"Error checking eligibility for user {user_id} in guild {guild_id}: {e}") + + async def send_removal_approval(self, guild_id: int, user_id: int, inf_id: int, + inf_type: str, reason: str, timestamp: str, mod_id: int): + """Send an infraction removal request to the approval channel.""" + try: + approval_channel = self.bot.get_channel(self.approval_channel_id) + if not approval_channel: + print(f"Approval channel {self.approval_channel_id} not found") + return + + # Get guild, user, and moderator info + guild = self.bot.get_guild(guild_id) + if not guild: + return + + try: + user = await self.bot.fetch_user(user_id) + user_tag = f"{user.name}#{user.discriminator}" + except: + user_tag = f"Unknown User ({user_id})" + + try: + moderator = await self.bot.fetch_user(mod_id) + mod_tag = f"{moderator.name}#{moderator.discriminator}" + except: + mod_tag = f"Unknown Mod ({mod_id})" + + # Create approval embed + embed = discord.Embed( + title="🔔 Infraction Eligible for Auto-Removal", + description=f"This user has stayed clean for 4 months. Should this infraction be removed?", + color=discord.Color.blue() + ) + embed.add_field(name="Server", value=guild.name, inline=False) + embed.add_field(name="User", value=user_tag, inline=True) + embed.add_field(name="Infraction ID", value=str(inf_id), inline=True) + embed.add_field(name="Type", value=inf_type, inline=True) + embed.add_field(name="Reason", value=reason or "None", inline=False) + embed.add_field(name="Original Date", value=timestamp.replace("T", " ")[:19], inline=True) + embed.add_field(name="Original Moderator", value=mod_tag, inline=True) + embed.set_footer(text=f"User ID: {user_id} | Guild ID: {guild_id}") + + # Create approval view + view = InfractionRemovalView(self, inf_id, user_id, guild_id, user_tag, inf_type, reason, timestamp) + + await approval_channel.send(embed=embed, view=view) + + # Mark infraction as pending approval so we don't send duplicate requests + self.c.execute(""" + UPDATE infractions + SET pending_approval=1 + WHERE id=? + """, (inf_id,)) + self.conn.commit() + + except Exception as e: + print(f"Error sending removal approval: {e}") + @commands.command(name="inf") @ModerationBase.is_admin() async def inf(self, ctx, action: str, *args): @@ -25,6 +217,33 @@ class InfractionCommand(ModerationBase): self.c.execute(""" SELECT id, user_id, type, reason, moderator_id, timestamp FROM infractions + WHERE user_id=? AND guild_id=? AND removed=0 + ORDER BY timestamp DESC + """, (user_id, ctx.guild.id)) + results = self.c.fetchall() + except Exception as e: + await ctx.send(f"Database error: {e}") + return + + if not results: + await ctx.send("This user has no active infractions.") + return + + elif action == "search_full": + if not args: + await ctx.send("You must provide a user ID to search.") + return + + try: + user_id = int(args[0]) + except ValueError: + await ctx.send("Invalid user ID.") + return + + try: + self.c.execute(""" + SELECT id, user_id, type, reason, moderator_id, timestamp, removed, removed_date + FROM infractions WHERE user_id=? AND guild_id=? ORDER BY timestamp DESC """, (user_id, ctx.guild.id)) @@ -37,13 +256,98 @@ class InfractionCommand(ModerationBase): await ctx.send("This user has no infractions.") return + # Build full history rows with status + ids_to_cache = set(row[1] for row in results) | set(row[4] for row in results) + user_cache = {u.id: u for u in self.bot.users if u.id in ids_to_cache} + + rows = [] + for row in results: + try: + user = user_cache.get(row[1]) or await self.bot.fetch_user(int(row[1])) + moderator = user_cache.get(row[4]) or await self.bot.fetch_user(int(row[4])) + user_cache[user.id] = user + user_cache[moderator.id] = moderator + except Exception as e: + await ctx.send(f"User fetch error: {e}") + return + + user_tag = f"{user.name}#{user.discriminator}" + mod_tag = f"{moderator.name}#{moderator.discriminator}" + timestamp = row[5].replace("T", " ")[:19] + reason = row[3] or "None" + + # Check if removed + is_removed = row[6] if len(row) > 6 else 0 + removed_date = row[7] if len(row) > 7 and row[7] else "" + + if is_removed: + status = f"Removed ({removed_date.replace('T', ' ')[:19]})" if removed_date else "Removed" + else: + status = "Active" + + rows.append({ + "id": str(row[0]), + "user": user_tag, + "moderator": mod_tag, + "timestamp": timestamp, + "type": row[2], + "reason": reason, + "status": status + }) + + # Prepare table + widths = {key: max(len(key), *(len(r[key]) for r in rows)) for key in rows[0].keys()} + header = " | ".join(f"{key.capitalize():{widths[key]}}" for key in rows[0].keys()) + separator = "-" * len(header) + + # Build pages + chunk_size = 1800 + pages = [] + current_chunk = [header, separator] + char_count = len("```md\n") + len(header) + len(separator) + 2 + + for r in rows: + line = " | ".join(f"{r[key]:{widths[key]}}" for key in r.keys()) + line_len = len(line) + 1 + if char_count + line_len > chunk_size: + pages.append("```md\n" + "\n".join(current_chunk) + "\n```") + current_chunk = [header, separator] + char_count = len("```md\n") + len(header) + len(separator) + 2 + current_chunk.append(line) + char_count += line_len + + if current_chunk: + pages.append("```md\n" + "\n".join(current_chunk) + "\n```") + + # Pagination with buttons + class PageView(discord.ui.View): + def __init__(self, pages): + super().__init__(timeout=180) + self.pages = pages + self.current = 0 + + async def update_message(self, interaction): + await interaction.response.edit_message(content=self.pages[self.current], view=self) + + @discord.ui.button(label="Previous", style=discord.ButtonStyle.blurple) + async def previous(self, button: discord.ui.Button, interaction: discord.Interaction): + self.current = (self.current - 1) % len(self.pages) + await self.update_message(interaction) + + @discord.ui.button(label="Next", style=discord.ButtonStyle.blurple) + async def next(self, button: discord.ui.Button, interaction: discord.Interaction): + self.current = (self.current + 1) % len(self.pages) + await self.update_message(interaction) + + await ctx.send(content=pages[0], view=PageView(pages)) + return elif action == "list": try: self.c.execute(""" SELECT id, user_id, type, reason, moderator_id, timestamp FROM infractions - WHERE guild_id=? + WHERE guild_id=? AND removed=0 ORDER BY timestamp DESC """, (ctx.guild.id,)) results = self.c.fetchall() @@ -52,7 +356,7 @@ class InfractionCommand(ModerationBase): return if not results: - await ctx.send("No infractions found in this server.") + await ctx.send("No active infractions found in this server.") return elif action == "delete": @@ -66,16 +370,54 @@ class InfractionCommand(ModerationBase): await ctx.send("Invalid infraction ID.") return + # Fetch infraction details before deleting + self.c.execute(""" + SELECT user_id, type, reason, timestamp + FROM infractions + WHERE id=? AND guild_id=? + """, (inf_id, ctx.guild.id)) + infraction = self.c.fetchone() + + if not infraction: + await ctx.send(f"Infraction {inf_id} not found.") + return + + user_id, inf_type, reason, timestamp = infraction + + # Delete the infraction (actually delete it, not just mark as removed) self.c.execute("DELETE FROM infractions WHERE id=? AND guild_id=?", (inf_id, ctx.guild.id)) self.conn.commit() - await ctx.send(f"Infraction {inf_id} deleted.") + + # Send DM to the user + try: + user = await self.bot.fetch_user(user_id) + embed = discord.Embed( + title="Infraction Removed", + description=f"An infraction has been removed from your record in **{ctx.guild.name}**.", + color=discord.Color.green(), + timestamp=datetime.utcnow() + ) + embed.add_field(name="Infraction ID", value=str(inf_id), inline=True) + embed.add_field(name="Type", value=inf_type, inline=True) + embed.add_field(name="Original Reason", value=reason or "None", inline=False) + embed.add_field(name="Original Date", value=timestamp.replace("T", " ")[:19], inline=True) + embed.add_field(name="Removed By", value=f"{ctx.author.name}#{ctx.author.discriminator}", inline=True) + embed.set_footer(text=f"Server: {ctx.guild.name}") + + await user.send(embed=embed) + await ctx.send(f"Infraction {inf_id} deleted and user notified.") + except discord.Forbidden: + await ctx.send(f"Infraction {inf_id} deleted, but couldn't DM the user (DMs disabled or blocked).") + except Exception as e: + await ctx.send(f"Infraction {inf_id} deleted, but failed to notify user: {e}") + return else: - await ctx.send("Unknown action. Use search, list, or delete.") + await ctx.send("Unknown action. Use search, search_full, list, or delete.") return - # Cache users to avoid repeated API calls + # Cache users to avoid repeated API calls (for search and list) ids_to_cache = set(row[1] for row in results) | set(row[4] for row in results) user_cache = {u.id: u for u in self.bot.users if u.id in ids_to_cache} @@ -152,5 +494,101 @@ class InfractionCommand(ModerationBase): await ctx.send(content=pages[0], view=PageView(pages)) +class InfractionRemovalView(discord.ui.View): + """View for approving or denying infraction auto-removals.""" + + def __init__(self, cog, inf_id, user_id, guild_id, user_tag, inf_type, reason, timestamp): + super().__init__(timeout=None) + self.cog = cog + self.inf_id = inf_id + self.user_id = user_id + self.guild_id = guild_id + self.user_tag = user_tag + self.inf_type = inf_type + self.reason = reason + self.timestamp = timestamp + + @discord.ui.button(label="Remove Infraction", style=discord.ButtonStyle.green, custom_id="approve_removal") + async def approve_button(self, interaction: discord.Interaction, button: discord.ui.Button): + """Approve the removal - mark infraction as removed.""" + try: + # Mark infraction as removed and clear pending flag + self.cog.c.execute(""" + UPDATE infractions + SET removed=1, removed_date=?, removed_by=?, pending_approval=0 + WHERE id=? + """, (datetime.utcnow().isoformat(), interaction.user.id, self.inf_id)) + self.cog.conn.commit() + + # Update embed + embed = discord.Embed( + title="✅ Infraction Removed", + description="This infraction has been removed from the user's active record.", + color=discord.Color.green() + ) + embed.add_field(name="User", value=self.user_tag, inline=True) + embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True) + embed.add_field(name="Type", value=self.inf_type, inline=True) + embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True) + embed.add_field(name="Removed By", value=interaction.user.mention, inline=True) + embed.add_field(name="Removed At", value=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), inline=True) + + await interaction.response.edit_message(embed=embed, view=None) + + # Notify user + try: + user = await self.cog.bot.fetch_user(self.user_id) + guild = self.cog.bot.get_guild(self.guild_id) + + notify_embed = discord.Embed( + title="✅ Infraction Removed", + description=f"Great news! An infraction has been removed from your record in **{guild.name}** for good behavior.", + color=discord.Color.green(), + timestamp=datetime.utcnow() + ) + notify_embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True) + notify_embed.add_field(name="Type", value=self.inf_type, inline=True) + notify_embed.add_field(name="Original Reason", value=self.reason or "None", inline=False) + notify_embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True) + notify_embed.set_footer(text=f"You stayed clean for 4 months! Keep up the good behavior.") + + await user.send(embed=notify_embed) + except: + pass # User has DMs disabled or bot can't reach them + + except Exception as e: + await interaction.response.send_message(f"Error removing infraction: {e}", ephemeral=True) + + @discord.ui.button(label="Keep Infraction", style=discord.ButtonStyle.red, custom_id="deny_removal") + async def deny_button(self, interaction: discord.Interaction, button: discord.ui.Button): + """Deny the removal - mark to skip future auto-removal checks.""" + try: + # Mark infraction to skip future checks and clear pending flag + self.cog.c.execute(""" + UPDATE infractions + SET skip_auto_removal=1, pending_approval=0 + WHERE id=? + """, (self.inf_id,)) + self.cog.conn.commit() + + # Update embed + embed = discord.Embed( + title="❌ Removal Denied", + description="This infraction will remain active and will not be checked for auto-removal again.", + color=discord.Color.red() + ) + embed.add_field(name="User", value=self.user_tag, inline=True) + embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True) + embed.add_field(name="Type", value=self.inf_type, inline=True) + embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True) + embed.add_field(name="Decision By", value=interaction.user.mention, inline=True) + embed.add_field(name="Decision At", value=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), inline=True) + + await interaction.response.edit_message(embed=embed, view=None) + + except Exception as e: + await interaction.response.send_message(f"Error denying removal: {e}", ephemeral=True) + + async def setup(bot: commands.Bot): - await bot.add_cog(InfractionCommand(bot)) + await bot.add_cog(InfractionCommand(bot)) \ No newline at end of file diff --git a/xp/sync.py b/xp/sync.py index c91a4e8..465cc5f 100644 --- a/xp/sync.py +++ b/xp/sync.py @@ -23,7 +23,7 @@ class XPSync(commands.Cog): async def sync_roles_for_user(self, member: discord.Member) -> tuple[int, list[str]]: """Sync roles for a member based on their lifetime XP level.""" config = load_config() - ROLE_REWARDS = {int(k): int(v) for k, v in config["ROLE_REWARDS"].items()} + ROLE_REWARDS = {int(k): int(v) for k, v in config["role_rewards"].items()} # Run database operation in executor to avoid blocking def get_level():