diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b75c286aa..4696b7067c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s - Modmail now uses per-server avatars if applicable. ([GH #3048](https://github.com/kyb3r/modmail/issues/3048)) - Use discord relative timedeltas. ([GH #3046](https://github.com/kyb3r/modmail/issues/3046)) - Use discord native buttons for all paginator sessions. +- Snippets can be used in aliases. ([GH #3108](https://github.com/kyb3r/modmail/issues/3108), [PR #3124](https://github.com/kyb3r/modmail/pull/3124)) ### Fixed diff --git a/bot.py b/bot.py index 2887fd79dc..4fb57f1886 100644 --- a/bot.py +++ b/bot.py @@ -46,7 +46,7 @@ ) from core.thread import ThreadManager from core.time import human_timedelta -from core.utils import normalize_alias, truncate, tryint +from core.utils import normalize_alias, parse_alias, truncate, tryint logger = getLogger(__name__) @@ -85,6 +85,30 @@ def __init__(self): self.plugin_db = PluginDatabaseClient(self) # Deprecated self.startup() + def _resolve_snippet(self, name: str) -> typing.Optional[str]: + """ + Get actual snippet names from direct aliases to snippets. + + If the provided name is a snippet, it's returned unchanged. + If there is an alias by this name, it is parsed to see if it + refers only to a snippet, in which case that snippet name is + returned. + + If no snippets were found, None is returned. + """ + if name in self.snippets: + return name + + try: + (command,) = parse_alias(self.aliases[name]) + except (KeyError, ValueError): + # There is either no alias by this name present or the + # alias has multiple steps. + pass + else: + if command in self.snippets: + return command + @property def uptime(self) -> str: now = discord.utils.utcnow() @@ -935,6 +959,16 @@ async def process_dm_modmail(self, message: discord.Message) -> None: await self.add_reaction(message, sent_emoji) self.dispatch("thread_reply", thread, False, message, False, False) + def _get_snippet_command(self) -> commands.Command: + """Get the correct reply command based on the snippet config""" + modifiers = "f" + if self.config["plain_snippets"]: + modifiers += "p" + if self.config["anonymous_snippets"]: + modifiers += "a" + + return self.get_command(f"{modifiers}reply") + async def get_contexts(self, message, *, cls=commands.Context): """ Returns all invocation contexts from the message. @@ -956,9 +990,17 @@ async def get_contexts(self, message, *, cls=commands.Context): invoker = view.get_word().lower() + # Check if a snippet is being called. + # This needs to be done before checking for aliases since + # snippets can have multiple words. + try: + snippet_text = self.snippets[message.content.removeprefix(invoked_prefix)] + except KeyError: + snippet_text = None + # Check if there is any aliases being called. alias = self.aliases.get(invoker) - if alias is not None: + if alias is not None and snippet_text is None: ctxs = [] aliases = normalize_alias(alias, message.content[len(f"{invoked_prefix}{invoker}") :]) if not aliases: @@ -966,18 +1008,36 @@ async def get_contexts(self, message, *, cls=commands.Context): self.aliases.pop(invoker) for alias in aliases: - view = StringView(invoked_prefix + alias) + command = None + try: + snippet_text = self.snippets[alias] + except KeyError: + command_invocation_text = alias + 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_.thread = thread discord.utils.find(view.skip_string, prefixes) ctx_.invoked_with = view.get_word().lower() - ctx_.command = self.all_commands.get(ctx_.invoked_with) + ctx_.command = command or self.all_commands.get(ctx_.invoked_with) ctxs += [ctx_] return ctxs ctx.thread = thread - ctx.invoked_with = invoker - ctx.command = self.all_commands.get(invoker) + + if snippet_text is not None: + # Process snippets + ctx.command = self._get_snippet_command() + reply_view = StringView(f"{invoked_prefix}{ctx.command} {snippet_text}") + discord.utils.find(reply_view.skip_string, prefixes) + ctx.invoked_with = reply_view.get_word().lower() + ctx.view = reply_view + else: + ctx.command = self.all_commands.get(invoker) + ctx.invoked_with = invoker + return [ctx] async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context): @@ -1119,20 +1179,6 @@ async def process_commands(self, message): if isinstance(message.channel, discord.DMChannel): return await self.process_dm_modmail(message) - if message.content.startswith(self.prefix): - cmd = message.content[len(self.prefix) :].strip() - - # Process snippets - cmd = cmd.lower() - if cmd in self.snippets: - snippet = self.snippets[cmd] - modifiers = "f" - if self.config["plain_snippets"]: - modifiers += "p" - if self.config["anonymous_snippets"]: - modifiers += "a" - message.content = f"{self.prefix}{modifiers}reply {snippet}" - ctxs = await self.get_contexts(message) for ctx in ctxs: if ctx.command: diff --git a/cogs/modmail.py b/cogs/modmail.py index ea36bef7b9..bb8c90e29e 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -7,6 +7,7 @@ import discord from discord.ext import commands +from discord.ext.commands.view import StringView from discord.ext.commands.cooldowns import BucketType from discord.role import Role from discord.utils import escape_markdown @@ -143,12 +144,14 @@ async def snippet(self, ctx, *, name: str.lower = None): """ if name is not None: - val = self.bot.snippets.get(name) - if val is None: + snippet_name = self.bot._resolve_snippet(name) + + if snippet_name is None: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") else: + val = self.bot.snippets[snippet_name] embed = discord.Embed( - title=f'Snippet - "{name}":', description=val, color=self.bot.main_color + title=f'Snippet - "{snippet_name}":', description=val, color=self.bot.main_color ) return await ctx.send(embed=embed) @@ -177,13 +180,13 @@ async def snippet_raw(self, ctx, *, name: str.lower): """ View the raw content of a snippet. """ - val = self.bot.snippets.get(name) - if val is None: + snippet_name = self.bot._resolve_snippet(name) + if snippet_name is None: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") else: - val = truncate(escape_code_block(val), 2048 - 7) + val = truncate(escape_code_block(self.bot.snippets[snippet_name]), 2048 - 7) embed = discord.Embed( - title=f'Raw snippet - "{name}":', + title=f'Raw snippet - "{snippet_name}":', description=f"```\n{val}```", color=self.bot.main_color, ) @@ -246,16 +249,103 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte ) return await ctx.send(embed=embed) + def _fix_aliases(self, snippet_being_deleted: str) -> tuple[list[str]]: + """ + Remove references to the snippet being deleted from aliases. + + Direct aliases to snippets are deleted, and aliases having + other steps are edited. + + A tuple of dictionaries are returned. The first dictionary + contains a mapping of alias names which were deleted to their + original value, and the second dictionary contains a mapping + of alias names which were edited to their original value. + """ + deleted = {} + edited = {} + + # Using a copy since we might need to delete aliases + for alias, val in self.bot.aliases.copy().items(): + values = parse_alias(val) + + save_aliases = [] + + for val in values: + view = StringView(val) + linked_command = view.get_word().lower() + message = view.read_rest() + + if linked_command == snippet_being_deleted: + continue + + is_valid_snippet = snippet_being_deleted in self.bot.snippets + + if not self.bot.get_command(linked_command) and not is_valid_snippet: + alias_command = self.bot.aliases[linked_command] + save_aliases.extend(normalize_alias(alias_command, message)) + else: + save_aliases.append(val) + + if not save_aliases: + original_value = self.bot.aliases.pop(alias) + deleted[alias] = original_value + else: + original_alias = self.bot.aliases[alias] + new_alias = " && ".join(f'"{a}"' for a in save_aliases) + + if original_alias != new_alias: + self.bot.aliases[alias] = new_alias + edited[alias] = original_alias + + return deleted, edited + @snippet.command(name="remove", aliases=["del", "delete"]) @checks.has_permissions(PermissionLevel.SUPPORTER) async def snippet_remove(self, ctx, *, name: str.lower): """Remove a snippet.""" - if name in self.bot.snippets: + deleted_aliases, edited_aliases = self._fix_aliases(name) + + deleted_aliases_string = ",".join(f"`{alias}`" for alias in deleted_aliases) + if len(deleted_aliases) == 1: + deleted_aliases_output = f"The `{deleted_aliases_string}` direct alias has been removed." + elif deleted_aliases: + deleted_aliases_output = ( + f"The following direct aliases have been removed: {deleted_aliases_string}." + ) + else: + deleted_aliases_output = None + + if len(edited_aliases) == 1: + alias, val = edited_aliases.popitem() + edited_aliases_output = ( + f"Steps pointing to this snippet have been removed from the `{alias}` alias" + f" (previous value: `{val}`).`" + ) + elif edited_aliases: + alias_list = "\n".join( + [ + f"- `{alias_name}` (previous value: `{val}`)" + for alias_name, val in edited_aliases.items() + ] + ) + edited_aliases_output = ( + f"Steps pointing to this snippet have been removed from the following aliases:" + f"\n\n{alias_list}" + ) + else: + edited_aliases_output = None + + description = f"Snippet `{name}` is now deleted." + if deleted_aliases_output: + description += f"\n\n{deleted_aliases_output}" + if edited_aliases_output: + description += f"\n\n{edited_aliases_output}" + embed = discord.Embed( title="Removed snippet", color=self.bot.main_color, - description=f"Snippet `{name}` is now deleted.", + description=description, ) self.bot.snippets.pop(name) await self.bot.config.update() diff --git a/cogs/utility.py b/cogs/utility.py index df904cd331..23827aa0c4 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -184,7 +184,18 @@ async def send_error_message(self, error): val = self.context.bot.snippets.get(command) if val is not None: embed = discord.Embed(title=f"{command} is a snippet.", color=self.context.bot.main_color) - embed.add_field(name=f"`{command}` will send:", value=val) + embed.add_field(name=f"`{command}` will send:", value=val, inline=False) + + snippet_aliases = [] + for alias in self.context.bot.aliases: + if self.context.bot._resolve_snippet(alias) == command: + snippet_aliases.append(f"`{alias}`") + + if snippet_aliases: + embed.add_field( + name=f"Aliases to this snippet:", value=",".join(snippet_aliases), inline=False + ) + return await self.get_destination().send(embed=embed) val = self.context.bot.aliases.get(command) @@ -1070,7 +1081,9 @@ async def make_alias(self, name, value, action): linked_command = view.get_word().lower() message = view.read_rest() - if not self.bot.get_command(linked_command): + is_snippet = val in self.bot.snippets + + if not self.bot.get_command(linked_command) and not is_snippet: alias_command = self.bot.aliases.get(linked_command) if alias_command is not None: save_aliases.extend(utils.normalize_alias(alias_command, message))