diff --git a/bot.py b/bot.py index 7eb971e..a9e919c 100644 --- a/bot.py +++ b/bot.py @@ -33,6 +33,8 @@ from urllib.parse import urlparse load_dotenv() +ATTACHMENT_BASE_PATH = "attachments" + DB_HOST = os.getenv("DB_HOST") DB_PORT = os.getenv("DB_PORT") DB_USER = os.getenv("DB_USER") @@ -462,6 +464,49 @@ def save_global_permission(user_id, permission_level): #----------------------------------------------------------------------------------------------------------- +async def download_message_attachments( + message: discord.Message, + guild_id: int, + subfolder: str # z.B. mute_id oder "honeypot/user_id" +) -> list[dict]: + """ + Downloads all attachments from a Discord message to local disk. + Returns a list of dicts with local path, filename, content_type and size. + """ + saved_attachments = [] + + if not message.attachments: + return saved_attachments + + save_dir = os.path.join(ATTACHMENT_BASE_PATH, str(guild_id), subfolder) + os.makedirs(save_dir, exist_ok=True) + + for attachment in message.attachments: + try: + local_filename = f"{attachment.id}_{attachment.filename}" + local_path = os.path.join(save_dir, local_filename) + + # discord.py's built-in save method — downloads via aiohttp internally + await attachment.save(local_path) + + saved_attachments.append({ + "filename": attachment.filename, + "local_path": local_path, + "content_type": attachment.content_type or "application/octet-stream", + "size": attachment.size, + "original_url": attachment.url, + "attachment_id": str(attachment.id) + }) + + logger.info(f"Saved attachment {attachment.filename} to {local_path}") + + except discord.HTTPException as e: + logger.error(f"HTTP error downloading attachment {attachment.filename}: {e}") + except Exception as e: + logger.error(f"Failed to save attachment {attachment.filename}: {e}") + + return saved_attachments + # Active Processes System - Robust system for storing and managing active processes def create_active_process(process_type, guild_id, channel_id=None, user_id=None, target_id=None, start_time=None, end_time=None, status="active", data=None, metadata=None): @@ -4775,54 +4820,57 @@ async def reactivate_warning(warning_id): async def get_message_data(channel, message_id, context_range=3): """Retrieves and processes message data for warning documentation with context messages""" try: - # Get the main message main_message = await channel.fetch_message(message_id) - - # 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 = [] + + # NEW: download all attachments of this message once + downloaded_attachments = await download_message_attachments( + message=message, + guild_id=channel.guild.id, + subfolder=f"message_archive/{message.id}" + ) + + # Build a lookup by original attachment id or filename + downloaded_map = { + str(item.get("attachment_id")): item + for item in downloaded_attachments + } + for attachment in message.attachments: + downloaded_file = downloaded_map.get(str(attachment.id)) + attachment_info = { "filename": attachment.filename, "url": attachment.url, "proxy_url": attachment.proxy_url, "size": attachment.size, - "content_type": attachment.content_type + "content_type": attachment.content_type, + "attachment_id": str(attachment.id) } - - # 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}") - + + # NEW: add local saved file info if download worked + if downloaded_file: + attachment_info["local_path"] = downloaded_file.get("local_path") + attachment_info["saved_permanently"] = True + else: + attachment_info["saved_permanently"] = False + attachments_data.append(attachment_info) - - # Process embeds for this message + embeds_data = [] for embed in message.embeds: embed_info = { @@ -4833,8 +4881,7 @@ async def get_message_data(channel, message_id, context_range=3): "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, @@ -4848,21 +4895,18 @@ async def get_message_data(channel, message_id, context_range=3): "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 + "is_main_message": message.id == message_id } - + all_messages_data.append(msg_data) - - # 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 - + except discord.NotFound: logger.warning(f"Message {message_id} not found") return None @@ -5982,30 +6026,27 @@ async def viewwarn(ctx, warning_id: int): @client.hybrid_command() async def viewmute(ctx, identifier: str): """View detailed information about a specific mute (Requires Permission Level 5 or higher) - + Parameters: - identifier: Mute ID (e.g. 123) or Process UUID (e.g. abc123def-456...) """ - # Check if it's a slash command and defer if needed - is_slash_command = hasattr(ctx, 'interaction') and ctx.interaction + 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): + + async def send_response(content=None, embed=None, ephemeral=False, files=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) + if hasattr(ctx, "followup") and ctx.followup: + await ctx.followup.send(content=content, embed=embed, ephemeral=ephemeral, files=files or []) + elif hasattr(ctx, "response") and not ctx.response.is_done(): + await ctx.response.send_message(content=content, embed=embed, ephemeral=ephemeral, files=files or []) else: - await ctx.send(content=content, embed=embed, file=file) + await ctx.send(content=content, embed=embed, files=files or []) else: - await ctx.send(content=content, embed=embed, file=file) + await ctx.send(content=content, embed=embed, files=files or []) 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) @@ -6013,12 +6054,60 @@ async def viewmute(ctx, identifier: str): await ctx.send(content=content) except Exception as fallback_error: logger.error(f"Fallback send also failed: {fallback_error}") - + + def safe_json_loads(value, default=None): + if default is None: + default = [] + if not value: + return default + try: + return json.loads(value) + except Exception: + return default + + def extract_attachment_records(mute_data): + attachment_records = [] + + # Preferred: archived full message JSON + possible_message_json_fields = [ + "message_data", + "message_context", + "context_messages_json", + "message_json" + ] + + for field_name in possible_message_json_fields: + raw_value = mute_data.get(field_name) + parsed = safe_json_loads(raw_value, default=None) + if isinstance(parsed, dict): + context_messages = parsed.get("context_messages", []) + for msg in context_messages: + msg_attachments = safe_json_loads(msg.get("attachments"), []) + for att in msg_attachments: + att["_source_message_id"] = msg.get("id") + att["_source_author"] = msg.get("author_name") + attachment_records.append(att) + if attachment_records: + return attachment_records + + # Fallback: direct attachment field + possible_attachment_fields = [ + "message_attachments", + "attachments", + "attachment_data" + ] + + for field_name in possible_attachment_fields: + raw_value = mute_data.get(field_name) + parsed = safe_json_loads(raw_value, []) + if isinstance(parsed, list) and parsed: + attachment_records.extend(parsed) + + return attachment_records + 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", @@ -6028,133 +6117,33 @@ async def viewmute(ctx, identifier: str): await send_response(embed=embed, ephemeral=True) return - # Determine if identifier is a mute ID (numeric) or process UUID (alphanumeric) is_mute_id = identifier.isdigit() - - # 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() - - # Try to find mute in user_mutes table + if is_mute_id: - # Search by mute ID select_query = """ - SELECT * FROM user_mutes + SELECT * FROM user_mutes WHERE id = %s AND guild_id = %s LIMIT 1 """ cursor.execute(select_query, (int(identifier), ctx.guild.id)) else: - # Search by process UUID select_query = """ - SELECT * FROM user_mutes + SELECT * FROM user_mutes WHERE process_uuid = %s AND guild_id = %s ORDER BY created_at DESC LIMIT 1 """ cursor.execute(select_query, (identifier, 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"`{mute_data['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 (only if identifier is UUID format) - if not is_mute_id: - 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, (identifier, ctx.guild.id)) - result = cursor.fetchone() - - if result: - # Process old format result... - # [Continue with existing fallback logic] - pass - - # If no results found + + if not mute_result: embed = discord.Embed( title="āŒ Mute Not Found", description=f"No mute with {'ID' if is_mute_id else 'UUID'} `{identifier}` found in this server.", @@ -6162,94 +6151,127 @@ async def viewmute(ctx, identifier: str): ) 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 + + columns = [desc[0] for desc in cursor.description] + mute_data = dict(zip(columns, mute_result)) + + muted_user = await client.fetch_user(int(mute_data["user_id"])) + moderator = await client.fetch_user(int(mute_data["moderator_id"])) + channel = ctx.guild.get_channel(int(mute_data["channel_id"])) if mute_data.get("channel_id") else None + embed = discord.Embed( - title=f"šŸ”‡ Mute Details - ID: {uuid[:8]}", + title=f"šŸ”‡ Mute Details - ID: {mute_data['id']}", color=0xff0000, - timestamp=created_at + 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 and duration information - status_emoji = {"active": "🟢", "completed": "āœ…", "expired": "ā°", "cancelled": "āŒ"}.get(status, "ā“") - status_text = f"{status_emoji} **{status.title()}**" + embed.add_field(name="šŸ‘® Moderator", value=f"{moderator.mention}\n`{moderator.id}`", inline=True) + embed.add_field(name="šŸ“… Muted At", value=f"", inline=True) + + 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) - - if end_time: - if status == "active": - embed.add_field(name="ā° Ends At", value=f"\n", inline=True) + + if mute_data.get("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="šŸ†” 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 + embed.add_field( + name="ā° Ended At", + value=f"", + inline=True + ) + + embed.add_field(name="ā±ļø Duration", value=mute_data["duration"], inline=True) + embed.add_field(name="šŸ“ Reason", value=mute_data["reason"], inline=False) + 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_data.get("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_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.add_field(name="šŸŽ­ Mute Role", value=f"āŒ Deleted Role\n`{mute_data['mute_role_id']}`", inline=True) + + if not mute_data["aktiv"] and mute_data.get("unmuted_at"): + unmute_info = f"" + if mute_data.get("unmuted_by"): + try: + unmuter = await client.fetch_user(int(mute_data["unmuted_by"])) + unmute_info += f"\nBy: {unmuter.mention}" + except Exception: + unmute_info += f"\nBy: `{mute_data['unmuted_by']}`" + if mute_data.get("auto_unmuted"): + unmute_info += "\nšŸ¤– Automatic unmute" + embed.add_field(name="šŸ”“ Unmuted At", value=unmute_info, inline=True) + + if mute_data.get("message_id") and mute_data.get("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 + ) + + attachment_records = extract_attachment_records(mute_data) + + files_to_send = [] + attachment_lines = [] + + for att in attachment_records[:10]: + filename = att.get("filename", "unknown_file") + local_path = att.get("local_path") + size = att.get("size") + content_type = att.get("content_type", "unknown") + saved_permanently = att.get("saved_permanently", False) + + size_text = f"{size} bytes" if size is not None else "unknown size" + status_text = "saved locally" if (saved_permanently and local_path) else "link only" + + attachment_lines.append(f"• `{filename}` ({content_type}, {size_text}) — {status_text}") + + if local_path: + try: + if os.path.exists(local_path): + files_to_send.append(discord.File(local_path, filename=os.path.basename(local_path))) + except Exception as e: + logger.warning(f"Could not attach local file {local_path}: {e}") + + if attachment_lines: + preview_lines = attachment_lines[:8] + if len(attachment_lines) > 8: + preview_lines.append(f"• +{len(attachment_lines) - 8} more attachment(s)") + embed.add_field( + name="šŸ“Ž Archived Attachments", + value="\n".join(preview_lines), + inline=False + ) + + embed.add_field(name="šŸ†” Process UUID", value=f"`{mute_data['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"Process Type: {process_type.title()} | Server: {ctx.guild.name}") - - await send_response(embed=embed) - + embed.set_footer(text=f"Mute Record from Database | Server: {ctx.guild.name}") + + # Discord has upload limits, so send only a few files directly + files_to_send = files_to_send[:3] + + await send_response(embed=embed, files=files_to_send if files_to_send else None) + 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( @@ -6257,7 +6279,7 @@ async def viewmute(ctx, identifier: str): description="An error occurred while retrieving mute details. Please try again.", color=0xff0000 ) - await send_response(embed=embed) + await send_response(embed=embed, ephemeral=True) @client.hybrid_command() async def removewarn(ctx, warning_id: int):