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 = [