jack1142 febca8ccbb
Migration to discord.py 2.0 (#5600)
* Temporarily set d.py to use latest git revision

* Remove `bot` param to Client.start

* Switch to aware datetimes

A lot of this is removing `.replace(...)` which while not technically
needed, simplifies the code base. There's only a few changes that are
actually necessary here.

* Update to work with new Asset design

* [threads] Update core ModLog API to support threads

- Added proper support for passing `Thread` to `channel`
  when creating/editing case
- Added `parent_channel_id` attribute to Modlog API's Case
    - Added `parent_channel` property that tries to get parent channel
- Updated case's content to show both thread and parent information

* [threads] Disallow usage of threads in some of the commands

- announceset channel
- filter channel clear
- filter channel add
- filter channel remove
- GlobalUniqueObjectFinder converter
    - permissions addglobalrule
    - permissions removeglobalrule
    - permissions removeserverrule
    - Permissions cog does not perform any validation for IDs
      when setting through YAML so that has not been touched
- streamalert twitch/youtube/picarto
- embedset channel
- set ownernotifications adddestination

* [threads] Handle threads in Red's permissions system (Requires)

- Made permissions system apply rules of (only) parent in threads

* [threads] Update embed_requested to support threads

- Threads don't have their own embed settings and inherit from parent

* [threads] Update Red.message_eligible_as_command to support threads

* [threads] Properly handle invocation of [p](un)mutechannel in threads

Usage of a (un)mutechannel will mute/unmute user in the parent channel
if it's invoked in a thread.

* [threads] Update Filter cog to properly handle threads

- `[p]filter channel list` in a threads sends list for parent channel
- Checking for filter hits for a message in a thread checks its parent
  channel's word list. There's no separate word list for threads.

* [threads] Support threads in Audio cog

- Handle threads being notify channels
- Update type hint for `is_query_allowed()`

* [threads] Update type hints and documentation to reflect thread support

- Documented that `{channel}` in CCs might be a thread
- Allowed (documented) usage of threads with `Config.channel()`
    - Separate thread scope is still in the picture though
      if it were to be done, it's going to be in separate in PR
- GuildContext.channel might be Thread

* Use less costy channel check in customcom's on_message_without_command

This isn't needed for d.py 2.0 but whatever...

* Update for in-place edits

* Embed's bool changed behavior, I'm hoping it doesn't affect us

* Address User.permissions_in() removal

* Swap VerificationLevel.extreme with VerificationLevel.highest

* Change to keyword-only parameters

* Change of `Guild.vanity_invite()` return type

* avatar -> display_avatar

* Fix metaclass shenanigans with Converter

* Update Red.add_cog() to be inline with `dpy_commands.Bot.add_cog()`

This means adding `override` keyword-only parameter and causing
small breakage by swapping RuntimeError with discord.ClientException.

* Address all DEP-WARNs

* Remove Context.clean_prefix and use upstream implementation instead

* Remove commands.Literal and use upstream implementation instead

Honestly, this was a rather bad implementation anyway...

Breaking but actually not really - it was provisional.

* Update Command.callback's setter

Support for functools.partial is now built into d.py

* Add new perms in HUMANIZED_PERM mapping (some from d.py 1.7 it seems)

BTW, that should really be in core instead of what we have now...

* Remove the part of do_conversion that has not worked for a long while

* Stop wrapping BadArgument in ConversionFailure

This is breaking but it's best to resolve it like this.

The functionality of ConversionFailure can be replicated with
Context.current_parameter and Context.current_argument.

* Add custom errors for int and float converters

* Remove Command.__call__ as it's now implemented in d.py

* Get rid of _dpy_reimplements

These were reimplemented for the purpose of typing
so it is no longer needed now that d.py is type hinted.

* Add return to Red.remove_cog

* Ensure we don't delete messages that differ only by used sticker

* discord.InvalidArgument->ValueError

* Move from raw <t:...> syntax to discord.utils.format_dt()

* Address AsyncIter removal

* Swap to pos-only for params that are pos-only in upstream

* Update for changes to Command.params

* [threads] Support threads in ignore checks and allow ignoring them

- Updated `[p](un)ignore channel` to accept threads
- Updated `[p]ignore list` to list ignored threads
- Updated logic in `Red.ignored_channel_or_guild()`

Ignores for guild channels now work as follows (only changes for threads):
- if channel is not a thread:
    - check if user has manage channels perm in channel
      and allow command usage if so
    - check if channel is ignored and disallow command usage if so
    - allow command usage if none of the conditions above happened
- if channel is a thread:
    - check if user has manage channels perm in parent channel
      and allow command usage if so
    - check if parent channel is ignored and disallow command usage
      if so
    - check if user has manage thread perm in parent channel
      and allow command usage if so
    - check if thread is ignored and disallow command usage if so
    - allow command usage if none of the conditions above happened

* [partial] Raise TypeError when channel is of PartialMessageable type

- Red.embed_requested
- Red.ignored_channel_or_guild

* [partial] Discard command messages when channel is PartialMessageable

* [threads] Add utilities for checking appropriate perms in both channels & threads

* [threads] Update code to use can_react_in() and @bot_can_react()

* [threads] Update code to use can_send_messages_in

* [threads] Add send_messages_in_threads perm to mute role and overrides

* [threads] Update code to use (bot/user)_can_manage_channel

* [threads] Update [p]diagnoseissues to work with threads

* Type hint fix

* [threads] Patch vendored discord.ext.menus to check proper perms in threads

I guess we've reached time when we have to patch the lib we vendor...

* Make docs generation work with non-final d.py releases

* Update discord.utils.oauth_url() usage

* Swap usage of discord.Embed.Empty/discord.embeds.EmptyEmbed to None

* Update usage of Guild.member_count to work with `None`

* Switch from Guild.vanity_invite() to Guild.vanity_url

* Update startup process to work with d.py's new asynchronous startup

* Use setup_hook() for pre-connect actions

* Update core's add_cog, remove_cog, and load_extension methods

* Update all setup functions to async and add awaits to bot.add_cog calls

* Modernize cogs by using async cog_load and cog_unload

* Address StoreChannel removal

* [partial] Disallow passing PartialMessageable to Case.channel

* [partial] Update cogs and utils to work better with PartialMessageable

- Ignore messages with PartialMessageable channel in CustomCommands cog
- In Filter cog, don't pass channel to modlog.create_case()
  if it's PartialMessageable
- In Trivia cog, only compare channel IDs
- Make `.utils.menus.menu()` work for messages
  with PartialMessageable channel
- Make checks in `.utils.tunnel.Tunnel.communicate()` more rigid

* Add few missing DEP-WARNs
2022-04-03 03:21:20 +02:00

480 lines
18 KiB
Python

import asyncio
import logging
from copy import copy
from re import search
from string import Formatter
from typing import Dict, List, Literal
import discord
from redbot.core import Config, commands, checks
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.chat_formatting import box, pagify
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
from redbot.core.bot import Red
from .alias_entry import AliasEntry, AliasCache, ArgParseError
_ = Translator("Alias", __file__)
log = logging.getLogger("red.cogs.alias")
class _TrackingFormatter(Formatter):
def __init__(self):
super().__init__()
self.max = -1
def get_value(self, key, args, kwargs):
if isinstance(key, int):
self.max = max((key, self.max))
return super().get_value(key, args, kwargs)
@cog_i18n(_)
class Alias(commands.Cog):
"""Create aliases for commands.
Aliases are alternative names/shortcuts for commands. They
can act as both a lambda (storing arguments for repeated use)
or as simply a shortcut to saying "x y z".
When run, aliases will accept any additional arguments
and append them to the stored alias.
"""
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.config = Config.get_conf(self, 8927348724)
self.config.register_global(entries=[], handled_string_creator=False)
self.config.register_guild(entries=[])
self._aliases: AliasCache = AliasCache(config=self.config, cache_enabled=True)
async def cog_load(self) -> None:
await self._maybe_handle_string_keys()
if not self._aliases._loaded:
await self._aliases.load_aliases()
async def red_delete_data_for_user(
self,
*,
requester: Literal["discord_deleted_user", "owner", "user", "user_strict"],
user_id: int,
):
if requester != "discord_deleted_user":
return
await self._aliases.anonymize_aliases(user_id)
async def _maybe_handle_string_keys(self):
# This isn't a normal schema migration because it's being added
# after the fact for GH-3788
if await self.config.handled_string_creator():
return
async with self.config.entries() as alias_list:
bad_aliases = []
for a in alias_list:
for keyname in ("creator", "guild"):
if isinstance((val := a.get(keyname)), str):
try:
a[keyname] = int(val)
except ValueError:
# Because migrations weren't created as changes were made,
# and the prior form was a string of an ID,
# if this fails, there's nothing to go back to
bad_aliases.append(a)
break
for a in bad_aliases:
alias_list.remove(a)
# if this was using a custom group of (guild_id, aliasname) it would be better but...
all_guild_aliases = await self.config.all_guilds()
for guild_id, guild_data in all_guild_aliases.items():
to_set = []
modified = False
for a in guild_data.get("entries", []):
for keyname in ("creator", "guild"):
if isinstance((val := a.get(keyname)), str):
try:
a[keyname] = int(val)
except ValueError:
break
finally:
modified = True
else:
to_set.append(a)
if modified:
await self.config.guild_from_id(guild_id).entries.set(to_set)
await asyncio.sleep(0)
# control yielded per loop since this is most likely to happen
# at bot startup, where this is most likely to have a performance
# hit.
await self.config.handled_string_creator.set(True)
def is_command(self, alias_name: str) -> bool:
"""
The logic here is that if this returns true, the name should not be used for an alias
The function name can be changed when alias is reworked
"""
command = self.bot.get_command(alias_name)
return command is not None or alias_name in commands.RESERVED_COMMAND_NAMES
@staticmethod
def is_valid_alias_name(alias_name: str) -> bool:
return not bool(search(r"\s", alias_name)) and alias_name.isprintable()
async def get_prefix(self, message: discord.Message) -> str:
"""
Tries to determine what prefix is used in a message object.
Looks to identify from longest prefix to smallest.
Will raise ValueError if no prefix is found.
:param message: Message object
:return:
"""
content = message.content
prefix_list = await self.bot.command_prefix(self.bot, message)
prefixes = sorted(prefix_list, key=lambda pfx: len(pfx), reverse=True)
for p in prefixes:
if content.startswith(p):
return p
raise ValueError("No prefix found.")
async def call_alias(self, message: discord.Message, prefix: str, alias: AliasEntry):
new_message = copy(message)
try:
args = alias.get_extra_args_from_alias(message, prefix)
except commands.BadArgument:
return
trackform = _TrackingFormatter()
command = trackform.format(alias.command, *args)
# noinspection PyDunderSlots
new_message.content = "{}{} {}".format(
prefix, command, " ".join(args[trackform.max + 1 :])
).strip()
await self.bot.process_commands(new_message)
async def paginate_alias_list(
self, ctx: commands.Context, alias_list: List[AliasEntry]
) -> None:
names = sorted(["+ " + a.name for a in alias_list])
message = "\n".join(names)
temp = list(pagify(message, delims=["\n"], page_length=1850))
alias_list = []
count = 0
for page in temp:
count += 1
page = page.lstrip("\n")
page = (
_("Aliases:\n")
+ page
+ _("\n\nPage {page}/{total}").format(page=count, total=len(temp))
)
alias_list.append(box("".join(page), "diff"))
if len(alias_list) == 1:
await ctx.send(alias_list[0])
return
await menu(ctx, alias_list, DEFAULT_CONTROLS)
@commands.group()
async def alias(self, ctx: commands.Context):
"""Manage command aliases."""
pass
@alias.group(name="global")
async def global_(self, ctx: commands.Context):
"""Manage global aliases."""
pass
@checks.mod_or_permissions(manage_guild=True)
@alias.command(name="add")
@commands.guild_only()
async def _add_alias(self, ctx: commands.Context, alias_name: str, *, command):
"""Add an alias for a command."""
# region Alias Add Validity Checking
is_command = self.is_command(alias_name)
if is_command:
await ctx.send(
_(
"You attempted to create a new alias"
" with the name {name} but that"
" name is already a command on this bot."
).format(name=alias_name)
)
return
alias = await self._aliases.get_alias(ctx.guild, alias_name)
if alias:
await ctx.send(
_(
"You attempted to create a new alias"
" with the name {name} but that"
" alias already exists."
).format(name=alias_name)
)
return
is_valid_name = self.is_valid_alias_name(alias_name)
if not is_valid_name:
await ctx.send(
_(
"You attempted to create a new alias"
" with the name {name} but that"
" name is an invalid alias name. Alias"
" names may not contain spaces."
).format(name=alias_name)
)
return
given_command_exists = self.bot.get_command(command.split(maxsplit=1)[0]) is not None
if not given_command_exists:
await ctx.send(
_("You attempted to create a new alias for a command that doesn't exist.")
)
return
# endregion
# At this point we know we need to make a new alias
# and that the alias name is valid.
try:
await self._aliases.add_alias(ctx, alias_name, command)
except ArgParseError as e:
return await ctx.send(" ".join(e.args))
await ctx.send(
_("A new alias with the trigger `{name}` has been created.").format(name=alias_name)
)
@checks.is_owner()
@global_.command(name="add")
async def _add_global_alias(self, ctx: commands.Context, alias_name: str, *, command):
"""Add a global alias for a command."""
# region Alias Add Validity Checking
is_command = self.is_command(alias_name)
if is_command:
await ctx.send(
_(
"You attempted to create a new global alias"
" with the name {name} but that"
" name is already a command on this bot."
).format(name=alias_name)
)
return
alias = await self._aliases.get_alias(None, alias_name)
if alias:
await ctx.send(
_(
"You attempted to create a new global alias"
" with the name {name} but that"
" alias already exists."
).format(name=alias_name)
)
return
is_valid_name = self.is_valid_alias_name(alias_name)
if not is_valid_name:
await ctx.send(
_(
"You attempted to create a new global alias"
" with the name {name} but that"
" name is an invalid alias name. Alias"
" names may not contain spaces."
).format(name=alias_name)
)
return
given_command_exists = self.bot.get_command(command.split(maxsplit=1)[0]) is not None
if not given_command_exists:
await ctx.send(
_("You attempted to create a new alias for a command that doesn't exist.")
)
return
# endregion
try:
await self._aliases.add_alias(ctx, alias_name, command, global_=True)
except ArgParseError as e:
return await ctx.send(" ".join(e.args))
await ctx.send(
_("A new global alias with the trigger `{name}` has been created.").format(
name=alias_name
)
)
@checks.mod_or_permissions(manage_guild=True)
@alias.command(name="edit")
@commands.guild_only()
async def _edit_alias(self, ctx: commands.Context, alias_name: str, *, command):
"""Edit an existing alias in this server."""
# region Alias Add Validity Checking
alias = await self._aliases.get_alias(ctx.guild, alias_name)
if not alias:
await ctx.send(
_("The alias with the name {name} does not exist.").format(name=alias_name)
)
return
given_command_exists = self.bot.get_command(command.split(maxsplit=1)[0]) is not None
if not given_command_exists:
await ctx.send(_("You attempted to edit an alias to a command that doesn't exist."))
return
# endregion
# So we figured it is a valid alias and the command exists
# we can go ahead editing the command
try:
if await self._aliases.edit_alias(ctx, alias_name, command):
await ctx.send(
_("The alias with the trigger `{name}` has been edited sucessfully.").format(
name=alias_name
)
)
else:
# This part should technically never be reached...
await ctx.send(
_("Alias with the name `{name}` was not found.").format(name=alias_name)
)
except ArgParseError as e:
return await ctx.send(" ".join(e.args))
@checks.is_owner()
@global_.command(name="edit")
async def _edit_global_alias(self, ctx: commands.Context, alias_name: str, *, command):
"""Edit an existing global alias."""
# region Alias Add Validity Checking
alias = await self._aliases.get_alias(None, alias_name)
if not alias:
await ctx.send(
_("The alias with the name {name} does not exist.").format(name=alias_name)
)
return
given_command_exists = self.bot.get_command(command.split(maxsplit=1)[0]) is not None
if not given_command_exists:
await ctx.send(_("You attempted to edit an alias to a command that doesn't exist."))
return
# endregion
try:
if await self._aliases.edit_alias(ctx, alias_name, command, global_=True):
await ctx.send(
_("The alias with the trigger `{name}` has been edited sucessfully.").format(
name=alias_name
)
)
else:
# This part should technically never be reached...
await ctx.send(
_("Alias with the name `{name}` was not found.").format(name=alias_name)
)
except ArgParseError as e:
return await ctx.send(" ".join(e.args))
@alias.command(name="help")
async def _help_alias(self, ctx: commands.Context, alias_name: str):
"""Try to execute help for the base command of the alias."""
alias = await self._aliases.get_alias(ctx.guild, alias_name=alias_name)
if alias:
await self.bot.send_help_for(ctx, alias.command)
else:
await ctx.send(_("No such alias exists."))
@alias.command(name="show")
async def _show_alias(self, ctx: commands.Context, alias_name: str):
"""Show what command the alias executes."""
alias = await self._aliases.get_alias(ctx.guild, alias_name)
if alias:
await ctx.send(
_("The `{alias_name}` alias will execute the command `{command}`").format(
alias_name=alias_name, command=alias.command
)
)
else:
await ctx.send(_("There is no alias with the name `{name}`").format(name=alias_name))
@checks.mod_or_permissions(manage_guild=True)
@alias.command(name="delete", aliases=["del", "remove"])
@commands.guild_only()
async def _del_alias(self, ctx: commands.Context, alias_name: str):
"""Delete an existing alias on this server."""
if not await self._aliases.get_guild_aliases(ctx.guild):
await ctx.send(_("There are no aliases on this server."))
return
if await self._aliases.delete_alias(ctx, alias_name):
await ctx.send(
_("Alias with the name `{name}` was successfully deleted.").format(name=alias_name)
)
else:
await ctx.send(_("Alias with name `{name}` was not found.").format(name=alias_name))
@checks.is_owner()
@global_.command(name="delete", aliases=["del", "remove"])
async def _del_global_alias(self, ctx: commands.Context, alias_name: str):
"""Delete an existing global alias."""
if not await self._aliases.get_global_aliases():
await ctx.send(_("There are no global aliases on this bot."))
return
if await self._aliases.delete_alias(ctx, alias_name, global_=True):
await ctx.send(
_("Alias with the name `{name}` was successfully deleted.").format(name=alias_name)
)
else:
await ctx.send(_("Alias with name `{name}` was not found.").format(name=alias_name))
@alias.command(name="list")
@commands.guild_only()
@commands.bot_can_react()
async def _list_alias(self, ctx: commands.Context):
"""List the available aliases on this server."""
guild_aliases = await self._aliases.get_guild_aliases(ctx.guild)
if not guild_aliases:
return await ctx.send(_("There are no aliases on this server."))
await self.paginate_alias_list(ctx, guild_aliases)
@global_.command(name="list")
@commands.bot_can_react()
async def _list_global_alias(self, ctx: commands.Context):
"""List the available global aliases on this bot."""
global_aliases = await self._aliases.get_global_aliases()
if not global_aliases:
return await ctx.send(_("There are no global aliases."))
await self.paginate_alias_list(ctx, global_aliases)
@commands.Cog.listener()
async def on_message_without_command(self, message: discord.Message):
if message.guild is not None:
if await self.bot.cog_disabled_in_guild(self, message.guild):
return
try:
prefix = await self.get_prefix(message)
except ValueError:
return
try:
potential_alias = message.content[len(prefix) :].split(" ")[0]
except IndexError:
return
alias = await self._aliases.get_alias(message.guild, potential_alias)
if alias:
await self.call_alias(message, prefix, alias)