From 3fd1146b86add7d0fda16c4f0b5dfdf579169fb2 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 18 Dec 2025 18:02:44 +0100 Subject: [PATCH 01/10] feat: adding attachments to snippets. This adds the ability to add attachments to snippets --- bot.py | 74 ++++++++++- cogs/modmail.py | 282 +++++++++++++++++++++++++++++++++++++++--- core/clients.py | 86 ++++++++++++- core/config.py | 12 ++ core/config_help.json | 12 ++ core/thread.py | 41 ++++-- 6 files changed, 474 insertions(+), 33 deletions(-) diff --git a/bot.py b/bot.py index 9f3de008a1..2037bf7074 100644 --- a/bot.py +++ b/bot.py @@ -1309,9 +1309,18 @@ async def get_contexts(self, message, *, cls=commands.Context): # Check if a snippet is being called. # This needs to be done before checking for aliases since # snippets can have multiple words. + snippet_invoked = False try: # Use removeprefix once PY3.9+ - snippet_text = self.snippets[message.content[len(invoked_prefix) :]] + snippet_data = self.snippets[message.content[len(invoked_prefix) :]] + # Extract text from snippet (handle both old string format and new dict format) + if isinstance(snippet_data, str): + snippet_text = snippet_data + elif isinstance(snippet_data, dict): + snippet_text = snippet_data.get("text", "") + else: + snippet_text = None + snippet_invoked = True except KeyError: snippet_text = None @@ -1327,9 +1336,43 @@ async def get_contexts(self, message, *, cls=commands.Context): for alias in aliases: command = None try: - snippet_text = self.snippets[alias] + snippet_data = self.snippets[alias] + # Extract text from snippet (handle both old string format and new dict format) + if isinstance(snippet_data, str): + snippet_text = snippet_data + elif isinstance(snippet_data, dict): + snippet_text = snippet_data.get("text", "") + # Download attachment if present + if snippet_data.get("file_id"): + try: + import io + + file_data, metadata = await self.api.download_snippet_attachment( + snippet_data["file_id"] + ) + + class AttachmentWrapper: + def __init__(self, file_data, metadata): + self.file_data = file_data + self.id = 0 + self.url = f"attachment://{metadata['filename']}" + self.filename = metadata["filename"] + self.size = metadata["length"] + self.width = None + + async def to_file(self): + return discord.File( + io.BytesIO(self.file_data), filename=self.filename + ) + + message.attachments = [AttachmentWrapper(file_data, metadata)] + except Exception as e: + logger.warning("Failed to download snippet attachment: %s", e) + else: + snippet_text = None except KeyError: command_invocation_text = alias + snippet_text = None else: command = self._get_snippet_command() command_invocation_text = f"{invoked_prefix}{command} {snippet_text}" @@ -1346,11 +1389,38 @@ async def get_contexts(self, message, *, cls=commands.Context): if snippet_text is not None: # Process snippets + snippet_name = message.content[len(invoked_prefix) :] + snippet_data = self.snippets.get(snippet_name) + # Download attachment if present + if isinstance(snippet_data, dict) and snippet_data.get("file_id"): + try: + import io + + file_data, metadata = await self.api.download_snippet_attachment(snippet_data["file_id"]) + + # Create a list-like object that mimics message.attachments + class AttachmentWrapper: + def __init__(self, file_data, metadata): + self.file_data = file_data + self.id = 0 + self.url = f"attachment://{metadata['filename']}" + self.filename = metadata["filename"] + self.size = metadata["length"] + self.width = None + + async def to_file(self): + return discord.File(io.BytesIO(self.file_data), filename=self.filename) + + message.attachments = [AttachmentWrapper(file_data, metadata)] + except Exception as e: + logger.warning("Failed to download snippet attachment: %s", e) ctx.command = self._get_snippet_command() reply_view = StringView(f"{invoked_prefix}{ctx.command} {snippet_text}") discord.utils.find(reply_view.skip_string, prefixes) ctx.invoked_with = reply_view.get_word().lower() ctx.view = reply_view + # Mark that a snippet was invoked so we can delete the command message + ctx.snippet_invoked = snippet_invoked else: ctx.command = self.all_commands.get(invoker) ctx.invoked_with = invoker diff --git a/cogs/modmail.py b/cogs/modmail.py index 0e39da920c..2e19c6e788 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -112,6 +112,30 @@ def _resolve_user(self, user_str): return int(match.group(1)) return None + def _get_snippet_text(self, snippet_data) -> str: + """ + Extract text from a snippet, handling both old string format and new dict format. + + Parameters + ---------- + snippet_data : str or dict + The snippet data (either old string format or new dict format). + + Returns + ------- + str + The text content of the snippet. + """ + if isinstance(snippet_data, str): + return snippet_data + elif isinstance(snippet_data, dict): + return snippet_data.get("text", "") + return "" + + def _has_snippet_attachment(self, snippet_data) -> bool: + """Check if a snippet has an attachment.""" + return isinstance(snippet_data, dict) and bool(snippet_data.get("file_id")) + @commands.command() @trigger_typing @checks.has_permissions(PermissionLevel.OWNER) @@ -246,10 +270,17 @@ async def snippet(self, ctx, *, name: str.lower = None): if snippet_name is None: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") else: - val = self.bot.snippets[snippet_name] + snippet_data = self.bot.snippets[snippet_name] + snippet_text = self._get_snippet_text(snippet_data) + has_attachment = self._has_snippet_attachment(snippet_data) + + description = snippet_text if snippet_text else "(No text content)" + if has_attachment: + description += "\n\nšŸ“Ž *This snippet has an attachment.*" + embed = discord.Embed( title=f'Snippet - "{snippet_name}":', - description=val, + description=description, color=self.bot.main_color, ) return await ctx.send(embed=embed) @@ -270,10 +301,15 @@ async def snippet(self, ctx, *, name: str.lower = None): for embed in embeds: embed.set_author(name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) - for i, snippet in enumerate(sorted(self.bot.snippets.items())): - embeds[i // 10].add_field( - name=snippet[0], value=return_or_truncate(snippet[1], 350), inline=False - ) + for i, (snippet_name, snippet_data) in enumerate(sorted(self.bot.snippets.items())): + snippet_text = self._get_snippet_text(snippet_data) + has_attachment = self._has_snippet_attachment(snippet_data) + + display_value = return_or_truncate(snippet_text, 350) if snippet_text else "(No text)" + if has_attachment: + display_value = "šŸ“Ž " + display_value + + embeds[i // 10].add_field(name=snippet_name, value=display_value, inline=False) session = EmbedPaginatorSession(ctx, *embeds) await session.run() @@ -288,10 +324,18 @@ async def snippet_raw(self, ctx, *, name: str.lower): if snippet_name is None: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") else: - val = truncate(escape_code_block(self.bot.snippets[snippet_name]), 2048 - 7) + snippet_data = self.bot.snippets[snippet_name] + snippet_text = self._get_snippet_text(snippet_data) + has_attachment = self._has_snippet_attachment(snippet_data) + + val = truncate(escape_code_block(snippet_text), 2048 - 7) if snippet_text else "(No text content)" + description = f"```\n{val}```" + if has_attachment: + description += "\n\nšŸ“Ž *This snippet has an attachment.*" + embed = discord.Embed( title=f'Raw snippet - "{snippet_name}":', - description=f"```\n{val}```", + description=description, color=self.bot.main_color, ) @@ -299,9 +343,9 @@ async def snippet_raw(self, ctx, *, name: str.lower): @snippet.command(name="add", aliases=["create", "make"]) @checks.has_permissions(PermissionLevel.SUPPORTER) - async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_content): + async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_content = None): """ - Add a snippet. + Add a snippet with an optional attachment. Simply to add a snippet, do: ``` {prefix}snippet add hey hello there :) @@ -311,6 +355,8 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte To add a multi-word snippet name, use quotes: ``` {prefix}snippet add "two word" this is a two word snippet. ``` + + You can also attach a file (max 10 MB) to include with the snippet. """ if self.bot.get_command(name): embed = discord.Embed( @@ -343,14 +389,122 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte ) return await ctx.send(embed=embed) - self.bot.snippets[name] = value + # Handle optional attachment + file_id = None + attachment_info = None + if ctx.message.attachments: + attachment = ctx.message.attachments[0] + + # Validate file size + max_size_mb = self.bot.config.get("snippet_attachment_max_size") + max_size_bytes = max_size_mb * 1024 * 1024 + if attachment.size > max_size_bytes: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Attachment exceeds the maximum file size of {max_size_mb} MB. " + f"Your file is {attachment.size / (1024 * 1024):.2f} MB.", + ) + return await ctx.send(embed=embed) + + # Confirmation for attachments 2MB or higher + confirm_msg = None + if attachment.size >= 2 * 1024 * 1024: + view = discord.ui.View(timeout=30) + confirmed = None + + async def confirm_callback(interaction: discord.Interaction): + nonlocal confirmed + if interaction.user.id != ctx.author.id: + return await interaction.response.send_message( + "Only the command author can confirm.", ephemeral=True + ) + confirmed = True + await interaction.response.defer() + view.stop() + + async def cancel_callback(interaction: discord.Interaction): + nonlocal confirmed + if interaction.user.id != ctx.author.id: + return await interaction.response.send_message( + "Only the command author can cancel.", ephemeral=True + ) + confirmed = False + await interaction.response.edit_message( + content="āŒ Cancelled. Snippet not created.", view=None, embed=None + ) + view.stop() + + confirm_button = discord.ui.Button(label="āœ“ Confirm", style=discord.ButtonStyle.green) + cancel_button = discord.ui.Button(label="āœ— Cancel", style=discord.ButtonStyle.red) + confirm_button.callback = confirm_callback + cancel_button.callback = cancel_callback + view.add_item(confirm_button) + view.add_item(cancel_button) + + embed = discord.Embed( + title="Confirm Large Attachment", + description=f"The attachment is {attachment.size / (1024 * 1024):.2f} MB (≄2 MB).\n" + f"Do you want to create the snippet `{name}` with this attachment?", + color=self.bot.main_color, + ) + confirm_msg = await ctx.send(embed=embed, view=view) + await view.wait() + + if confirmed is None or not confirmed: + if confirmed is None: + await confirm_msg.edit( + content="ā±ļø Timed out. Snippet not created.", view=None, embed=None + ) + return + + # Download and upload to GridFS + try: + file_data = await attachment.read() + file_id = await self.bot.api.upload_snippet_attachment( + file_data, + attachment.filename, + attachment.content_type or "application/octet-stream", + ) + attachment_info = attachment.filename + except Exception as e: + logger.error("Failed to upload snippet attachment: %s", e) + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Failed to upload attachment. Please try again.", + ) + return await ctx.send(embed=embed) + + # Require at least text or attachment + if not value and not file_id: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="You must provide either text content or an attachment for the snippet.", + ) + return await ctx.send(embed=embed) + + # Store snippet as dict with text and optional file_id + snippet_data = {"text": value or ""} + if file_id: + snippet_data["file_id"] = file_id + + self.bot.snippets[name] = snippet_data await self.bot.config.update() + description = "Successfully created snippet." + if attachment_info: + description += f"\nšŸ“Ž Attachment: `{attachment_info}`" + embed = discord.Embed( title="Added snippet", color=self.bot.main_color, - description="Successfully created snippet.", + description=description, ) + + if confirm_msg: + return await confirm_msg.edit(content=None, embed=embed, view=None) return await ctx.send(embed=embed) def _fix_aliases(self, snippet_being_deleted: str) -> Tuple[List[str]]: @@ -408,6 +562,14 @@ def _fix_aliases(self, snippet_being_deleted: str) -> Tuple[List[str]]: async def snippet_remove(self, ctx, *, name: str.lower): """Remove a snippet.""" if name in self.bot.snippets: + # Delete GridFS attachment if present + snippet_data = self.bot.snippets[name] + if isinstance(snippet_data, dict) and snippet_data.get("file_id"): + try: + await self.bot.api.delete_snippet_attachment(snippet_data["file_id"]) + except Exception as e: + logger.warning("Failed to delete snippet attachment for %s: %s", name, e) + deleted_aliases, edited_aliases = self._fix_aliases(name) deleted_aliases_string = ",".join(f"`{alias}`" for alias in deleted_aliases) @@ -459,22 +621,103 @@ async def snippet_remove(self, ctx, *, name: str.lower): @snippet.command(name="edit") @checks.has_permissions(PermissionLevel.SUPPORTER) - async def snippet_edit(self, ctx, name: str.lower, *, value): + async def snippet_edit(self, ctx, name: str.lower, *, value: commands.clean_content = None): """ - Edit a snippet. + Edit a snippet's text and/or attachment. To edit a multi-word snippet name, use quotes: ``` {prefix}snippet edit "two word" this is a new two word snippet. ``` + + Attach a new file to replace the existing attachment. + Provide text without attachment to keep the existing attachment. """ if name in self.bot.snippets: - self.bot.snippets[name] = value + snippet_data = self.bot.snippets[name] + + # Handle old string format + if isinstance(snippet_data, str): + old_text = snippet_data + old_file_id = None + else: + old_text = snippet_data.get("text", "") + old_file_id = snippet_data.get("file_id") + + # Handle new attachment if provided + new_file_id = old_file_id + attachment_info = None + if ctx.message.attachments: + attachment = ctx.message.attachments[0] + + # Validate file size + max_size_mb = self.bot.config.get("snippet_attachment_max_size") + max_size_bytes = max_size_mb * 1024 * 1024 + if attachment.size > max_size_bytes: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Attachment exceeds the maximum file size of {max_size_mb} MB. " + f"Your file is {attachment.size / (1024 * 1024):.2f} MB.", + ) + return await ctx.send(embed=embed) + + # Delete old attachment if present + if old_file_id: + try: + await self.bot.api.delete_snippet_attachment(old_file_id) + except Exception as e: + logger.warning("Failed to delete old attachment for %s: %s", name, e) + + # Upload new attachment + try: + file_data = await attachment.read() + new_file_id = await self.bot.api.upload_snippet_attachment( + file_data, + attachment.filename, + attachment.content_type or "application/octet-stream", + ) + attachment_info = attachment.filename + except Exception as e: + logger.error("Failed to upload snippet attachment: %s", e) + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Failed to upload attachment. Please try again.", + ) + return await ctx.send(embed=embed) + + # Use new text if provided, otherwise keep old text + new_text = value if value is not None else old_text + + # Require at least text or attachment + if not new_text and not new_file_id: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Snippet must have either text content or an attachment.", + ) + return await ctx.send(embed=embed) + + # Update snippet + updated_snippet = {"text": new_text or ""} + if new_file_id: + updated_snippet["file_id"] = new_file_id + + self.bot.snippets[name] = updated_snippet await self.bot.config.update() + description = f"`{name}` has been updated." + if value: + description += f'\nText: "{truncate(value, 100)}"' + if attachment_info: + description += f"\nšŸ“Ž New attachment: `{attachment_info}`" + elif new_file_id: + description += f"\nšŸ“Ž Attachment kept." + embed = discord.Embed( title="Edited snippet", color=self.bot.main_color, - description=f'`{name}` will now send "{value}".', + description=description, ) else: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") @@ -1541,6 +1784,13 @@ async def freply(self, ctx, *, msg: str = ""): async with safe_typing(ctx): await ctx.thread.reply(ctx.message, msg) + # Delete the snippet command message if it was invoked via snippet + if getattr(ctx, "snippet_invoked", False): + try: + await ctx.message.delete() + except Exception as e: + logger.warning("Failed to delete snippet command message: %s", e) + @commands.command(aliases=["formatanonreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() diff --git a/core/clients.py b/core/clients.py index 90f09b3b48..a30b237cb5 100644 --- a/core/clients.py +++ b/core/clients.py @@ -1,14 +1,15 @@ import secrets import sys from json import JSONDecodeError -from typing import Any, Dict, Union, Optional +from typing import Any, Dict, Union, Optional, Tuple import discord from discord import Member, DMChannel, TextChannel, Message from discord.ext import commands from aiohttp import ClientResponseError, ClientResponse -from motor.motor_asyncio import AsyncIOMotorClient +from bson import ObjectId +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorGridFSBucket from pymongo.errors import ConfigurationError from core.models import InvalidConfigError, getLogger @@ -460,6 +461,7 @@ def __init__(self, bot): sys.exit(0) super().__init__(bot, db) + self.fs = AsyncIOMotorGridFSBucket(db, bucket_name="snippet_attachments") async def setup_indexes(self): """Setup text indexes so we can use the $search operator""" @@ -779,6 +781,86 @@ async def get_user_info(self) -> Optional[dict]: } } + # ==================== GridFS Methods for Snippet Attachments ==================== + + async def upload_snippet_attachment( + self, file_data: bytes, filename: str, content_type: str = "application/octet-stream" + ) -> str: + """ + Upload a file to GridFS for snippet attachments. + + Parameters + ---------- + file_data : bytes + The raw file data to upload. + filename : str + The original filename. + content_type : str + The MIME type of the file. + + Returns + ------- + str + The string representation of the GridFS file ID. + """ + file_id = await self.fs.upload_from_stream( + filename, + file_data, + metadata={"content_type": content_type, "filename": filename}, + ) + logger.debug("Uploaded snippet attachment %s with file_id %s.", filename, file_id) + return str(file_id) + + async def download_snippet_attachment(self, file_id: str) -> Tuple[bytes, Dict[str, Any]]: + """ + Download a file from GridFS. + + Parameters + ---------- + file_id : str + The string representation of the GridFS file ID. + + Returns + ------- + Tuple[bytes, Dict[str, Any]] + A tuple of (file_data, metadata) where metadata includes filename and content_type. + """ + grid_out = await self.fs.open_download_stream(ObjectId(file_id)) + file_data = await grid_out.read() + metadata = { + "filename": grid_out.filename, + "content_type": ( + grid_out.metadata.get("content_type", "application/octet-stream") + if grid_out.metadata + else "application/octet-stream" + ), + "length": grid_out.length, + } + logger.debug("Downloaded snippet attachment with file_id %s.", file_id) + return file_data, metadata + + async def delete_snippet_attachment(self, file_id: str) -> bool: + """ + Delete a file from GridFS. + + Parameters + ---------- + file_id : str + The string representation of the GridFS file ID. + + Returns + ------- + bool + True if deletion was successful. + """ + try: + await self.fs.delete(ObjectId(file_id)) + logger.debug("Deleted snippet attachment with file_id %s.", file_id) + return True + except Exception as e: + logger.warning("Failed to delete snippet attachment %s: %s", file_id, e) + return False + class PluginDatabaseClient: def __init__(self, bot): diff --git a/core/config.py b/core/config.py index 0e45b00175..df0f5e4503 100644 --- a/core/config.py +++ b/core/config.py @@ -164,6 +164,8 @@ class ConfigManager: "thread_creation_menu_embed_large_image": False, "thread_creation_menu_embed_footer_icon_url": None, "thread_creation_menu_embed_color": str(discord.Color.green()), + # snippet attachments + "snippet_attachment_max_size": 10, # in MB } private_keys = { @@ -242,6 +244,8 @@ class ConfigManager: duration_seconds = {"snooze_default_duration"} + megabytes = {"snippet_attachment_max_size"} + booleans = { "use_user_id_channel_name", "use_timestamp_channel_name", @@ -421,6 +425,14 @@ def get(self, key: str, *, convert: bool = True) -> typing.Any: logger.warning("Invalid %s %s.", key, value) value = self.remove(key) + elif key in self.megabytes: + if not isinstance(value, int): + try: + value = int(value) + except (ValueError, TypeError): + logger.warning("Invalid %s %s.", key, value) + value = self.remove(key) + elif key in self.force_str: # Temporary: as we saved in int previously, leading to int32 overflow, # this is transitioning IDs to strings diff --git a/core/config_help.json b/core/config_help.json index fedf9279ed..8d6c825c38 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -856,6 +856,18 @@ "See also: `anonymous_snippets`." ] }, + "snippet_attachment_max_size": { + "default": "10 (MB)", + "description": "Maximum file size in megabytes (MB) for attachments when creating or editing snippets.", + "examples": [ + "`{prefix}config set snippet_attachment_max_size 5` (5 MB)", + "`{prefix}config set snippet_attachment_max_size 20` (20 MB)" + ], + "notes": [ + "Attachments larger than this size will be rejected when adding or editing snippets.", + "Value is specified in megabytes (MB)." + ] + }, "require_close_reason": { "default" : "No", "description": "Require a reason to close threads.", diff --git a/core/thread.py b/core/thread.py index 09263b197d..3601ab065f 100644 --- a/core/thread.py +++ b/core/thread.py @@ -224,16 +224,12 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): "author_name": ( getattr(m.embeds[0].author, "name", "").split(" (")[0] if m.embeds and m.embeds[0].author and m.author == self.bot.user - else getattr(m.author, "name", None) - if m.author != self.bot.user - else None + else getattr(m.author, "name", None) if m.author != self.bot.user else None ), "author_avatar": ( getattr(m.embeds[0].author, "icon_url", None) if m.embeds and m.embeds[0].author and m.author == self.bot.user - else m.author.display_avatar.url - if m.author != self.bot.user - else None + else m.author.display_avatar.url if m.author != self.bot.user else None ), } async for m in channel.history(limit=None, oldest_first=True) @@ -1950,11 +1946,16 @@ async def send( images = [] attachments = [] - for attachment in ext: + files_to_upload = [] + for i, a in enumerate(message.attachments): + attachment = ext[i] if is_image_url(attachment[0]): images.append(attachment) else: - attachments.append(attachment) + if hasattr(a, "to_file") and callable(a.to_file): + files_to_upload.append(a) + else: + attachments.append(attachment) image_urls = re.findall( r"http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", @@ -2169,6 +2170,13 @@ def lottie_to_png(data): else: mentions = None + discord_files = [] + for att in files_to_upload: + try: + discord_files.append(await att.to_file()) + except Exception: + logger.warning("Failed to convert AttachmentWrapper to file.", exc_info=True) + if plain: if from_mod and not isinstance(destination, discord.TextChannel): # Plain to user (DM) @@ -2180,12 +2188,13 @@ def lottie_to_png(data): body = embed.description or "" plain_message = f"{prefix}{embed.author.name}:** {body}" - files = [] + files = discord_files[:] for att in message.attachments: - try: - files.append(await att.to_file()) - except Exception: - logger.warning("Failed to attach file in plain DM.", exc_info=True) + if not (hasattr(att, "to_file") and callable(att.to_file)): + try: + files.append(await att.to_file()) + except Exception: + logger.warning("Failed to attach file in plain DM.", exc_info=True) msg = await destination.send(plain_message, files=files or None) else: @@ -2193,10 +2202,14 @@ def lottie_to_png(data): footer_text = embed.footer.text if embed.footer else "" embed.set_footer(text=f"[PLAIN] {footer_text}".strip()) msg = await destination.send(mentions, embed=embed) + if discord_files: + await destination.send(files=discord_files) else: try: msg = await destination.send(mentions, embed=embed) + if discord_files: + await destination.send(files=discord_files) except discord.NotFound: if ( isinstance(destination, discord.TextChannel) @@ -2207,6 +2220,8 @@ def lottie_to_png(data): await self.restore_from_snooze() destination = self.channel or destination msg = await destination.send(mentions, embed=embed) + if discord_files: + await destination.send(files=discord_files) else: logger.warning("Channel not found during send.") raise From 123cc62b7abaafbfa7c9ae13ad387ec5d96e98eb Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 18 Dec 2025 18:04:33 +0100 Subject: [PATCH 02/10] black formatting --- core/thread.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/thread.py b/core/thread.py index 3601ab065f..d4bb3529e9 100644 --- a/core/thread.py +++ b/core/thread.py @@ -224,12 +224,16 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): "author_name": ( getattr(m.embeds[0].author, "name", "").split(" (")[0] if m.embeds and m.embeds[0].author and m.author == self.bot.user - else getattr(m.author, "name", None) if m.author != self.bot.user else None + else getattr(m.author, "name", None) + if m.author != self.bot.user + else None ), "author_avatar": ( getattr(m.embeds[0].author, "icon_url", None) if m.embeds and m.embeds[0].author and m.author == self.bot.user - else m.author.display_avatar.url if m.author != self.bot.user else None + else m.author.display_avatar.url + if m.author != self.bot.user + else None ), } async for m in channel.history(limit=None, oldest_first=True) From d7e6fc997aa606e6ac55f7fcd291388c2947ded1 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Tue, 23 Dec 2025 16:33:08 +0100 Subject: [PATCH 03/10] change displaying method. --- bot.py | 27 ++++++++++++++++++--------- core/thread.py | 31 +++++++++++++++++++++---------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/bot.py b/bot.py index 2037bf7074..766aa22d8a 100644 --- a/bot.py +++ b/bot.py @@ -4,6 +4,7 @@ import asyncio import copy import hashlib +import io import os import re import string @@ -1345,27 +1346,32 @@ async def get_contexts(self, message, *, cls=commands.Context): # Download attachment if present if snippet_data.get("file_id"): try: - import io - file_data, metadata = await self.api.download_snippet_attachment( snippet_data["file_id"] ) + # Check if the attachment is an image based on content type + content_type = metadata.get("content_type", "") + is_image = content_type.startswith("image/") + class AttachmentWrapper: - def __init__(self, file_data, metadata): + def __init__(self, file_data, metadata, is_image): self.file_data = file_data self.id = 0 + # Use attachment:// syntax for referencing in embed self.url = f"attachment://{metadata['filename']}" self.filename = metadata["filename"] self.size = metadata["length"] self.width = None + # Flag to identify snippet images for special handling in thread.py + self.is_snippet_image = is_image async def to_file(self): return discord.File( io.BytesIO(self.file_data), filename=self.filename ) - message.attachments = [AttachmentWrapper(file_data, metadata)] + message.attachments = [AttachmentWrapper(file_data, metadata, is_image)] except Exception as e: logger.warning("Failed to download snippet attachment: %s", e) else: @@ -1394,24 +1400,27 @@ async def to_file(self): # Download attachment if present if isinstance(snippet_data, dict) and snippet_data.get("file_id"): try: - import io - file_data, metadata = await self.api.download_snippet_attachment(snippet_data["file_id"]) - # Create a list-like object that mimics message.attachments + # Check if the attachment is an image based on content type + content_type = metadata.get("content_type", "") + is_image = content_type.startswith("image/") + class AttachmentWrapper: - def __init__(self, file_data, metadata): + def __init__(self, file_data, metadata, is_image): self.file_data = file_data self.id = 0 + # Use attachment:// syntax self.url = f"attachment://{metadata['filename']}" self.filename = metadata["filename"] self.size = metadata["length"] self.width = None + self.is_snippet_image = is_image async def to_file(self): return discord.File(io.BytesIO(self.file_data), filename=self.filename) - message.attachments = [AttachmentWrapper(file_data, metadata)] + message.attachments = [AttachmentWrapper(file_data, metadata, is_image)] except Exception as e: logger.warning("Failed to download snippet attachment: %s", e) ctx.command = self._get_snippet_command() diff --git a/core/thread.py b/core/thread.py index 0df9e96a61..2ab8f6941f 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1958,9 +1958,16 @@ async def send( images = [] attachments = [] files_to_upload = [] + + # List to track snippet images that should be uploaded but not listed as file attachments + snippet_images_to_upload = [] + for i, a in enumerate(message.attachments): attachment = ext[i] - if is_image_url(attachment[0]): + if getattr(a, "is_snippet_image", False): + # If it's a snippet image, we want to embed it using attachment:// syntax + snippet_images_to_upload.append(a) + elif is_image_url(attachment[0]): images.append(attachment) else: if hasattr(a, "to_file") and callable(a.to_file): @@ -2037,6 +2044,16 @@ def lottie_to_png(data): embedded_image = False + + # Handle snippet images first (embedded directly) + for a in snippet_images_to_upload: + if not embedded_image: + embed.set_image(url=f"attachment://{a.filename}") + embed.add_field(name="Image", value=a.filename) + embedded_image = True + # Always add to files_to_upload so the attachment is physically present + files_to_upload.append(a) + prioritize_uploads = any(i[1] is not None for i in images) additional_images = [] @@ -2212,15 +2229,11 @@ def lottie_to_png(data): # Plain to mods footer_text = embed.footer.text if embed.footer else "" embed.set_footer(text=f"[PLAIN] {footer_text}".strip()) - msg = await destination.send(mentions, embed=embed) - if discord_files: - await destination.send(files=discord_files) + msg = await destination.send(mentions, embed=embed, files=discord_files or None) else: try: - msg = await destination.send(mentions, embed=embed) - if discord_files: - await destination.send(files=discord_files) + msg = await destination.send(mentions, embed=embed, files=discord_files or None) except discord.NotFound: if ( isinstance(destination, discord.TextChannel) @@ -2230,9 +2243,7 @@ def lottie_to_png(data): logger.info("Thread channel missing while sending; attempting restore and resend.") await self.restore_from_snooze() destination = self.channel or destination - msg = await destination.send(mentions, embed=embed) - if discord_files: - await destination.send(files=discord_files) + msg = await destination.send(mentions, embed=embed, files=discord_files or None) else: logger.warning("Channel not found during send.") raise From 7b7d5bf36dd288684080d64db32625a4fae37757 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Tue, 23 Dec 2025 16:35:12 +0100 Subject: [PATCH 04/10] black formatting --- core/thread.py | 1 - 1 file changed, 1 deletion(-) diff --git a/core/thread.py b/core/thread.py index 2ab8f6941f..747554db90 100644 --- a/core/thread.py +++ b/core/thread.py @@ -2044,7 +2044,6 @@ def lottie_to_png(data): embedded_image = False - # Handle snippet images first (embedded directly) for a in snippet_images_to_upload: if not embedded_image: From ae69b04793b090f62a204f3d85c6f04176ea1deb Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 2 Aug 2026 13:36:26 +0200 Subject: [PATCH 05/10] Fix snippet attachment handling --- bot.py | 104 +++++++++++++++++++----------------------------- cogs/modmail.py | 43 ++++++++++---------- core/clients.py | 6 ++- core/thread.py | 37 ++++++++++------- 4 files changed, 90 insertions(+), 100 deletions(-) diff --git a/bot.py b/bot.py index c7ed0951b8..59a94b3439 100644 --- a/bot.py +++ b/bot.py @@ -60,6 +60,24 @@ logger = getLogger(__name__) + +class SnippetAttachment: + """In-memory attachment loaded from the snippet GridFS bucket.""" + + def __init__(self, file_data, metadata, is_image): + self.file_data = file_data + self.id = 0 + self.url = f"attachment://{metadata['filename']}" + self.filename = metadata["filename"] + self.size = metadata["length"] + self.width = None + self.is_snippet_attachment = True + self.is_snippet_image = is_image + + async def to_file(self): + return discord.File(io.BytesIO(self.file_data), filename=self.filename) + + temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp") if not os.path.exists(temp_dir): os.mkdir(temp_dir) @@ -1292,6 +1310,20 @@ def _get_snippet_command(self) -> commands.Command: return self.get_command(f"{modifiers}reply") + async def _download_snippet_attachment(self, snippet_data): + """Download and wrap a snippet attachment, returning None on failure.""" + if not isinstance(snippet_data, dict) or not snippet_data.get("file_id"): + return None + + try: + file_data, metadata = await self.api.download_snippet_attachment(snippet_data["file_id"]) + except Exception as e: + logger.warning("Failed to download snippet attachment: %s", e) + return None + + content_type = metadata.get("content_type", "") + return SnippetAttachment(file_data, metadata, content_type.startswith("image/")) + async def get_contexts(self, message, *, cls=commands.Context): """ Returns all invocation contexts from the message. @@ -1316,7 +1348,6 @@ async def get_contexts(self, message, *, cls=commands.Context): # Check if a snippet is being called. # This needs to be done before checking for aliases since # snippets can have multiple words. - snippet_invoked = False try: # Use removeprefix once PY3.9+ snippet_data = self.snippets[message.content[len(invoked_prefix) :]] @@ -1327,7 +1358,6 @@ async def get_contexts(self, message, *, cls=commands.Context): snippet_text = snippet_data.get("text", "") else: snippet_text = None - snippet_invoked = True except KeyError: snippet_text = None @@ -1342,6 +1372,7 @@ async def get_contexts(self, message, *, cls=commands.Context): for alias in aliases: command = None + context_message = copy.copy(message) try: snippet_data = self.snippets[alias] # Extract text from snippet (handle both old string format and new dict format) @@ -1349,37 +1380,9 @@ async def get_contexts(self, message, *, cls=commands.Context): snippet_text = snippet_data elif isinstance(snippet_data, dict): snippet_text = snippet_data.get("text", "") - # Download attachment if present - if snippet_data.get("file_id"): - try: - file_data, metadata = await self.api.download_snippet_attachment( - snippet_data["file_id"] - ) - - # Check if the attachment is an image based on content type - content_type = metadata.get("content_type", "") - is_image = content_type.startswith("image/") - - class AttachmentWrapper: - def __init__(self, file_data, metadata, is_image): - self.file_data = file_data - self.id = 0 - # Use attachment:// syntax for referencing in embed - self.url = f"attachment://{metadata['filename']}" - self.filename = metadata["filename"] - self.size = metadata["length"] - self.width = None - # Flag to identify snippet images for special handling in thread.py - self.is_snippet_image = is_image - - async def to_file(self): - return discord.File( - io.BytesIO(self.file_data), filename=self.filename - ) - - message.attachments = [AttachmentWrapper(file_data, metadata, is_image)] - except Exception as e: - logger.warning("Failed to download snippet attachment: %s", e) + attachment = await self._download_snippet_attachment(snippet_data) + if attachment is not None: + context_message.attachments = [attachment] else: snippet_text = None except KeyError: @@ -1389,7 +1392,7 @@ async def to_file(self): command = self._get_snippet_command() command_invocation_text = f"{invoked_prefix}{command} {snippet_text}" view = StringView(invoked_prefix + command_invocation_text) - ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) + ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=context_message) ctx_.thread = thread discord.utils.find(view.skip_string, prefixes) ctx_.invoked_with = view.get_word().lower() @@ -1403,39 +1406,16 @@ async def to_file(self): # Process snippets snippet_name = message.content[len(invoked_prefix) :] snippet_data = self.snippets.get(snippet_name) - # Download attachment if present - if isinstance(snippet_data, dict) and snippet_data.get("file_id"): - try: - file_data, metadata = await self.api.download_snippet_attachment(snippet_data["file_id"]) - - # Check if the attachment is an image based on content type - content_type = metadata.get("content_type", "") - is_image = content_type.startswith("image/") - - class AttachmentWrapper: - def __init__(self, file_data, metadata, is_image): - self.file_data = file_data - self.id = 0 - # Use attachment:// syntax - self.url = f"attachment://{metadata['filename']}" - self.filename = metadata["filename"] - self.size = metadata["length"] - self.width = None - self.is_snippet_image = is_image - - async def to_file(self): - return discord.File(io.BytesIO(self.file_data), filename=self.filename) - - message.attachments = [AttachmentWrapper(file_data, metadata, is_image)] - except Exception as e: - logger.warning("Failed to download snippet attachment: %s", e) + attachment = await self._download_snippet_attachment(snippet_data) + if attachment is not None: + snippet_message = copy.copy(message) + snippet_message.attachments = [attachment] + ctx.message = snippet_message ctx.command = self._get_snippet_command() reply_view = StringView(f"{invoked_prefix}{ctx.command} {snippet_text}") discord.utils.find(reply_view.skip_string, prefixes) ctx.invoked_with = reply_view.get_word().lower() ctx.view = reply_view - # Mark that a snippet was invoked so we can delete the command message - ctx.snippet_invoked = snippet_invoked else: ctx.command = self.all_commands.get(invoker) ctx.invoked_with = invoker diff --git a/cogs/modmail.py b/cogs/modmail.py index d68d1c3001..f56f386985 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -495,7 +495,13 @@ async def cancel_callback(interaction: discord.Interaction): snippet_data["file_id"] = file_id self.bot.snippets[name] = snippet_data - await self.bot.config.update() + try: + await self.bot.config.update() + except Exception: + self.bot.snippets.pop(name, None) + if file_id: + await self.bot.api.delete_snippet_attachment(file_id) + raise description = "Successfully created snippet." if attachment_info: @@ -566,13 +572,8 @@ def _fix_aliases(self, snippet_being_deleted: str) -> Tuple[List[str]]: async def snippet_remove(self, ctx, *, name: str.lower): """Remove a snippet.""" if name in self.bot.snippets: - # Delete GridFS attachment if present snippet_data = self.bot.snippets[name] - if isinstance(snippet_data, dict) and snippet_data.get("file_id"): - try: - await self.bot.api.delete_snippet_attachment(snippet_data["file_id"]) - except Exception as e: - logger.warning("Failed to delete snippet attachment for %s: %s", name, e) + file_id = snippet_data.get("file_id") if isinstance(snippet_data, dict) else None deleted_aliases, edited_aliases = self._fix_aliases(name) @@ -619,6 +620,8 @@ async def snippet_remove(self, ctx, *, name: str.lower): ) self.bot.snippets.pop(name) await self.bot.config.update() + if file_id and not await self.bot.api.delete_snippet_attachment(file_id): + logger.warning("Failed to delete snippet attachment for %s.", name) else: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") await ctx.send(embed=embed) @@ -665,13 +668,6 @@ async def snippet_edit(self, ctx, name: str.lower, *, value: commands.clean_cont ) return await ctx.send(embed=embed) - # Delete old attachment if present - if old_file_id: - try: - await self.bot.api.delete_snippet_attachment(old_file_id) - except Exception as e: - logger.warning("Failed to delete old attachment for %s: %s", name, e) - # Upload new attachment try: file_data = await attachment.read() @@ -708,7 +704,17 @@ async def snippet_edit(self, ctx, name: str.lower, *, value: commands.clean_cont updated_snippet["file_id"] = new_file_id self.bot.snippets[name] = updated_snippet - await self.bot.config.update() + try: + await self.bot.config.update() + except Exception: + self.bot.snippets[name] = snippet_data + if attachment_info and new_file_id != old_file_id: + await self.bot.api.delete_snippet_attachment(new_file_id) + raise + + if attachment_info and old_file_id: + if not await self.bot.api.delete_snippet_attachment(old_file_id): + logger.warning("Failed to delete old attachment for %s.", name) description = f"`{name}` has been updated." if value: @@ -2021,13 +2027,6 @@ async def freply(self, ctx, *, msg: str = ""): async with safe_typing(ctx): await ctx.thread.reply(ctx.message, msg) - # Delete the snippet command message if it was invoked via snippet - if getattr(ctx, "snippet_invoked", False): - try: - await ctx.message.delete() - except Exception as e: - logger.warning("Failed to delete snippet command message: %s", e) - @commands.command(aliases=["formatanonreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() diff --git a/core/clients.py b/core/clients.py index d86680a9cf..b14adbbc19 100644 --- a/core/clients.py +++ b/core/clients.py @@ -400,6 +400,7 @@ async def append_log( message_id: str = "", channel_id: str = "", type_: str = "thread_message", + attachments=None, ) -> dict: return NotImplemented @@ -663,11 +664,13 @@ async def append_log( message_id: str = "", channel_id: str = "", type_: str = "thread_message", + attachments=None, ) -> dict: channel_id = str(channel_id) or (str(message.channel.id) if message else "") message_id = str(message_id) or (str(message.id) if message else "") if message: + log_attachments = message.attachments if attachments is None else attachments content = message.content or "" if forwarded := extract_forwarded_content(message): if content: @@ -697,7 +700,7 @@ async def append_log( "size": a.size, "url": a.url, } - for a in message.attachments + for a in log_attachments ], } else: @@ -717,7 +720,6 @@ async def append_log( "type": type_, "attachments": [], } - return await self.logs.find_one_and_update( {"channel_id": channel_id}, {"$push": {"messages": data}}, diff --git a/core/thread.py b/core/thread.py index 92ecb3ff73..197701d8f6 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1750,12 +1750,18 @@ async def reply( msg = None if msg is not None: + log_attachments = None + if any( + getattr(attachment, "is_snippet_attachment", False) for attachment in message.attachments + ): + log_attachments = msg.attachments tasks.append( self.bot.api.append_log( message, message_id=msg.id, channel_id=self.channel.id, type_="anonymous" if anonymous else "thread_message", + attachments=log_attachments, ) ) else: @@ -2011,16 +2017,16 @@ async def send( for i, a in enumerate(message.attachments): attachment = ext[i] - if getattr(a, "is_snippet_image", False): - # If it's a snippet image, we want to embed it using attachment:// syntax - snippet_images_to_upload.append(a) + if getattr(a, "is_snippet_attachment", False): + if getattr(a, "is_snippet_image", False): + # Embed snippet images using attachment:// syntax. + snippet_images_to_upload.append(a) + else: + files_to_upload.append(a) elif is_image_url(attachment[0]): images.append(attachment) else: - if hasattr(a, "to_file") and callable(a.to_file): - files_to_upload.append(a) - else: - attachments.append(attachment) + attachments.append(attachment) image_urls = re.findall( r"http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", @@ -2192,7 +2198,9 @@ def lottie_to_png(data): embed.colour = self.bot.recipient_color if (from_mod or note) and not thread_creation: - delete_message = not bool(message.attachments) + delete_message = not any( + not getattr(attachment, "is_snippet_attachment", False) for attachment in message.attachments + ) # Only delete the source command message when it's in a guild text # channel; attempting to delete a DM message can raise 50003. if ( @@ -2249,7 +2257,7 @@ def lottie_to_png(data): try: discord_files.append(await att.to_file()) except Exception: - logger.warning("Failed to convert AttachmentWrapper to file.", exc_info=True) + logger.warning("Failed to convert snippet attachment to file.", exc_info=True) if plain: if from_mod and not isinstance(destination, discord.TextChannel): @@ -2264,11 +2272,12 @@ def lottie_to_png(data): files = discord_files[:] for att in message.attachments: - if not (hasattr(att, "to_file") and callable(att.to_file)): - try: - files.append(await att.to_file()) - except Exception: - logger.warning("Failed to attach file in plain DM.", exc_info=True) + if getattr(att, "is_snippet_attachment", False): + continue + try: + files.append(await att.to_file()) + except Exception: + logger.warning("Failed to attach file in plain DM.", exc_info=True) msg = await destination.send(plain_message, files=files or None) else: From a4e27c5d2bc2a5cd3d765cd6dde8a7e07ec61907 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 2 Aug 2026 13:43:05 +0200 Subject: [PATCH 06/10] Preserve forwarded attachments with snippets --- core/thread.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/thread.py b/core/thread.py index 197701d8f6..6528aa9e70 100644 --- a/core/thread.py +++ b/core/thread.py @@ -2028,6 +2028,14 @@ async def send( else: attachments.append(attachment) + # Forwarded attachments are represented only in ``ext`` rather than + # ``message.attachments``, so classify the remaining entries separately. + for attachment in ext[len(message.attachments) :]: + if is_image_url(attachment[0]): + images.append(attachment) + else: + attachments.append(attachment) + image_urls = re.findall( r"http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", message.content, From 53ff1048bf4c98b326370ea5884c9ca328b9efb6 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 2 Aug 2026 15:07:41 +0200 Subject: [PATCH 07/10] fix: help command --- cogs/utility.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/utility.py b/cogs/utility.py index 283823d39c..4bbe66b79c 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -109,7 +109,7 @@ async def format_cog_help(self, cog, *, no_cog=False): return embeds def process_help_msg(self, help_: str): - return help_.format(prefix=self.context.clean_prefix) if help_ else "No help message." + return help_.replace("{prefix}", self.context.clean_prefix) if help_ else "No help message." async def send_bot_help(self, mapping): embeds = [] From 9b05a4a3cf11f93b2efaef4783c94bc784df8ca2 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Fri, 7 Aug 2026 14:54:30 +0200 Subject: [PATCH 08/10] Resolve feedback. --- bot.py | 6 +- cogs/modmail.py | 546 +++++++++++++++++++++++++++++------------- cogs/utility.py | 4 +- core/clients.py | 5 + core/config.py | 37 ++- core/config_help.json | 13 +- core/thread.py | 7 +- 7 files changed, 429 insertions(+), 189 deletions(-) diff --git a/bot.py b/bot.py index 59a94b3439..a3ea05cd58 100644 --- a/bot.py +++ b/bot.py @@ -393,7 +393,7 @@ async def wait_for_connected(self) -> None: await self.config.wait_until_ready() @property - def snippets(self) -> typing.Dict[str, str]: + def snippets(self) -> typing.Dict[str, typing.Union[str, typing.Dict[str, str]]]: return self.config["snippets"] @property @@ -1382,7 +1382,7 @@ async def get_contexts(self, message, *, cls=commands.Context): snippet_text = snippet_data.get("text", "") attachment = await self._download_snippet_attachment(snippet_data) if attachment is not None: - context_message.attachments = [attachment] + context_message.attachments = [*message.attachments, attachment] else: snippet_text = None except KeyError: @@ -1409,7 +1409,7 @@ async def get_contexts(self, message, *, cls=commands.Context): attachment = await self._download_snippet_attachment(snippet_data) if attachment is not None: snippet_message = copy.copy(message) - snippet_message.attachments = [attachment] + snippet_message.attachments = [*message.attachments, attachment] ctx.message = snippet_message ctx.command = self._get_snippet_command() reply_view = StringView(f"{invoked_prefix}{ctx.command} {snippet_text}") diff --git a/cogs/modmail.py b/cogs/modmail.py index f56f386985..5fe29c3054 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -28,6 +28,9 @@ # Arg names reserved by formatreply commands (channel, recipient, author). RESERVED_ARG_NAMES = {"channel", "recipient", "author"} +SNIPPET_ATTACHMENT_BYTES_PER_MIB = 1024**2 +SNIPPET_ATTACHMENT_CONFIRMATION_SIZE = 2 * SNIPPET_ATTACHMENT_BYTES_PER_MIB + class Modmail(commands.Cog): """Commands directly related to Modmail functionality.""" @@ -140,6 +143,120 @@ def _has_snippet_attachment(self, snippet_data) -> bool: """Check if a snippet has an attachment.""" return isinstance(snippet_data, dict) and bool(snippet_data.get("file_id")) + async def _get_snippet_attachment_file(self, snippet_data): + """Return a stored snippet attachment as a Discord file, if available.""" + attachment = await self.bot._download_snippet_attachment(snippet_data) + if attachment is None: + return None + + try: + return await attachment.to_file() + except Exception: + logger.warning("Failed to prepare snippet attachment for display.", exc_info=True) + return None + + async def _delete_snippet_attachment(self, file_id, snippet_name): + """Delete a stored attachment without allowing cleanup errors to mask command errors.""" + try: + deleted = await self.bot.api.delete_snippet_attachment(file_id) + except Exception: + logger.warning("Failed to delete snippet attachment for %s.", snippet_name, exc_info=True) + return False + + if not deleted: + logger.warning("Failed to delete snippet attachment for %s.", snippet_name) + return deleted + + async def _send_snippet_preview(self, ctx, embed, snippet_data): + """Send a snippet preview and include its stored attachment.""" + if not self._has_snippet_attachment(snippet_data): + return await ctx.send(embed=embed) + + file = await self._get_snippet_attachment_file(snippet_data) + if file is None: + embed.set_footer(text="The attachment could not be retrieved.") + return await ctx.send(embed=embed) + try: + return await ctx.send(embed=embed, file=file) + except discord.HTTPException: + logger.warning("Failed to send snippet attachment preview.", exc_info=True) + embed.set_footer(text="The attachment could not be sent in this channel.") + return await ctx.send(embed=embed) + + @staticmethod + async def _send_snippet_result(ctx, embed, confirmation_message=None): + """Send a result, replacing a large-attachment confirmation when present.""" + if confirmation_message is not None: + return await confirmation_message.edit(content=None, embed=embed, view=None) + return await ctx.send(embed=embed) + + async def _validate_snippet_attachment(self, ctx, attachment, name, action): + """Validate an attachment and ask for confirmation when it is at least 2 MiB.""" + max_size_mib = self.bot.config.get("snippet_attachment_max_size") + # Convert the configured MiB limit to bytes for comparison with Discord's byte size. + max_size_bytes = max_size_mib * SNIPPET_ATTACHMENT_BYTES_PER_MIB + if attachment.size > max_size_bytes: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Attachment exceeds the maximum file size of {max_size_mib} MiB. " + f"Your file is {attachment.size / SNIPPET_ATTACHMENT_BYTES_PER_MIB:.2f} MiB.", + ) + await ctx.send(embed=embed) + return False, None + + if attachment.size < SNIPPET_ATTACHMENT_CONFIRMATION_SIZE: + return True, None + + view = discord.ui.View(timeout=30) + confirmed = None + past_tense = {"create": "created", "update": "updated"}[action] + + async def confirm_callback(interaction: discord.Interaction): + nonlocal confirmed + if interaction.user.id != ctx.author.id: + return await interaction.response.send_message( + "Only the command author can confirm.", ephemeral=True + ) + confirmed = True + await interaction.response.edit_message(view=None) + view.stop() + + async def cancel_callback(interaction: discord.Interaction): + nonlocal confirmed + if interaction.user.id != ctx.author.id: + return await interaction.response.send_message( + "Only the command author can cancel.", ephemeral=True + ) + confirmed = False + await interaction.response.edit_message( + content=f"Cancelled. Snippet not {past_tense}.", view=None, embed=None + ) + view.stop() + + confirm_button = discord.ui.Button(label="Confirm", style=discord.ButtonStyle.green) + cancel_button = discord.ui.Button(label="Cancel", style=discord.ButtonStyle.red) + confirm_button.callback = confirm_callback + cancel_button.callback = cancel_callback + view.add_item(confirm_button) + view.add_item(cancel_button) + + embed = discord.Embed( + title="Confirm large attachment", + description=( + f"The attachment is " + f"{attachment.size / SNIPPET_ATTACHMENT_BYTES_PER_MIB:.2f} MiB " + f"(at least 2 MiB).\nDo you want to {action} the snippet `{name}` with this attachment?" + ), + color=self.bot.main_color, + ) + confirm_msg = await ctx.send(embed=embed, view=view) + await view.wait() + + if confirmed is None: + await confirm_msg.edit(content=f"Timed out. Snippet not {past_tense}.", view=None, embed=None) + return confirmed is True, confirm_msg + @commands.command() @trigger_typing @checks.has_permissions(PermissionLevel.OWNER) @@ -278,16 +395,21 @@ async def snippet(self, ctx, *, name: str.lower = None): snippet_text = self._get_snippet_text(snippet_data) has_attachment = self._has_snippet_attachment(snippet_data) - description = snippet_text if snippet_text else "(No text content)" - if has_attachment: - description += "\n\nšŸ“Ž *This snippet has an attachment.*" + if snippet_text: + description = snippet_text + elif has_attachment: + description = "This is an attachment-only snippet." + else: + description = "(No text content)" embed = discord.Embed( title=f'Snippet - "{snippet_name}":', description=description, color=self.bot.main_color, ) - return await ctx.send(embed=embed) + if snippet_name is None: + return await ctx.send(embed=embed) + return await self._send_snippet_preview(ctx, embed, snippet_data) if not self.bot.snippets: embed = discord.Embed( @@ -309,12 +431,21 @@ async def snippet(self, ctx, *, name: str.lower = None): snippet_text = self._get_snippet_text(snippet_data) has_attachment = self._has_snippet_attachment(snippet_data) - display_value = return_or_truncate(snippet_text, 350) if snippet_text else "(No text)" + if snippet_text: + display_value = return_or_truncate(snippet_text, 350) + elif has_attachment: + display_value = "This is an attachment-only snippet." + else: + display_value = "(No text)" if has_attachment: display_value = "šŸ“Ž " + display_value embeds[i // 10].add_field(name=snippet_name, value=display_value, inline=False) + if any(self._has_snippet_attachment(data) for data in self.bot.snippets.values()): + for embed in embeds: + embed.set_footer(text="šŸ“Ž indicates that a snippet has an attachment.") + session = EmbedPaginatorSession(ctx, *embeds) await session.run() @@ -332,10 +463,13 @@ async def snippet_raw(self, ctx, *, name: str.lower): snippet_text = self._get_snippet_text(snippet_data) has_attachment = self._has_snippet_attachment(snippet_data) - val = truncate(escape_code_block(snippet_text), 2048 - 7) if snippet_text else "(No text content)" - description = f"```\n{val}```" - if has_attachment: - description += "\n\nšŸ“Ž *This snippet has an attachment.*" + if snippet_text: + val = truncate(escape_code_block(snippet_text), 2048 - 7) + description = f"```\n{val}```" + elif has_attachment: + description = "This is an attachment-only snippet." + else: + description = "(No text content)" embed = discord.Embed( title=f'Raw snippet - "{snippet_name}":', @@ -343,7 +477,9 @@ async def snippet_raw(self, ctx, *, name: str.lower): color=self.bot.main_color, ) - return await ctx.send(embed=embed) + if snippet_name is None: + return await ctx.send(embed=embed) + return await self._send_snippet_preview(ctx, embed, snippet_data) @snippet.command(name="add", aliases=["create", "make"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -360,8 +496,20 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte {prefix}snippet add "two word" this is a two word snippet. ``` - You can also attach a file (max 10 MB) to include with the snippet. + You can also attach one file (10 MiB by default) to include with the snippet. + Attachments are stored in the database and frequent use can consume significant storage. """ + if not value and len(ctx.message.attachments) == 0: + return await ctx.send_help(ctx.command) + + if len(ctx.message.attachments) > 1: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="You can only attach one file to a snippet.", + ) + return await ctx.send(embed=embed) + if self.bot.get_command(name): embed = discord.Embed( title="Error", @@ -396,71 +544,12 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte # Handle optional attachment file_id = None attachment_info = None + confirm_msg = None if ctx.message.attachments: attachment = ctx.message.attachments[0] - - # Validate file size - max_size_mb = self.bot.config.get("snippet_attachment_max_size") - max_size_bytes = max_size_mb * 1024 * 1024 - if attachment.size > max_size_bytes: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"Attachment exceeds the maximum file size of {max_size_mb} MB. " - f"Your file is {attachment.size / (1024 * 1024):.2f} MB.", - ) - return await ctx.send(embed=embed) - - # Confirmation for attachments 2MB or higher - confirm_msg = None - if attachment.size >= 2 * 1024 * 1024: - view = discord.ui.View(timeout=30) - confirmed = None - - async def confirm_callback(interaction: discord.Interaction): - nonlocal confirmed - if interaction.user.id != ctx.author.id: - return await interaction.response.send_message( - "Only the command author can confirm.", ephemeral=True - ) - confirmed = True - await interaction.response.defer() - view.stop() - - async def cancel_callback(interaction: discord.Interaction): - nonlocal confirmed - if interaction.user.id != ctx.author.id: - return await interaction.response.send_message( - "Only the command author can cancel.", ephemeral=True - ) - confirmed = False - await interaction.response.edit_message( - content="āŒ Cancelled. Snippet not created.", view=None, embed=None - ) - view.stop() - - confirm_button = discord.ui.Button(label="āœ“ Confirm", style=discord.ButtonStyle.green) - cancel_button = discord.ui.Button(label="āœ— Cancel", style=discord.ButtonStyle.red) - confirm_button.callback = confirm_callback - cancel_button.callback = cancel_callback - view.add_item(confirm_button) - view.add_item(cancel_button) - - embed = discord.Embed( - title="Confirm Large Attachment", - description=f"The attachment is {attachment.size / (1024 * 1024):.2f} MB (≄2 MB).\n" - f"Do you want to create the snippet `{name}` with this attachment?", - color=self.bot.main_color, - ) - confirm_msg = await ctx.send(embed=embed, view=view) - await view.wait() - - if confirmed is None or not confirmed: - if confirmed is None: - await confirm_msg.edit( - content="ā±ļø Timed out. Snippet not created.", view=None, embed=None - ) - return + is_valid, confirm_msg = await self._validate_snippet_attachment(ctx, attachment, name, "create") + if not is_valid: + return # Download and upload to GridFS try: @@ -478,16 +567,22 @@ async def cancel_callback(interaction: discord.Interaction): color=self.bot.error_color, description="Failed to upload attachment. Please try again.", ) - return await ctx.send(embed=embed) + return await self._send_snippet_result(ctx, embed, confirm_msg) - # Require at least text or attachment - if not value and not file_id: + if file_id and (self.bot.get_command(name) or name in self.bot.snippets or name in self.bot.aliases): + cleanup_failed = not await self._delete_snippet_attachment(file_id, name) + description = ( + f"The name `{name}` became unavailable while the attachment was being uploaded. " + "Please try again with another name." + ) + if cleanup_failed: + description += " The uploaded file could not be cleaned up." embed = discord.Embed( title="Error", color=self.bot.error_color, - description="You must provide either text content or an attachment for the snippet.", + description=description, ) - return await ctx.send(embed=embed) + return await self._send_snippet_result(ctx, embed, confirm_msg) # Store snippet as dict with text and optional file_id snippet_data = {"text": value or ""} @@ -498,14 +593,25 @@ async def cancel_callback(interaction: discord.Interaction): try: await self.bot.config.update() except Exception: + logger.error("Failed to save snippet %s.", name, exc_info=True) self.bot.snippets.pop(name, None) + cleanup_failed = False if file_id: - await self.bot.api.delete_snippet_attachment(file_id) - raise + cleanup_failed = not await self._delete_snippet_attachment(file_id, name) + + description = "Failed to save the snippet. Please try again." + if cleanup_failed: + description += " The uploaded file could not be cleaned up." + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=description, + ) + return await self._send_snippet_result(ctx, embed, confirm_msg) description = "Successfully created snippet." if attachment_info: - description += f"\nšŸ“Ž Attachment: `{attachment_info}`" + description += f"\nAttachment: `{attachment_info}`" embed = discord.Embed( title="Added snippet", @@ -513,9 +619,7 @@ async def cancel_callback(interaction: discord.Interaction): description=description, ) - if confirm_msg: - return await confirm_msg.edit(content=None, embed=embed, view=None) - return await ctx.send(embed=embed) + return await self._send_snippet_result(ctx, embed, confirm_msg) def _fix_aliases(self, snippet_being_deleted: str) -> Tuple[List[str]]: """ @@ -588,10 +692,10 @@ async def snippet_remove(self, ctx, *, name: str.lower): deleted_aliases_output = None if len(edited_aliases) == 1: - alias, val = edited_aliases.popitem() + alias, val = next(iter(edited_aliases.items())) edited_aliases_output = ( f"Steps pointing to this snippet have been removed from the `{alias}` alias" - f" (previous value: `{val}`).`" + f" (previous value: `{val}`)." ) elif edited_aliases: alias_list = "\n".join( @@ -619,9 +723,50 @@ async def snippet_remove(self, ctx, *, name: str.lower): description=description, ) self.bot.snippets.pop(name) - await self.bot.config.update() - if file_id and not await self.bot.api.delete_snippet_attachment(file_id): - logger.warning("Failed to delete snippet attachment for %s.", name) + try: + await self.bot.config.update() + except Exception: + logger.error("Failed to save removal of snippet %s.", name, exc_info=True) + self.bot.snippets[name] = snippet_data + self.bot.aliases.update(deleted_aliases) + self.bot.aliases.update(edited_aliases) + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Failed to remove snippet `{name}`. Please try again.", + ) + return await ctx.send(embed=embed) + + if file_id: + attachment_deleted = await self._delete_snippet_attachment(file_id, name) + + if not attachment_deleted: + self.bot.snippets[name] = snippet_data + self.bot.aliases.update(deleted_aliases) + self.bot.aliases.update(edited_aliases) + + rollback_failed = False + try: + await self.bot.config.update() + except Exception: + logger.error("Failed to roll back removal of snippet %s.", name, exc_info=True) + rollback_failed = True + + description = ( + f"Snippet `{name}` was not removed because its attachment could not be " + "deleted. Please try again." + ) + if rollback_failed: + description = ( + f"The attachment for snippet `{name}` could not be deleted, and the " + "database rollback also failed. Contact the bot owner and check the logs " + "before retrying." + ) + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=description, + ) else: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") await ctx.send(embed=embed) @@ -636,102 +781,165 @@ async def snippet_edit(self, ctx, name: str.lower, *, value: commands.clean_cont {prefix}snippet edit "two word" this is a new two word snippet. ``` - Attach a new file to replace the existing attachment. - Provide text without attachment to keep the existing attachment. + Attach one new file to replace the existing attachment. + Editing without a file removes the existing attachment. + Omit the text to keep the existing text. """ - if name in self.bot.snippets: - snippet_data = self.bot.snippets[name] + if len(ctx.message.attachments) > 1: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="You can only attach one file to a snippet.", + ) + return await ctx.send(embed=embed) - # Handle old string format - if isinstance(snippet_data, str): - old_text = snippet_data - old_file_id = None - else: - old_text = snippet_data.get("text", "") - old_file_id = snippet_data.get("file_id") - - # Handle new attachment if provided - new_file_id = old_file_id - attachment_info = None - if ctx.message.attachments: - attachment = ctx.message.attachments[0] - - # Validate file size - max_size_mb = self.bot.config.get("snippet_attachment_max_size") - max_size_bytes = max_size_mb * 1024 * 1024 - if attachment.size > max_size_bytes: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"Attachment exceeds the maximum file size of {max_size_mb} MB. " - f"Your file is {attachment.size / (1024 * 1024):.2f} MB.", - ) - return await ctx.send(embed=embed) + if name not in self.bot.snippets: + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") + return await ctx.send(embed=embed) - # Upload new attachment - try: - file_data = await attachment.read() - new_file_id = await self.bot.api.upload_snippet_attachment( - file_data, - attachment.filename, - attachment.content_type or "application/octet-stream", - ) - attachment_info = attachment.filename - except Exception as e: - logger.error("Failed to upload snippet attachment: %s", e) - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description="Failed to upload attachment. Please try again.", - ) - return await ctx.send(embed=embed) + snippet_data = self.bot.snippets[name] + + # Handle the legacy string format as well as attachment-aware snippets. + if isinstance(snippet_data, str): + old_text = snippet_data + old_file_id = None + else: + old_text = snippet_data.get("text", "") + old_file_id = snippet_data.get("file_id") - # Use new text if provided, otherwise keep old text - new_text = value if value is not None else old_text + has_new_attachment = bool(ctx.message.attachments) + if value is None and not has_new_attachment and not old_file_id: + return await ctx.send_help(ctx.command) - # Require at least text or attachment - if not new_text and not new_file_id: + new_text = value if value is not None else old_text + if not has_new_attachment and not new_text: + if old_file_id: embed = discord.Embed( title="Error", color=self.bot.error_color, - description="Snippet must have either text content or an attachment.", + description=( + "Removing this attachment would leave the snippet empty. " + "Provide replacement text or attach a new file." + ), ) return await ctx.send(embed=embed) + return await ctx.send_help(ctx.command) - # Update snippet - updated_snippet = {"text": new_text or ""} - if new_file_id: - updated_snippet["file_id"] = new_file_id + new_file_id = None + attachment_info = None + confirm_msg = None + if has_new_attachment: + attachment = ctx.message.attachments[0] + is_valid, confirm_msg = await self._validate_snippet_attachment(ctx, attachment, name, "update") + if not is_valid: + return - self.bot.snippets[name] = updated_snippet try: - await self.bot.config.update() - except Exception: - self.bot.snippets[name] = snippet_data - if attachment_info and new_file_id != old_file_id: - await self.bot.api.delete_snippet_attachment(new_file_id) - raise - - if attachment_info and old_file_id: - if not await self.bot.api.delete_snippet_attachment(old_file_id): - logger.warning("Failed to delete old attachment for %s.", name) - - description = f"`{name}` has been updated." - if value: - description += f'\nText: "{truncate(value, 100)}"' - if attachment_info: - description += f"\nšŸ“Ž New attachment: `{attachment_info}`" - elif new_file_id: - description += f"\nšŸ“Ž Attachment kept." + file_data = await attachment.read() + new_file_id = await self.bot.api.upload_snippet_attachment( + file_data, + attachment.filename, + attachment.content_type or "application/octet-stream", + ) + attachment_info = attachment.filename + except Exception as e: + logger.error("Failed to upload snippet attachment: %s", e) + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Failed to upload attachment. Please try again.", + ) + return await self._send_snippet_result(ctx, embed, confirm_msg) + if new_file_id and self.bot.snippets.get(name) is not snippet_data: + cleanup_failed = not await self._delete_snippet_attachment(new_file_id, name) + description = ( + f"Snippet `{name}` changed while the attachment was being uploaded. Please try again." + ) + if cleanup_failed: + description += " The uploaded file could not be cleaned up." embed = discord.Embed( - title="Edited snippet", - color=self.bot.main_color, + title="Error", + color=self.bot.error_color, description=description, ) - else: - embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") - await ctx.send(embed=embed) + return await self._send_snippet_result(ctx, embed, confirm_msg) + + updated_snippet = {"text": new_text or ""} + if new_file_id: + updated_snippet["file_id"] = new_file_id + + self.bot.snippets[name] = updated_snippet + try: + await self.bot.config.update() + except Exception: + logger.error("Failed to save edits to snippet %s.", name, exc_info=True) + self.bot.snippets[name] = snippet_data + cleanup_failed = False + if new_file_id: + cleanup_failed = not await self._delete_snippet_attachment(new_file_id, name) + + description = "Failed to save the snippet changes. Please try again." + if cleanup_failed: + description += " The newly uploaded file could not be cleaned up." + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=description, + ) + return await self._send_snippet_result(ctx, embed, confirm_msg) + + if old_file_id: + old_attachment_deleted = await self._delete_snippet_attachment(old_file_id, name) + + if not old_attachment_deleted: + self.bot.snippets[name] = snippet_data + + rollback_failed = False + try: + await self.bot.config.update() + except Exception: + logger.error("Failed to roll back edits to snippet %s.", name, exc_info=True) + rollback_failed = True + + cleanup_failed = False + if new_file_id and not rollback_failed: + cleanup_failed = not await self._delete_snippet_attachment(new_file_id, name) + + if rollback_failed: + description = ( + f"The previous attachment for snippet `{name}` could not be deleted, and " + "the database rollback also failed. Contact the bot owner and check the logs " + "before retrying." + ) + else: + description = ( + f"Snippet `{name}` was not updated because its previous attachment could not " + "be deleted. Please try again." + ) + if cleanup_failed: + description += " The newly uploaded file could not be cleaned up." + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=description, + ) + return await self._send_snippet_result(ctx, embed, confirm_msg) + + description = f"`{name}` has been updated." + if value is not None: + description += f'\nText: "{truncate(value, 100)}"' + if attachment_info: + description += f"\nNew attachment: `{attachment_info}`" + elif old_file_id: + description += "\nAttachment removed." + + embed = discord.Embed( + title="Edited snippet", + color=self.bot.main_color, + description=description, + ) + return await self._send_snippet_result(ctx, embed, confirm_msg) @snippet.command(name="rename") @checks.has_permissions(PermissionLevel.SUPPORTER) diff --git a/cogs/utility.py b/cogs/utility.py index 4bbe66b79c..173690f0d2 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -109,7 +109,7 @@ async def format_cog_help(self, cog, *, no_cog=False): return embeds def process_help_msg(self, help_: str): - return help_.replace("{prefix}", self.context.clean_prefix) if help_ else "No help message." + return help_.format(prefix=self.context.clean_prefix) if help_ else "No help message." async def send_bot_help(self, mapping): embeds = [] @@ -195,6 +195,8 @@ async def send_error_message(self, error): command = self.context.kwargs.get("command") val = self.context.bot.snippets.get(command) if val is not None: + if isinstance(val, dict): + val = val.get("text") or "This is an attachment-only snippet." embed = discord.Embed(title=f"{command} is a snippet.", color=self.context.bot.main_color) embed.add_field(name=f"`{command}` will send:", value=val, inline=False) diff --git a/core/clients.py b/core/clients.py index b14adbbc19..8884baa8bc 100644 --- a/core/clients.py +++ b/core/clients.py @@ -9,6 +9,8 @@ from aiohttp import ClientResponseError, ClientResponse from bson import ObjectId +from bson.errors import InvalidId +from gridfs.errors import NoFile from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorGridFSBucket from pymongo.errors import ConfigurationError @@ -887,6 +889,9 @@ async def delete_snippet_attachment(self, file_id: str) -> bool: await self.fs.delete(ObjectId(file_id)) logger.debug("Deleted snippet attachment with file_id %s.", file_id) return True + except (InvalidId, NoFile): + logger.info("Snippet attachment %s was already absent.", file_id) + return True except Exception as e: logger.warning("Failed to delete snippet attachment %s: %s", file_id, e) return False diff --git a/core/config.py b/core/config.py index 529ba405c3..51d3233d21 100644 --- a/core/config.py +++ b/core/config.py @@ -165,7 +165,7 @@ class ConfigManager: "thread_creation_menu_embed_footer_icon_url": None, "thread_creation_menu_embed_color": str(discord.Color.green()), # snippet attachments - "snippet_attachment_max_size": 10, # in MB + "snippet_attachment_max_size": 10, # in MiB } private_keys = { @@ -245,7 +245,7 @@ class ConfigManager: duration_seconds = {"snooze_default_duration", "thread_creation_menu_timeout"} - megabytes = {"snippet_attachment_max_size"} + mebibytes = {"snippet_attachment_max_size"} booleans = { "use_user_id_channel_name", @@ -318,6 +318,23 @@ def __init__(self, bot): def __repr__(self): return repr(self._cache) + @staticmethod + def _convert_mebibytes(value: typing.Any) -> int: + """Convert a positive whole-number MiB value without rounding it.""" + if isinstance(value, bool): + raise InvalidConfigError("Must be a positive whole number of MiB.") + + if isinstance(value, int): + converted = value + elif isinstance(value, str) and re.fullmatch(r"[1-9]\d*", value.strip()): + converted = int(value) + else: + raise InvalidConfigError("Must be a positive whole number of MiB.") + + if converted <= 0: + raise InvalidConfigError("Must be a positive whole number of MiB.") + return converted + def populate_cache(self) -> dict: data = deepcopy(self.defaults) @@ -426,13 +443,12 @@ def get(self, key: str, *, convert: bool = True) -> typing.Any: logger.warning("Invalid %s %s.", key, value) value = self.remove(key) - elif key in self.megabytes: - if not isinstance(value, int): - try: - value = int(value) - except (ValueError, TypeError): - logger.warning("Invalid %s %s.", key, value) - value = self.remove(key) + elif key in self.mebibytes: + try: + value = self._convert_mebibytes(value) + except InvalidConfigError: + logger.warning("Invalid %s %s.", key, value) + value = self.remove(key) elif key in self.force_str: # Temporary: as we saved in int previously, leading to int32 overflow, @@ -537,6 +553,9 @@ async def set(self, key: str, item: typing.Any, convert=True) -> None: duration_seconds = int((time.dt - now).total_seconds()) return self.__setitem__(key, duration_seconds) + elif key in self.mebibytes: + return self.__setitem__(key, self._convert_mebibytes(item)) + elif key in self.enums: if isinstance(item, self.enums[key]): # value is an enum type diff --git a/core/config_help.json b/core/config_help.json index 8d6c825c38..0679dfb73c 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -857,15 +857,16 @@ ] }, "snippet_attachment_max_size": { - "default": "10 (MB)", - "description": "Maximum file size in megabytes (MB) for attachments when creating or editing snippets.", + "default": "10 MiB", + "description": "Maximum file size in mebibytes (MiB) for attachments when creating or editing snippets.", "examples": [ - "`{prefix}config set snippet_attachment_max_size 5` (5 MB)", - "`{prefix}config set snippet_attachment_max_size 20` (20 MB)" + "`{prefix}config set snippet_attachment_max_size 5` (5 MiB)", + "`{prefix}config set snippet_attachment_max_size 20` (20 MiB)" ], "notes": [ "Attachments larger than this size will be rejected when adding or editing snippets.", - "Value is specified in megabytes (MB)." + "The value must be a positive whole number of MiB; fractional and negative values are rejected.", + "Increasing this limit can substantially increase long-term database storage usage, especially when attachments are used frequently." ] }, "require_close_reason": { @@ -1606,4 +1607,4 @@ "Color names map to the built-in palette (e.g., 'red', 'green', 'blurple')." ] } -} \ No newline at end of file +} diff --git a/core/thread.py b/core/thread.py index 6528aa9e70..0085e9844f 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1754,7 +1754,12 @@ async def reply( if any( getattr(attachment, "is_snippet_attachment", False) for attachment in message.attachments ): - log_attachments = msg.attachments + original_attachments = [ + attachment + for attachment in message.attachments + if not getattr(attachment, "is_snippet_attachment", False) + ] + log_attachments = [*original_attachments, *msg.attachments] tasks.append( self.bot.api.append_log( message, From 075e9f56497833ae0dbd74ddc7d5300d1b1cbab8 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 8 Aug 2026 13:36:58 +0200 Subject: [PATCH 09/10] Resolve requests. --- cogs/modmail.py | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index 5fe29c3054..758f1a9540 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -212,11 +212,18 @@ async def _validate_snippet_attachment(self, ctx, attachment, name, action): confirmed = None past_tense = {"create": "created", "update": "updated"}[action] + def error_embed(description): + return discord.Embed( + title="Error", + description=description, + color=self.bot.error_color, + ) + async def confirm_callback(interaction: discord.Interaction): nonlocal confirmed if interaction.user.id != ctx.author.id: return await interaction.response.send_message( - "Only the command author can confirm.", ephemeral=True + embed=error_embed("Only the command author can confirm."), ephemeral=True ) confirmed = True await interaction.response.edit_message(view=None) @@ -226,11 +233,13 @@ async def cancel_callback(interaction: discord.Interaction): nonlocal confirmed if interaction.user.id != ctx.author.id: return await interaction.response.send_message( - "Only the command author can cancel.", ephemeral=True + embed=error_embed("Only the command author can cancel."), ephemeral=True ) confirmed = False await interaction.response.edit_message( - content=f"Cancelled. Snippet not {past_tense}.", view=None, embed=None + content=None, + embed=error_embed(f"Cancelled. Snippet not {past_tense}."), + view=None, ) view.stop() @@ -254,7 +263,11 @@ async def cancel_callback(interaction: discord.Interaction): await view.wait() if confirmed is None: - await confirm_msg.edit(content=f"Timed out. Snippet not {past_tense}.", view=None, embed=None) + await confirm_msg.edit( + content=None, + embed=error_embed(f"Timed out. Snippet not {past_tense}."), + view=None, + ) return confirmed is True, confirm_msg @commands.command() @@ -576,7 +589,10 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte "Please try again with another name." ) if cleanup_failed: - description += " The uploaded file could not be cleaned up." + description += ( + " The uploaded file could not be cleaned up. If this error happens again, " + "contact the bot owner and ask them to check the logs." + ) embed = discord.Embed( title="Error", color=self.bot.error_color, @@ -601,7 +617,10 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte description = "Failed to save the snippet. Please try again." if cleanup_failed: - description += " The uploaded file could not be cleaned up." + description += ( + " The uploaded file could not be cleaned up. If this error happens again, " + "contact the bot owner and ask them to check the logs." + ) embed = discord.Embed( title="Error", color=self.bot.error_color, @@ -857,7 +876,10 @@ async def snippet_edit(self, ctx, name: str.lower, *, value: commands.clean_cont f"Snippet `{name}` changed while the attachment was being uploaded. Please try again." ) if cleanup_failed: - description += " The uploaded file could not be cleaned up." + description += ( + " The uploaded file could not be cleaned up. If this error happens again, " + "contact the bot owner and ask them to check the logs." + ) embed = discord.Embed( title="Error", color=self.bot.error_color, @@ -881,7 +903,10 @@ async def snippet_edit(self, ctx, name: str.lower, *, value: commands.clean_cont description = "Failed to save the snippet changes. Please try again." if cleanup_failed: - description += " The newly uploaded file could not be cleaned up." + description += ( + " The newly uploaded file could not be cleaned up. If this error happens again, " + "contact the bot owner and ask them to check the logs." + ) embed = discord.Embed( title="Error", color=self.bot.error_color, @@ -918,7 +943,10 @@ async def snippet_edit(self, ctx, name: str.lower, *, value: commands.clean_cont "be deleted. Please try again." ) if cleanup_failed: - description += " The newly uploaded file could not be cleaned up." + description += ( + " The newly uploaded file could not be cleaned up. If this error happens " + "again, contact the bot owner and ask them to check the logs." + ) embed = discord.Embed( title="Error", color=self.bot.error_color, From 4cc0de321d8a21226399669e38713cb40d486173 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 8 Aug 2026 13:49:30 +0200 Subject: [PATCH 10/10] fix broken attachment state This solves that if conversion later fails, the embed is sent without its referenced file. --- core/thread.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/core/thread.py b/core/thread.py index 0085e9844f..7c34355ec7 100644 --- a/core/thread.py +++ b/core/thread.py @@ -2109,15 +2109,26 @@ def lottie_to_png(data): images.append((None, i.name, True)) embedded_image = False + discord_files = [] # Handle snippet images first (embedded directly) for a in snippet_images_to_upload: + try: + file = await a.to_file() + except Exception: + logger.warning( + "Failed to convert snippet image %s to file.", + getattr(a, "filename", "unknown"), + exc_info=True, + ) + continue + if not embedded_image: embed.set_image(url=f"attachment://{a.filename}") embed.add_field(name="Image", value=a.filename) embedded_image = True - # Always add to files_to_upload so the attachment is physically present - files_to_upload.append(a) + # Only reference images that were successfully converted and will be sent. + discord_files.append(file) prioritize_uploads = any(i[1] is not None for i in images) @@ -2265,7 +2276,6 @@ def lottie_to_png(data): else: mentions = None - discord_files = [] for att in files_to_upload: try: discord_files.append(await att.to_file())