diff --git a/.env.example b/.env.example index 972eca4517..dbfbc5015f 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ TOKEN=MyBotToken LOG_URL=https://logviewername.herokuapp.com/ GUILD_ID=1234567890 +# MODMAIL_GUILD_ID=1234567890 OWNERS=Owner1ID,Owner2ID,Owner3ID CONNECTION_URI=mongodb+srv://mongodburi -DISABLE_AUTOUPDATES=true \ No newline at end of file +DISABLE_AUTOUPDATES=true +USE_SLASH_COMMANDS=true +ENABLE_PREFIX_COMMANDS=false diff --git a/CHANGELOG.md b/CHANGELOG.md index f1c8d12311..9d606b2cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s ### Fixed * Confirm thread creation (react to contact) no longer leaves a thread stuck in a "not ready" cache state when the recipient has DMs disabled. The bot now catches `discord.Forbidden` when sending the confirmation prompt, cancels the thread, and clears the cache entry immediately instead of requiring a bot restart. (#3442) -# v4.2.1 +# v4.4.0 ### Added diff --git a/README.md b/README.md index 58243cab61..bf16dbf2ac 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
- +
@@ -61,7 +61,7 @@ Our Logviewer will save the threads so you can view previous threads through the ## Features * **Highly Customisable:** - * Bot activity, prefix, category, log channel, etc. + * Bot activity, categories, log channels, command interfaces, etc. * Command permission system. * Interface elements (color, responses, reactions, etc.). * Snippets and *command aliases*. @@ -73,16 +73,35 @@ Our Logviewer will save the threads so you can view previous threads through the * Native Discord dark-mode feel. * Markdown/formatting support. * Login via Discord to protect your logs ([premium feature](https://buymeacoffee.com/modmaildev/membership)). - * See past logs of a user with `?logs`. - * Searchable by text queries using `?logs search`. + * See past logs of a user with `/logs view`. + * Searchable by text queries using `/logs search`. * **Robust implementation:** - * Schedule tasks in human time, e.g. `?close in 2 hours silently`. + * Schedule tasks in human time, e.g. `/close after:in 2 hours silently`. * Editing and deleting messages are synced. * Support for the diverse range of message contents (multiple images, files). * Paginated commands interfaces via reactions. -This list is ever-growing thanks to active development and our exceptional contributors. See a full list of documented commands by using the `?help` command. +This list is ever-growing thanks to active development and our exceptional contributors. See a full list of documented commands by using `/help`. + +## Commands + +Slash commands are enabled by default and are registered only in the inbox server +configured by `MODMAIL_GUILD_ID`, falling back to `GUILD_ID` for a single-server setup. +Setting `USE_SLASH_COMMANDS=false` removes the guild-scoped commands from both configured +servers on startup. Commands with subcommands are grouped naturally, for example +`/config set` and `/logs search`. Each command only exposes the named options it supports: +simple commands such as `/selfcontact` have no options, `/snippet add` has `name`, `value`, +and `attachment`, and replies require `message` with an optional `attachment`. Values are +translated back through the existing converters so aliases, snippets, mentions, and +human-readable durations keep working consistently. Parameters with a fixed set of valid +values, such as `/close option`, use Discord choices instead of unrestricted text. + +Commands can also be invoked with `@bot command`. This mention form does not require +the privileged message-content intent. Legacy text-prefix commands are disabled by +default; set `ENABLE_PREFIX_COMMANDS=true` and restart the bot only when Discord has +approved that intent for the application. Slash registration can be controlled with +`USE_SLASH_COMMANDS`, which defaults to `true`. ## Installation @@ -109,7 +128,7 @@ If you don't want the trouble of renting and configuring your server to host Mod Modmail supports the use of third-party plugins to extend or add functionalities to the bot. Plugins allow niche features as well as anything else outside of the scope of the core functionality of Modmail. -You can find a list of third-party plugins using the `?plugins registry` command or visit the [Unofficial List of Plugins](https://github.com/modmail-dev/modmail/wiki/Unofficial-List-of-Plugins) for a list of plugins contributed by the community. +You can find a list of third-party plugins using `/plugins registry browse` or visit the [Unofficial List of Plugins](https://github.com/modmail-dev/modmail/wiki/Unofficial-List-of-Plugins) for a list of plugins contributed by the community. To develop your own, check out the [plugins documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). diff --git a/app.json b/app.json index decd58695c..264318337b 100644 --- a/app.json +++ b/app.json @@ -11,6 +11,10 @@ "description": "The id for the server you are hosting this bot for.", "required": true }, + "MODMAIL_GUILD_ID": { + "description": "Optional separate inbox server where ticket channels and slash commands belong. Defaults to GUILD_ID.", + "required": false + }, "OWNERS": { "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval).", "required": true @@ -34,6 +38,16 @@ "REGISTRY_PLUGINS_ONLY": { "description": "If set to true, only plugins that are in the registry can be loaded.", "required": false + }, + "USE_SLASH_COMMANDS": { + "description": "Register Discord slash commands only in the configured Modmail inbox server. Disabling removes them.", + "value": "true", + "required": false + }, + "ENABLE_PREFIX_COMMANDS": { + "description": "Enable legacy text-prefix commands and the privileged message-content intent.", + "value": "false", + "required": false } } } diff --git a/bot.py b/bot.py index edc2e4b319..ecf6c4cadc 100644 --- a/bot.py +++ b/bot.py @@ -1,9 +1,10 @@ -__version__ = "4.2.1" +__version__ = "4.4.0" import asyncio import copy import hashlib +import io import os import re import string @@ -45,6 +46,7 @@ configure_logging, getLogger, ) +from core.slash_commands import SlashCommandManager from core.thread import ThreadManager from core.time import human_timedelta from core.utils import ( @@ -59,6 +61,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) @@ -78,8 +98,13 @@ def __init__(self): intents = discord.Intents.all() if not self.config["enable_presence_intent"]: intents.presences = False + # Prefix commands are the only guild feature that requires privileged + # message content. Mentions and direct messages remain available under + # Discord's message-content exceptions. + intents.message_content = bool(self.config["enable_prefix_commands"]) super().__init__(command_prefix=None, intents=intents) # implemented in `get_prefix` + self.slash_commands = SlashCommandManager(self) self.session = None self._api = None self.formatter = SafeFormatter() @@ -220,7 +245,12 @@ def db(self): return self.api.db async def get_prefix(self, message=None): - return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] + prefixes = [] + if self.config["enable_prefix_commands"]: + prefixes.append(self.prefix) + if self.user is not None: + prefixes.extend((f"<@{self.user.id}> ", f"<@!{self.user.id}> ")) + return prefixes def run(self): async def runner(): @@ -418,6 +448,19 @@ def guild(self) -> typing.Optional[discord.Guild]: """ return discord.utils.get(self.guilds, id=self.guild_id) + @property + def inbox_guild_id(self) -> typing.Optional[int]: + """The server where Modmail ticket channels and staff commands belong.""" + guild_id = self.config["modmail_guild_id"] + if guild_id is None: + return self.guild_id + try: + return int(str(guild_id)) + except ValueError: + self.config.remove("modmail_guild_id") + logger.critical("Invalid MODMAIL_GUILD_ID set; falling back to GUILD_ID.") + return self.guild_id + @property def modmail_guild(self) -> typing.Optional[discord.Guild]: """ @@ -479,6 +522,13 @@ def blocked_whitelisted_users(self) -> typing.List[str]: def prefix(self) -> str: return str(self.config["prefix"]) + @property + def command_display_prefix(self) -> str: + """Prefix used in user-facing command examples.""" + if self.config["enable_prefix_commands"]: + return self.prefix + return "/" + @property def mod_color(self) -> int: return self.config.get("mod_color") @@ -528,6 +578,11 @@ async def on_connect(self): await self.config.refresh() await self.api.setup_indexes() await self.load_extensions() + if self.config["use_slash_commands"]: + await self.slash_commands.sync() + else: + logger.warning("Slash commands are disabled by USE_SLASH_COMMANDS=false.") + await self.slash_commands.disable() self._connected.set() async def on_ready(self): @@ -555,7 +610,11 @@ async def on_ready(self): getattr(self.get_user(owner_id), "name", str(owner_id)) for owner_id in self.bot_owner_ids ) logger.info("Owners: %s", owners) - logger.info("Prefix: %s", self.prefix) + logger.info("Slash commands: %s", "enabled" if self.config["use_slash_commands"] else "disabled") + logger.info( + "Prefix commands: %s", + f"enabled ({self.prefix})" if self.config["enable_prefix_commands"] else "disabled", + ) logger.info("Guild Name: %s", self.guild.name) logger.info("Guild ID: %s", self.guild.id) if self.using_multiple_server_setup: @@ -1291,6 +1350,37 @@ 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/")) + + @staticmethod + def _message_with_snippet_attachment(message, attachment): + """Copy a command message and append a stored snippet attachment.""" + snippet_message = copy.copy(message) + snippet_message.attachments = [*message.attachments, attachment] + return snippet_message + + @staticmethod + def _get_message_interaction(message): + """Return interaction data without accessing discord.py's deprecated property.""" + try: + return message.interaction_metadata + except AttributeError: + # Synthetic slash-command messages and discord.py versions older + # than interaction_metadata expose the interaction directly. + return getattr(message, "interaction", None) + async def get_contexts(self, message, *, cls=commands.Context): """ Returns all invocation contexts from the message. @@ -1298,7 +1388,15 @@ async def get_contexts(self, message, *, cls=commands.Context): """ view = StringView(message.content) - ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + interaction = self._get_message_interaction(message) + context_prefix = "/" if interaction is not None else self.prefix + ctx = cls( + prefix=context_prefix, + view=view, + bot=self, + message=message, + interaction=interaction, + ) thread = await self.threads.find(channel=ctx.channel) if message.author.id == self.user.id: # type: ignore @@ -1317,7 +1415,14 @@ async def get_contexts(self, message, *, cls=commands.Context): # snippets can have multiple words. 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 except KeyError: snippet_text = None @@ -1332,15 +1437,33 @@ async def get_contexts(self, message, *, cls=commands.Context): for alias in aliases: command = None + context_message = copy.copy(message) 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", "") + attachment = await self._download_snippet_attachment(snippet_data) + if attachment is not None: + context_message = self._message_with_snippet_attachment(message, attachment) + 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}" view = StringView(invoked_prefix + command_invocation_text) - ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) + ctx_ = cls( + prefix=context_prefix, + view=view, + bot=self, + message=context_message, + interaction=interaction, + ) ctx_.thread = thread discord.utils.find(view.skip_string, prefixes) ctx_.invoked_with = view.get_word().lower() @@ -1352,6 +1475,11 @@ 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) + attachment = await self._download_snippet_attachment(snippet_data) + if attachment is not None: + ctx.message = self._message_with_snippet_attachment(message, attachment) ctx.command = self._get_snippet_command() reply_view = StringView(f"{invoked_prefix}{ctx.command} {snippet_text}") discord.utils.find(reply_view.skip_string, prefixes) @@ -1369,7 +1497,15 @@ async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context) message.guild = channel.guild view = StringView(message.content) - ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + interaction = self._get_message_interaction(message) + context_prefix = "/" if interaction is not None else self.prefix + ctx = cls( + prefix=context_prefix, + view=view, + bot=self, + message=message, + interaction=interaction, + ) thread = await self.threads.find(channel=ctx.channel) invoked_prefix = self.prefix @@ -1424,7 +1560,15 @@ async def get_context(self, message, *, cls=commands.Context): """ view = StringView(message.content) - ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + interaction = self._get_message_interaction(message) + context_prefix = "/" if interaction is not None else self.prefix + ctx = cls( + prefix=context_prefix, + view=view, + bot=self, + message=message, + interaction=interaction, + ) if message.author.id == self.user.id: return ctx @@ -1513,14 +1657,14 @@ async def on_message(self, message): await self.process_commands(message) - async def process_commands(self, message): + async def process_commands(self, message, *, cls=commands.Context): if message.author.bot: return if isinstance(message.channel, discord.DMChannel): return await self._queue_dm_message(message) - ctxs = await self.get_contexts(message) + ctxs = await self.get_contexts(message, cls=cls) for ctx in ctxs: if ctx.command: if not any(1 for check in ctx.command.checks if hasattr(check, "permission_level")): diff --git a/cogs/modmail.py b/cogs/modmail.py index ca1498c2bc..2980eca2f4 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -116,6 +116,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) @@ -171,7 +195,7 @@ async def setup(self, ctx): embed = discord.Embed( title="Friendly Reminder", - description=f"You may use the `{self.bot.prefix}config set log_channel_id " + description=f"You may use the `{self.bot.command_display_prefix}config set log_channel_id " "` command to set up a custom log channel, then you can delete this default " f"{log_channel.mention} log channel.", color=self.bot.main_color, @@ -184,7 +208,9 @@ async def setup(self, ctx): "feeling extra generous, buy us coffee on [Buy Me A Coffee](https://buymeacoffee.com/modmaildev) :heart:!", ) - embed.set_footer(text=f'Type "{self.bot.prefix}help" for a complete list of commands.') + embed.set_footer( + text=f'Type "{self.bot.command_display_prefix}help" for a complete list of commands.' + ) await log_channel.send(embed=embed) self.bot.config["main_category_id"] = category.id @@ -195,9 +221,10 @@ async def setup(self, ctx): "**Successfully set up server.**\n" "Consider setting permission levels to give access to roles " "or users the ability to use Modmail.\n\n" - f"Type:\n- `{self.bot.prefix}permissions` and `{self.bot.prefix}permissions add` " + f"Type:\n- `{self.bot.command_display_prefix}permissions` and " + f"`{self.bot.command_display_prefix}permissions add` " "for more info on setting permissions.\n" - f"- `{self.bot.prefix}config help` for a list of available customizations." + f"- `{self.bot.command_display_prefix}config help` for a list of available customizations." ) if not self.bot.config["command_permissions"] and not self.bot.config["level_permissions"]: @@ -250,10 +277,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) @@ -263,7 +297,9 @@ async def snippet(self, ctx, *, name: str.lower = None): color=self.bot.error_color, description="You dont have any snippets at the moment.", ) - embed.set_footer(text=f'Check "{self.bot.prefix}help snippet add" to add a snippet.') + embed.set_footer( + text=f'Check "{self.bot.command_display_prefix}help snippet add" to add a snippet.' + ) embed.set_author( name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), @@ -274,10 +310,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() @@ -292,10 +333,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, ) @@ -303,9 +352,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 :) @@ -315,6 +364,9 @@ 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 (up to the configured + `snippet_attachment_max_size`, default 10 MB) to include with the snippet. """ if self.bot.get_command(name): embed = discord.Embed( @@ -347,14 +399,128 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte ) return await ctx.send(embed=embed) - self.bot.snippets[name] = value - await self.bot.config.update() + # 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 + 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: + 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]]: @@ -412,6 +578,9 @@ 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: + snippet_data = self.bot.snippets[name] + file_id = snippet_data.get("file_id") if isinstance(snippet_data, dict) else None + deleted_aliases, edited_aliases = self._fix_aliases(name) deleted_aliases_string = ",".join(f"`{alias}`" for alias in deleted_aliases) @@ -457,28 +626,114 @@ 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) @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 - await self.bot.config.update() + 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) + + # 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 + 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." 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") @@ -585,7 +840,9 @@ async def args(self, ctx, *, name: str.lower = None): color=self.bot.error_color, description="You don't have any args at the moment.", ) - embed.set_footer(text=f'See "{self.bot.prefix}help args add" for how to add an arg.') + embed.set_footer( + text=f'See "{self.bot.command_display_prefix}help args add" for how to add an arg.' + ) embed.set_author( name="Args", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), @@ -2500,7 +2757,8 @@ async def unblock(self, ctx, *, user_or_role: Union[User, Role] = None): embed.set_footer( text="However, if the original system block reason still applies, " f"{name} will be automatically blocked again. " - f'Use "{self.bot.prefix}blocked whitelist {user_or_role.id}" to whitelist the user.' + f'Use "{self.bot.command_display_prefix}blocked whitelist ' + f'{user_or_role.id}" to whitelist the user.' ) else: embed = discord.Embed( diff --git a/cogs/plugins.py b/cogs/plugins.py index 310b2f06c9..2fd5ac8d27 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -273,6 +273,7 @@ async def load_plugin(self, plugin): await self.bot.load_extension(plugin.ext_string) logger.info("Loaded plugin: %s", plugin.ext_string.split(".")[-1]) self.loaded_plugins.add(plugin) + await self.bot.slash_commands.refresh() except commands.ExtensionError as exc: logger.error("Plugin load failure: %s", plugin.ext_string, exc_info=True) @@ -281,6 +282,7 @@ async def load_plugin(self, plugin): async def unload_plugin(self, plugin: Plugin) -> None: try: await self.bot.unload_extension(plugin.ext_string) + await self.bot.slash_commands.refresh() except commands.ExtensionError as exc: raise exc @@ -706,7 +708,10 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N title=details["repository"], ) - embed.add_field(name="Installation", value=f"```{self.bot.prefix}plugins add {name}```") + embed.add_field( + name="Installation", + value=f"```{self.bot.command_display_prefix}plugins add {name}```", + ) embed.set_author(name=details["title"], icon_url=details.get("icon_url"), url=plugin.link) diff --git a/cogs/utility.py b/cogs/utility.py index 283823d39c..a550bbadad 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 = [] @@ -379,7 +379,10 @@ async def about(self, ctx): embed.add_field( name="Project Sponsors", - value=f"Checkout the people who supported Modmail with command `{self.bot.prefix}sponsors`!", + value=( + "Checkout the people who supported Modmail with command " + f"`{self.bot.command_display_prefix}sponsors`!" + ), inline=False, ) @@ -760,6 +763,15 @@ async def prefix(self, ctx, *, prefix=None): current = self.bot.prefix embed = discord.Embed(title="Current prefix", color=self.bot.main_color, description=f"{current}") + if not self.bot.config["enable_prefix_commands"]: + embed.title = "Prefix commands are disabled" + embed.description = ( + "Slash commands and `@bot command` mentions are active. Set " + "`ENABLE_PREFIX_COMMANDS=true` in the environment and restart the bot " + "to enable the configured legacy prefix." + ) + return await ctx.send(embed=embed) + if prefix is None: await ctx.send(embed=embed) else: @@ -837,7 +849,10 @@ async def config_set(self, ctx, key: str.lower, *, value: str): except Exception: valid = False if not valid: - example = f"`{self.bot.prefix}config set snoozed_category_id `" + example = ( + f"`{self.bot.command_display_prefix}config set " + "snoozed_category_id `" + ) embed.add_field( name="Action required", value=( @@ -929,7 +944,10 @@ async def config_get(self, ctx, *, key: str.lower = None): description=f"`{key}` is an invalid key.", ) embed.set_footer( - text=f'Type "{self.bot.prefix}config options" for a list of config variables.' + text=( + f'Type "{self.bot.command_display_prefix}config options" ' + "for a list of config variables." + ) ) else: @@ -1007,7 +1025,11 @@ async def config_help(self, ctx, key: str.lower = None): return await ctx.send(embed=embed) def fmt(val): - return UnseenFormatter().format(val, prefix=self.bot.prefix, bot=self.bot) + return UnseenFormatter().format( + val, + prefix=self.bot.command_display_prefix, + bot=self.bot, + ) index = 0 embeds = [] @@ -1107,7 +1129,7 @@ async def alias(self, ctx, *, name: str.lower = None): color=self.bot.error_color, description="You dont have any aliases at the moment.", ) - embed.set_footer(text=f'Do "{self.bot.prefix}help alias" for more commands.') + embed.set_footer(text=f'Do "{self.bot.command_display_prefix}help alias" for more commands.') embed.set_author( name="Aliases", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), @@ -1156,7 +1178,7 @@ async def make_alias(self, name, value, action, ctx): color=self.bot.error_color, description="Invalid multi-step alias, try wrapping each steps in quotes.", ) - embed.set_footer(text=f'See "{self.bot.prefix}alias add" for more details.') + embed.set_footer(text=f'See "{self.bot.command_display_prefix}alias add" for more details.') return embed if len(values) > 25: @@ -2331,7 +2353,9 @@ async def autotrigger_list(self, ctx): discord.Embed( title="No autotrigger set", color=self.bot.error_color, - description=f"Use `{self.bot.prefix}autotrigger add` to add new autotriggers.", + description=( + f"Use `{self.bot.command_display_prefix}autotrigger add` " "to add new autotriggers." + ), ) ) diff --git a/core/clients.py b/core/clients.py index ff0d3ff8cd..b14adbbc19 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 @@ -399,6 +400,7 @@ async def append_log( message_id: str = "", channel_id: str = "", type_: str = "thread_message", + attachments=None, ) -> dict: return NotImplemented @@ -461,6 +463,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""" @@ -661,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: @@ -695,7 +700,7 @@ async def append_log( "size": a.size, "url": a.url, } - for a in message.attachments + for a in log_attachments ], } else: @@ -715,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}}, @@ -807,6 +811,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 b3c79bcd69..8bb8f00927 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 = { @@ -208,6 +210,9 @@ class ConfigManager: "owners": None, "enable_presence_intent": False, "registry_plugins_only": False, + # command interfaces (environment/config.json only) + "use_slash_commands": True, + "enable_prefix_commands": False, # bot "token": None, "enable_plugins": True, @@ -243,6 +248,8 @@ class ConfigManager: duration_seconds = {"snooze_default_duration", "thread_creation_menu_timeout"} + megabytes = {"snippet_attachment_max_size"} + booleans = { "use_user_id_channel_name", "use_timestamp_channel_name", @@ -282,6 +289,8 @@ class ConfigManager: "use_hoisted_top_role", "enable_presence_intent", "registry_plugins_only", + "use_slash_commands", + "enable_prefix_commands", # snooze "snooze_store_attachments", # thread creation menu booleans @@ -422,6 +431,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/slash_commands.py b/core/slash_commands.py new file mode 100644 index 0000000000..6377da0adc --- /dev/null +++ b/core/slash_commands.py @@ -0,0 +1,735 @@ +import asyncio +import inspect +import re +import typing + +import discord +from discord import app_commands +from discord.ext import commands + +from core.models import getLogger + + +logger = getLogger(__name__) + + +class SlashCommandMessage: + """Message-compatible view of an application-command interaction. + + The existing command callbacks intentionally continue to use + :class:`commands.Context`. This small adapter gives the legacy parser the + message attributes it needs while responses are handled by the interaction + attached to :class:`SlashContext`. + """ + + def __init__( + self, + interaction: discord.Interaction, + content: str, + attachment: typing.Optional[discord.Attachment] = None, + ): + self.id = interaction.id + self.author = interaction.user + self.channel = interaction.channel + self.guild = interaction.guild + self.content = content + self.created_at = interaction.created_at + self.attachments = [attachment] if attachment is not None else [] + self.stickers = [] + self.embeds = [] + self.message_snapshots = [] + self.reference = None + self.type = discord.MessageType.default + self.webhook_id = None + self.interaction = interaction + self.response_sent = False + self._state = interaction._state + + @property + def jump_url(self) -> str: + # An interaction ID is not a message ID, and slash invocations do not + # provide a user-authored message that can be linked to. + return "" + + async def add_reaction(self, _emoji) -> None: + # Interactions have no invocation message to react to. The slash-command + # runner supplies a small completion response when a callback only used + # a reaction as its acknowledgement. + return None + + async def delete(self, *, delay: typing.Optional[float] = None) -> None: + # There is no user-authored command message to remove. + return None + + async def pin(self, *, reason: typing.Optional[str] = None) -> None: + # There is no user-authored command message to pin. + return None + + +class SlashContext(commands.Context): + """Context that records whether a legacy callback produced a response.""" + + async def send(self, *args, **kwargs): + self.message.response_sent = True + return await super().send(*args, **kwargs) + + +class SlashCommandManager: + """Expose prefix-style commands through Discord application commands.""" + + ATTACHMENT_COMMANDS = { + "areply", + "fareply", + "fpareply", + "fpreply", + "freply", + "pareply", + "preply", + "reply", + "snippet add", + "snippet edit", + "threadmenu load_config", + } + REQUIRED_ATTACHMENT_COMMANDS = {"threadmenu load_config"} + REQUIRED_SLASH_PARAMETERS = { + "areply": {"msg"}, + "fareply": {"msg"}, + "fpareply": {"msg"}, + "fpreply": {"msg"}, + "freply": {"msg"}, + "pareply": {"msg"}, + "preply": {"msg"}, + "reply": {"msg"}, + } + PARAMETER_NAMES = { + "activity_type": "activity", + "msg": "message", + "plugin_name": "plugin", + "status_type": "status", + "type_": "type", + "user_or_role": "target", + "users_arg": "users", + } + IGNORED_PARAMETERS = {"contact": {"manual_trigger"}} + GROUP_CALLBACKS = { + "alias": ("show", "List saved aliases or show one by name."), + "args": ("show", "List saved reply arguments or show one by name."), + "blocked": ("list", "List users and roles currently blocked from Modmail."), + "debug": ("show", "Show the bot's recent application logs."), + "logs": ("view", "View previous Modmail logs for a user."), + "note": ("add", "Add a note to the current Modmail thread."), + "plugins registry": ("browse", "Browse approved plugins or show one plugin."), + "snippet": ("show", "List saved snippets or show one by name."), + } + # These legacy group callbacks only display prefix-command help. Discord + # already presents their real subcommands, so exposing another action for + # the callback would create a misleading synthetic subcommand. + CONTAINER_ONLY_GROUPS = { + "autotrigger", + "config", + "disable", + "oauth", + "permissions", + "plugins", + "threadmenu", + "threadmenu option", + "threadmenu submenu", + "threadmenu submenu option", + } + STATIC_CHOICES = { + ("activity", "activity_type"): ( + ("Playing", "playing"), + ("Streaming", "streaming"), + ("Listening", "listening"), + ("Watching", "watching"), + ("Competing", "competing"), + ("Custom", "custom"), + ("Clear activity", "clear"), + ), + ("close", "option"): ( + ("Silent", "silent"), + ("Cancel scheduled close", "cancel"), + ), + ("permissions add", "type_"): ( + ("Command", "command"), + ("Permission level", "level"), + ), + ("permissions override", "level_name"): ( + ("Owner", "owner"), + ("Administrator", "administrator"), + ("Moderator", "moderator"), + ("Supporter", "supporter"), + ("Regular", "regular"), + ), + ("permissions remove", "type_"): ( + ("Command", "command"), + ("Permission level", "level"), + ("Override", "override"), + ), + ("status", "status_type"): ( + ("Online", "online"), + ("Idle", "idle"), + ("Do Not Disturb", "dnd"), + ("Invisible", "invisible"), + ("Offline", "offline"), + ("Clear status", "clear"), + ), + ("update", "flag"): (("Force", "force"),), + } + + def __init__(self, bot: commands.Bot): + self.bot = bot + self._synced = False + self._synced_guild_id = None + self._registered_names = set() + + @staticmethod + def _description(command: commands.Command, fallback: typing.Optional[str] = None) -> str: + description = fallback or command.short_doc or f"Run {command.qualified_name}." + description = re.sub(r"\s+", " ", description).strip() + if not description: + description = f"Run {command.qualified_name}." + if len(description) > 100: + description = description[:97].rstrip() + "..." + return description + + @staticmethod + def _name(name: str) -> str: + name = name.lower().replace("_", "-") + name = re.sub(r"[^a-z0-9-]", "-", name) + name = re.sub(r"-+", "-", name).strip("-") + return (name or "command")[:32] + + @staticmethod + def _unique_name(name: str, used: typing.Set[str]) -> str: + base = SlashCommandManager._name(name) + candidate = base + suffix = 2 + while candidate in used: + marker = f"-{suffix}" + candidate = base[: 32 - len(marker)].rstrip("-") + marker + suffix += 1 + used.add(candidate) + return candidate + + def _parameter_name(self, parameter_name: str, used: typing.Set[str]) -> str: + display_name = self.PARAMETER_NAMES.get(parameter_name, parameter_name) + if display_name == "attachment": + display_name = "value" + return self._unique_name(display_name, used) + + def _should_register(self, command: commands.Command) -> bool: + """Return whether a legacy command belongs in Discord's public registry.""" + if command.hidden or not command.enabled: + return False + if command.qualified_name != "prefix": + return True + + config = getattr(self.bot, "config", None) + if config is None: + return True + try: + return bool(config["enable_prefix_commands"]) + except (KeyError, TypeError): + return True + + def _group_callback(self, command: commands.Group): + """Describe an intentional slash action for a callable legacy group.""" + name = command.extras.get("slash_callback_name") + if name: + description = command.extras.get("slash_callback_description") + return str(name), description + if command.qualified_name in self.CONTAINER_ONLY_GROUPS: + return None + return self.GROUP_CALLBACKS.get(command.qualified_name) + + @classmethod + def _literal_values(cls, converter) -> typing.Tuple[typing.Any, ...]: + if typing.get_origin(converter) is typing.Literal: + return typing.get_args(converter) + + values = [] + for argument in typing.get_args(converter): + if argument is type(None): + continue + values.extend(cls._literal_values(argument)) + return tuple(values) + + def _parameter_choices( + self, + command: commands.Command, + parameter: commands.Parameter, + ) -> typing.List[app_commands.Choice[str]]: + configured = self.STATIC_CHOICES.get((command.qualified_name, parameter.name)) + if configured is None: + configured = tuple( + (str(value).replace("_", " ").title(), str(value)) + for value in self._literal_values(parameter.converter) + ) + if len(configured) > 25 or any( + not name or len(name) > 100 or not value or len(value) > 100 for name, value in configured + ): + logger.warning( + "Ignoring invalid slash choices configured for %s.%s.", + command.qualified_name, + parameter.name, + ) + return [] + return [app_commands.Choice(name=name, value=value) for name, value in configured] + + @staticmethod + def _parameter_description(display_name: str) -> str: + descriptions = { + "activity": "Activity type to set, or clear the current activity.", + "after": "Delay, duration, or message using the command's documented syntax.", + "arguments": "Saved command name followed by any arguments.", + "attachment": "File used by this command.", + "body": "Python code to evaluate.", + "category": "Category ID, mention, or name.", + "command": "Command text to run.", + "duration": "A human-readable duration.", + "flag": "Optional command mode.", + "level-name": "Permission level to assign.", + "message": "Message text.", + "option": "Close mode.", + "status": "Status to set, or clear the current status.", + "target": "User or role ID, mention, or name.", + "type": "Permission target type.", + "users": "One or more user or role IDs, mentions, or names.", + } + return descriptions.get( + display_name, + f"Value for {display_name.replace('-', ' ')}.", + ) + + @staticmethod + def _quote_positional(value: str) -> str: + if not value or any(character.isspace() for character in value) or '"' in value: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + return value + + def _serialize_arguments( + self, + parameters: typing.Sequence[commands.Parameter], + values: typing.Dict[str, str], + ) -> typing.Optional[str]: + arguments = [] + for parameter in parameters: + value = values.get(parameter.name) + if value is None: + continue + value = str(value) + converter = parameter.converter + greedy = converter.__class__.__name__ == "Greedy" + if parameter.kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.VAR_POSITIONAL) or greedy: + arguments.append(value) + else: + arguments.append(self._quote_positional(value)) + return " ".join(arguments) or None + + def _callback( + self, + command: commands.Command, + legacy_path: str, + *, + raw_arguments: bool, + has_attachment: bool, + attachment_required: bool, + ): + ignored = self.IGNORED_PARAMETERS.get(command.qualified_name, set()) + parameters = [ + parameter for parameter in command.clean_params.values() if parameter.name not in ignored + ] + + async def callback(interaction, **values): + attachment = values.pop("attachment", None) + if raw_arguments: + arguments = values.get("arguments") + else: + arguments = self._serialize_arguments(parameters, values) + await self.invoke( + interaction, + legacy_path, + arguments=arguments, + attachment=attachment, + raw_arguments=raw_arguments, + ) + + used_names = set() + option_specs = [] + choices = {} + if raw_arguments: + option_specs.append(("arguments", "arguments", True, str)) + else: + required_parameters = self.REQUIRED_SLASH_PARAMETERS.get(command.qualified_name, set()) + for parameter in parameters: + display_name = self._parameter_name(parameter.name, used_names) + required = parameter.required or parameter.name in required_parameters + option_specs.append((parameter.name, display_name, required, str)) + parameter_choices = self._parameter_choices(command, parameter) + if parameter_choices: + choices[parameter.name] = parameter_choices + + if has_attachment: + used_names.add("attachment") + option_specs.append( + ( + "attachment", + "attachment", + attachment_required, + discord.Attachment, + ) + ) + + # Discord requires required options before optional options. Slash + # options are named, so this does not change legacy parsing order. + option_specs.sort(key=lambda item: not item[2]) + signature = [ + inspect.Parameter( + "interaction", + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=discord.Interaction, + ) + ] + descriptions = {} + renames = {} + for python_name, display_name, required, annotation in option_specs: + signature.append( + inspect.Parameter( + python_name, + inspect.Parameter.KEYWORD_ONLY, + annotation=annotation if required else typing.Optional[annotation], + default=inspect.Parameter.empty if required else None, + ) + ) + descriptions[python_name] = self._parameter_description(display_name) + if python_name != display_name: + renames[python_name] = display_name + + callback.__signature__ = inspect.Signature(signature) + if descriptions: + callback = app_commands.describe(**descriptions)(callback) + if choices: + callback = app_commands.choices(**choices)(callback) + if renames: + callback = app_commands.rename(**renames)(callback) + return callback + + def _application_command( + self, + command: commands.Command, + *, + name: typing.Optional[str] = None, + legacy_path: typing.Optional[str] = None, + description: typing.Optional[str] = None, + raw_arguments: bool = False, + has_attachment: typing.Optional[bool] = None, + ) -> app_commands.Command: + qualified_name = command.qualified_name + if has_attachment is None: + has_attachment = qualified_name in self.ATTACHMENT_COMMANDS or bool( + command.extras.get("slash_attachment") + ) + attachment_required = qualified_name in self.REQUIRED_ATTACHMENT_COMMANDS or bool( + command.extras.get("slash_attachment_required") + ) + return app_commands.Command( + name=self._name(name or command.name), + description=self._description(command, description), + callback=self._callback( + command, + legacy_path if legacy_path is not None else command.qualified_name, + raw_arguments=raw_arguments, + has_attachment=has_attachment, + attachment_required=attachment_required, + ), + extras={ + "modmail_legacy_command": command.qualified_name, + "modmail_legacy_path": (legacy_path if legacy_path is not None else command.qualified_name), + }, + ) + + def _add_group_callback( + self, + target: app_commands.Group, + legacy_group: commands.Group, + used: typing.Set[str], + ) -> None: + callback = self._group_callback(legacy_group) + if callback is None: + return + name, description = callback + target.add_command( + self._application_command( + legacy_group, + name=self._unique_name(name, used), + legacy_path=legacy_group.qualified_name, + description=description, + ) + ) + + def _subgroup(self, legacy_group: commands.Group) -> app_commands.Group: + subgroup = app_commands.Group( + name=self._name(legacy_group.name), + description=self._description(legacy_group), + ) + used = set() + self._add_group_callback(subgroup, legacy_group, used) + + for child in sorted(legacy_group.commands, key=lambda item: item.name): + if isinstance(child, commands.Group) or not self._should_register(child): + continue + name = self._unique_name(child.name, used) + subgroup.add_command(self._application_command(child, name=name)) + + return subgroup + + def _add_hoisted_groups( + self, + target: app_commands.Group, + legacy_group: commands.Group, + name_prefix: str, + used: typing.Set[str], + ) -> None: + """Hoist groups deeper than Discord's nesting limit into named sibling groups.""" + for child in sorted(legacy_group.commands, key=lambda item: item.name): + if not isinstance(child, commands.Group) or not self._should_register(child): + continue + + composite_name = f"{name_prefix}-{child.name}" + subgroup = self._subgroup(child) + if subgroup.commands: + subgroup.name = self._unique_name(composite_name, used) + target.add_command(subgroup) + self._add_hoisted_groups(target, child, composite_name, used) + + def _root_group(self, legacy_group: commands.Group) -> app_commands.Group: + group = app_commands.Group( + name=self._name(legacy_group.name), + description=self._description(legacy_group), + ) + used = set() + self._add_group_callback(group, legacy_group, used) + + for child in sorted(legacy_group.commands, key=lambda item: item.name): + if not self._should_register(child): + continue + if isinstance(child, commands.Group): + subgroup = self._subgroup(child) + if subgroup.commands: + subgroup.name = self._unique_name(child.name, used) + group.add_command(subgroup) + self._add_hoisted_groups(group, child, child.name, used) + continue + name = self._unique_name(child.name, used) + group.add_command(self._application_command(child, name=name)) + + # Saved snippet and alias names are dynamic and therefore cannot be + # registered as Discord subcommands. These are the only intentional + # synthetic actions in the generated tree. + if legacy_group.name == "snippet" and "send" not in used: + used.add("send") + group.add_command( + self._application_command( + legacy_group, + name="send", + legacy_path="", + description="Send a saved snippet by name.", + raw_arguments=True, + ) + ) + elif legacy_group.name == "alias" and "run" not in used: + used.add("run") + group.add_command( + self._application_command( + legacy_group, + name="run", + legacy_path="", + description="Run a saved command alias by name.", + raw_arguments=True, + has_attachment=True, + ) + ) + + return group + + def register(self, guild: discord.Object) -> int: + count = 0 + used = set() + + for legacy_command in sorted(self.bot.commands, key=lambda item: item.name): + if not self._should_register(legacy_command): + continue + name = self._unique_name(legacy_command.name, used) + try: + if isinstance(legacy_command, commands.Group): + command = self._root_group(legacy_command) + if not command.commands: + continue + command.name = name + else: + command = self._application_command(legacy_command, name=name) + + existing = self.bot.tree.get_command(name, guild=guild) + if existing is not None: + logger.warning( + "Skipping generated slash command /%s because an application command " + "already uses it.", + name, + ) + continue + + self.bot.tree.add_command(command, guild=guild) + except (app_commands.CommandAlreadyRegistered, app_commands.CommandLimitReached): + logger.exception("Slash command /%s could not be registered.", name) + continue + self._registered_names.add(name) + count += 1 + + return count + + def _configured_guild_ids(self) -> typing.Set[int]: + """Guilds that may contain commands from this Modmail instance.""" + return { + guild_id + for guild_id in ( + self.bot.guild_id, + self.bot.inbox_guild_id, + self._synced_guild_id, + ) + if guild_id is not None + } + + async def _clear_guild(self, guild_id: int) -> bool: + guild = discord.Object(id=guild_id) + self.bot.tree.clear_commands(guild=guild) + try: + await self.bot.tree.sync(guild=guild) + except Exception: + logger.exception("Failed to remove slash commands from guild %s.", guild_id) + return False + logger.info("Slash commands removed from guild %s.", guild_id) + return True + + async def sync(self, *, force: bool = False) -> None: + if self._synced and not force: + return + target_guild_id = self.bot.inbox_guild_id + if target_guild_id is None: + logger.error( + "Slash commands could not be synced because neither MODMAIL_GUILD_ID nor " + "GUILD_ID is configured." + ) + return + + # Older builds registered commands in GUILD_ID. Remove that stale + # registry when a separate MODMAIL_GUILD_ID inbox is configured. + for guild_id in self._configured_guild_ids() - {target_guild_id}: + await self._clear_guild(guild_id) + + guild = discord.Object(id=target_guild_id) + if force: + for name in self._registered_names: + self.bot.tree.remove_command(name, guild=guild) + self._registered_names.clear() + # Prefer any native application commands supplied by plugins. Generated + # compatibility commands fill only the remaining names. + self.bot.tree.copy_global_to(guild=guild) + generated = self.register(guild) + try: + synced = await self.bot.tree.sync(guild=guild) + except Exception: + logger.exception("Failed to sync slash commands to guild %s.", guild.id) + return + + self._synced = True + self._synced_guild_id = target_guild_id + logger.info( + "Synced %d slash command roots to inbox guild %s (%d generated).", + len(synced), + guild.id, + generated, + ) + + async def refresh(self) -> None: + """Refresh Discord's registry after a runtime plugin change.""" + if self._synced: + await self.sync(force=True) + + async def disable(self) -> None: + """Remove this instance's guild commands when slash commands are disabled.""" + guild_ids = self._configured_guild_ids() + if not guild_ids: + logger.warning( + "Slash commands could not be removed because neither MODMAIL_GUILD_ID nor " + "GUILD_ID is configured." + ) + return + + removed = True + for guild_id in guild_ids: + removed = await self._clear_guild(guild_id) and removed + + if not removed: + return + self._registered_names.clear() + self._synced = False + self._synced_guild_id = None + + async def invoke( + self, + interaction: discord.Interaction, + legacy_path: str, + *, + arguments: typing.Optional[str], + attachment: typing.Optional[discord.Attachment], + raw_arguments: bool, + ) -> None: + if not interaction.response.is_done(): + await interaction.response.defer(thinking=True) + + command_text = (arguments or "").strip() if raw_arguments else legacy_path + if not raw_arguments and arguments and arguments.strip(): + command_text = f"{command_text} {arguments.strip()}" + + if not command_text: + await interaction.edit_original_response(content="Please provide a command name.") + return + + mention_prefix = f"<@{self.bot.user.id}> " + message = SlashCommandMessage( + interaction, + mention_prefix + command_text, + attachment=attachment, + ) + + try: + await self.bot.process_commands(message, cls=SlashContext) + # Bot event dispatch is scheduled; give command error handlers a + # chance to send their interaction response before the fallback. + await asyncio.sleep(0) + except Exception: + logger.exception("Unexpected failure while running slash command %s.", command_text) + if not message.response_sent: + await interaction.edit_original_response( + content="The command could not be completed. Check the bot logs for details." + ) + return + + if not message.response_sent: + # Commands such as /reply acknowledge prefix invocations with a + # reaction. A slash interaction has no message to react to, so its + # deferred response is only transport-level bookkeeping. Removing + # it keeps successful, no-output commands out of the channel while + # preserving real command responses and errors. + try: + await interaction.delete_original_response() + except discord.NotFound: + pass + except discord.HTTPException: + logger.warning( + "Failed to remove the acknowledgement for slash command %s.", + command_text, + exc_info=True, + ) diff --git a/core/thread.py b/core/thread.py index 8bc83f7324..8e26491fbc 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1622,6 +1622,19 @@ async def note( return msg + @staticmethod + def _get_log_attachments(message: discord.Message, sent_message: discord.Message): + """Use uploaded snippet URLs while retaining ordinary source attachments.""" + if not any(getattr(attachment, "is_snippet_attachment", False) for attachment in message.attachments): + return None + + source_attachments = [ + attachment + for attachment in message.attachments + if not getattr(attachment, "is_snippet_attachment", False) + ] + return [*sent_message.attachments, *source_attachments] + async def reply( self, message: discord.Message, @@ -1750,12 +1763,14 @@ async def reply( msg = None if msg is not None: + log_attachments = self._get_log_attachments(message, msg) 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: @@ -2004,7 +2019,27 @@ async def send( images = [] attachments = [] - for attachment in ext: + 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 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: + 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: @@ -2079,6 +2114,15 @@ 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 = [] @@ -2171,7 +2215,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 ( @@ -2223,6 +2269,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 snippet attachment to file.", exc_info=True) + if plain: if from_mod and not isinstance(destination, discord.TextChannel): # Plain to user (DM) @@ -2234,8 +2287,10 @@ 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: + if getattr(att, "is_snippet_attachment", False): + continue try: files.append(await att.to_file()) except Exception: @@ -2246,11 +2301,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) + msg = await destination.send(mentions, embed=embed, files=discord_files or None) else: try: - msg = await destination.send(mentions, embed=embed) + msg = await destination.send(mentions, embed=embed, files=discord_files or None) except discord.NotFound: if ( isinstance(destination, discord.TextChannel) @@ -2260,7 +2315,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) + msg = await destination.send(mentions, embed=embed, files=discord_files or None) else: logger.warning("Channel not found during send.") raise diff --git a/pyproject.toml b/pyproject.toml index 719abc9447..769d657b60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ extend-exclude = ''' [tool.poetry] name = 'Modmail' -version = '4.2.1' +version = '4.4.0' description = "Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way." license = 'AGPL-3.0-only' authors = [