From 5b9e7717292c227d0d4cedf7c33fb6250e86156a Mon Sep 17 00:00:00 2001 From: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:10:24 +0200 Subject: [PATCH] modified: bot.py --- bot.py | 431 +++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 310 insertions(+), 121 deletions(-) diff --git a/bot.py b/bot.py index d1f4614..808e20d 100644 --- a/bot.py +++ b/bot.py @@ -2619,7 +2619,7 @@ async def log_moderation_action(guild, action_type, moderator, target_user, reas logger.error(f"Error logging moderation action: {e}") async def save_warning_to_database(user_id, guild_id, moderator_id, reason, timestamp=None, message_data=None, message_id=None): - """Saves individual warning records to the database with optional message data""" + """Saves individual warning records to the database with optional message data and context""" connection = None cursor = None try: @@ -2635,29 +2635,44 @@ async def save_warning_to_database(user_id, guild_id, moderator_id, reason, time message_attachments = None message_author_id = None message_channel_id = None + context_messages = None if message_data: - message_id_db = 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') + if isinstance(message_data, dict) and "main_message" in message_data: + # New format with context messages + main_msg = message_data.get("main_message") + if main_msg: + message_id_db = main_msg.get('id') + 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') + + # Store all context messages as JSON + context_messages = json.dumps(message_data.get("context_messages", [])) + else: + # Old format - single message + message_id_db = 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, 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) + message_channel_id, context_messages, created_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """ cursor.execute(insert_query, ( user_id, guild_id, moderator_id, reason, message_id_db, message_content, message_attachments, message_author_id, - message_channel_id, timestamp + message_channel_id, context_messages, timestamp )) connection.commit() - logger.info(f"Saved warning record for user {user_id} in guild {guild_id}") + logger.info(f"Saved warning record for user {user_id} in guild {guild_id} with context") return cursor.lastrowid except Exception as e: @@ -2691,6 +2706,7 @@ def create_warnings_table(): message_attachments LONGTEXT NULL, message_author_id BIGINT NULL, message_channel_id BIGINT NULL, + context_messages LONGTEXT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_guild (user_id, guild_id), INDEX idx_created_at (created_at), @@ -2707,6 +2723,7 @@ def create_warnings_table(): "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 COLUMN context_messages LONGTEXT NULL", "ALTER TABLE user_warnings ADD INDEX idx_message_id (message_id)" ] @@ -2738,7 +2755,7 @@ async def get_user_warnings(user_id, guild_id): select_query = """ SELECT id, moderator_id, reason, created_at, message_id, message_content, - message_attachments, message_author_id, message_channel_id + message_attachments, message_author_id, message_channel_id, context_messages FROM user_warnings WHERE user_id = %s AND guild_id = %s ORDER BY created_at DESC @@ -2758,7 +2775,8 @@ async def get_user_warnings(user_id, guild_id): "message_content": row[5], "message_attachments": row[6], "message_author_id": row[7], - "message_channel_id": row[8] + "message_channel_id": row[8], + "context_messages": row[9] }) return warnings @@ -2772,61 +2790,93 @@ 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""" +async def get_message_data(channel, message_id, context_range=3): + """Retrieves and processes message data for warning documentation with context messages""" try: - message = await channel.fetch_message(message_id) + # Get the main message + main_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 + # Get context messages (before and after) + context_messages = [] + try: + # Get messages around the target message + async for msg in channel.history(limit=context_range * 2 + 1, around=main_message.created_at): + context_messages.append(msg) + + # Sort messages by timestamp + context_messages.sort(key=lambda m: m.created_at) + except Exception as e: + logger.warning(f"Could not fetch context messages: {e}") + context_messages = [main_message] + + # Process all messages (main + context) + all_messages_data = [] + + for message in context_messages: + # Process attachments for this message + 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 for this message + 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) + + # Create message data + msg_data = { + "id": message.id, + "content": message.content, + "author_id": message.author.id, + "author_name": message.author.display_name, + "author_username": message.author.name, + "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, + "is_main_message": message.id == message_id # Mark the main referenced message } - # 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) + all_messages_data.append(msg_data) - # 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 structured data with main message and context + return { + "main_message": next((msg for msg in all_messages_data if msg["is_main_message"]), None), + "context_messages": all_messages_data, + "context_range": context_range, + "total_messages": len(all_messages_data) } return message_data @@ -2915,17 +2965,19 @@ 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", message_id: str = None): +async def warn(ctx, user: discord.User, reason: str = "No reason provided", message_id: str = None, context_range: int = 3): """Warns a user (Requires Permission Level 5 or higher) Usage: /warn @user "Inappropriate behavior" /warn @user "Bad language" 1407754702564884622 + /warn @user "Spam" 1407754702564884622 15 Parameters: - user: The user to warn - reason: Reason for the warning - message_id: Optional message ID to reference + - context_range: Number of messages before/after to archive (default: 3, max: 25) """ # Check if it's a slash command and defer if needed is_slash_command = hasattr(ctx, 'interaction') and ctx.interaction @@ -2948,19 +3000,50 @@ async def warn(ctx, user: discord.User, reason: str = "No reason provided", mess pass try: + # Parse message ID and context range from reason if provided inline + original_reason = reason + + # Check if reason ends with potential message ID and/or context range + reason_words = reason.split() + parsed_message_id = message_id + parsed_context_range = context_range + + # Look for patterns like "reason 1234567890123456789" or "reason 1234567890123456789 15" + if len(reason_words) >= 2: + # Check if last word is a number (could be context range) + if reason_words[-1].isdigit() and len(reason_words[-1]) <= 3: + potential_context = int(reason_words[-1]) + if 1 <= potential_context <= 25: # Valid context range + parsed_context_range = potential_context + reason_words = reason_words[:-1] # Remove context range from reason + + # Check if last word (after removing context) is a message ID + if len(reason_words) >= 2 and len(reason_words[-1]) >= 17 and len(reason_words[-1]) <= 20 and reason_words[-1].isdigit(): + parsed_message_id = reason_words[-1] + reason_words = reason_words[:-1] # Remove message ID from reason + + # Update reason without the message ID and context range + reason = " ".join(reason_words) + + # Validate and limit context range + if parsed_context_range < 1: + parsed_context_range = 1 + elif parsed_context_range > 25: + parsed_context_range = 25 + # message_data will be populated if message_id is provided message_data = None # Try to get message data if message ID was provided - if message_id: + if parsed_message_id: # Convert message_id string to int try: - message_id_int = int(message_id) + message_id_int = int(parsed_message_id) except ValueError: - await send_response(content=f"❌ Invalid message ID: {message_id}") + await send_response(content=f"❌ Invalid message ID: {parsed_message_id}") return - # Try to get message data from current channel first - message_data = await get_message_data(ctx.channel, message_id_int) + # Try to get message data from current channel first with specified context range + message_data = await get_message_data(ctx.channel, message_id_int, parsed_context_range) # If not found in current channel, try other channels the bot can access if message_data is None: @@ -2970,7 +3053,7 @@ async def warn(ctx, user: discord.User, reason: str = "No reason provided", mess if channel.id == ctx.channel.id: continue # Skip current channel, already checked try: - message_data = await get_message_data(channel, message_id_int) + message_data = await get_message_data(channel, message_id_int, parsed_context_range) if message_data is not None: break except discord.Forbidden: @@ -3045,25 +3128,48 @@ async def warn(ctx, user: discord.User, reason: str = "No reason provided", mess # 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) + # Handle new context message format + if isinstance(message_data, dict) and "main_message" in message_data: + main_msg = message_data.get("main_message") + context_msgs = message_data.get("context_messages", []) + + if main_msg: + message_info = f"**Message ID:** `{main_msg['id']}`\n" + message_info += f"**Channel:** <#{main_msg['channel_id']}>\n" + message_info += f"**Author:** {main_msg['author_name']}\n" + + if main_msg['content']: + content_preview = main_msg['content'] + if len(content_preview) > 100: + content_preview = content_preview[:97] + "..." + message_info += f"**Content:** {content_preview}\n" + + # Show attachment info + if main_msg['attachments']: + attachments = json.loads(main_msg['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" + + # Add context info + if len(context_msgs) > 1: + message_info += f"\n**Context:** {len(context_msgs)} messages archived (±{parsed_context_range})" + + embed.add_field(name="📄 Referenced Message", value=message_info, inline=False) + else: + # Handle old format for backward compatibility + message_info = f"**Message ID:** `{message_data.get('id', 'Unknown')}`\n" + message_info += f"**Channel:** <#{message_data.get('channel_id', 'Unknown')}>\n" + + if message_data.get('content'): + content_preview = message_data['content'] + if len(content_preview) > 100: + content_preview = content_preview[:97] + "..." + message_info += f"**Content:** {content_preview}\n" + + embed.add_field(name="📄 Referenced Message", value=message_info, inline=False) elif message_id: embed.add_field( name="⚠️ Message Not Found", @@ -3544,7 +3650,7 @@ async def viewwarn(ctx, warning_id: int): select_query = """ SELECT user_id, guild_id, moderator_id, reason, created_at, message_id, - message_content, message_attachments, message_author_id, message_channel_id + message_content, message_attachments, message_author_id, message_channel_id, context_messages FROM user_warnings WHERE id = %s AND guild_id = %s """ @@ -3562,7 +3668,7 @@ async def viewwarn(ctx, warning_id: int): 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 + user_id, guild_id, moderator_id, reason, created_at, message_id, message_content, message_attachments, message_author_id, message_channel_id, context_messages = result # Get user and moderator objects warned_user = await client.fetch_user(user_id) @@ -3637,6 +3743,43 @@ async def viewwarn(ctx, warning_id: int): embed.set_thumbnail(url=warned_user.display_avatar.url) embed.set_footer(text=f"Warning ID: {warning_id} | Guild: {ctx.guild.name}") + # Display context messages if available + if context_messages: + try: + context_data = json.loads(context_messages) + if context_data and len(context_data) > 1: + context_display = "**📋 Message Context:**\n" + + for i, msg in enumerate(context_data): + timestamp = datetime.fromisoformat(msg['created_at'].replace('Z', '+00:00')) + author_name = msg.get('author_name', 'Unknown') + content = msg.get('content', '*No content*') + + # Truncate long messages + if len(content) > 100: + content = content[:97] + "..." + + # Mark the main message + marker = "🎯 " if msg.get('is_main_message') else "💬 " + + context_display += f"{marker}**{author_name}** ():\n`{content}`\n\n" + + # Limit to prevent embed overflow + if len(context_display) > 1800: + context_display += "*... (truncated)*" + break + + # Send context as separate message to avoid embed limits + context_embed = discord.Embed( + title=f"📋 Message Context for Warning {warning_id}", + description=context_display, + color=0x3498db + ) + await send_response(embed=context_embed) + + except Exception as e: + logger.error(f"Error displaying context messages: {e}") + await send_response(embed=embed) finally: @@ -3655,23 +3798,28 @@ async def viewwarn(ctx, warning_id: int): await send_response(embed=embed) @client.hybrid_command() -async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason provided", message_id: str = None): +async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason provided", message_id: str = None, context_range: int = 3): """Mutes a user for a specified duration (Requires Permission Level 5 or higher) Usage: /mute @user 10m "Inappropriate behavior" /mute @user 1h "Bad language" 1407754702564884622 + /mute @user 1h "Bad language" 1407754702564884622 15 Parameters: - user: The user to mute - duration: Duration (10m, 1h, 2d) - - reason: Reason for the mute + - reason: Reason for the mute (can include message ID and context range) - message_id: Optional message ID to reference + - context_range: Number of context messages to archive (1-25, default: 3) Duration examples: - 10m = 10 minutes - 1h = 1 hour - 2d = 2 days + + You can also specify message ID and context range in the reason: + "Bad language 1407754702564884622 15" (15 messages before/after) """ # Check if it's a slash command and defer if needed is_slash_command = hasattr(ctx, 'interaction') and ctx.interaction @@ -3694,16 +3842,36 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason pass try: - # Parse message ID from reason if it looks like a message ID + # Parse message ID and context range from reason if they look valid original_reason = reason message_data = None + parsed_context_range = context_range - # Check if reason ends with a potential message ID + # Check if reason contains potential message ID and context range reason_words = reason.split() - if len(reason_words) > 1 and len(reason_words[-1]) >= 17 and len(reason_words[-1]) <= 20 and reason_words[-1].isdigit(): - # Extract message ID from reason - message_id = reason_words[-1] - reason = " ".join(reason_words[:-1]) # Remove message ID from reason + if len(reason_words) >= 2: + # Check for pattern: "reason text 1234567890123456789 15" + potential_msg_id = reason_words[-2] if len(reason_words) >= 2 else None + potential_context = reason_words[-1] if len(reason_words) >= 1 else None + + # Check if last two elements are message ID and context range + if (potential_msg_id and len(potential_msg_id) >= 17 and len(potential_msg_id) <= 20 and potential_msg_id.isdigit() and + potential_context and len(potential_context) <= 3 and potential_context.isdigit()): + parsed_context_range = int(potential_context) + message_id = potential_msg_id + reason = " ".join(reason_words[:-2]) # Remove both from reason + elif len(reason_words) >= 1: + # Check if reason ends with a potential message ID only + potential_msg_id = reason_words[-1] + if len(potential_msg_id) >= 17 and len(potential_msg_id) <= 20 and potential_msg_id.isdigit(): + message_id = potential_msg_id + reason = " ".join(reason_words[:-1]) # Remove message ID from reason + + # Validate and limit context range + if parsed_context_range < 1: + parsed_context_range = 1 + elif parsed_context_range > 25: + parsed_context_range = 25 # Try to get message data if message ID was provided if message_id: @@ -3714,7 +3882,7 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason return # Try to get message data from current channel first - message_data = await get_message_data(ctx.channel, message_id_int) + message_data = await get_message_data(ctx.channel, message_id_int, context_range=parsed_context_range) # If not found in current channel, try other channels if message_data is None: @@ -3722,7 +3890,7 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason channels_to_check = [ctx.channel] + [ch for ch in ctx.guild.text_channels[:10] if ch.id != ctx.channel.id] for channel in channels_to_check[1:]: # Skip current channel, already checked try: - message_data = await get_message_data(channel, message_id_int) + message_data = await get_message_data(channel, message_id_int, context_range=parsed_context_range) if message_data is not None: break except discord.Forbidden: @@ -3841,27 +4009,48 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason # 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" - message_info += f"**Author:** <@{message_data['author_id']}>\n" - if message_data['content']: - content_preview = message_data['content'][:200] + "..." if len(message_data['content']) > 200 else message_data['content'] - message_info += f"**Content:** {content_preview}" - embed.add_field(name="📄 Referenced Message", value=message_info, inline=False) - - # Process attachments for archival if any - if message_data.get('attachments'): - try: - attachments_data = json.loads(message_data['attachments']) - if attachments_data: - attachment_info = "" - for i, att in enumerate(attachments_data[:3]): # Show first 3 attachments - attachment_info += f"• {att.get('filename', 'Unknown file')}\n" - if len(attachments_data) > 3: - attachment_info += f"• +{len(attachments_data) - 3} more attachments" - embed.add_field(name="📎 Archived Attachments", value=attachment_info, inline=False) - except: - pass + # Handle new context message format + if isinstance(message_data, dict) and "main_message" in message_data: + main_msg = message_data.get("main_message") + context_msgs = message_data.get("context_messages", []) + + if main_msg: + message_info = f"**Message ID:** `{main_msg['id']}`\n" + message_info += f"**Channel:** <#{main_msg['channel_id']}>\n" + message_info += f"**Author:** {main_msg['author_name']}\n" + + if main_msg['content']: + content_preview = main_msg['content'][:200] + "..." if len(main_msg['content']) > 200 else main_msg['content'] + message_info += f"**Content:** {content_preview}" + + # Add context info + if len(context_msgs) > 1: + message_info += f"\n**Context:** {len(context_msgs)} messages archived (±{parsed_context_range})" + + embed.add_field(name="📄 Referenced Message", value=message_info, inline=False) + + # Process attachments for archival if any + if main_msg.get('attachments'): + try: + attachments_data = json.loads(main_msg['attachments']) + if attachments_data: + attachment_info = "" + for i, att in enumerate(attachments_data[:3]): # Show first 3 attachments + attachment_info += f"• {att.get('filename', 'Unknown file')}\n" + if len(attachments_data) > 3: + attachment_info += f"• +{len(attachments_data) - 3} more attachments" + embed.add_field(name="📎 Archived Attachments", value=attachment_info, inline=False) + except: + pass + else: + # Handle old format for backward compatibility + message_info = f"**Message ID:** `{message_data.get('id', 'Unknown')}`\n" + message_info += f"**Channel:** <#{message_data.get('channel_id', 'Unknown')}>\n" + message_info += f"**Author:** <@{message_data.get('author_id', 'Unknown')}>\n" + if message_data.get('content'): + content_preview = message_data['content'][:200] + "..." if len(message_data['content']) > 200 else message_data['content'] + message_info += f"**Content:** {content_preview}" + embed.add_field(name="📄 Referenced Message", value=message_info, inline=False) elif message_id: embed.add_field(name="📄 Referenced Message", value=f"Message ID: `{message_id}` (Message not found or inaccessible)", inline=False)