Skip to content

Allow snippets to be used in aliases. #3124

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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))
- Snippets can be used in aliases. ([GH #3108](https://github.com/kyb3r/modmail/issues/3108))

### Fixed

Expand Down
86 changes: 66 additions & 20 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -955,6 +979,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.
Expand All @@ -976,28 +1010,54 @@ 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:
logger.warning("Alias %s is invalid, removing.", invoker)
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):
Expand Down Expand Up @@ -1139,20 +1199,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:
Expand Down
108 changes: 99 additions & 9 deletions cogs/modmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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()
Expand Down
17 changes: 15 additions & 2 deletions cogs/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down