From ac301f337043da8c04e57d97069a91a7535ca784 Mon Sep 17 00:00:00 2001 From: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:06:26 +0200 Subject: [PATCH] modified: bot.py modified: requirements.txt --- bot.py | 1195 ++++++++++++++++++++++++++-------------------- requirements.txt | 3 +- 2 files changed, 683 insertions(+), 515 deletions(-) diff --git a/bot.py b/bot.py index 66645fc..df5540f 100644 --- a/bot.py +++ b/bot.py @@ -1,4 +1,4 @@ -__version__ = "dev-1.0.3" +__version__ = "dev-0.9.9" __all__ = ["Discordbot-chatai (Discord)"] __author__ = "SimolZimol" @@ -30,11 +30,10 @@ import random import time import hashlib from urllib.parse import urlparse +import aiofiles load_dotenv() -ATTACHMENT_BASE_PATH = "/cache/attachments" - DB_HOST = os.getenv("DB_HOST") DB_PORT = os.getenv("DB_PORT") DB_USER = os.getenv("DB_USER") @@ -52,6 +51,8 @@ features = { "summarize": bool(int(os.getenv("SUMMARIZE_ENABLED", 0))) } +ATTACHMENT_STORAGE_PATH = "cache/attachments" + giveaways = {} LOGS_DIR = "logs" @@ -464,48 +465,81 @@ 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 = [] +os.makedirs(ATTACHMENT_STORAGE_PATH, exist_ok=True) - 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) +async def download_and_store_attachment(attachment: discord.Attachment, mute_id_prefix: str) -> dict: + """ + Downloads a Discord attachment and saves it locally. + Returns a dict with local path and metadata. + """ + safe_filename = f"{mute_id_prefix}_{attachment.id}_{attachment.filename}" + safe_filename = "".join(c for c in safe_filename if c.isalnum() or c in ("_", ".", "-")) + local_path = os.path.join(ATTACHMENT_STORAGE_PATH, safe_filename) + + try: + file_bytes = await attachment.read() + async with aiofiles.open(local_path, "wb") as f: + await f.write(file_bytes) + + return { + "filename": attachment.filename, + "local_path": local_path, + "content_type": attachment.content_type or "application/octet-stream", + "size": attachment.size, + "original_url": attachment.url, + "stored": True + } + except Exception as e: + logger.error(f"Failed to download attachment {attachment.filename}: {e}") + return { + "filename": attachment.filename, + "local_path": None, + "content_type": attachment.content_type or "application/octet-stream", + "size": attachment.size, + "original_url": attachment.url, + "stored": False + } + + +async def archive_message_with_attachments(message: discord.Message, mute_id_prefix: str) -> dict: + """ + Archives a Discord message including downloading all attachments locally. + Returns a message_data dict compatible with apply_full_mute(). + """ + downloaded_attachments = [] for attachment in message.attachments: - try: - local_filename = f"{attachment.id}_{attachment.filename}" - local_path = os.path.join(save_dir, local_filename) + att_data = await download_and_store_attachment(attachment, mute_id_prefix) + downloaded_attachments.append(att_data) - # discord.py's built-in save method — downloads via aiohttp internally - await attachment.save(local_path) + return { + "id": message.id, + "channel_id": message.channel.id, + "author_id": message.author.id, + "author_name": str(message.author), + "content": message.content, + "attachments": json.dumps(downloaded_attachments) + } - 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 +async def get_local_attachment_files(attachments_json: str) -> list[discord.File]: + """ + Loads locally stored attachment files and returns a list of discord.File objects. + Used when sending attachments in /viewmute or mod logs. + """ + files = [] + try: + attachments = json.loads(attachments_json) + for att in attachments: + local_path = att.get("local_path") + if local_path and os.path.exists(local_path): + files.append(discord.File(local_path, filename=att["filename"])) + else: + logger.warning(f"Local attachment not found: {local_path} ({att.get('filename')})") + except Exception as e: + logger.error(f"Error loading local attachments: {e}") + return files # 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, @@ -2939,12 +2973,9 @@ async def on_message(message): member_role_ids = {role.id for role in member.roles} if not (ignore_role_ids & member_role_ids): - try: - await message.delete() - except Exception: - pass - action_taken = None + honeypot_mute_id = None + acc_age_min = int(guild_settings.get("honeypot_acc_age_min") or 30) preserve = guild_settings.get("honeypot_preserve_old_accounts", False) @@ -2957,8 +2988,17 @@ async def on_message(message): is_old_account = False if is_old_account: + # Archive message BEFORE deleting it + temp_prefix = f"hp_{guild_id}_{member.id}_{int(datetime.now().timestamp())}" + archived_data = await archive_message_with_attachments(message, temp_prefix) + try: - result = await apply_mute_action( + await message.delete() + except Exception: + pass + + try: + result = await apply_full_mute( guild=message.guild, member=member, moderator=client.user, @@ -2966,19 +3006,26 @@ async def on_message(message): duration_label="365d", reason="Honeypot: wrote in honeypot channel (protected old member)", source_channel=message.channel, - message_data=None, + message_data=archived_data, message_id=message.id, - remove_existing_roles=False, - save_removed_roles=False, + send_dm=True, + log_action=True, + remove_existing_roles=True, + save_current_roles=True, increment_mute_count=True ) - action_taken = "mute" honeypot_mute_id = result["mute_id"] + action_taken = "mute" except discord.Forbidden: logger.warning(f"Honeypot: no permission to mute {member.id} in guild {guild_id}") except Exception as e: logger.error(f"Honeypot mute failed for {member.id} in guild {guild_id}: {e}") else: + try: + await message.delete() + except Exception: + pass + try: await message.guild.ban( member, @@ -3009,7 +3056,7 @@ async def on_message(message): embed.add_field(name="User", value=f"{member} (`{member.id}`)", inline=True) embed.add_field(name="Channel", value=f"<#{message.channel.id}>", inline=True) - if action_taken == "mute": + if honeypot_mute_id: embed.add_field(name="Mute Record ID", value=f"`{honeypot_mute_id}`", inline=True) if preserve: @@ -3026,8 +3073,31 @@ async def on_message(message): inline=False ) - embed.set_thumbnail(url=member.display_avatar.url) - await log_ch.send(embed=embed) + # Attach locally archived files to the honeypot log + if is_old_account and action_taken == "mute": + try: + archived_data_after = result.get("message_data") or {} + atts_json = archived_data_after.get("attachments", "[]") + local_files = await get_local_attachment_files(atts_json) + if local_files: + embed.add_field( + name="šŸ“Ž Archived Attachments", + value=f"{len(local_files)} file(s) attached below", + inline=False + ) + embed.set_thumbnail(url=member.display_avatar.url) + await log_ch.send(embed=embed, files=local_files[:10]) + else: + embed.set_thumbnail(url=member.display_avatar.url) + await log_ch.send(embed=embed) + except Exception as e: + logger.error(f"Honeypot: error attaching files to log: {e}") + embed.set_thumbnail(url=member.display_avatar.url) + await log_ch.send(embed=embed) + else: + embed.set_thumbnail(url=member.display_avatar.url) + await log_ch.send(embed=embed) + except Exception as e: logger.error(f"Honeypot: error sending log: {e}") @@ -4404,121 +4474,67 @@ def create_mutes_table(): 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. - Attachments are stored as local file paths instead of Discord CDN links.""" +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 - - def extract_local_attachments(raw_attachments): - """ - Converts raw attachment data to local-path-only format. - Strips Discord CDN URLs and only keeps locally saved file info. - raw_attachments can be a JSON string or a list of dicts. - """ - if not raw_attachments: - return None - - if isinstance(raw_attachments, str): - try: - attachments_list = json.loads(raw_attachments) - except Exception: - return raw_attachments # Return as-is if not parseable - else: - attachments_list = raw_attachments - - if not isinstance(attachments_list, list): - return None - - cleaned = [] - for att in attachments_list: - if not isinstance(att, dict): - continue - - entry = { - "filename": att.get("filename", "unknown"), - "content_type": att.get("content_type", "application/octet-stream"), - "size": att.get("size"), - "saved_permanently": att.get("saved_permanently", False) - } - - # Only store local_path — no Discord CDN URLs - local_path = att.get("local_path") - if local_path: - entry["local_path"] = local_path - else: - # No local file saved — note it but don't store the expiring URL - entry["local_path"] = None - entry["saved_permanently"] = False - - # Optionally keep the original filename/attachment ID for reference - if att.get("attachment_id"): - entry["attachment_id"] = att.get("attachment_id") - - cleaned.append(entry) - - return json.dumps(cleaned) if cleaned else 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") - - # Use local paths only, not Discord URLs - message_attachments = extract_local_attachments(main_msg.get("attachments")) - - # Also clean attachments in context_messages before saving - raw_context = message_data.get("context_messages", []) - cleaned_context = [] - for ctx_msg in raw_context: - ctx_copy = dict(ctx_msg) - ctx_copy["attachments"] = extract_local_attachments(ctx_msg.get("attachments")) - cleaned_context.append(ctx_copy) - context_messages = json.dumps(cleaned_context) - + 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") - - # Use local paths only - message_attachments = extract_local_attachments(message_data.get("attachments")) - + + # 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, + 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, + 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: @@ -4851,54 +4867,54 @@ 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, - "attachment_id": str(attachment.id), - "saved_permanently": False, - "local_path": None + "content_type": attachment.content_type } - - if downloaded_file and downloaded_file.get("local_path"): - attachment_info["saved_permanently"] = True - attachment_info["local_path"] = downloaded_file["local_path"] - + + # 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 = { @@ -4909,7 +4925,8 @@ 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, @@ -4923,18 +4940,21 @@ 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 + "is_main_message": message.id == message_id # Mark the main referenced message } - + 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 @@ -6058,21 +6078,21 @@ async def viewmute(ctx, identifier: str): Parameters: - identifier: Mute ID (e.g. 123) or Process UUID (e.g. abc123def-456...) """ - is_slash_command = hasattr(ctx, "interaction") and ctx.interaction + is_slash_command = hasattr(ctx, "interaction") and ctx.interaction is not None if is_slash_command: await ctx.defer() - async def send_response(content=None, embed=None, ephemeral=False, files=None): + async def send_response(content=None, embed=None, ephemeral=False, file=None, files=None): try: if is_slash_command: if hasattr(ctx, "followup") and ctx.followup: - await ctx.followup.send(content=content, embed=embed, ephemeral=ephemeral, files=files or []) + await ctx.followup.send(content=content, embed=embed, ephemeral=ephemeral, file=file, 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 []) + await ctx.response.send_message(content=content, embed=embed, ephemeral=ephemeral, file=file, files=files or []) else: - await ctx.send(content=content, embed=embed, files=files or []) + await ctx.send(content=content, embed=embed, file=file, files=files or []) else: - await ctx.send(content=content, embed=embed, files=files or []) + await ctx.send(content=content, embed=embed, file=file, files=files or []) except Exception as e: logger.error(f"Error sending response in viewmute command: {e}") try: @@ -6083,55 +6103,42 @@ async def viewmute(ctx, identifier: str): 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 + async def build_attachment_field(attachments_json: str) -> tuple[str, list[discord.File]]: + """ + Parses attachment JSON, loads local files, returns (field_value_text, discord_files). + """ + field_text = "" + discord_files = [] try: - return json.loads(value) - except Exception: - return default + atts = json.loads(attachments_json) if isinstance(attachments_json, str) else attachments_json + if not atts: + return field_text, discord_files - def extract_attachment_records(mute_data): - attachment_records = [] + for att in atts: + filename = att.get("filename", "Unknown") + local_path = att.get("local_path") + size = att.get("size", 0) + size_kb = round(size / 1024, 1) if size else "?" + content_type = att.get("content_type", "unknown") - # Preferred: archived full message JSON - possible_message_json_fields = [ - "message_data", - "message_context", - "context_messages_json", - "message_json" - ] + if local_path and os.path.exists(local_path): + field_text += f"• āœ… `{filename}` ({size_kb} KB, {content_type})\n" + discord_files.append(discord.File(local_path, filename=filename)) + else: + original_url = att.get("original_url", "") + stored = att.get("stored", False) + if stored: + field_text += f"• āŒ `{filename}` — locally saved file missing\n" + elif original_url: + field_text += f"• āš ļø `{filename}` — [original CDN link]({original_url}) (may be expired)\n" + else: + field_text += f"• ā“ `{filename}` — not available\n" - 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 + except Exception as e: + logger.error(f"Error building attachment field: {e}") + field_text = "āš ļø Error loading attachment data." - # 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 + return field_text.strip(), discord_files try: mod_data = await load_user_data(ctx.author.id, ctx.guild.id) @@ -6153,146 +6160,250 @@ async def viewmute(ctx, identifier: str): connection = connect_to_database() cursor = connection.cursor() + # ── Primary lookup: user_mutes table ────────────────────────────── if is_mute_id: - select_query = """ - SELECT * FROM user_mutes - WHERE id = %s AND guild_id = %s - LIMIT 1 - """ - cursor.execute(select_query, (int(identifier), ctx.guild.id)) + cursor.execute( + "SELECT * FROM user_mutes WHERE id = %s AND guild_id = %s LIMIT 1", + (int(identifier), ctx.guild.id) + ) else: - 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, (identifier, ctx.guild.id)) + cursor.execute( + """SELECT * FROM user_mutes + WHERE process_uuid = %s AND guild_id = %s + ORDER BY created_at DESC LIMIT 1""", + (identifier, ctx.guild.id) + ) mute_result = cursor.fetchone() - if not mute_result: + if mute_result: + 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="āŒ Mute Not Found", - description=f"No mute with {'ID' if is_mute_id else 'UUID'} `{identifier}` found in this server.", - color=0xff0000 + title=f"šŸ”‡ Mute Details — ID: {mute_data['id']}", + color=0xff0000, + timestamp=mute_data["created_at"] ) - await send_response(embed=embed, ephemeral=True) - return - columns = [desc[0] for desc in cursor.description] - mute_data = dict(zip(columns, mute_result)) + 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) - 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 + status_emoji = {"active": "🟢", "completed": "āœ…", "expired": "ā°", "cancelled": "āŒ"}.get(mute_data["status"], "ā“") + aktiv_status = "🟢 Active" if mute_data["aktiv"] else "šŸ”“ Inactive" + embed.add_field(name="šŸ“Š Status", value=f"{status_emoji} **{mute_data['status'].title()}** ({aktiv_status})", inline=True) - embed = discord.Embed( - title=f"šŸ”‡ Mute Details - ID: {mute_data['id']}", - color=0xff0000, - timestamp=mute_data["created_at"] - ) + 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="šŸ‘¤ 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) + embed.add_field(name="ā±ļø Duration", value=mute_data["duration"], 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) + # Time remaining (only when active) + if mute_data["aktiv"] and mute_data["status"] == "active" and mute_data.get("end_time"): + now = datetime.now() + if mute_data["end_time"] > now: + delta = mute_data["end_time"] - now + days = delta.days + hours, remainder = divmod(delta.seconds, 3600) + minutes, _ = divmod(remainder, 60) + parts = [] + if days: parts.append(f"{days}d") + if hours: parts.append(f"{hours}h") + if minutes: parts.append(f"{minutes}m") + embed.add_field( + name="ā³ Time Remaining", + value=" ".join(parts) if parts else "Less than 1 minute", + 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="šŸ“ Reason", value=mute_data.get("reason") or "No reason provided", inline=False) - 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) - if channel: - embed.add_field(name="šŸ“ Channel", value=f"{channel.mention}\n`{channel.id}`", inline=True) + 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_data['mute_role_id']}`", inline=True) - 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_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: + if not mute_data["aktiv"] and mute_data.get("unmuted_at"): + unmute_info = f"" + if mute_data.get("unmuted_by"): 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("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 - ) + # ── Referenced message (from message_data JSON in DB) ────────── + raw_msg_data = mute_data.get("message_data") + local_files = [] - 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: + if raw_msg_data: try: - if os.path.exists(local_path): - files_to_send.append(discord.File(local_path, filename=os.path.basename(local_path))) + msg_data = json.loads(raw_msg_data) if isinstance(raw_msg_data, str) else raw_msg_data + + # Determine which format (new context dict or flat dict) + if isinstance(msg_data, dict) and "main_message" in msg_data: + main_msg = msg_data.get("main_message") or {} + atts_json = main_msg.get("attachments", "[]") + msg_id = main_msg.get("id", mute_data.get("message_id", "?")) + msg_ch_id = main_msg.get("channel_id", "?") + msg_author = main_msg.get("author_name", "Unknown") + msg_content = main_msg.get("content", "") + else: + atts_json = msg_data.get("attachments", "[]") + msg_id = msg_data.get("id", mute_data.get("message_id", "?")) + msg_ch_id = msg_data.get("channel_id", "?") + msg_author = msg_data.get("author_name") or f"<@{msg_data.get('author_id', '?')}>" + msg_content = msg_data.get("content", "") + + msg_info = f"**Message ID:** `{msg_id}`\n" + msg_info += f"**Channel:** <#{msg_ch_id}>\n" + msg_info += f"**Author:** {msg_author}\n" + if msg_content: + preview = msg_content[:150] + "..." if len(msg_content) > 150 else msg_content + msg_info += f"**Content:** {preview}" + + embed.add_field(name="šŸ“„ Referenced Message", value=msg_info, inline=False) + + # ── Attachment section ───────────────────────────────── + if atts_json and atts_json != "[]": + att_field_text, local_files = await build_attachment_field(atts_json) + if att_field_text: + stored_count = len(local_files) + total_count = len(json.loads(atts_json) if isinstance(atts_json, str) else atts_json) + field_title = f"šŸ“Ž Archived Attachments ({stored_count}/{total_count} available locally)" + embed.add_field(name=field_title, value=att_field_text, inline=False) + except Exception as e: - logger.warning(f"Could not attach local file {local_path}: {e}") + logger.error(f"Error parsing message_data in viewmute: {e}") + embed.add_field(name="šŸ“„ Referenced Message", value="āš ļø Error loading message data.", inline=False) - 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 + # Fallback: if no message_data but message_id + message_content columns exist + elif mute_data.get("message_id") and mute_data.get("message_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: {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}") + + if local_files: + # Send embed + files together + await send_response(embed=embed, files=local_files[:10]) + if len(local_files) > 10: + # Discord allows max 10 files per message + await send_response( + content=f"šŸ“Ž +{len(local_files) - 10} more attachment(s) exceeded the Discord file limit.", + ephemeral=True + ) + else: + await send_response(embed=embed) + return + + # ── Fallback lookup: active_processes table ──────────────────────── + if not is_mute_id: + cursor.execute( + """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'""", + (identifier, ctx.guild.id) ) + result = cursor.fetchone() - 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) + if result: + uuid, process_type, guild_id, channel_id, user_id, target_id, created_at, end_time, status, data = result + proc_data = json.loads(data) if data else {} + muted_user = await client.fetch_user(target_id) + moderator_id = proc_data.get("moderator_id", user_id) + moderator = await client.fetch_user(moderator_id) + channel = ctx.guild.get_channel(channel_id) if channel_id else None - embed.set_thumbnail(url=muted_user.display_avatar.url) - embed.set_footer(text=f"Mute Record from Database | Server: {ctx.guild.name}") + embed = discord.Embed( + title=f"šŸ”‡ Mute Details — UUID: {str(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) - # Discord has upload limits, so send only a few files directly - files_to_send = files_to_send[:3] + status_emoji = {"active": "🟢", "completed": "āœ…", "expired": "ā°", "cancelled": "āŒ"}.get(status, "ā“") + embed.add_field(name="šŸ“Š Status", value=f"{status_emoji} **{status.title()}**", inline=True) - await send_response(embed=embed, files=files_to_send if files_to_send else None) + 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) + + if status == "active" and end_time and end_time > datetime.now(): + delta = end_time - datetime.now() + days = delta.days + hours, remainder = divmod(delta.seconds, 3600) + minutes, _ = divmod(remainder, 60) + parts = [] + if days: parts.append(f"{days}d") + if hours: parts.append(f"{hours}h") + if minutes: parts.append(f"{minutes}m") + embed.add_field( + name="ā³ Time Remaining", + value=" ".join(parts) if parts else "Less than 1 minute", + inline=True + ) + + embed.add_field(name="šŸ“ Reason", value=proc_data.get("reason") or "No reason provided", inline=False) + + if channel: + embed.add_field(name="šŸ“ Channel", value=f"{channel.mention}\n`{channel.id}`", inline=True) + + mute_role_id = proc_data.get("mute_role_id") + if mute_role_id: + mute_role = ctx.guild.get_role(int(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) + + embed.add_field(name="šŸ†” Process UUID", value=f"`{uuid}`", inline=False) + 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) + return + + # ── Nothing found ────────────────────────────────────────────────── + embed = discord.Embed( + title="āŒ Mute Not Found", + description=f"No mute with {'ID' if is_mute_id else 'UUID'} `{identifier}` found in this server.", + color=0xff0000 + ) + await send_response(embed=embed, ephemeral=True) finally: if cursor: @@ -6546,6 +6657,173 @@ async def restorewarn(ctx, warning_id: int): close_database_connection(connection) +async def apply_full_mute( + *, + guild: discord.Guild, + member: discord.Member, + moderator, + duration_seconds: int, + duration_label: str, + reason: str, + source_channel: discord.TextChannel | None = None, + raw_message: discord.Message | None = None, + message_data: dict | None = None, + message_id: int | None = None, + send_dm: bool = True, + log_action: bool = True, + remove_existing_roles: bool = True, + save_current_roles: bool = True, + increment_mute_count: bool = True +): + guild_settings = get_guild_settings(guild.id) + + if save_current_roles: + await save_user_roles(member.id, guild.id, member.roles) + + if remove_existing_roles: + roles_to_remove = [role for role in member.roles if not role.is_default()] + if roles_to_remove: + await member.remove_roles(*roles_to_remove, reason=f"Muted by {moderator}") + + mute_role = await get_or_create_mute_role(guild, guild_settings) + if not mute_role: + raise RuntimeError("Could not find or create mute role") + + await member.add_roles(mute_role, reason=f"Muted by {moderator} for {duration_label}") + + user_data = await load_user_data(member.id, guild.id) + if increment_mute_count: + user_data["mutes"] += 1 + update_user_data(member.id, guild.id, "mutes", user_data["mutes"]) + + start_time = datetime.now() + end_time = start_time + timedelta(seconds=duration_seconds) + + # Generate a temp prefix for attachment filenames before we have the mute_id + temp_prefix = f"{guild.id}_{member.id}_{int(start_time.timestamp())}" + + # If a raw discord.Message was passed, archive it (downloads attachments locally) + if raw_message is not None and message_data is None: + message_data = await archive_message_with_attachments(raw_message, temp_prefix) + + process_data = { + "user_id": member.id, + "guild_id": guild.id, + "channel_id": source_channel.id if source_channel else None, + "reason": reason, + "moderator_id": moderator.id, + "mute_role_id": mute_role.id + } + + process_uuid = create_active_process( + process_type="mute", + guild_id=guild.id, + channel_id=source_channel.id if source_channel else 0, + user_id=member.id, + target_id=member.id, + end_time=end_time, + data=process_data + ) + + mute_id = await save_mute_to_database( + user_id=member.id, + guild_id=guild.id, + moderator_id=moderator.id, + reason=reason, + duration=duration_label, + start_time=start_time, + end_time=end_time, + process_uuid=process_uuid, + channel_id=source_channel.id if source_channel else None, + mute_role_id=mute_role.id, + message_data=message_data, + message_id=message_id + ) + + if log_action: + additional_info = { + "Mute Count": str(user_data["mutes"]), + "Process ID": str(process_uuid)[:8], + "Mute Record ID": str(mute_id) + } + + if message_data: + if isinstance(message_data, dict) and "main_message" in message_data: + main_msg = message_data.get("main_message") + if main_msg: + additional_info["Referenced Message"] = f"ID: {main_msg['id']} in <#{main_msg['channel_id']}>" + if main_msg.get("attachments"): + try: + atts = json.loads(main_msg["attachments"]) + stored = sum(1 for a in atts if a.get("stored")) + if stored: + additional_info["Archived Attachments"] = f"{stored} file(s) saved locally" + except Exception: + pass + else: + if message_data.get("id") and message_data.get("channel_id"): + additional_info["Referenced Message"] = f"ID: {message_data['id']} in <#{message_data['channel_id']}>" + if message_data.get("attachments"): + try: + atts = json.loads(message_data["attachments"]) + stored = sum(1 for a in atts if a.get("stored")) + if stored: + additional_info["Archived Attachments"] = f"{stored} file(s) saved locally" + except Exception: + pass + + await log_moderation_action( + guild=guild, + action_type="mute", + moderator=moderator, + target_user=member, + reason=reason, + duration=duration_label, + additional_info=additional_info + ) + + if send_dm: + try: + dm_embed = discord.Embed( + title="šŸ”‡ You have been muted", + description=f"You have been muted in **{guild.name}**", + color=0xff0000, + timestamp=datetime.now() + ) + dm_embed.add_field(name="ā±ļø Duration", value=duration_label, inline=True) + dm_embed.add_field(name="ā° Ends At", value=f"", inline=True) + dm_embed.add_field(name="šŸ“ Reason", value=reason or "No reason provided", inline=False) + dm_embed.add_field(name="šŸ‘® Moderator", value=getattr(moderator, "display_name", str(moderator)), inline=True) + + preview_content = None + if message_data: + if isinstance(message_data, dict) and "main_message" in message_data: + main_msg = message_data.get("main_message") + if main_msg: + preview_content = main_msg.get("content") + else: + preview_content = message_data.get("content") + + if preview_content: + content_preview = preview_content[:200] + "..." if len(preview_content) > 200 else preview_content + dm_embed.add_field(name="šŸ“„ Referenced Message", value=f"```{content_preview}```", inline=False) + + dm_embed.set_footer(text=f"Server: {guild.name}") + await member.send(embed=dm_embed) + except discord.Forbidden: + pass + except Exception as e: + logger.error(f"Failed to DM muted user {member.id}: {e}") + + return { + "mute_role": mute_role, + "user_data": user_data, + "start_time": start_time, + "end_time": end_time, + "process_uuid": process_uuid, + "mute_id": mute_id, + "message_data": message_data + } @client.hybrid_command() async def mute( @@ -6557,36 +6835,36 @@ async def mute( context_range: int = 3, silent: bool = False ): - """Mute a user for a specified duration.""" + """Mutes a user for a specified duration (Requires Permission Level 5 or higher).""" is_slash_command = hasattr(ctx, "interaction") and ctx.interaction is not None if is_slash_command: await ctx.defer(ephemeral=silent) - async def send_response(content=None, embed=None, ephemeral=False, file=None): + async def send_response(content=None, embed=None, ephemeral=False, file=None, 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) + await ctx.followup.send(content=content, embed=embed, ephemeral=ephemeral, file=file, files=files) elif hasattr(ctx, "interaction") and ctx.interaction: - await ctx.interaction.followup.send(content=content, embed=embed, ephemeral=ephemeral, file=file) + await ctx.interaction.followup.send(content=content, embed=embed, ephemeral=ephemeral, file=file, files=files) else: - await ctx.send(content=content, embed=embed, file=file) + await ctx.send(content=content, embed=embed, file=file, files=files) else: - await ctx.send(content=content, embed=embed, file=file) + await ctx.send(content=content, embed=embed, file=file, files=files) except Exception as e: - logger.error(f"Error sending mute response: {e}") + logger.error(f"Error sending response in mute command: {e}") try: if embed: await ctx.send(embed=embed) elif content: - await ctx.send(content) + await ctx.send(content=content) except Exception as fallback_error: logger.error(f"Fallback send failed: {fallback_error}") try: - original_reason = reason + raw_message_obj = None message_data = None parsed_context_range = 3 @@ -6594,14 +6872,9 @@ async def mute( if len(reason_words) >= 2: potential_msg_id = reason_words[-2] potential_context = reason_words[-1] - if ( - potential_msg_id - and 17 <= len(potential_msg_id) <= 20 - and potential_msg_id.isdigit() - and potential_context - and len(potential_context) <= 3 - and potential_context.isdigit() + potential_msg_id and 17 <= 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 @@ -6617,10 +6890,7 @@ async def mute( elif not message_id: parsed_context_range = 3 - if parsed_context_range < 1: - parsed_context_range = 1 - elif parsed_context_range > 25: - parsed_context_range = 25 + parsed_context_range = max(1, min(25, parsed_context_range)) if message_id: try: @@ -6631,16 +6901,42 @@ async def mute( message_data = await get_message_data(ctx.channel, message_id_int, context_range=parsed_context_range) + # Try to fetch the raw message object for attachment downloading + try: + raw_message_obj = await ctx.channel.fetch_message(message_id_int) + except Exception: + raw_message_obj = None + if message_data is None: 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:]: try: message_data = await get_message_data(channel, message_id_int, context_range=parsed_context_range) if message_data is not None: + if raw_message_obj is None: + try: + raw_message_obj = await channel.fetch_message(message_id_int) + except Exception: + pass break except discord.Forbidden: continue + # Download attachments from raw message if we found it + if raw_message_obj is not None and raw_message_obj.attachments: + temp_prefix = f"{ctx.guild.id}_{user.id}_{int(datetime.now().timestamp())}" + downloaded_atts = [] + for att in raw_message_obj.attachments: + att_data = await download_and_store_attachment(att, temp_prefix) + downloaded_atts.append(att_data) + + # Inject into message_data + if message_data and isinstance(message_data, dict): + if "main_message" in message_data and message_data["main_message"]: + message_data["main_message"]["attachments"] = json.dumps(downloaded_atts) + else: + message_data["attachments"] = json.dumps(downloaded_atts) + mod_data = await load_user_data(ctx.author.id, ctx.guild.id) if not check_moderation_permission(mod_data["permission"]): @@ -6683,7 +6979,7 @@ async def mute( await send_response(embed=embed, ephemeral=True) return - result = await apply_mute_action( + result = await apply_full_mute( guild=ctx.guild, member=member, moderator=ctx.author, @@ -6693,16 +6989,18 @@ async def mute( source_channel=ctx.channel, message_data=message_data, message_id=int(message_id) if message_id else None, + send_dm=True, + log_action=True, remove_existing_roles=True, - save_removed_roles=True, + save_current_roles=True, increment_mute_count=True ) end_time = result["end_time"] mute_id = result["mute_id"] process_uuid = result["process_uuid"] - mute_role = result["mute_role"] user_data = result["user_data"] + message_data = result["message_data"] embed = discord.Embed( title="šŸ”‡ User Muted", @@ -6714,7 +7012,7 @@ async def mute( embed.add_field(name="ā° Ends At", value=f"", inline=True) embed.add_field(name="šŸ“ Reason", value=reason or "No reason provided", inline=False) embed.add_field(name="šŸ‘® Moderator", value=ctx.author.mention, inline=True) - embed.add_field(name="šŸ”‡ Mute Count", value=str(user_data["mutes"]), inline=True) + embed.add_field(name="šŸ”‡ Mute Count", value=f"{user_data['mutes']}", inline=True) if message_data: if isinstance(message_data, dict) and "main_message" in message_data: @@ -6724,20 +7022,21 @@ async def mute( message_info += f"**Channel:** <#{main_msg['channel_id']}>\n" message_info += f"**Author:** {main_msg['author_name']}\n" if main_msg.get("content"): - content_preview = main_msg["content"][:200] + "..." if len(main_msg["content"]) > 200 else main_msg["content"] - message_info += f"**Content:** {content_preview}" + preview = main_msg["content"][:200] + "..." if len(main_msg["content"]) > 200 else main_msg["content"] + message_info += f"**Content:** {preview}" embed.add_field(name="šŸ“„ Referenced Message", value=message_info, inline=False) 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]): - 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) + atts = json.loads(main_msg["attachments"]) + if atts: + att_info = "" + for att in atts[:3]: + status = "āœ…" if att.get("stored") else "āŒ" + att_info += f"• {status} {att.get('filename', 'Unknown')}\n" + if len(atts) > 3: + att_info += f"• +{len(atts) - 3} more" + embed.add_field(name="šŸ“Ž Archived Attachments (Locally Saved)", value=att_info, inline=False) except Exception: pass else: @@ -6745,9 +7044,23 @@ async def mute( 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}" + preview = message_data["content"][:200] + "..." if len(message_data["content"]) > 200 else message_data["content"] + message_info += f"**Content:** {preview}" embed.add_field(name="šŸ“„ Referenced Message", value=message_info, inline=False) + + if message_data.get("attachments"): + try: + atts = json.loads(message_data["attachments"]) + if atts: + att_info = "" + for att in atts[:3]: + status = "āœ…" if att.get("stored") else "āŒ" + att_info += f"• {status} {att.get('filename', 'Unknown')}\n" + if len(atts) > 3: + att_info += f"• +{len(atts) - 3} more" + embed.add_field(name="šŸ“Ž Archived Attachments (Locally Saved)", value=att_info, inline=False) + except Exception: + pass elif message_id: embed.add_field( name="šŸ“„ Referenced Message", @@ -6769,84 +7082,19 @@ async def mute( silent_embed.add_field(name="ā±ļø Duration", value=duration, inline=True) silent_embed.add_field(name="ā° Ends At", value=f"", inline=True) silent_embed.add_field(name="šŸ“ Reason", value=reason or "No reason provided", inline=False) - silent_embed.add_field(name="šŸ”‡ Mute Count", value=str(user_data["mutes"]), inline=True) + silent_embed.add_field(name="šŸ”‡ Mute Count", value=f"{user_data['mutes']}", inline=True) silent_embed.add_field(name="šŸ†” Mute Record ID", value=f"`{mute_id}`", inline=True) silent_embed.add_field( name="šŸ”” Actions Taken", - value="• User muted\n• User received DM notification if possible\n• Mod log entry created\n• No public announcement", + value="• User muted\n• Roles saved\n• Attachments archived locally\n• User received DM if possible\n• Mod log entry created\n• No public announcement", inline=False ) silent_embed.set_footer(text=f"Silent Mode • User ID: {user.id} | Use /viewmute {mute_id} for details") silent_embed.set_thumbnail(url=user.display_avatar.url) + await send_response(embed=silent_embed, ephemeral=True) + return - try: - if is_slash_command: - if hasattr(ctx, "followup") and ctx.followup is not None: - await ctx.followup.send(embed=silent_embed, ephemeral=True) - elif hasattr(ctx, "interaction") and ctx.interaction: - await ctx.interaction.followup.send(embed=silent_embed, ephemeral=True) - else: - raise RuntimeError("No followup available after defer") - else: - await ctx.send(embed=silent_embed) - except Exception as e: - logger.error(f"Error sending silent mute response: {e}") - else: - await send_response(embed=embed) - - log_additional_info = { - "Mute Count": str(user_data["mutes"]), - "Process ID": str(process_uuid)[:8], - "Mute Record ID": str(mute_id) - } - - if message_data: - if isinstance(message_data, dict) and "main_message" in message_data: - main_msg = message_data.get("main_message") - if main_msg: - log_additional_info["Referenced Message"] = f"ID: {main_msg['id']} in <#{main_msg['channel_id']}>" - else: - log_additional_info["Referenced Message"] = f"ID: {message_data['id']} in <#{message_data['channel_id']}>" - - await log_moderation_action( - guild=ctx.guild, - action_type="mute", - moderator=ctx.author, - target_user=user, - reason=reason, - duration=duration, - additional_info=log_additional_info - ) - - try: - dm_embed = discord.Embed( - title="šŸ”‡ You have been muted", - description=f"You have been muted in **{ctx.guild.name}**", - color=0xff0000, - timestamp=datetime.now() - ) - dm_embed.add_field(name="ā±ļø Duration", value=duration, inline=True) - dm_embed.add_field(name="ā° Ends At", value=f"", inline=True) - dm_embed.add_field(name="šŸ“ Reason", value=reason or "No reason provided", inline=False) - dm_embed.add_field(name="šŸ‘® Moderator", value=ctx.author.display_name, inline=True) - - if message_data: - preview_content = None - if isinstance(message_data, dict) and "main_message" in message_data: - main_msg = message_data.get("main_message") - if main_msg: - preview_content = main_msg.get("content") - else: - preview_content = message_data.get("content") - - if preview_content: - content_preview = preview_content[:200] + "..." if len(preview_content) > 200 else preview_content - dm_embed.add_field(name="šŸ“„ Referenced Message", value=f"```{content_preview}```", inline=False) - - dm_embed.set_footer(text=f"Server: {ctx.guild.name}") - await user.send(embed=dm_embed) - except discord.Forbidden: - pass + await send_response(embed=embed) logger.info(f"User {user.id} muted by {ctx.author.id} in guild {ctx.guild.id} for {duration}. Reason: {reason}") @@ -6859,87 +7107,6 @@ async def mute( ) await send_response(embed=embed, ephemeral=True) - -async def apply_mute_action( - *, - guild: discord.Guild, - member: discord.Member, - moderator, - duration_seconds: int, - duration_label: str, - reason: str, - source_channel=None, - message_data: dict | None = None, - message_id: int | None = None, - remove_existing_roles: bool = True, - save_removed_roles: bool = True, - increment_mute_count: bool = True -): - guild_settings = get_guild_settings(guild.id) - - if save_removed_roles: - await save_user_roles(member.id, guild.id, member.roles) - - if remove_existing_roles: - roles_to_remove = [role for role in member.roles if not role.is_default()] - if roles_to_remove: - await member.remove_roles(*roles_to_remove, reason=f"Muted by {moderator}") - - mute_role = await get_or_create_mute_role(guild, guild_settings) - if not mute_role: - raise RuntimeError("Could not find or create mute role") - - await member.add_roles(mute_role, reason=f"Muted by {moderator} for {duration_label}") - - user_data = await load_user_data(member.id, guild.id) - if increment_mute_count: - user_data["mutes"] += 1 - update_user_data(member.id, guild.id, "mutes", user_data["mutes"]) - - end_time = datetime.now() + timedelta(seconds=duration_seconds) - - process_data = { - "user_id": member.id, - "guild_id": guild.id, - "channel_id": source_channel.id if source_channel else None, - "reason": reason, - "moderator_id": moderator.id, - "mute_role_id": mute_role.id - } - - process_uuid = create_active_process( - process_type="mute", - guild_id=guild.id, - channel_id=source_channel.id if source_channel else 0, - user_id=member.id, - target_id=member.id, - end_time=end_time, - data=process_data - ) - - mute_id = await save_mute_to_database( - user_id=member.id, - guild_id=guild.id, - moderator_id=moderator.id, - reason=reason, - duration=duration_label, - start_time=datetime.now(), - end_time=end_time, - process_uuid=process_uuid, - channel_id=source_channel.id if source_channel else None, - mute_role_id=mute_role.id, - message_data=message_data, - message_id=message_id - ) - - return { - "mute_role": mute_role, - "user_data": user_data, - "end_time": end_time, - "process_uuid": process_uuid, - "mute_id": mute_id - } - @client.hybrid_command() async def unmute(ctx, user: discord.User): """Unmutes a user manually (Requires Permission Level 5 or higher)""" diff --git a/requirements.txt b/requirements.txt index 52bd315..7baa60f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,5 @@ flask psutil requests_oauthlib Flask-Session -redis \ No newline at end of file +redis +aiofiles \ No newline at end of file