diff --git a/bot.py b/bot.py index c6298f6..449b420 100644 --- a/bot.py +++ b/bot.py @@ -947,6 +947,15 @@ async def handle_expired_mute(process_uuid, data): except (discord.Forbidden, discord.NotFound): pass # User has DMs disabled or doesn't exist + # Update user_mutes table to mark as auto-unmuted + try: + mute_record = await get_mute_by_process_uuid(process_uuid) + if mute_record: + await deactivate_mute(mute_record['id'], auto_unmuted=True) + logger.info(f"Updated mute record {mute_record['id']} as auto-unmuted") + except Exception as e: + logger.error(f"Error updating user_mutes table for process {process_uuid}: {e}") + logger.info(f"Successfully unmuted user {user_id} in guild {guild_id}") update_process_status(process_uuid, "completed") @@ -3004,6 +3013,248 @@ def create_contact_messages_table(): if connection: close_database_connection(connection) +def create_mutes_table(): + """Creates the user_mutes table if it doesn't exist""" + connection = None + cursor = None + try: + connection = connect_to_database() + cursor = connection.cursor() + + create_table_query = """ + CREATE TABLE IF NOT EXISTS user_mutes ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(50) NOT NULL, + guild_id VARCHAR(50) NOT NULL, + moderator_id VARCHAR(50) NOT NULL, + reason TEXT NOT NULL, + duration VARCHAR(20) NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(20) DEFAULT 'active', + process_uuid VARCHAR(36), + channel_id VARCHAR(50), + mute_role_id VARCHAR(50), + message_id BIGINT, + message_content TEXT, + message_attachments JSON, + message_author_id VARCHAR(50), + message_channel_id VARCHAR(50), + context_messages JSON, + aktiv BOOLEAN DEFAULT TRUE, + unmuted_at TIMESTAMP NULL, + unmuted_by VARCHAR(50) NULL, + auto_unmuted BOOLEAN DEFAULT FALSE, + INDEX idx_user_guild (user_id, guild_id), + INDEX idx_guild (guild_id), + INDEX idx_moderator (moderator_id), + INDEX idx_process_uuid (process_uuid), + INDEX idx_aktiv (aktiv), + INDEX idx_status (status) + ) + """ + + cursor.execute(create_table_query) + connection.commit() + logger.info("User mutes table created or already exists") + + except Exception as e: + logger.error(f"Error creating user mutes table: {e}") + finally: + if cursor: + cursor.close() + if connection: + close_database_connection(connection) + +async def save_mute_to_database(user_id, guild_id, moderator_id, reason, duration, start_time, end_time, + process_uuid=None, channel_id=None, mute_role_id=None, message_data=None, message_id=None): + """Saves individual mute records to the database with optional message data and context""" + connection = None + cursor = None + try: + connection = connect_to_database() + cursor = connection.cursor() + + # Extract message data if provided + message_content = None + message_attachments = None + message_author_id = None + message_channel_id = None + context_messages = None + + if message_data: + if isinstance(message_data, dict) and "main_message" in message_data: + # New format with context + main_msg = message_data.get("main_message", {}) + message_content = main_msg.get("content") + message_attachments = main_msg.get("attachments") + message_author_id = main_msg.get("author_id") + message_channel_id = main_msg.get("channel_id") + context_messages = json.dumps(message_data.get("context_messages", [])) + else: + # Old format or simple dict + message_content = message_data.get("content") + message_attachments = message_data.get("attachments") + message_author_id = message_data.get("author_id") + message_channel_id = message_data.get("channel_id") + + # Convert JSON fields + if message_attachments and isinstance(message_attachments, str): + # Already JSON string + pass + elif message_attachments: + # Convert to JSON string + message_attachments = json.dumps(message_attachments) + + insert_query = """ + INSERT INTO user_mutes ( + user_id, guild_id, moderator_id, reason, duration, start_time, end_time, + process_uuid, channel_id, mute_role_id, message_id, message_content, + message_attachments, message_author_id, message_channel_id, context_messages + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + + cursor.execute(insert_query, ( + user_id, guild_id, moderator_id, reason, duration, start_time, end_time, + str(process_uuid) if process_uuid else None, channel_id, mute_role_id, + message_id, message_content, message_attachments, message_author_id, + message_channel_id, context_messages + )) + + mute_id = cursor.lastrowid + connection.commit() + + logger.info(f"Mute record saved to database: ID={mute_id}, User={user_id}, Guild={guild_id}") + return mute_id + + except Exception as e: + logger.error(f"Error saving mute to database: {e}") + if connection: + connection.rollback() + return None + finally: + if cursor: + cursor.close() + if connection: + close_database_connection(connection) + +async def get_user_mutes(user_id, guild_id, active_only=True): + """Retrieves mute records for a user + + Args: + user_id: Discord user ID + guild_id: Discord guild ID + active_only: If True, only returns active mutes. If False, returns all mutes. + """ + connection = None + cursor = None + try: + connection = connect_to_database() + cursor = connection.cursor() + + if active_only: + select_query = """ + SELECT * FROM user_mutes + WHERE user_id = %s AND guild_id = %s AND aktiv = TRUE + ORDER BY created_at DESC + """ + else: + select_query = """ + SELECT * FROM user_mutes + WHERE user_id = %s AND guild_id = %s + ORDER BY created_at DESC + """ + + cursor.execute(select_query, (user_id, guild_id)) + results = cursor.fetchall() + + # Convert results to list of dictionaries + mutes = [] + columns = [desc[0] for desc in cursor.description] + for row in results: + mute_dict = dict(zip(columns, row)) + mutes.append(mute_dict) + + return mutes + + except Exception as e: + logger.error(f"Error retrieving user mutes: {e}") + return [] + finally: + if cursor: + cursor.close() + if connection: + close_database_connection(connection) + +async def deactivate_mute(mute_id, unmuted_by=None, auto_unmuted=False): + """Deactivates a mute by setting aktiv to FALSE and recording unmute info""" + connection = None + cursor = None + try: + connection = connect_to_database() + cursor = connection.cursor() + + update_query = """ + UPDATE user_mutes + SET aktiv = FALSE, unmuted_at = NOW(), unmuted_by = %s, auto_unmuted = %s, status = 'completed' + WHERE id = %s + """ + + cursor.execute(update_query, (unmuted_by, auto_unmuted, mute_id)) + connection.commit() + + if cursor.rowcount > 0: + logger.info(f"Mute {mute_id} deactivated successfully") + return True + else: + logger.warning(f"No mute found with ID {mute_id}") + return False + + except Exception as e: + logger.error(f"Error deactivating mute: {e}") + if connection: + connection.rollback() + return False + finally: + if cursor: + cursor.close() + if connection: + close_database_connection(connection) + +async def get_mute_by_process_uuid(process_uuid): + """Gets mute record by process UUID""" + connection = None + cursor = None + try: + connection = connect_to_database() + cursor = connection.cursor() + + select_query = """ + SELECT * FROM user_mutes + WHERE process_uuid = %s + ORDER BY created_at DESC + LIMIT 1 + """ + + cursor.execute(select_query, (str(process_uuid),)) + result = cursor.fetchone() + + if result: + columns = [desc[0] for desc in cursor.description] + mute_dict = dict(zip(columns, result)) + return mute_dict + return None + + except Exception as e: + logger.error(f"Error getting mute by process UUID: {e}") + return None + finally: + if cursor: + cursor.close() + if connection: + close_database_connection(connection) + async def send_contact_message_to_admin(message_data): """Sends a contact message to the admin via Discord DM""" try: @@ -4373,6 +4624,263 @@ async def viewwarn(ctx, warning_id: int): ) await send_response(embed=embed) +@client.hybrid_command() +async def viewmute(ctx, process_uuid: str): + """View detailed information about a specific mute (Requires Permission Level 5 or higher)""" + # Check if it's a slash command and defer if needed + is_slash_command = hasattr(ctx, 'interaction') and ctx.interaction + if is_slash_command: + await ctx.defer() + + # Helper function for sending responses + async def send_response(content=None, embed=None, ephemeral=False, file=None): + try: + if is_slash_command: + if hasattr(ctx, 'followup') and ctx.followup: + await ctx.followup.send(content=content, embed=embed, ephemeral=ephemeral, file=file) + elif hasattr(ctx, 'response') and not ctx.response.is_done(): + await ctx.response.send_message(content=content, embed=embed, ephemeral=ephemeral, file=file) + else: + await ctx.send(content=content, embed=embed, file=file) + else: + await ctx.send(content=content, embed=embed, file=file) + except Exception as e: + logger.error(f"Error sending response in viewmute command: {e}") + # Final fallback - try basic send + try: + if embed: + await ctx.send(embed=embed) + elif content: + await ctx.send(content=content) + except Exception as fallback_error: + logger.error(f"Fallback send also failed: {fallback_error}") + + try: + # Load moderator data + mod_data = await load_user_data(ctx.author.id, ctx.guild.id) + + # Check moderation rights + if not check_moderation_permission(mod_data["permission"]): + embed = discord.Embed( + title="āŒ Insufficient Permissions", + description="You need moderation permissions (Level 5 or higher) to use this command.", + color=0xff0000 + ) + await send_response(embed=embed, ephemeral=True) + return + + # Get mute details from user_mutes database (preferred) or active_processes as fallback + connection = None + cursor = None + try: + connection = connect_to_database() + cursor = connection.cursor() + + # First try to find mute in user_mutes table by process_uuid + select_query = """ + SELECT * FROM user_mutes + WHERE process_uuid = %s AND guild_id = %s + ORDER BY created_at DESC + LIMIT 1 + """ + + cursor.execute(select_query, (process_uuid, ctx.guild.id)) + mute_result = cursor.fetchone() + + if mute_result: + # Found in user_mutes table + columns = [desc[0] for desc in cursor.description] + mute_data = dict(zip(columns, mute_result)) + + # Get user and moderator objects + muted_user = await client.fetch_user(int(mute_data['user_id'])) + moderator = await client.fetch_user(int(mute_data['moderator_id'])) + + # Get channel + channel = ctx.guild.get_channel(int(mute_data['channel_id'])) if mute_data['channel_id'] else None + + # Create detailed embed + embed = discord.Embed( + title=f"šŸ”‡ Mute Details - ID: {mute_data['id']}", + color=0xff0000, + timestamp=mute_data['created_at'] + ) + + embed.add_field(name="šŸ‘¤ Muted User", value=f"{muted_user.mention}\n`{muted_user.id}`", inline=True) + embed.add_field(name="šŸ‘® Moderator", value=f"{moderator.mention}\n`{moderator.id}`", inline=True) + embed.add_field(name="šŸ“… Muted At", value=f"", inline=True) + + # Add status information + status_emoji = {"active": "🟢", "completed": "āœ…", "expired": "ā°", "cancelled": "āŒ"}.get(mute_data['status'], "ā“") + aktiv_status = "🟢 Active" if mute_data['aktiv'] else "šŸ”“ Inactive" + status_text = f"{status_emoji} **{mute_data['status'].title()}** ({aktiv_status})" + embed.add_field(name="šŸ“Š Status", value=status_text, inline=True) + + # End time and duration info + if mute_data['end_time']: + if mute_data['aktiv'] and mute_data['status'] == 'active': + embed.add_field(name="ā° Ends At", value=f"\n", inline=True) + else: + embed.add_field(name="ā° Ended At", value=f"", inline=True) + + embed.add_field(name="ā±ļø Duration", value=mute_data['duration'], inline=True) + + # Add reason + embed.add_field(name="šŸ“ Reason", value=mute_data['reason'], inline=False) + + # Add channel information + if channel: + embed.add_field(name="šŸ“ Channel", value=f"{channel.mention}\n`{channel.id}`", inline=True) + + # Add mute role information + if mute_data['mute_role_id']: + mute_role = ctx.guild.get_role(int(mute_data['mute_role_id'])) + if mute_role: + embed.add_field(name="šŸŽ­ Mute Role", value=f"{mute_role.mention}\n`{mute_role.id}`", inline=True) + else: + embed.add_field(name="šŸŽ­ Mute Role", value=f"āŒ Deleted Role\n`{mute_data['mute_role_id']}`", inline=True) + + # Unmute information + if not mute_data['aktiv'] and mute_data['unmuted_at']: + unmute_info = f"" + if mute_data['unmuted_by']: + unmuter = await client.fetch_user(int(mute_data['unmuted_by'])) + unmute_info += f"\nBy: {unmuter.mention}" + if mute_data['auto_unmuted']: + unmute_info += "\nšŸ¤– Automatic unmute" + embed.add_field(name="šŸ”“ Unmuted At", value=unmute_info, inline=True) + + # Message reference if available + if mute_data['message_id'] and mute_data['message_content']: + content_preview = mute_data['message_content'][:100] + "..." if len(mute_data['message_content']) > 100 else mute_data['message_content'] + embed.add_field(name="šŸ“„ Referenced Message", value=f"ID: `{mute_data['message_id']}`\nContent: {content_preview}", inline=False) + + embed.add_field(name="šŸ†” Process UUID", value=f"`{process_uuid}`", inline=False) + embed.add_field(name="šŸ†” Mute Record ID", value=f"`{mute_data['id']}`", inline=True) + + embed.set_thumbnail(url=muted_user.display_avatar.url) + embed.set_footer(text=f"Mute Record from Database | Server: {ctx.guild.name}") + + await send_response(embed=embed) + return + + else: + # Fallback to active_processes table + select_query = """ + SELECT uuid, process_type, guild_id, channel_id, user_id, target_id, + created_at, end_time, status, data + FROM active_processes + WHERE uuid = %s AND guild_id = %s AND process_type = 'mute' + """ + + cursor.execute(select_query, (process_uuid, ctx.guild.id)) + result = cursor.fetchone() + + if not result: + embed = discord.Embed( + title="āŒ Mute Not Found", + description=f"No mute with UUID `{process_uuid}` found in this server.", + color=0xff0000 + ) + await send_response(embed=embed, ephemeral=True) + return + + # Parse result (fallback to old format) + uuid, process_type, guild_id, channel_id, user_id, target_id, created_at, end_time, status, data = result + + # Parse data JSON + import json + proc_data = json.loads(data) if data else {} + + # Get user and moderator objects + muted_user = await client.fetch_user(target_id) + moderator_id = proc_data.get('moderator_id', user_id) + moderator = await client.fetch_user(moderator_id) + + # Get channel + channel = ctx.guild.get_channel(channel_id) if channel_id else None + + # Create detailed embed + embed = discord.Embed( + title=f"šŸ”‡ Mute Details - ID: {uuid[:8]}", + color=0xff0000, + timestamp=created_at + ) + + embed.add_field(name="šŸ‘¤ Muted User", value=f"{muted_user.mention}\n`{muted_user.id}`", inline=True) + embed.add_field(name="šŸ‘® Moderator", value=f"{moderator.mention}\n`{moderator_id}`", inline=True) + embed.add_field(name="šŸ“… Muted At", value=f"", inline=True) + + # Add status and duration information + status_emoji = {"active": "🟢", "completed": "āœ…", "expired": "ā°", "cancelled": "āŒ"}.get(status, "ā“") + status_text = f"{status_emoji} **{status.title()}**" + embed.add_field(name="šŸ“Š Status", value=status_text, inline=True) + + if end_time: + if status == "active": + embed.add_field(name="ā° Ends At", value=f"\n", inline=True) + else: + embed.add_field(name="ā° Ended At", value=f"", inline=True) + + embed.add_field(name="šŸ†” Process UUID", value=f"`{uuid}`", inline=True) + + # Add reason + reason = mute_data.get('reason', 'No reason provided') + embed.add_field(name="šŸ“ Reason", value=reason, inline=False) + + # Add channel information + if channel: + embed.add_field(name="šŸ“ Channel", value=f"{channel.mention}\n`{channel.id}`", inline=True) + + # Add mute role information + mute_role_id = mute_data.get('mute_role_id') + if mute_role_id: + mute_role = ctx.guild.get_role(mute_role_id) + if mute_role: + embed.add_field(name="šŸŽ­ Mute Role", value=f"{mute_role.mention}\n`{mute_role.id}`", inline=True) + else: + embed.add_field(name="šŸŽ­ Mute Role", value=f"āŒ Deleted Role\n`{mute_role_id}`", inline=True) + + # Add duration calculation if still active + if status == "active" and end_time: + from datetime import datetime + now = datetime.now() + if end_time > now: + duration_left = end_time - now + days = duration_left.days + hours, remainder = divmod(duration_left.seconds, 3600) + minutes, _ = divmod(remainder, 60) + + duration_text = [] + if days > 0: + duration_text.append(f"{days}d") + if hours > 0: + duration_text.append(f"{hours}h") + if minutes > 0: + duration_text.append(f"{minutes}m") + + embed.add_field(name="ā³ Time Remaining", value=" ".join(duration_text) if duration_text else "Less than 1 minute", inline=True) + + embed.set_thumbnail(url=muted_user.display_avatar.url) + embed.set_footer(text=f"Process Type: {process_type.title()} | Server: {ctx.guild.name}") + + await send_response(embed=embed) + + finally: + if cursor: + cursor.close() + if connection: + close_database_connection(connection) + + except Exception as e: + logger.error(f"Error in viewmute command: {e}") + embed = discord.Embed( + title="āŒ Error", + description="An error occurred while retrieving mute details. Please try again.", + color=0xff0000 + ) + await send_response(embed=embed) + @client.hybrid_command() async def removewarn(ctx, warning_id: int): """Deactivates a warning (hides from /account but keeps data) - Level 6+ required""" @@ -4824,6 +5332,22 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason data=process_data ) + # Save detailed mute record to database + mute_id = await save_mute_to_database( + user_id=user.id, + guild_id=ctx.guild.id, + moderator_id=ctx.author.id, + reason=reason, + duration=duration, + start_time=datetime.now(), + end_time=end_time, + process_uuid=process_uuid, + channel_id=ctx.channel.id, + mute_role_id=mute_role.id, + message_data=message_data, + message_id=int(message_id) if message_id else None + ) + # Create embed embed = discord.Embed( title="šŸ”‡ User Muted", @@ -5004,6 +5528,190 @@ async def unmute(ctx, user: discord.User): logger.error(f"Error in unmute command: {e}") await ctx.send("āŒ Ein Fehler ist aufgetreten beim Entmuten des Benutzers.") +@client.hybrid_command() +async def listmutes(ctx, status: str = "active"): + """List all mutes in the server (Requires Permission Level 5 or higher) + + Parameters: + - status: Filter by status (active, completed, expired, all) - Default: active + """ + # Check if it's a slash command and defer if needed + is_slash_command = hasattr(ctx, 'interaction') and ctx.interaction + if is_slash_command: + await ctx.defer() + + # Helper function for sending responses + async def send_response(content=None, embed=None, ephemeral=False, file=None): + try: + if is_slash_command: + if hasattr(ctx, 'followup') and ctx.followup: + await ctx.followup.send(content=content, embed=embed, ephemeral=ephemeral, file=file) + elif hasattr(ctx, 'response') and not ctx.response.is_done(): + await ctx.response.send_message(content=content, embed=embed, ephemeral=ephemeral, file=file) + else: + await ctx.send(content=content, embed=embed, file=file) + else: + await ctx.send(content=content, embed=embed, file=file) + except Exception as e: + logger.error(f"Error sending response in listmutes command: {e}") + # Final fallback + try: + if embed: + await ctx.send(embed=embed) + elif content: + await ctx.send(content=content) + except Exception as fallback_error: + logger.error(f"Fallback send also failed: {fallback_error}") + + try: + # Load moderator data + mod_data = await load_user_data(ctx.author.id, ctx.guild.id) + + # Check moderation rights + if not check_moderation_permission(mod_data["permission"]): + embed = discord.Embed( + title="āŒ Insufficient Permissions", + description="You need moderation permissions (Level 5 or higher) to use this command.", + color=0xff0000 + ) + await send_response(embed=embed, ephemeral=True) + return + + # Validate status parameter + valid_statuses = ["active", "completed", "expired", "cancelled", "all"] + if status.lower() not in valid_statuses: + embed = discord.Embed( + title="āŒ Invalid Status", + description=f"Invalid status `{status}`. Valid options: {', '.join(valid_statuses)}", + color=0xff0000 + ) + await send_response(embed=embed, ephemeral=True) + return + + # Get mutes from user_mutes database + connection = None + cursor = None + try: + connection = connect_to_database() + cursor = connection.cursor() + + if status.lower() == "all": + select_query = """ + SELECT process_uuid, user_id, created_at, end_time, status, reason, + duration, aktiv, moderator_id, auto_unmuted, unmuted_at + FROM user_mutes + WHERE guild_id = %s + ORDER BY created_at DESC + LIMIT 50 + """ + cursor.execute(select_query, (ctx.guild.id,)) + else: + # Map status filters + if status.lower() == "active": + where_condition = "aktiv = 1 AND status = 'active'" + elif status.lower() == "completed": + where_condition = "aktiv = 0 OR status = 'completed'" + elif status.lower() == "expired": + where_condition = "status = 'expired' OR (end_time < NOW() AND aktiv = 0)" + elif status.lower() == "cancelled": + where_condition = "status = 'cancelled'" + + select_query = f""" + SELECT process_uuid, user_id, created_at, end_time, status, reason, + duration, aktiv, moderator_id, auto_unmuted, unmuted_at + FROM user_mutes + WHERE guild_id = %s AND ({where_condition}) + ORDER BY created_at DESC + LIMIT 50 + """ + cursor.execute(select_query, (ctx.guild.id,)) + + results = cursor.fetchall() + + if not results: + embed = discord.Embed( + title="šŸ“‹ No Mutes Found", + description=f"No mutes with status `{status}` found in this server.", + color=0x3498db + ) + await send_response(embed=embed) + return + + # Create embed with mute list + embed = discord.Embed( + title=f"šŸ”‡ Server Mutes - {status.title()}", + description=f"Found {len(results)} mute(s) in {ctx.guild.name}", + color=0xff0000, + timestamp=datetime.now() + ) + + mute_list = [] + for result in results[:15]: # Limit to 15 to avoid embed limits + process_uuid, user_id, created_at, end_time, mute_status, reason, duration, aktiv, moderator_id, auto_unmuted, unmuted_at = result + + try: + # Get user + user = ctx.guild.get_member(int(user_id)) + user_display = user.display_name if user else f"Unknown User ({user_id})" + + # Format reason + reason_display = reason[:40] + ("..." if len(reason) > 40 else "") if reason else "No reason" + + # Status emoji based on aktiv status and mute_status + if aktiv and mute_status == "active": + status_emoji = "🟢" + display_status = "Active" + elif not aktiv and auto_unmuted: + status_emoji = "ā°" + display_status = "Auto-Expired" + elif not aktiv and unmuted_at: + status_emoji = "āœ…" + display_status = "Manually Unmuted" + elif mute_status == "cancelled": + status_emoji = "āŒ" + display_status = "Cancelled" + else: + status_emoji = "ā“" + display_status = mute_status.title() + + # Time info + if aktiv and mute_status == "active" and end_time: + time_info = f"ends " + elif unmuted_at: + time_info = f"unmuted " + else: + time_info = f"" + + mute_entry = f"{status_emoji} **{user_display}** - {reason_display}\n`{process_uuid[:8]}` • {duration} • {time_info}" + mute_list.append(mute_entry) + + except Exception as e: + logger.error(f"Error processing mute entry: {e}") + continue + + if mute_list: + mute_text = "\n\n".join(mute_list) + embed.add_field(name="šŸ“‹ Mutes", value=mute_text, inline=False) + + embed.set_footer(text=f"Use /viewmute for detailed info • Showing max 15 results") + + await send_response(embed=embed) + + finally: + if cursor: + cursor.close() + if connection: + close_database_connection(connection) + + except Exception as e: + logger.error(f"Error in listmutes command: {e}") + embed = discord.Embed( + title="āŒ Error", + description="An error occurred while retrieving mute list. Please try again.", + color=0xff0000 + ) + await send_response(embed=embed) + @client.hybrid_command() async def modstats(ctx, user: discord.User = None): """Zeigt Moderationsstatistiken für einen Benutzer an""" @@ -5705,6 +6413,7 @@ async def contact_status(ctx): try: # Initialize database tables create_warnings_table() + create_mutes_table() create_contact_messages_table() logger.info("Database tables initialized successfully")