diff --git a/bot.py b/bot.py index 06790c8..966d022 100644 --- a/bot.py +++ b/bot.py @@ -1991,10 +1991,11 @@ async def modhelp(ctx): embed.add_field( name="👮 Moderator Commands (Level 5+)", value=( - "`/warn [reason]` - Warn a user\n" + "`/warn [reason | message_id]` - Warn a user (with optional message reference)\n" "`/mute [reason]` - Mute a user temporarily\n" "`/unmute ` - Manually unmute a user\n" "`/modinfo [user]` - View comprehensive user information\n" + "`/viewwarn ` - View detailed warning information\n" "`/modstats [user]` - View moderation statistics\n" "`/processes [type]` - Manage active processes\n" "`/startgiveaway` - Create server giveaways\n" @@ -2046,12 +2047,13 @@ async def modhelp(ctx): # Duration Formats embed.add_field( - name="⏱️ Duration Formats", + name="⏱️ Command Examples", value=( - "When using mute commands:\n" - "`10m` = 10 minutes\n" - "`1h` = 1 hour\n" - "`2d` = 2 days" + "**Mute duration formats:**\n" + "`10m` = 10 minutes, `1h` = 1 hour, `2d` = 2 days\n\n" + "**Warning with message reference:**\n" + "`/warn @user Inappropriate behavior | 1234567890123456789`\n" + "The message will be saved even if deleted later." ), inline=False ) @@ -2638,8 +2640,8 @@ async def log_moderation_action(guild, action_type, moderator, target_user, reas except Exception as e: logger.error(f"Error logging moderation action: {e}") -async def save_warning_to_database(user_id, guild_id, moderator_id, reason, timestamp=None): - """Saves individual warning records to the database""" +async def save_warning_to_database(user_id, guild_id, moderator_id, reason, timestamp=None, message_data=None): + """Saves individual warning records to the database with optional message data""" connection = None cursor = None try: @@ -2649,12 +2651,32 @@ async def save_warning_to_database(user_id, guild_id, moderator_id, reason, time if timestamp is None: timestamp = datetime.now() + # Prepare message data if provided + message_id = None + message_content = None + message_attachments = None + message_author_id = None + message_channel_id = None + + if message_data: + message_id = message_data.get('id') + message_content = message_data.get('content') + message_attachments = message_data.get('attachments') # JSON string + message_author_id = message_data.get('author_id') + message_channel_id = message_data.get('channel_id') + insert_query = """ - INSERT INTO user_warnings (user_id, guild_id, moderator_id, reason, created_at) - VALUES (%s, %s, %s, %s, %s) + INSERT INTO user_warnings (user_id, guild_id, moderator_id, reason, message_id, + message_content, message_attachments, message_author_id, + message_channel_id, created_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """ - cursor.execute(insert_query, (user_id, guild_id, moderator_id, reason, timestamp)) + cursor.execute(insert_query, ( + user_id, guild_id, moderator_id, reason, message_id, + message_content, message_attachments, message_author_id, + message_channel_id, timestamp + )) connection.commit() logger.info(f"Saved warning record for user {user_id} in guild {guild_id}") @@ -2686,13 +2708,37 @@ def create_warnings_table(): guild_id BIGINT NOT NULL, moderator_id BIGINT NOT NULL, reason TEXT NOT NULL, + message_id BIGINT NULL, + message_content LONGTEXT NULL, + message_attachments LONGTEXT NULL, + message_author_id BIGINT NULL, + message_channel_id BIGINT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_guild (user_id, guild_id), - INDEX idx_created_at (created_at) + INDEX idx_created_at (created_at), + INDEX idx_message_id (message_id) ) """ cursor.execute(create_table_query) + + # Add new columns if they don't exist (for existing databases) + alter_queries = [ + "ALTER TABLE user_warnings ADD COLUMN message_id BIGINT NULL", + "ALTER TABLE user_warnings ADD COLUMN message_content LONGTEXT NULL", + "ALTER TABLE user_warnings ADD COLUMN message_attachments LONGTEXT NULL", + "ALTER TABLE user_warnings ADD COLUMN message_author_id BIGINT NULL", + "ALTER TABLE user_warnings ADD COLUMN message_channel_id BIGINT NULL", + "ALTER TABLE user_warnings ADD INDEX idx_message_id (message_id)" + ] + + for alter_query in alter_queries: + try: + cursor.execute(alter_query) + except Exception: + # Column already exists, ignore error + pass + connection.commit() logger.info("User warnings table checked/created successfully") @@ -2713,7 +2759,8 @@ async def get_user_warnings(user_id, guild_id): cursor = connection.cursor() select_query = """ - SELECT id, moderator_id, reason, created_at + SELECT id, moderator_id, reason, created_at, message_id, message_content, + message_attachments, message_author_id, message_channel_id FROM user_warnings WHERE user_id = %s AND guild_id = %s ORDER BY created_at DESC @@ -2728,7 +2775,12 @@ async def get_user_warnings(user_id, guild_id): "id": row[0], "moderator_id": row[1], "reason": row[2], - "created_at": row[3] + "created_at": row[3], + "message_id": row[4], + "message_content": row[5], + "message_attachments": row[6], + "message_author_id": row[7], + "message_channel_id": row[8] }) return warnings @@ -2742,6 +2794,75 @@ async def get_user_warnings(user_id, guild_id): if connection: close_database_connection(connection) +async def get_message_data(channel, message_id): + """Retrieves and processes message data for warning documentation""" + try: + message = await channel.fetch_message(message_id) + + # Process attachments + attachments_data = [] + for attachment in message.attachments: + attachment_info = { + "filename": attachment.filename, + "url": attachment.url, + "proxy_url": attachment.proxy_url, + "size": attachment.size, + "content_type": attachment.content_type + } + + # Download and encode image attachments for permanent storage + if attachment.content_type and attachment.content_type.startswith('image/'): + try: + import aiohttp + import base64 + + async with aiohttp.ClientSession() as session: + async with session.get(attachment.url) as response: + if response.status == 200 and len(await response.read()) < 8 * 1024 * 1024: # Max 8MB + image_data = await response.read() + attachment_info["data"] = base64.b64encode(image_data).decode('utf-8') + except Exception as e: + logger.warning(f"Could not download attachment {attachment.filename}: {e}") + + attachments_data.append(attachment_info) + + # Process embeds + embeds_data = [] + for embed in message.embeds: + embed_info = { + "title": embed.title, + "description": embed.description, + "url": embed.url, + "color": embed.color.value if embed.color else None, + "timestamp": embed.timestamp.isoformat() if embed.timestamp else None + } + embeds_data.append(embed_info) + + message_data = { + "id": message.id, + "content": message.content, + "author_id": message.author.id, + "channel_id": message.channel.id, + "attachments": json.dumps(attachments_data) if attachments_data else None, + "embeds": json.dumps(embeds_data) if embeds_data else None, + "created_at": message.created_at.isoformat(), + "edited_at": message.edited_at.isoformat() if message.edited_at else None, + "message_type": str(message.type), + "flags": message.flags.value if message.flags else 0 + } + + return message_data + + except discord.NotFound: + logger.warning(f"Message {message_id} not found") + return None + except discord.Forbidden: + logger.warning(f"No permission to access message {message_id}") + return None + except Exception as e: + logger.error(f"Error retrieving message data: {e}") + return None + async def save_user_roles(user_id, guild_id, roles): """Saves a user's roles before a mute""" connection = None @@ -2816,9 +2937,42 @@ async def restore_user_roles(user, guild): close_database_connection(connection) @client.hybrid_command() -async def warn(ctx, user: discord.User, *, reason: str = "No reason provided"): - """Warns a user (Requires Permission Level 5 or higher)""" +async def warn(ctx, user: discord.User, *, reason_and_message: str = "No reason provided"): + """Warns a user (Requires Permission Level 5 or higher) + + Usage: + /warn @user Inappropriate behavior + /warn @user Spam messages | 1234567890123456789 + + Format: reason | message_id (optional) + """ try: + # Parse reason and optional message ID + reason_parts = reason_and_message.split(" | ") + reason = reason_parts[0].strip() + message_id = None + message_data = None + + if len(reason_parts) > 1: + try: + message_id = int(reason_parts[1].strip()) + # Try to get message data from current channel first + message_data = await get_message_data(ctx.channel, message_id) + + # If not found in current channel, try other channels the bot can access + if message_data is None: + for channel in ctx.guild.text_channels: + try: + message_data = await get_message_data(channel, message_id) + if message_data is not None: + break + except discord.Forbidden: + continue + + except ValueError: + await ctx.send("❌ Invalid message ID format. Use: `/warn @user reason | 1234567890123456789`", ephemeral=True) + return + # Load moderator data mod_data = await load_user_data(ctx.author.id, ctx.guild.id) @@ -2863,7 +3017,8 @@ async def warn(ctx, user: discord.User, *, reason: str = "No reason provided"): user_id=user.id, guild_id=ctx.guild.id, moderator_id=ctx.author.id, - reason=reason + reason=reason, + message_data=message_data ) # Get guild settings for threshold checking @@ -2884,6 +3039,34 @@ async def warn(ctx, user: discord.User, *, reason: str = "No reason provided"): if warning_id: embed.add_field(name="🆔 Warning ID", value=str(warning_id), inline=True) + # Add message information if available + if message_data: + message_info = f"**Message ID:** `{message_data['id']}`\n" + message_info += f"**Channel:** <#{message_data['channel_id']}>\n" + + if message_data['content']: + content_preview = message_data['content'] + if len(content_preview) > 100: + content_preview = content_preview[:97] + "..." + message_info += f"**Content:** {content_preview}\n" + + # Show attachment info + if message_data['attachments']: + attachments = json.loads(message_data['attachments']) + if attachments: + attachment_names = [att['filename'] for att in attachments[:3]] + message_info += f"**Attachments:** {', '.join(attachment_names)}" + if len(attachments) > 3: + message_info += f" +{len(attachments) - 3} more" + + embed.add_field(name="📄 Referenced Message", value=message_info, inline=False) + elif message_id: + embed.add_field( + name="⚠️ Message Not Found", + value=f"Could not retrieve message `{message_id}` (deleted or no permission)", + inline=False + ) + embed.set_footer(text=f"User ID: {user.id}") embed.set_thumbnail(url=user.display_avatar.url) @@ -2929,16 +3112,25 @@ async def warn(ctx, user: discord.User, *, reason: str = "No reason provided"): await ctx.send(embed=embed) # Log the warning action + log_additional_info = { + "Warning Count": f"{target_data['warns']}/{warn_threshold}", + "Warning ID": str(warning_id) if warning_id else "N/A" + } + + if message_data: + log_additional_info["Referenced Message"] = f"ID: {message_data['id']}" + log_additional_info["Message Channel"] = f"<#{message_data['channel_id']}>" + if message_data['content']: + content_preview = message_data['content'][:200] + "..." if len(message_data['content']) > 200 else message_data['content'] + log_additional_info["Message Content"] = content_preview + await log_moderation_action( guild=ctx.guild, action_type="warn", moderator=ctx.author, target_user=user, reason=reason, - additional_info={ - "Warning Count": f"{target_data['warns']}/{warn_threshold}", - "Warning ID": str(warning_id) if warning_id else "N/A" - } + additional_info=log_additional_info ) # Try to DM the user @@ -3053,10 +3245,16 @@ async def mywarns(ctx): # Truncate reason if too long reason = record["reason"] - if len(reason) > 50: - reason = reason[:47] + "..." + if len(reason) > 40: + reason = reason[:37] + "..." - warning_history += f"`{warning_date}` - **{mod_name}**: {reason}\n" + warning_line = f"`{warning_date}` **{mod_name}**: {reason}" + + # Add message indicator if warning was linked to a message + if record.get("message_id"): + warning_line += " 📄" + + warning_history += warning_line + "\n" if len(warning_records) > 5: warning_history += f"\n*... and {len(warning_records) - 5} more warning(s)*" @@ -3218,10 +3416,19 @@ async def modinfo(ctx, user: discord.User = None): # Truncate reason if too long reason = record["reason"] - if len(reason) > 60: - reason = reason[:57] + "..." + if len(reason) > 50: + reason = reason[:47] + "..." - warning_history += f"`{warning_date}` **{mod_name}**: {reason}\n" + warning_line = f"`{warning_date}` **{mod_name}**: {reason}" + + # Add message indicator and content preview if available + if record.get("message_id"): + warning_line += " 📄" + if record.get("message_content"): + content_preview = record["message_content"][:30] + "..." if len(record["message_content"]) > 30 else record["message_content"] + warning_line += f"\n *Message: {content_preview}*" + + warning_history += warning_line + "\n" if len(warning_records) > 3: warning_history += f"*... and {len(warning_records) - 3} more warning(s)*" @@ -3287,6 +3494,142 @@ async def modinfo(ctx, user: discord.User = None): ) await ctx.send(embed=embed) +@client.hybrid_command() +async def viewwarn(ctx, warning_id: int): + """View detailed information about a specific warning (Requires Permission Level 5 or higher)""" + 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 ctx.send(embed=embed, ephemeral=True) + return + + # Get warning details from database + connection = None + cursor = None + try: + connection = connect_to_database() + cursor = connection.cursor() + + select_query = """ + SELECT user_id, guild_id, moderator_id, reason, created_at, message_id, + message_content, message_attachments, message_author_id, message_channel_id + FROM user_warnings + WHERE id = %s AND guild_id = %s + """ + + cursor.execute(select_query, (warning_id, ctx.guild.id)) + result = cursor.fetchone() + + if not result: + embed = discord.Embed( + title="❌ Warning Not Found", + description=f"No warning with ID {warning_id} found in this server.", + color=0xff0000 + ) + await ctx.send(embed=embed, ephemeral=True) + return + + # Parse result + user_id, guild_id, moderator_id, reason, created_at, message_id, message_content, message_attachments, message_author_id, message_channel_id = result + + # Get user and moderator objects + warned_user = await client.fetch_user(user_id) + moderator = await client.fetch_user(moderator_id) + + # Create detailed embed + embed = discord.Embed( + title=f"⚠️ Warning Details - ID: {warning_id}", + color=0xff9500, + timestamp=created_at + ) + + embed.add_field(name="👤 Warned User", value=f"{warned_user.mention}\n`{warned_user.id}`", inline=True) + embed.add_field(name="👮 Moderator", value=f"{moderator.mention}\n`{moderator.id}`", inline=True) + embed.add_field(name="📅 Date", value=f"", inline=True) + embed.add_field(name="📝 Reason", value=reason, inline=False) + + # Add message information if available + if message_id: + message_info = f"**Message ID:** `{message_id}`\n" + + if message_channel_id: + message_info += f"**Channel:** <#{message_channel_id}>\n" + + if message_author_id: + try: + msg_author = await client.fetch_user(message_author_id) + message_info += f"**Author:** {msg_author.mention}\n" + except: + message_info += f"**Author ID:** `{message_author_id}`\n" + + if message_content: + content_display = message_content + if len(content_display) > 500: + content_display = content_display[:497] + "..." + message_info += f"**Content:**\n```\n{content_display}\n```" + + embed.add_field(name="📄 Referenced Message", value=message_info, inline=False) + + # Handle attachments + if message_attachments: + try: + attachments = json.loads(message_attachments) + if attachments: + attachment_info = "" + for i, att in enumerate(attachments[:3]): # Show max 3 attachments + attachment_info += f"**{att['filename']}** ({att['size']} bytes)\n" + + # Show image if available and encoded + if att.get('data') and att['content_type'].startswith('image/'): + try: + import base64 + import io + + # Create temporary file-like object + image_data = base64.b64decode(att['data']) + file = discord.File(io.BytesIO(image_data), filename=att['filename']) + + # Send image separately if it's the first attachment + if i == 0: + await ctx.send(f"📎 **Attachment from Warning {warning_id}:**", file=file) + except Exception as e: + logger.warning(f"Could not display attachment: {e}") + + if len(attachments) > 3: + attachment_info += f"*... and {len(attachments) - 3} more attachment(s)*" + + embed.add_field(name="📎 Attachments", value=attachment_info, inline=False) + except Exception as e: + logger.error(f"Error processing attachments: {e}") + + embed.set_thumbnail(url=warned_user.display_avatar.url) + embed.set_footer(text=f"Warning ID: {warning_id} | Guild: {ctx.guild.name}") + + await ctx.send(embed=embed) + + finally: + if cursor: + cursor.close() + if connection: + close_database_connection(connection) + + except Exception as e: + logger.error(f"Error in viewwarn command: {e}") + embed = discord.Embed( + title="❌ Error", + description="An error occurred while retrieving warning details. Please try again.", + color=0xff0000 + ) + await ctx.send(embed=embed) + @client.hybrid_command() async def mute(ctx, user: discord.User, duration: str, *, reason: str = "No reason provided"): """Mutes a user for a specified duration (Requires Permission Level 5 or higher)