diff --git a/docs/instantcommands.rst b/docs/instantcommands.rst index d2c613e7..0ba89071 100644 --- a/docs/instantcommands.rst +++ b/docs/instantcommands.rst @@ -2,7 +2,7 @@ InstantCommands =============== -.. note:: These docs refers to the version **1.3.0**. +.. note:: These docs refers to the version **2..0**. Make sure you're under the good version by typing ``[p]cog update``. This is the guide for the ``instantcmd`` cog. Everything you need is here. @@ -35,261 +35,197 @@ Finally, you can install the cog:: Usage ----- -InstantCommands is designed to create new commands and listeners directly +InstantCommands is designed to create new objects, like commands, directly from Discord. You just need basic Python and discord.py knowledge. -You can also edit the Dev's environment added with Red 3.4.6. - Here's an example of his it works: .. image:: .ressources/EXAMPLES/InstantCommands-example.png -Here's a list of all commands of this cog: - -.. _command-instantcommand: - -~~~~~~~~~~~~~~ -instantcommand -~~~~~~~~~~~~~~ - -**Syntax**:: +From a code snippet in Discord, you can create the following objects: - [p][instacmd|instantcmd|instantcommand] +- :ref:`commands ` +- listeners +- dev env values -**Description** +More objects will come in future releases, like application commands, message +components, cogs... -This is the main command used for setting up the code. -It will be used for all other commands. +To add a code snippet, use :ref:`instantcmd create +` and paste the code you want, following the +format described below. You can then manage code snippets with :ref:`instantcmd +list `. -.. _command-instantcommand-create: +.. _usage-adding-commands: +~~~~~~~~~~~~~~~ +Adding commands +~~~~~~~~~~~~~~~ -~~~~~~~~~~~~~~~~~~~~~ -instantcommand create -~~~~~~~~~~~~~~~~~~~~~ +Adding a command is very straightforward: -**Syntax**:: +.. code-block:: py + @commands.command() + async def hello(ctx): + await ctx.send(f"Hi {ctx.author.name}!") + + return hello - [p]instantcommand [create|add] +.. warning:: Don't forget to always return your object at the end! -**Description** +.. _usage-adding-listeners: +~~~~~~~~~~~~~~~~ +Adding listeners +~~~~~~~~~~~~~~~~ -Creates a new command/listener from a code snippet. +Adding a listener requires a custom decorator: -You will be asked to give a code snippet which will contain your function. -It can be a command (you will need to add the ``commands`` decorator) or a listener -(your function name must correspond to an existing discord.py listener). +.. code-block:: py + from instantcmd.utils import listener -.. tip:: Here are some examples + @listener() + async def on_member_join(member): + await member.send("Welcome there new member!") - .. code-block:: python - - @commands.command() - async def command(ctx, *, argument): - """Say your text with some magic""" + return on_member_join - await ctx.send("You excepted to see your text, " - "but it was I, Dio!") - - return command - - .. code-block:: python - - async def on_reaction_add(reaction, user): - if user.bot: - return - await reaction.message.add_reaction('โค') - await reaction.message.channel.send("Here's some love for " + user.mention) - - return on_reaction_add - -.. note:: +To prevent conflicts, or name your code snippets better, you can give your +function a different name and provide the listener name in the decorator: - Here are the available values for your code snippet: +.. code-block:: py + from instantcmd.utils import listener - * ``bot`` (client object) - - * ``discord`` + @listener("on_member_join") + async def member_welcomer(member): + await member.send("Welcome there new member!") - * ``asyncio`` - - * ``redbot`` + return member_welcomer -If you try to add a new command/listener that already exists, the bot will ask -you if you want to replace the command/listener, useful for a quick bug fix -instead of deleting each time. +Your code will be saved and referred as "member_welcomer". -You can have multiple listeners for the same event but with a different -function name by using the :func:`instantcmd.utils.listener` decorator. It -doesn't work like :attr:`discord.ext.commands.Cog.listener`, it only exists so -you can provide the name of the event you want to listen for. +.. _usage-adding-dev-values: +~~~~~~~~~~~~~~~~~~~~~ +Adding dev env values +~~~~~~~~~~~~~~~~~~~~~ -.. admonition:: Example +You can add custom dev env values, which will be made available to Red's dev +cog (``[p]debug``, ``[p]eval`` and ``[p]repl`` commands). For more information, +see :ref:`Red's documentation `. - .. code-block:: python +The format is similar to listeners: - from instantcmd.utils import listener +.. code-block:: py + from instantcmd.utils import dev_env_value - @listener("on_message_without_command") - async def my_listener(message: discord.Message): - # do your thing - - return my_listener + @dev_env_value() + def fluff_derg(ctx): + ID = 215640856839979008 + if ctx.guild: + return ctx.guild.get_member(ID) or bot.get_user(ID) + else: + return bot.get_user(ID) - This listener will be registered as ``my_listener`` and be suscribed to the - event ``on_message_without_command``. - -.. _command-instantcommand-delete: + return fluff_derg -~~~~~~~~~~~~~~~~~~~~ -instantcommad delete -~~~~~~~~~~~~~~~~~~~~ +Just like listeners, you can give your function a different name and provide +the dev value name in the decorator: -**Syntax** +.. code-block:: py + from instantcmd.utils import dev_env_value -.. code-block:: none + @dev_env_value("fluff_derg") + def give_me_a_dragon(ctx): + ID = 215640856839979008 + if ctx.guild: + return ctx.guild.get_member(ID) or bot.get_user(ID) + else: + return bot.get_user(ID) - [p]instantcommand [delete|del|remove] - -**Description** + return give_me_a_dragon -Remove an instant command or a listener from what you registered before. - -**Arguments** +Your code will be saved and referred as "give_me_a_dragon". -* ```` The name of the command/listener. +-------- +Commands +-------- -.. _command-instantcommand-list: +Here's a list of all commands of this cog: -~~~~~~~~~~~~~~~~~~~ -instantcommand list -~~~~~~~~~~~~~~~~~~~ +.. _command-instantcommand: -**Syntax** +~~~~~~~~~~~~~~ +instantcommand +~~~~~~~~~~~~~~ -.. code-block:: none +**Syntax**:: - [p]instantcommand list + [p][instacmd|instantcmd|instantcommand] **Description** -Lists the commands and listeners added with instantcmd. +This is the main command used for setting up the code. +It will be used for all other commands. -.. _command-instantcommand-source: +.. _command-instantcommand-create: ~~~~~~~~~~~~~~~~~~~~~ -instantcommand source +instantcommand create ~~~~~~~~~~~~~~~~~~~~~ -**Syntax** +**Syntax**:: -.. code-block:: none + [p]instantcommand [create|add] - [p]instantcommand source [command] - **Description** -Shows the source code of an instantcmd command or listener. +Creates a new command/listener from a code snippet. -.. note:: +You will be asked to give a code snippet which will contain your function. +It can be any supported object as described above. - This only works with InstantCommands' commands and listeners. - -**Arguments** +.. tip:: -* ``[command]`` The command/listener name to get the source code from. + Here are the available values within your code snippet: -.. _command-instnatcommand-env: + * ``bot`` (client object) + * ``discord`` + * ``commands`` + * ``checks`` + * ``asyncio`` + * ``redbot`` + * ``instantcmd_cog`` (well, the InstantCommands cog) -~~~~~~~~~~~~~~~~~~ -instantcommand env -~~~~~~~~~~~~~~~~~~ +If you try to add a new command/listener that already exists, the bot will ask +you if you want to replace the command/listener, useful for a quick bug fix +instead of deleting each time. + +The code can be provided in the same message of the command, in a new +followup message, or inside an attached text file. + +~~~~~~~~~~~~~~~~~~~ +instantcommand list +~~~~~~~~~~~~~~~~~~~ **Syntax** .. code-block:: none - [p]instantcommand env + [p]instantcommand list **Description** -This will allow you to add custom values to the dev environment. - -Those values will be accessible with any dev command (``[p]debug``, -``[p]eval``, ``[p]repl``), allowing you to make shortcuts to objects, -import more libraries by default or having fixed values and functions. - -This group subcommand has itself 4 subcommands, similar to the base commands: - -* ``[p]instantcommand env add``: Add a new env value -* ``[p]instantcommand env delete``: Remove an env value -* ``[p]instantcommand env list``: List all env values registered to Red -* ``[p]instantcommand env source``: Show an env value's source code - -Use ``[p]instantcmd env add `` to add a new value, then the bot will -prompt for the code of your value. **You must return a callable taking** -:class:`ctx ` **as its sole parameter.** - -```` will be the name given to that value. - -.. warning:: You must have the dev mode enabled to use this. Make sure you're - running Red with the ``--dev`` flag. - -Once added, that value will stay available with your dev commands. - -For more informations, see the -:meth:`add_dev_env_value ` method. +Lists the code snippets added with instantcmd. -.. admonition:: Examples +Multiple select menus will be sent for each type of object, click them and +select the object you want to edit. - * ``[p]instantcmd env add me return lambda ctx: ctx.guild.me`` - - * ``[p]instantcmd env add inspect import inspect - return lambda ctx: inspect`` - - * ``[p]instantcmd env add conf`` :: - - def get_conf(ctx): - return ctx.bot.get_cog("MyCog").config - - return get_conf - - * ``[p]instantcmd env add smile`` :: - - def smile(ctx): - def make_smile(text): - return "๐Ÿ˜ƒ" + text + "๐Ÿ˜ƒ" - return make_smile - - return smile +Once selected, a new message will be sent containing the source of the +message and 3 buttons: download the source file, enable/disable this object, +and delete it. -------------------------- Frequently Asked Questions -------------------------- -.. note:: - - **Your question is not in the list or you got an unexcpected issue?** - - You should join the `Discord server `_ or - `post an issue `_ - on the repo. - -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -It's written in the help message that I can add a listener. How can I do so? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Instead of giving a :class:`~discord.ext.commands.Command` object, just -give a simple function (don't put the command decorator) and make sure -its name is matching the lowercased `Discord API listeners -`_. - -.. warning:: **Do not use** the new ``@commands.Cog.listener`` decorator - introduced in Red 3.1. The bot uses ``bot.add_listener`` which - doesn't need a decorator. - - *Added in 1.1:* InstantCommands now has its own listener decorator. It is - optional and used for providing the event name. - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ My command was added but doesn't respond when invoked. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -340,9 +276,9 @@ You can use the :class:`~redbot.core.checks` module, like in a normal cog. return command -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -How can I import a module without problem? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~ +How can I import a module? +~~~~~~~~~~~~~~~~~~~~~~~~~~ You can import your modules outside the function as you wish. diff --git a/instantcmd/__init__.py b/instantcmd/__init__.py index 7a622e4a..e62ad00f 100644 --- a/instantcmd/__init__.py +++ b/instantcmd/__init__.py @@ -1,11 +1,17 @@ import logging import importlib.util + +from typing import TYPE_CHECKING, Dict from .instantcmd import InstantCommands from redbot.core.data_manager import cog_data_path from redbot.core.errors import CogLoadError from laggron_utils import init_logger +if TYPE_CHECKING: + from redbot.core.bot import Red + from redbot.core import Config + if not importlib.util.find_spec("laggron_utils"): raise CogLoadError( "You need the `laggron_utils` package for any cog from Laggron's Dumb Cogs. " @@ -17,54 +23,95 @@ log = logging.getLogger("red.laggron.instantcmd") -async def ask_reset(bot, commands): - owner = bot.get_user(bot.owner_id) +async def save_old_commands(bot: "Red", config: "Config", data: Dict[str, Dict[str, str]]): + # save data + path = cog_data_path(None, raw_name="InstantCommands") / "pre-2.0-backup" + path.mkdir(exist_ok=True) + + commands = data.get("commands", {}) + dev_values = data.get("dev_values", {}) + commands_file_path = path / "commands.py" + dev_values_file_path = path / "dev_env_values.py" - def check(message): - return message.author == owner and message.channel == owner.dm_channel + if commands: + with commands_file_path.open("w") as file: + for name, content in commands.items(): + file.write("# ====================================\n") + file.write(f'# command or listener "{name}"\n') + file.write("# ====================================\n\n") + file.write(content) + file.write("\n\n\n") + log.info(f"Backed up commands and listeners at {commands_file_path.absolute()}") - if not owner: - await owner.send( - "InstantCommands was updated to its first release! Internal code was modified " - "to allow more features to be implemented, such as using `bot` in listeners " - "or storing values with Config.\n" - "It is needed to reset all of your instant commands and listeners to be " - "ready for this version.\n\n" - "**Modifications to bring:** Instead of providing only the desired function, " - "you can now put whatever you want in your code snippet, but you must return " - "your command/function at the end.\n\n" - "Example:\n" - "```py\n" - "@commands.command()\n" - "async def hello(ctx):\n" - ' await ctx.send("Hello world!")\n\n' - "return hello\n" - "```" - ) - path = cog_data_path(None, raw_name="InstantCommands") / "commands_backup.txt" - with path.open(mode="w") as file: - text = "" - for name, command in commands.items(): - text += f"[Command/listener: {name}]\n{command}\n\n\n" - file.write(text) - log.info(f"Wrote backup file at {path}") - await owner.send( - "A file was successfully written as a backup of what you previously did at the " - f"following path:\n```{str(path)}```\n" - "Please read the docs if you want to know what exactly changed, and what you must " - "do\nhttps://laggrons-dumb-cogs.readthedocs.io/instantcommands.html" - ) + if dev_values: + with dev_values_file_path.open("w") as file: + for name, content in commands.items(): + file.write("# ====================================\n") + file.write(f'# dev env value "{name}"\n') + file.write("# ====================================\n\n") + file.write(content) + file.write("\n\n\n") + log.info(f"Backed up dev env values at {dev_values_file_path.absolute()}") + + await config.commands.clear() + await config.dev_values.clear() + log.warning("Deleted old data") + + await bot.send_to_owners( + "**InstantCommands was updated to version 2.0!**\n" + "The cog changed a lot, and even more new features are on the way. A lot of internal " + "changes were done, which means it's migration time again! Don't worry, there shouldn't " + "be much stuff to change.\n\n\n" + "**Modifications to bring:**\n\n" + "- **Commands:** Nothing is changed, but that had to be reset anyway for internal " + "reasons :D (they were mixed with listeners, now it's separated)\n\n" + "- **Listeners:** All listeners now require the decorator `instantcmd.utils.listener`. " + "Example:\n" + "```py\n" + "from instantcmd.utils import listener\n\n" + "@listener()\n" + "async def on_member_join(member):\n" + ' await member.send("Welcome new member!") # don\'t do this\n\n' + "return on_member_join\n" + "```\n\n" + "- **Dev env values:** Important changes for this, they have to be added like commands " + "in the following form:\n" + "```py\n" + "from instantcmd.utils import dev_env_value\n\n" + "@dev_env_value()\n" + "def fluff_derg(ctx):\n" + " ID = 215640856839979008\n" + " if ctx.guild:\n" + " return ctx.guild.get_member(ID) or bot.get_user(ID)\n" + " else:\n" + " return bot.get_user(ID)\n\n" + "return fluff_derg\n" + "```\n\n" + "A backup of your old commands and listeners was done in " + f"`{commands_file_path.absolute()}`\n" + "A backup of your old dev_env_values was done in " + f"`{dev_values_file_path.absolute()}`\n\n" + "The old config was removed, open these files and add the commands back, you should be " + "good to go!\n" + "Now there are only two commands, `create` and `list`, the rest is done through " + "components. Anything can be toggled on/off in a click (without deletion), and more " + "supported objects are on the way, like application commands, message components and " + "cogs!\n" + "By the way, glossary change due to the increasing number of supported objects, we're not " + 'referring to "commands" anymore, but "code snippets". The cog will keep its name.' + ) -async def setup(bot): +async def setup(bot: "Red"): init_logger(log, InstantCommands.__class__.__name__, "instantcmd") n = InstantCommands(bot) - if not await n.data.updated_body(): - commands = await n.data.commands() - if commands: - # the data is outdated and must be cleaned to be ready for the new version - await ask_reset(bot, commands) - await n.data.commands.set({}) - await n.data.updated_body.set(True) - bot.add_cog(n) + global_data = await n.data.all() + if global_data.get("commands", {}) or global_data.get("dev_values", {}): + log.info("Detected data from previous version, starting backup and removal") + try: + await save_old_commands(bot, n.data, global_data) + except Exception: + log.critical("Failed to backup and remove data for 2.0 update!", exc_info=True) + raise CogLoadError("The cog failed to backup data for the 2.0 update!") + await bot.add_cog(n) log.debug("Cog successfully loaded on the instance.") diff --git a/instantcmd/code_runner.py b/instantcmd/code_runner.py new file mode 100644 index 00000000..591dc910 --- /dev/null +++ b/instantcmd/code_runner.py @@ -0,0 +1,77 @@ +import os +import sys +import textwrap + +from typing import TypeVar, Type, Dict, Any + +from redbot.core import commands + +from instantcmd.core import ( + CodeSnippet, + CommandSnippet, + DevEnvSnippet, + ListenerSnippet, + ExecutionException, + UnknownType, +) +from instantcmd.core.listener import Listener +from instantcmd.core.dev_env_value import DevEnv + +T = TypeVar("T") +OBJECT_TYPES_MAPPING = { + commands.Command: CommandSnippet, + Listener: ListenerSnippet, + DevEnv: DevEnvSnippet, +} + + +# from DEV cog, made by Cog Creators (tekulvw) +def cleanup_code(content): + """Automatically removes code blocks from the code.""" + # remove ```py\n``` + if content.startswith("```") and content.endswith("```"): + return "\n".join(content.split("\n")[1:-1]) + + # remove `foo` + return content.strip("` \n") + + +def get_code_from_str(content: str, env: Dict[str, Any]) -> T: + """ + Execute a string, and try to get a function from it. + """ + # The Python code is wrapped inside a function + to_compile = "def func():\n%s" % textwrap.indent(content, " ") + + # Setting the instantcmd cog available in path, allowing imports like instantcmd.utils + sys.path.append(os.path.dirname(__file__)) + try: + exec(to_compile, env) + except Exception as e: + raise ExecutionException("Failed to compile the code") from e + finally: + sys.path.remove(os.path.dirname(__file__)) + + # Execute and get the return value of the function + try: + result = env["func"]() + except Exception as e: + raise ExecutionException("Failed to execute the function") from e + + # Function does not have a return value + if not result: + raise ExecutionException("Nothing detected. Make sure to return something") + return result + + +def find_matching_type(code: T) -> Type[CodeSnippet]: + for source, dest in OBJECT_TYPES_MAPPING.items(): + if isinstance(code, source): + return dest + if hasattr(code, "__name__"): + raise UnknownType( + f"The function `{code.__name__}` needs to be transformed into something. " + "Did you forget a decorator?" + ) + else: + raise UnknownType(f"The type `{type(code)}` is currently not supported by instantcmd.") diff --git a/instantcmd/components.py b/instantcmd/components.py new file mode 100644 index 00000000..9dab695e --- /dev/null +++ b/instantcmd/components.py @@ -0,0 +1,216 @@ +import discord +import logging + +from typing import TYPE_CHECKING, Optional, TypeVar, Type, List +from discord.ui import Select, Button, View +from redbot.core.utils.chat_formatting import text_to_file + +from instantcmd.core import CodeSnippet + +if TYPE_CHECKING: + from redbot.core.bot import Red + +log = logging.getLogger("red.laggron.instantcmd.components") +T = TypeVar("T", bound=CodeSnippet) + + +def char_limit(text: str, limit: int) -> str: + if len(text) > limit: + return text[: limit - 3] + "..." + else: + return text + + +class OwnerOnlyView(View): + """ + A view where only bot owners are allowed to interact. + """ + + def __init__(self, bot: "Red", *, timeout: Optional[float] = 180): + self.bot = bot + super().__init__(timeout=timeout) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return await self.bot.is_owner(interaction.user) + + +class DownloadButton(Button): + """ + A button to download the source file. + """ + + def __init__(self, code_snippet: T): + self.code_snippet = code_snippet + super().__init__( + style=discord.ButtonStyle.primary, + label="Download source file", + emoji="\N{FLOPPY DISK}", + ) + + async def callback(self, interaction: discord.Interaction): + if not interaction.channel.permissions_for(interaction.guild.me).attach_files: + await interaction.response.send_message("I lack the permission to upload files.") + else: + await interaction.response.send_message( + f"Here is the content of your code snippet.", + file=text_to_file(self.code_snippet.source, filename=f"{self.code_snippet}.py"), + ) + log.debug(f"File download of {self.code_snippet} requested and uploaded.") + + +class ActivateDeactivateButton(Button): + """ + A button to activate or deactivate the code snippet. + """ + + def __init__(self, code_snippet: T): + super().__init__() + self.code_snippet = code_snippet + self.set_style() + + def set_style(self): + if self.code_snippet.enabled: + self.style = discord.ButtonStyle.secondary + self.label = "Disable" + self.emoji = "\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}" + else: + self.style = discord.ButtonStyle.success + self.label = "Enable" + self.emoji = "\N{HEAVY CHECK MARK}\N{VARIATION SELECTOR-16}" + + async def callback(self, interaction: discord.Interaction): + # interaction = self.view.og_interaction + if self.code_snippet.enabled: + log.info(f"Code snippet {self.code_snippet} disabled.") + self.code_snippet.enabled = False + await self.code_snippet.save() + try: + self.code_snippet.unregister() + except Exception: + log.error( + f"Failed to unregister {self.code_snippet} when deactivation requested", + exc_info=True, + ) + await interaction.response.send_message( + "An error occured when trying to unregister this object, you can check for " + "details in your logs.\n" + "It is still deactivated and will not be loaded on next cog load." + ) + else: + self.set_style() + await interaction.response.send_message( + "The object was successfully unregistered and will not be loaded again." + ) + await self.view.edit() + else: + try: + self.code_snippet.register() + except Exception: + log.error( + f"Failed to register {self.code_snippet} when activation requested", + exc_info=True, + ) + await interaction.response.send_message( + "An error occured when trying to register this object, you can check for " + "details in your logs.\n" + "It is still deactivated, you can try to activate it again." + ) + else: + log.info(f"Code snippet {self.code_snippet} enabled.") + self.code_snippet.enabled = True + await self.code_snippet.save() + self.set_style() + await interaction.response.send_message( + "The object was successfully registered and will be loaded on cog load." + ) + await self.view.edit() + + +class DeleteButton(Button): + """ + A button to completly suppress an object. + """ + + def __init__(self, code_snippet: T): + self.code_snippet = code_snippet + super().__init__( + style=discord.ButtonStyle.danger, + label="Delete", + emoji="\N{OCTAGONAL SIGN}", + ) + + async def callback(self, interaction: discord.Interaction): + await self.code_snippet.delete() + try: + self.code_snippet.unregister() + except Exception: + log.error( + f"Failed to unregister {self.code_snippet} when deletion requested", + exc_info=True, + ) + await interaction.response.send_message( + "An error occured when trying to unregister this object, you can check for " + "details in your logs.\n" + "It was still removed and will not be loaded on next cog load." + ) + else: + await interaction.response.send_message("Object successfully removed.") + finally: + self.view.stop() + + +class CodeSnippetView(OwnerOnlyView): + """ + List of buttons for a single code snippet. + """ + + def __init__(self, bot: "Red", interaction: discord.Interaction, code_snippet: T): + self.code_snippet = code_snippet + self.og_interaction = interaction + super().__init__(bot) + self.add_item(DownloadButton(code_snippet)) + self.add_item(ActivateDeactivateButton(code_snippet)) + self.add_item(DeleteButton(code_snippet)) + + async def edit(self): + # refresh activate/deactivate button + await self.og_interaction.followup.edit_message("@original", view=self) + + +class CodeSnippetsList(Select): + """ + A list of items for a specific type of code snippet. + """ + + def __init__(self, bot: "Red", type: Type[T], code_snippets: List[T]): + self.bot = bot + self.snippet_type = type + self.code_snippets = code_snippets + + placeholder = f"List of {type.name} objects" + objects: List[discord.SelectOption] = [] + + # TODO: Support more than 25 items! + for i, code_snippet in enumerate(code_snippets[:25]): + lines = code_snippet.source.count("\n") + 1 + value = f"{lines} lines of code" + if code_snippet.verbose_name != str(code_snippet): + value += f" โ€ข {code_snippet.description}" + objects.append( + discord.SelectOption( + label=char_limit(str(code_snippet), 25), + description=char_limit(value, 50), + value=str(i), + ) + ) + super().__init__(placeholder=placeholder, min_values=1, max_values=1, options=objects) + + async def callback(self, interaction: discord.Interaction): + selected = self.code_snippets[int(self.values[0])] + message = f"__{selected.name} `{selected}`__" + if selected.verbose_name != str(selected): + message += f" ({selected.description})" + message += "\n\n" + next(selected.get_formatted_code()) + await interaction.response.send_message( + message, view=CodeSnippetView(self.bot, interaction, selected) + ) diff --git a/instantcmd/core/__init__.py b/instantcmd/core/__init__.py new file mode 100644 index 00000000..779cacb4 --- /dev/null +++ b/instantcmd/core/__init__.py @@ -0,0 +1,5 @@ +from .core import CodeSnippet +from .command import CommandSnippet +from .dev_env_value import DevEnvSnippet +from .listener import ListenerSnippet +from .exceptions import * diff --git a/instantcmd/core/command.py b/instantcmd/core/command.py new file mode 100644 index 00000000..273ea5e6 --- /dev/null +++ b/instantcmd/core/command.py @@ -0,0 +1,46 @@ +import logging + +from typing import TYPE_CHECKING, TypeVar + +from redbot.core import commands + +from instantcmd.core import CodeSnippet + +if TYPE_CHECKING: + from redbot.core.bot import Red + from redbot.core import Config + +Command = TypeVar("Command", bound=commands.Command) +log = logging.getLogger("red.laggron.instantcmd.core.command") + + +class CommandSnippet(CodeSnippet[Command]): + """ + Represents a text command from discord.ext.commands + """ + + name = "command" + + def __init__(self, bot: "Red", config: "Config", command: Command, source: str): + super().__init__(bot, config, command, source) + + def __str__(self) -> str: + return self.value.callback.__name__ + + @property + def verbose_name(self) -> str: + return self.value.name + + @property + def description(self) -> str: + return f"Command {self.verbose_name}" + + def register(self): + self.bot.add_command(self.value) + log.debug(f"Registered command {self}") + + def unregister(self): + if self.bot.remove_command(self.value.name) is not None: + log.debug(f"Removed command {self}") + else: + log.warning(f"Tried to remove command {self} but it was not registered") diff --git a/instantcmd/core/core.py b/instantcmd/core/core.py new file mode 100644 index 00000000..df46e43a --- /dev/null +++ b/instantcmd/core/core.py @@ -0,0 +1,107 @@ +from typing import TYPE_CHECKING, Generic, TypeVar, Iterator + +from redbot.core.utils.chat_formatting import box, pagify + +if TYPE_CHECKING: + from redbot.core.bot import Red + from redbot.core import Config + +T = TypeVar("T") +MAX_CHARS_PER_PAGE = 1900 + + +class CodeSnippet(Generic[T]): + """ + Represents a code snippet sent from Discord. + This class should be subclassed to represent an actual object to implement. + + Attributes + ---------- + enbaled: bool + If this code is enabled or not. + registered: bool + If this code is currently registered on the bot. + name: str + The verbose name of the current subclass. + + Parameters + ---------- + bot: ~redbot.core.bot.Red + The bot object. Used for many functions that require the bot object to register stuff. + value: T + The value contained by an instance of this class. + source: str + Actual source code of this function. + """ + + name: str = "command" + + def __init__(self, bot: "Red", config: "Config", value: T, source: str): + self.bot = bot + self.data = config + self.value = value + self.source = source + self.enabled: bool = True + self.registered: bool = False + + @classmethod + def from_saved_data(cls, bot: "Red", config: "Config", value: T, data: dict): + code_snippet = cls(bot, config, value, data["code"]) + code_snippet.enabled = data["enabled"] + return code_snippet + + async def save(self): + await self.data.custom("CODE_SNIPPET", self.name, str(self)).set_raw( + value={"code": self.source, "enabled": self.enabled} + ) + + async def delete(self): + await self.data.custom("CODE_SNIPPET", self.name).clear_raw(str(self)) + + def get_formatted_code(self) -> Iterator[str]: + """ + Get a string representing the code, formatted for Discord and pagified. + """ + for page in pagify( + text=self.source, + delims=["\n\n", "\n"], + priority=True, + page_length=MAX_CHARS_PER_PAGE, + ): + yield box(page, lang="py") + + def __str__(self) -> str: + """ + Return the instance's function name. + """ + raise NotImplementedError + + @property + def verbose_name(self) -> str: + """ + Return the instance's display name. + """ + raise NotImplementedError + + @property + def description(self) -> str: + """ + Return a more detailed description of this object. + """ + return str(self) + + def register(self): + """ + Register the object to the bot. + + Varies on the implementation. + """ + raise NotImplementedError + + def unregister(self): + """ + Removes the object from the bot. + + Varies on the implementation. + """ + raise NotImplementedError diff --git a/instantcmd/core/dev_env_value.py b/instantcmd/core/dev_env_value.py new file mode 100644 index 00000000..de52369d --- /dev/null +++ b/instantcmd/core/dev_env_value.py @@ -0,0 +1,65 @@ +import logging +import asyncio + +from typing import TYPE_CHECKING, TypeVar, Callable +from redbot.core import commands + +from instantcmd.core import CodeSnippet +from instantcmd.core.exceptions import InvalidType + +if TYPE_CHECKING: + from redbot.core.bot import Red + from redbot.core import Config + +T = TypeVar("T") +DevEnvValue = Callable[[commands.Context], T] +log = logging.getLogger("red.laggron.instantcmd.core.listener") + + +class DevEnv: + """ + A class representing a dev env value for Redbot's dev cog. + """ + + def __init__(self, function: DevEnvValue, name: str): + if asyncio.iscoroutinefunction(function): + raise InvalidType("Dev env functions cannot be async.") + self.func = function + self.name = name + self.id = id(function) + + def __call__(self, ctx: commands.Context): + self.func(ctx) + + +class DevEnvSnippet(CodeSnippet[DevEnv]): + """ + Represents a dev env value + """ + + name = "dev env value" + + def __init__(self, bot: "Red", config: "Config", dev_env: DevEnv, source: str): + super().__init__(bot, config, dev_env, source) + + def __str__(self) -> str: + return self.value.func.__name__ + + @property + def verbose_name(self) -> str: + return self.value.name + + @property + def description(self) -> str: + return f'Value "{self.verbose_name}" assigned to {self}' + + def register(self): + self.bot.add_dev_env_value(self.value.name, self.value.func) + log.debug( + f"Registered dev env value with name {self.verbose_name} " + f"and assigned to function {self}" + ) + + def unregister(self): + self.bot.remove_dev_env_value(self.value.name) + log.debug(f"Removed dev env value with name {self.verbose_name}") diff --git a/instantcmd/core/exceptions.py b/instantcmd/core/exceptions.py new file mode 100644 index 00000000..e501f300 --- /dev/null +++ b/instantcmd/core/exceptions.py @@ -0,0 +1,38 @@ +__all__ = ( + "InstantcmdException", + "ExecutionException", + "UnknownType", + "InvalidType", +) + + +class InstantcmdException(Exception): + """ + Base error for commands raised within a command snippet. + """ + + pass + + +class ExecutionException(InstantcmdException): + """ + Failed to execute the code. + """ + + pass + + +class UnknownType(InstantcmdException): + """ + The return value's type was not recognized. + """ + + pass + + +class InvalidType(InstantcmdException): + """ + The object does not match the associated implementation. + """ + + pass diff --git a/instantcmd/core/listener.py b/instantcmd/core/listener.py new file mode 100644 index 00000000..e6c8bf1d --- /dev/null +++ b/instantcmd/core/listener.py @@ -0,0 +1,62 @@ +import logging + +from typing import TYPE_CHECKING, Callable, Awaitable + +from instantcmd.core import CodeSnippet + +if TYPE_CHECKING: + from redbot.core.bot import Red + from redbot.core import Config + +Awaitable = Callable[..., Awaitable] +log = logging.getLogger("red.laggron.instantcmd.core.listener") + + +class Listener: + """ + A class representing a discord.py listener. + """ + + def __init__(self, function: Awaitable, name: str): + self.func = function + self.name = name + self.id = id(function) + + def __call__(self, *args, **kwargs): + self.func(*args, **kwargs) + + +class ListenerSnippet(CodeSnippet[Listener]): + """ + Represents a listener + """ + + name = "listener" + + def __init__(self, bot: "Red", config: "Config", listener: Listener, source: str): + super().__init__(bot, config, listener, source) + + def __str__(self) -> str: + return self.value.func.__name__ + + @property + def verbose_name(self) -> str: + return self.value.name + + @property + def description(self) -> str: + return f"Listens for event {self.verbose_name}" + + def register(self): + self.bot.add_listener(self.value.func, name=self.value.name) + if self.value.name == self.value.func.__name__: + log.debug(f"Registered listener {self}") + else: + log.debug(f"Registered listener {self} listening for event {self.value.name}") + + def unregister(self): + self.bot.remove_listener(self.value.func, name=self.value.name) + if self.value.name == self.value.func.__name__: + log.debug(f"Removed listener {self}") + else: + log.debug(f"Removed listener {self} listening for event {self.value.name}") diff --git a/instantcmd/instantcmd.py b/instantcmd/instantcmd.py index 31b0f3f8..f444ac68 100644 --- a/instantcmd/instantcmd.py +++ b/instantcmd/instantcmd.py @@ -4,13 +4,11 @@ import discord import asyncio import traceback -import textwrap import logging -import os -import sys -from typing import Optional -from laggron_utils.logging import close_logger, DisabledConsoleOutput +from typing import TypeVar, Type, Optional, Any, Dict, List, Tuple, Iterator +from discord.ui import View +from laggron_utils.logging import close_logger from redbot.core import commands from redbot.core import checks @@ -20,40 +18,31 @@ from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.chat_formatting import pagify -from .utils import Listener +from instantcmd.utils import Listener +from instantcmd.components import CodeSnippetsList, OwnerOnlyView +from instantcmd.code_runner import cleanup_code, get_code_from_str, find_matching_type +from instantcmd.core import ( + CodeSnippet, + CommandSnippet, + DevEnvSnippet, + ListenerSnippet, + InstantcmdException, + ExecutionException, +) log = logging.getLogger("red.laggron.instantcmd") -BaseCog = getattr(commands, "Cog", object) +T = TypeVar("T") +CODE_SNIPPET = "CODE_SNIPPET" -# Red 3.0 backwards compatibility, thanks Sinbad -listener = getattr(commands.Cog, "listener", None) -if listener is None: +# --- Glossary --- +# +# "code", "snippet" or "code snippet" +# Refers to a block of code written by the user that returns an object +# like a command or a listener that we will register. They are usually +# objects derived from `instantcmd.core.core.CodeSnippet` - def listener(name=None): - return lambda x: x - -class FakeListener: - """ - A fake listener used to remove the extra listeners. - - This is needed due to how extra listeners works, and how the cog stores these. - When adding a listener to the list, we get its ID. Then, when we need to remove\ - the listener, we call this fake class with that ID, so discord.py thinks this is\ - that listener. - - Credit to mikeshardmind for finding this solution. For more info, please look at this issue: - https://github.com/Rapptz/discord.py/issues/1284 - """ - - def __init__(self, idx): - self.idx = idx - - def __eq__(self, function): - return self.idx == id(function) - - -class InstantCommands(BaseCog): +class InstantCommands(commands.Cog): """ Generate a new command from a code snippet, without making a new cog. @@ -64,72 +53,100 @@ def __init__(self, bot: Red): self.bot = bot self.data = Config.get_conf(self, 260) - def_global = {"commands": {}, "dev_values": {}, "updated_body": False} - self.data.register_global(**def_global) - self.listeners = {} - - # resume all commands and listeners - bot.loop.create_task(self.resume_commands()) - - __author__ = ["retke (El Laggron)"] - __version__ = "1.3.2" + self.data.init_custom(CODE_SNIPPET, 2) + self.data.register_custom(CODE_SNIPPET, code=None, enabled=True, version=1) - # def get_config_identifier(self, name): - # """ - # Get a random ID from a string for Config - # """ + try: + self.bot.add_dev_env_value("instantcmd", lambda ctx: self) + except RuntimeError: + log.warning("Failed to load dev env value", exc_info=True) - # random.seed(name) - # identifier = random.randint(0, 999999) - # self.env["config"] = Config.get_conf(self, identifier) + self.code_snippets: List[CodeSnippet] = [] - def get_function_from_str(self, command, name=None): - """ - Execute a string, and try to get a function from it. - """ + __author__ = ["retke (El Laggron)"] + __version__ = "2.0.0" - # self.get_config_identifier(name) - to_compile = "def func():\n%s" % textwrap.indent(command, " ") - sys.path.append(os.path.dirname(__file__)) - env = { + @property + def env(self) -> Dict[str, Any]: + return { "bot": self.bot, "discord": discord, "commands": commands, "checks": checks, "asyncio": asyncio, + "instantcmd_cog": self, } - exec(to_compile, env) - sys.path.remove(os.path.dirname(__file__)) - result = env["func"]() - if not result: - raise RuntimeError("Nothing detected. Make sure to return something") - return result - - def load_command_or_listener(self, function): + + async def _load_code_snippets_from_config(self): + types: Dict[str, Type[CodeSnippet]] = { + "command": CommandSnippet, + "listener": ListenerSnippet, + "dev env value": DevEnvSnippet, + } + data: Dict[str, Dict[str, dict]] = await self.data.custom(CODE_SNIPPET).all() + for category, code_snippets in data.items(): + try: + snippet_type = types[category] + except KeyError: + log.critical( + f"Unknown category {category}, skipping {len(code_snippets)} " + "potential code snippets from loading!", + exc_info=True, + ) + continue + for name, code_snippet_data in code_snippets.items(): + try: + value = get_code_from_str(code_snippet_data["code"], self.env) + self.code_snippets.append( + snippet_type.from_saved_data(self.bot, self.data, value, code_snippet_data) + ) + except Exception: + log.error(f"Failed to compile {category} {name}.", exc_info=True) + + def load_code_snippet(self, code: CodeSnippet): """ - Add a command to discord.py or create a listener + Register a code snippet """ + if code.enabled == False: + log.debug(f"Skipping snippet {code} as it is disabled.") + return + try: + code.register() + except Exception: + log.error(f"Failed to register snippet {code}", exc_info=True) + else: + code.registered = True - if isinstance(function, commands.Command): - self.bot.add_command(function) - log.debug(f"Added command {function.name}") + async def load_all_code_snippets(self): + """ + Reload all code snippets saved. + This is executed on cog load. + """ + try: + await self._load_code_snippets_from_config() + except Exception: + log.critical("Failed to load data from config.", exc_info=True) + return + for code in self.code_snippets: + self.load_code_snippet(code) + + def unload_code_snippet(self, code: CodeSnippet): + """ + Unregister a code snippet + """ + if code.registered == False: + return + try: + code.unregister() + except Exception: + log.error(f"Failed to unregister snippet {code}", exc_info=True) else: - if not isinstance(function, Listener): - function = Listener(function, function.__name__) - self.bot.add_listener(function.func, name=function.name) - self.listeners[function.func.__name__] = (function.id, function.name) - if function.name != function.func.__name__: - log.debug( - f"Added listener {function.func.__name__} listening for the " - f"event {function.name} (ID: {function.id})" - ) - else: - log.debug(f"Added listener {function.name} (ID: {function.id})") + code.registered = False - async def resume_commands(self): + async def unload_all_code_snippets(self): """ - Load all instant commands made. - This is executed on load with __init__ + Unload all code snippets saved. + This is executed on cog unload. """ dev_values = await self.data.dev_values() for name, code in dev_values.items(): @@ -140,47 +157,45 @@ async def resume_commands(self): else: self.bot.add_dev_env_value(name, function) log.debug(f"Added dev value %s", name) - - _commands = await self.data.commands() - for name, command_string in _commands.items(): - try: - function = self.get_function_from_str(command_string, name) - except Exception as e: - log.exception("An exception occurred while trying to resume command %s", name) - else: - self.load_command_or_listener(function) - - async def remove_commands(self): - async with self.data.commands() as _commands: - for command in _commands: - if command in self.listeners: - # remove a listener - listener_id, name = self.listeners[command] - self.bot.remove_listener(FakeListener(listener_id), name=name) - log.debug(f"Removed listener {command} due to cog unload.") - else: - # remove a command - self.bot.remove_command(command) - log.debug(f"Removed command {command} due to cog unload.") - async with self.data.dev_values() as values: - for name in values: - self.bot.remove_dev_env_value(name) - log.debug(f"Removed dev value {name} due to cog unload.") - - # from DEV cog, made by Cog Creators (tekulvw) - @staticmethod - def cleanup_code(content): - """Automatically removes code blocks from the code.""" - # remove ```py\n``` - if content.startswith("```") and content.endswith("```"): - return "\n".join(content.split("\n")[1:-1]) - - # remove `foo` - return content.strip("` \n") - - async def _ask_for_edit(self, ctx: commands.Context, kind: str) -> bool: + for code in self.code_snippets: + self.unload_code_snippet(code) + + def get_code_snippets( + self, + enabled: Optional[bool] = True, + registered: Optional[bool] = True, + type: Optional[Type[CodeSnippet]] = None, + ) -> Iterator[CodeSnippet]: + """ + Get all saved code snippets. + + Parameters + ---------- + enabled: Optional[bool] + If `True`, only return enabled code snippets. Defaults to `True`. + registered: Optional[bool] + If `True`, only return registered code snippets (excluding the ones that failed to + load). Defaults to `True`. + type: Optional[Type[CodeSnippet]] + Filter the results by the given type. + + Returns + ------- + Iterator[CodeSnippet] + An iterator of the results. + """ + for code in self.code_snippets: + if enabled and not code.enabled: + continue + if registered and not code.registered: + continue + if type and not isinstance(code, type): + continue + yield code + + async def _ask_for_edit(self, ctx: commands.Context, code: CodeSnippet) -> bool: msg = await ctx.send( - f"That {kind} is already registered with InstantCommands. " + f"That {code} is already registered with InstantCommands. " "Would you like to replace it?" ) pred = ReactionPredicate.yes_or_no(msg, ctx.author) @@ -195,7 +210,7 @@ async def _ask_for_edit(self, ctx: commands.Context, kind: str) -> bool: return False return True - async def _read_from_file(self, ctx: commands.Context, msg: discord.Message): + async def _read_from_file(self, ctx: commands.Context, msg: discord.Message) -> str: content = await msg.attachments[0].read() try: function_string = content.decode() @@ -206,32 +221,23 @@ async def _read_from_file(self, ctx: commands.Context, msg: discord.Message): ) function_string = content.decode(errors="replace") finally: - return self.cleanup_code(function_string) + return cleanup_code(function_string) async def _extract_code( - self, ctx: commands.Context, command: Optional[str] = None, is_instantcmd=True - ): + self, ctx: commands.Context, code_string: Optional[str] = None + ) -> Tuple[T, str]: if ctx.message.attachments: - function_string = await self.read_from_file(ctx, ctx.message) - elif command: - function_string = self.cleanup_code(command) + function_string = await self._read_from_file(ctx, ctx.message) + elif code_string: + function_string = cleanup_code(code_string) else: message = ( - ( - "You're about to create a new command.\n" - "Your next message will be the code of the command.\n\n" - "If this is the first time you're adding instant commands, " - "please read the wiki:\n" - "https://laggron.red/instantcommands.html#usage" - ) - if is_instantcmd - else ( - "You're about to add a new value to the dev environment.\n" - "Your next message will be the code returning that value.\n\n" - "If this is the first time you're editing the dev environment " - "with InstantCommands, please read the wiki:\n" - "https://laggron.red/instantcommands.html#usage" - ) + # TODO: redo this message + "You're about to add a new object object to the bot.\n" + "Your next message will be the code of your object.\n\n" + "If this is the first time you're adding instant commands, " + "please read the wiki:\n" + "https://laggron.red/instantcommands.html#usage" ) await ctx.send(message) pred = MessagePredicate.same_context(ctx) @@ -244,274 +250,100 @@ async def _extract_code( return if response.content == "" and response.attachments: - function_string = await self.read_from_file(ctx, response) + function_string = await self._read_from_file(ctx, response) else: - function_string = self.cleanup_code(response.content) + function_string = cleanup_code(response.content) try: - function = self.get_function_from_str(function_string) + function = get_code_from_str(function_string, self.env) + except InstantcmdException: + raise # do not add another step for already commented errors except Exception as e: - exception = "".join(traceback.format_exception(type(e), e, e.__traceback__)) - message = ( - f"An exception has occured while compiling your code:\n```py\n{exception}\n```" - ) - for page in pagify(message): - await ctx.send(page) - return + raise ExecutionException("An exception has occured while compiling your code") from e return function, function_string @checks.is_owner() @commands.group(aliases=["instacmd", "instantcommand"]) - async def instantcmd(self, ctx): + async def instantcmd(self, ctx: commands.Context): """Instant Commands cog management""" pass @instantcmd.command(aliases=["add"]) - async def create(self, ctx, *, command: str = None): + async def create(self, ctx: commands.Context, *, command: str = None): """ - Instantly generate a new command from a code snippet. + Instantly generate a new object from a code snippet. - If you want to make a listener, give its name instead of the command name. + The following objects are supported: commands, listeners You can upload a text file if the command is too long, but you should consider coding a \ cog at this point. """ - function = await self._extract_code(ctx, command) - if function is None: - return - function, function_string = function - # if the user used the command correctly, we should have one async function - if isinstance(function, commands.Command): - async with self.data.commands() as _commands: - if function.name in _commands: - response = await self._ask_for_edit(ctx, "command") - if response is False: - return - self.bot.remove_command(function.name) - log.debug(f"Removed command {function.name} due to incoming overwrite (edit).") - try: - self.bot.add_command(function) - except Exception as e: - exception = "".join(traceback.format_exception(type(e), e, e.__traceback__)) - message = ( - "An expetion has occured while adding the command to discord.py:\n" - f"```py\n{exception}\n```" - ) - for page in pagify(message): - await ctx.send(page) - return - else: - async with self.data.commands() as _commands: - _commands[function.name] = function_string - await ctx.send(f"The command `{function.name}` was successfully added.") - log.debug(f"Added command {function.name}") - - else: - if not isinstance(function, Listener): - function = Listener(function, function.__name__) - async with self.data.commands() as _commands: - if function.func.__name__ in _commands: - response = await self._ask_for_edit(ctx, "listener") - if response is False: - return - listener_id, listener_name = self.listeners[function.func.__name__] - self.bot.remove_listener(FakeListener(listener_id), name=listener_name) - del listener_id, listener_name - log.debug( - f"Removed listener {function.name} due to incoming overwrite (edit)." - ) - try: - self.bot.add_listener(function.func, name=function.name) - except Exception as e: - exception = "".join(traceback.format_exception(type(e), e, e.__traceback__)) - message = ( - "An expetion has occured while adding the listener to discord.py:\n" - f"```py\n{exception}\n```" + try: + function, function_string = await self._extract_code(ctx, command) + snippet_type = find_matching_type(function) + # this is a CodeSnippet object (command, listener or whatever is currently supported) + code_snippet = snippet_type(self.bot, self.data, function, function_string) + except InstantcmdException as e: + message = e.args[0] + exc = e.__cause__ + if exc: + message += ( + "\n```py\n" + + "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + + "\n```" ) - for page in pagify(message): - await ctx.send(page) - return - else: - self.listeners[function.func.__name__] = (function.id, function.name) - async with self.data.commands() as _commands: - _commands[function.func.__name__] = function_string - if function.name != function.func.__name__: - await ctx.send( - f"The listener `{function.func.__name__}` listening for the " - f"event `{function.name}` was successfully added." - ) - log.debug( - f"Added listener {function.func.__name__} listening for the " - f"event {function.name} (ID: {function.id})" - ) - else: - await ctx.send(f"The listener {function.name} was successfully added.") - log.debug(f"Added listener {function.name} (ID: {function.id})") - - @instantcmd.command(aliases=["del", "remove"]) - async def delete(self, ctx, command_or_listener: str): - """ - Remove a command or a listener from the registered instant commands. - """ - command = command_or_listener - async with self.data.commands() as _commands: - if command not in _commands: - await ctx.send("That instant command doesn't exist") - return - if command in self.listeners: - text = "listener" - function, name = self.listeners[command] - self.bot.remove_listener(FakeListener(function), name=name) - else: - text = "command" - self.bot.remove_command(command) - _commands.pop(command) - await ctx.send(f"The {text} `{command}` was successfully removed.") - - @instantcmd.command(name="list") - async def _list(self, ctx): - """ - List all existing commands made using Instant Commands. - """ - message = "List of instant commands:\n" "```Diff\n" - _commands = await self.data.commands() - for name, command in _commands.items(): - message += f"+ {name}\n" - message += ( - "```\n" - "You can show the command source code by typing " - f"`{ctx.prefix}instacmd source `" - ) - if _commands == {}: - await ctx.send("No instant command created.") - return - for page in pagify(message): - await ctx.send(page) - - @instantcmd.command() - async def source(self, ctx: commands.Context, command: str): - """ - Show the code of an instantcmd command or listener. - """ - _commands = await self.data.commands() - if command not in _commands: - await ctx.send("Command not found.") + for page in pagify(message): + await ctx.send(page) return - _function = self.get_function_from_str(_commands[command]) - prefix = ctx.clean_prefix if isinstance(_function, commands.Command) else "" - await ctx.send(f"Source code for `{prefix}{command}`:") - await ctx.send_interactive( - pagify(_commands[command], shorten_by=10), box_lang="py", timeout=60 - ) - - @instantcmd.group() - async def env(self, ctx: commands.Context): - """ - Manage Red's dev environment - - This allows you to add custom values to the developer's environement used by the \ -core dev commands (debug, eval, repl). - Note that this cannot be used inside instantcommands due to the context requirement. - """ - pass - - @env.command(name="add") - async def env_add(self, ctx: commands.Context, name: str, *, code: str = None): - """ - Add a new value to Red's dev environement. - The code is in the form of an eval (like instantcmds) and must return a callable that \ -takes the context as its sole parameter. - """ - function = await self._extract_code(ctx, code, False) - if function is None: - return - function, function_string = function - # if the user used the command correctly, we should have one async function - async with self.data.dev_values() as values: - if name in values: - response = await self._ask_for_edit(ctx, "dev value") - if response is False: + # detecting if this name isn't already registered + for saved_code in self.get_code_snippets(type=snippet_type): + if str(saved_code) == str(code_snippet): + edit = await self._ask_for_edit(ctx, code_snippet) + if not edit: return - self.bot.remove_dev_env_value(name) - log.debug(f"Removed dev value {name} due to incoming overwrite (edit).") + try: - self.bot.add_dev_env_value(name, function) + code_snippet.register() except Exception as e: + log.error( + f"Failed to register snippet {code_snippet} given by {ctx.author}", exc_info=e + ) exception = "".join(traceback.format_exception(type(e), e, e.__traceback__)) message = ( - "An expetion has occured while adding the value to Red:\n" + f"An expetion has occured while registering your {code_snippet} to the bot:\n" f"```py\n{exception}\n```" ) for page in pagify(message): await ctx.send(page) return - else: - async with self.data.dev_values() as values: - values[name] = function_string - await ctx.send(f"The dev value `{name}` was successfully added.") - log.debug(f"Added dev value {name}") - @env.command(name="delete", aliases=["del", "remove"]) - async def env_delete(self, ctx: commands.Context, name: str): - """ - Unload and remove a dev value from the registered ones with instantcmd. - """ - async with self.data.dev_values() as values: - if name not in values: - await ctx.send("That value doesn't exist") - return - self.bot.remove_dev_env_value(name) - values.pop(name) - await ctx.send(f"The dev env value `{name}` was successfully removed.") - - @env.command(name="list") - async def env_list(self, ctx: commands.Context): - """ - List all dev env values registered. - """ - embed = discord.Embed(name="List of dev env values") - dev_cog = self.bot.get_cog("Dev") - values = await self.data.dev_values() - if not values: - message = "Nothing set yet." - else: - message = "- " + "\n- ".join(values) - embed.set_footer( - text=( - "You can show the command source code by typing " - f"`{ctx.prefix}instacmd env source `" - ) - ) - embed.add_field(name="Registered with InstantCommands", value=message, inline=False) - if dev_cog: - embed.description = "Dev mode is currently enabled" - other_values = [x for x in dev_cog.env_extensions if x not in values] - if other_values: - embed.add_field( - name="Other dev env values", - value="- " + "\n- ".join(other_values), - inline=False, - ) - else: - embed.description = "Dev mode is currently disabled" - embed.colour = await ctx.embed_colour() - await ctx.send(embed=embed) + code_snippet.registered = True + await code_snippet.save() + self.code_snippets.append(code_snippet) + await ctx.send(f"Successfully added your new {code_snippet.name}.") - @env.command(name="source") - async def env_source(self, ctx: commands.Context, name: str): + @instantcmd.command(name="list") + async def _list(self, ctx: commands.Context): """ - Show the code of a dev env value. + List all existing commands made using Instant Commands. """ - values = await self.data.dev_values() - if name not in values: - await ctx.send("Value not found.") + view = OwnerOnlyView(self.bot, timeout=300) + total = 0 + types = (CommandSnippet, ListenerSnippet, DevEnvSnippet) + for type in types: + objects = list(self.get_code_snippets(enabled=False, registered=False, type=type)) + if not objects: + continue + total += len(objects) + view.add_item(CodeSnippetsList(self.bot, type, objects)) + if total == 0: + await ctx.send("No instant command created.") return - await ctx.send(f"Source code for `{name}`:") - await ctx.send_interactive(pagify(values[name], shorten_by=10), box_lang="py", timeout=60) + await ctx.send(f"{total} instant commands created so far!", view=view) @commands.command(hidden=True) @checks.is_owner() - async def instantcmdinfo(self, ctx): + async def instantcmdinfo(self, ctx: commands.Context): """ Get informations about the cog. """ @@ -525,44 +357,16 @@ async def instantcmdinfo(self, ctx): "Support my work on Patreon: https://www.patreon.com/retke" ).format(self) - @listener() - async def on_command_error(self, ctx, error): - if not isinstance(error, commands.CommandInvokeError): - return - if not ctx.command.cog_name == self.__class__.__name__: - # That error doesn't belong to the cog - return - async with self.data.commands() as _commands: - if ctx.command.name in _commands: - log.info(f"Error in instant command {ctx.command.name}.", exc_info=error.original) - return - if isinstance(error, commands.MissingPermissions): - await ctx.send( - "I need the `Add reactions` and `Manage messages` in the " - "current channel if you want to use this command." - ) - with DisabledConsoleOutput(log): - log.error( - f"Exception in command '{ctx.command.qualified_name}'.\n\n", - exc_info=error.original, - ) - - # correctly unload the cog - def __unload(self): - self.cog_unload() - - def cog_unload(self): + async def cog_unload(self): log.debug("Unloading cog...") + # removes commands and listeners + await self.unload_all_code_snippets() - async def unload(): - # removes commands and listeners - await self.remove_commands() + self.bot.remove_dev_env_value("instantcmd") - # remove all handlers from the logger, this prevents adding - # multiple times the same handler if the cog gets reloaded - close_logger(log) + # remove all handlers from the logger, this prevents adding + # multiple times the same handler if the cog gets reloaded + close_logger(log) - # I am forced to put everything in an async function to execute the remove_commands - # function, and then remove the handlers. Using loop.create_task on remove_commands only - # executes it after removing the log handlers, while it needs to log... - self.bot.loop.create_task(unload()) + async def cog_load(self): + await self.load_all_code_snippets() diff --git a/instantcmd/utils.py b/instantcmd/utils.py index 03cfd648..3c749728 100644 --- a/instantcmd/utils.py +++ b/instantcmd/utils.py @@ -4,33 +4,34 @@ The instantcmd folder is added to sys.path only when executing the code on load. """ -from typing import Callable +from instantcmd.core.listener import Listener +from instantcmd.core.dev_env_value import DevEnv -class Listener: +def listener(name: str = None): """ - A class representing a discord.py listener. + A decorator that represents a discord.py listener. """ - def __init__(self, function: Callable, name: str): - self.func = function - self.name = name - self.id = id(function) + def decorator(func): + nonlocal name + if name is None: + name = func.__name__ + result = Listener(func, name) + return result - def __call__(self, *args, **kwargs): - self.func(*args, **kwargs) + return decorator -def listener(name: str = None): +def dev_env_value(name: str = None): """ - A decorator that represents a discord.py listener. + A decorator that represents a dev env value for Red. """ def decorator(func): nonlocal name if name is None: name = func.__name__ - result = Listener(func, name) - return result + return DevEnv(func, name) return decorator diff --git a/say/__init__.py b/say/__init__.py index bb298de4..f91fd39b 100644 --- a/say/__init__.py +++ b/say/__init__.py @@ -1,10 +1,17 @@ +import asyncio import logging import importlib.util + from .say import Say +from typing import TYPE_CHECKING +from discord import app_commands from redbot.core.errors import CogLoadError from laggron_utils import init_logger +if TYPE_CHECKING: + from redbot.core.bot import Red + if not importlib.util.find_spec("laggron_utils"): raise CogLoadError( "You need the `laggron_utils` package for any cog from Laggron's Dumb Cogs. " @@ -16,8 +23,8 @@ log = logging.getLogger("red.laggron.say") -async def setup(bot): - init_logger(log, Say.__class__.__name__) +async def setup(bot: "Red"): + init_logger(log, "Say") n = Say(bot) - bot.add_cog(n) + await bot.add_cog(n) log.debug("Cog successfully loaded on the instance.") diff --git a/say/say.py b/say/say.py index 80bdf260..3c2ee119 100644 --- a/say/say.py +++ b/say/say.py @@ -5,42 +5,39 @@ import logging import re -from typing import Optional -from laggron_utils.logging import close_logger, DisabledConsoleOutput +from discord import app_commands + +from typing import TYPE_CHECKING, Optional +from laggron_utils import close_logger from redbot.core import checks, commands from redbot.core.i18n import Translator, cog_i18n from redbot.core.utils.tunnel import Tunnel +if TYPE_CHECKING: + from redbot.core.bot import Red + log = logging.getLogger("red.laggron.say") _ = Translator("Say", __file__) -BaseCog = getattr(commands, "Cog", object) - -# Red 3.0 backwards compatibility, thanks Sinbad -listener = getattr(commands.Cog, "listener", None) -if listener is None: - - def listener(name=None): - return lambda x: x ROLE_MENTION_REGEX = re.compile(r"<@&(?P[0-9]{17,19})>") @cog_i18n(_) -class Say(BaseCog): +class Say(commands.Cog): """ Speak as if you were the bot Documentation: http://laggron.red/say.html """ - def __init__(self, bot): + def __init__(self, bot: "Red"): self.bot = bot self.interaction = [] __author__ = ["retke (El Laggron)"] - __version__ = "1.6.1" + __version__ = "2.0.0" async def say( self, @@ -57,48 +54,41 @@ async def say( await ctx.send_help() return - # preparing context info in case of an error - if files != []: - error_message = ( - "Has files: yes\n" - f"Number of files: {len(files)}\n" - f"Files URL: " + ", ".join([x.url for x in ctx.message.attachments]) - ) - else: - error_message = "Has files: no" + author = ctx.author + guild = ctx.guild - # sending the message - try: - await channel.send(text, files=files, allowed_mentions=mentions, delete_after=delete) - except discord.errors.HTTPException as e: - author = ctx.author - if not ctx.guild.me.permissions_in(channel).send_messages: - try: - await ctx.send( - _("I am not allowed to send messages in ") + channel.mention, - delete_after=2, - ) - except discord.errors.Forbidden: - await author.send( - _("I am not allowed to send messages in ") + channel.mention, - delete_after=15, - ) - # If this fails then fuck the command author - elif not ctx.guild.me.permissions_in(channel).attach_files: - try: - await ctx.send( - _("I am not allowed to upload files in ") + channel.mention, delete_after=2 - ) - except discord.errors.Forbidden: - await author.send( - _("I am not allowed to upload files in ") + channel.mention, - delete_after=15, - ) + # checking perms + if not channel.permissions_for(guild.me).send_messages: + if channel != ctx.channel: + await ctx.send( + _("I am not allowed to send messages in ") + channel.mention, + delete_after=2, + ) else: - log.error( - f"Unknown permissions error when sending a message.\n{error_message}", - exc_info=e, + await author.send(_("I am not allowed to send messages in ") + channel.mention) + # If this fails then fuck the command author + return + + if files and not channel.permissions_for(guild.me).attach_files: + try: + await ctx.send( + _("I am not allowed to upload files in ") + channel.mention, delete_after=2 + ) + except discord.errors.Forbidden: + await author.send( + _("I am not allowed to upload files in ") + channel.mention, + delete_after=15, ) + return + + try: + await channel.send(text, files=files, allowed_mentions=mentions, delete_after=delete) + except discord.errors.HTTPException: + try: + await ctx.send("An error occured when sending the message.") + except discord.errors.HTTPException: + pass + log.error("Failed to send message.", exc_info=True) @commands.command(name="say") @checks.admin_or_permissions(administrator=True) @@ -276,7 +266,7 @@ async def _interact(self, ctx: commands.Context, channel: discord.TextChannel = embed = discord.Embed() embed.set_author( name="{} | {}".format(str(message.author), message.author.id), - icon_url=message.author.avatar_url, + icon_url=message.author.avatar.url, ) embed.set_footer(text=message.created_at.strftime("%d %b %Y %H:%M")) embed.description = message.content @@ -306,35 +296,86 @@ async def sayinfo(self, ctx): ).format(self) ) - @listener() - async def on_reaction_add(self, reaction, user): - if user in self.interaction: - channel = reaction.message.channel - if isinstance(channel, discord.DMChannel): - await self.stop_interaction(user) + # ----- Slash commands ----- + @app_commands.command(name="say", description="Make the bot send a message") + @app_commands.describe( + message="The content of the message you want to send", + channel="The channel where you want to send the message (default to current)", + delete_delay="Delete the message sent after X seconds", + mentions="Allow @everyone, @here and role mentions in your message", + file="A file you want to attach to the message sent (message content becomes optional)", + ) + @app_commands.default_permissions() + @app_commands.guild_only() + async def slash_say( + self, + interaction: discord.Interaction, + message: Optional[str] = "", + channel: Optional[discord.TextChannel] = None, + delete_delay: Optional[int] = None, + mentions: Optional[bool] = False, + file: Optional[discord.Attachment] = None, + ): + guild = interaction.guild + channel = channel or interaction.channel - @listener() - async def on_command_error(self, ctx, error): - if not isinstance(error, commands.CommandInvokeError): + if not message and not file: + await interaction.response.send_message( + _("You cannot send an empty message."), ephemeral=True + ) + return + + if not channel.permissions_for(guild.me).send_messages: + await interaction.response.send_message( + _("I don't have the permission to send messages there."), ephemeral=True + ) return - if not ctx.command.cog_name == self.__class__.__name__: - # That error doesn't belong to the cog + if file and not channel.permissions_for(guild.me).attach_files: + await interaction.response.send_message( + _("I don't have the permission to upload files there."), ephemeral=True + ) return - with DisabledConsoleOutput(log): + + if mentions: + mentions = discord.AllowedMentions( + everyone=interaction.user.guild_permissions.mention_everyone, + roles=interaction.user.guild_permissions.mention_everyone + or [x for x in interaction.guild.roles if x.mentionable], + ) + else: + mentions = None + + file = await file.to_file(use_cached=True) if file else None + try: + await channel.send(message, file=file, delete_after=delete_delay) + except discord.HTTPException: + await interaction.response.send_message( + _("An error occured when sending the message."), ephemeral=True + ) log.error( - f"Exception in command '{ctx.command.qualified_name}'.\n\n", - exc_info=error.original, + f"Cannot send message in {channel.name} ({channel.id}) requested by " + f"{interaction.user} ({interaction.user.id}). " + f"Command: {interaction.message.content}", + exc_info=True, ) + else: + # acknowledge the command, but don't actually send an additional message + await interaction.response.defer(ephemeral=False) + await interaction.followup.delete_message("@original") + + @commands.Cog.listener() + async def on_reaction_add(self, reaction, user): + if user in self.interaction: + channel = reaction.message.channel + if isinstance(channel, discord.DMChannel): + await self.stop_interaction(user) async def stop_interaction(self, user): self.interaction.remove(user) await user.send(_("Session closed")) - def __unload(self): - self.cog_unload() - - def cog_unload(self): + async def cog_unload(self): log.debug("Unloading cog...") for user in self.interaction: - self.bot.loop.create_task(self.stop_interaction(user)) + await self.stop_interaction(user) close_logger(log) diff --git a/tournaments/games.py b/tournaments/games.py index cc64b670..90378f87 100644 --- a/tournaments/games.py +++ b/tournaments/games.py @@ -4,7 +4,7 @@ import logging import re -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from copy import deepcopy from typing import List, Mapping, Optional @@ -288,7 +288,7 @@ async def stop_tournament(): async def clear_channels(): nonlocal failed, i # This isn't actually two weeks ago to allow some wiggle room on API limits - two_weeks_ago = datetime.utcnow() - timedelta(days=14, minutes=-5) + two_weeks_ago = datetime.now(timezone.utc) - timedelta(days=14, minutes=-5) for channel in channels: try: messages = await channel.history(limit=None, after=two_weeks_ago).flatten() diff --git a/warnsystem/api.py b/warnsystem/api.py index b3129752..cf24901e 100644 --- a/warnsystem/api.py +++ b/warnsystem/api.py @@ -7,7 +7,7 @@ from copy import deepcopy from collections import namedtuple from typing import Union, Optional, Iterable, Callable, Awaitable -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from multiprocessing import TimeoutError from multiprocessing.pool import Pool @@ -55,7 +55,11 @@ class FakeRole: """ position = 0 - colour = discord.Embed.Empty + colour = None + + +class FakeAsset: + url = "" class UnavailableMember(discord.abc.User, discord.abc.Messageable): @@ -70,6 +74,7 @@ def __init__(self, bot, state, user_id: int): self._state = state self.id = user_id self.top_role = FakeRole() + self.avatar = FakeAsset() @classmethod def _check_id(cls, member_id): @@ -108,10 +113,6 @@ def display_name(self): def mention(self): return f"<@{self.id}>" - @property - def avatar_url(self): - return "" - def __str__(self): return "Unknown#0000" @@ -453,13 +454,149 @@ async def edit_case( ~warnsystem.errors.NotFound The case requested doesn't exist. """ + + async def edit_message(channel_id: int, message_id: int, new_reason: str): + channel: discord.TextChannel = guild.get_channel(channel_id) + if channel is None: + log.warn( + f"[Guild {guild.id}] Failed to edit modlog message. " + f"Channel {channel_id} not found." + ) + return False + try: + message: discord.Message = await channel.fetch_message(message_id) + except discord.errors.NotFound: + log.warn( + f"[Guild {guild.id}] Failed to edit modlog message. " + f"Message {message_id} in channel {channel.id} not found." + ) + return False + except discord.errors.Forbidden: + log.warn( + f"[Guild {guild.id}] Failed to edit modlog message. " + f"No permissions to fetch messages in channel {channel.id}." + ) + return False + except discord.errors.HTTPException as e: + log.error( + f"[Guild {guild.id}] Failed to edit modlog message. API exception raised.", + exc_info=e, + ) + return False + try: + embed: discord.Embed = message.embeds[0] + embed.set_field_at( + len(embed.fields) - 2, name=_("Reason"), value=new_reason, inline=False + ) + except IndexError as e: + log.error( + f"[Guild {guild.id}] Failed to edit modlog message. Embed is malformed.", + exc_info=e, + ) + return False + try: + await message.edit(embed=embed) + except discord.errors.HTTPException as e: + log.error( + f"[Guild {guild.id}] Failed to edit modlog message. " + "Unknown error when attempting message edition.", + exc_info=e, + ) + return False + return True + if len(new_reason) > 1024: raise errors.BadArgument("The reason must not be above 1024 characters.") case = await self.get_case(guild, user, index) case["reason"] = new_reason case["time"] = int(case["time"].timestamp()) + try: + channel_id, message_id = case["modlog_message"].values() + except KeyError: + pass + else: + await edit_message(channel_id, message_id, new_reason) async with self.data.custom("MODLOGS", guild.id, user.id).x() as logs: logs[index - 1] = case + log.debug( + f"[Guild {guild.id}] Edited case #{index} from member {user} (ID: {user.id}). " + f"New reason: {new_reason}" + ) + return True + + async def delete_case( + self, + guild: discord.Guild, + user: Union[discord.Member, UnavailableMember], + index: int, + ): + async def delete_message(channel_id: int, message_id: int): + channel: discord.TextChannel = guild.get_channel(channel_id) + if channel is None: + log.warn( + f"[Guild {guild.id}] Failed to delete modlog message. " + f"Channel {channel_id} not found." + ) + return False + try: + message: discord.Message = await channel.fetch_message(message_id) + except discord.errors.NotFound: + log.warn( + f"[Guild {guild.id}] Failed to delete modlog message. " + f"Message {message_id} in channel {channel.id} not found." + ) + return False + except discord.errors.Forbidden: + log.warn( + f"[Guild {guild.id}] Failed to delete modlog message. " + f"No permissions to fetch messages in channel {channel.id}." + ) + return False + except discord.errors.HTTPException as e: + log.error( + f"[Guild {guild.id}] Failed to delete modlog message. API exception raised.", + exc_info=e, + ) + return False + try: + await message.delete() + except discord.errors.HTTPException as e: + log.error( + f"[Guild {guild.id}] Failed to delete modlog message. " + "Unknown error when attempting message deletion.", + exc_info=e, + ) + return False + return True + + case = await self.get_case(guild, user, index) + can_unmute = False + add_roles = False + if case["level"] == 2: + mute_role = guild.get_role(await self.cache.get_mute_role(guild)) + member = guild.get_member(self.user) + if member: + if mute_role and mute_role in member.roles: + can_unmute = True + add_roles = await self.ws.data.guild(guild).remove_roles() + if can_unmute: + await member.remove_roles(mute_role, reason=_("Warning deleted.")) + async with self.data.custom("MODLOGS", guild.id, user.id).x() as logs: + try: + roles = logs[index - 1]["roles"] + except KeyError: + roles = [] + try: + channel_id, message_id = logs[index - 1]["modlog_message"].values() + except KeyError: + pass + else: + await delete_message(channel_id, message_id) + logs.remove(logs[index - 1]) + if add_roles and roles: + roles = [guild.get_role(x) for x in roles] + await member.add_roles(*roles, reason=_("Adding removed roles back after unmute.")) + log.debug(f"[Guild {guild.id}] Removed case #{index} from member {user} (ID: {user.id}).") return True async def get_modlog_channel( @@ -637,7 +774,7 @@ async def get_embeds( if date: today = date.strftime("%a %d %B %Y %H:%M") else: - today = datetime.utcnow() + today = datetime.now(timezone.utc) if time: duration = self._format_timedelta(time) else: @@ -662,7 +799,7 @@ def format_description(text): # embed for the modlog log_embed = discord.Embed() - log_embed.set_author(name=f"{member.name} | {member.id}", icon_url=member.avatar_url) + log_embed.set_author(name=f"{member.name} | {member.id}", icon_url=member.avatar) log_embed.title = _("Level {level} warning ({action})").format( level=level, action=action[0] ) @@ -1122,7 +1259,7 @@ async def warn_member(member: Union[discord.Member, UnavailableMember], audit_re else: audit_reason += _("Reason too long to be shown.") if not date: - date = datetime.utcnow() + date = datetime.now(timezone.utc) i = 0 fails = [await warn_member(x, audit_reason) for x in members if x] @@ -1171,7 +1308,7 @@ async def reinvite(guild, user, reason, duration): f"(ID: {member.id}) after its temporary ban." ) - now = datetime.utcnow() + now = datetime.now(timezone.utc) for guild in self.bot.guilds: data = await self.cache.get_temp_action(guild) if not data: @@ -1590,7 +1727,7 @@ def is_autowarn_valid(warn): # we increase this value until reaching the given limit time = autowarn["time"] if time: - until = datetime.utcnow() - timedelta(seconds=time) + until = datetime.now(timezone.utc) - timedelta(seconds=time) autowarns[i]["until"] = until del time found_warnings = {} # we fill this list with the valid autowarns, there can be more than 1 diff --git a/warnsystem/components.py b/warnsystem/components.py new file mode 100644 index 00000000..ff0f918d --- /dev/null +++ b/warnsystem/components.py @@ -0,0 +1,377 @@ +import discord + +from discord.components import SelectOption +from discord.ui import Button, Select, View +from asyncio import TimeoutError as AsyncTimeoutError +from datetime import datetime +from typing import Optional, Union, TYPE_CHECKING + +from redbot.core.i18n import Translator +from redbot.core.utils import predicates, mod + +from .api import UnavailableMember + +if TYPE_CHECKING: + from redbot.core.bot import Red + from .api import API + from .cache import MemoryCache + +_ = Translator("WarnSystem", __file__) + + +def pretty_date(time: datetime): + """ + Get a datetime object and return a pretty string like 'an hour ago', + 'Yesterday', '3 months ago', 'just now', etc + + This is based on this answer, modified for i18n compatibility: + https://stackoverflow.com/questions/1551382/user-friendly-time-format-in-python + """ + + def text(amount: float, unit: tuple): + amount = round(amount) + if amount > 1: + unit = unit[1] + else: + unit = unit[0] + return _("{amount} {unit} ago").format(amount=amount, unit=unit) + + units_name = { + 0: (_("year"), _("years")), + 1: (_("month"), _("months")), + 2: (_("week"), _("weeks")), + 3: (_("day"), _("days")), + 4: (_("hour"), _("hours")), + 5: (_("minute"), _("minutes")), + 6: (_("second"), _("seconds")), + } + now = datetime.now() + diff = now - time + second_diff = diff.seconds + day_diff = diff.days + if day_diff < 0: + return "" + if day_diff == 0: + if second_diff < 10: + return _("Just now") + if second_diff < 60: + return text(second_diff, units_name[6]) + if second_diff < 120: + return _("A minute ago") + if second_diff < 3600: + return text(second_diff / 60, units_name[5]) + if second_diff < 7200: + return _("An hour ago") + if second_diff < 86400: + return text(second_diff / 3600, units_name[4]) + if day_diff == 1: + return _("Yesterday") + if day_diff < 7: + return text(day_diff, units_name[3]) + if day_diff < 31: + return text(day_diff / 7, units_name[2]) + if day_diff < 365: + return text(day_diff / 30, units_name[1]) + return text(day_diff / 365, units_name[0]) + + +async def prompt_yes_or_no( + bot: "Red", + interaction: discord.Interaction, + content: Optional[str] = None, + *, + embed: Optional[discord.Embed] = None, + timeout: int = 30, + clear_after: bool = True, + negative_response: bool = True, +) -> bool: + """ + Sends a message and waits for used confirmation, using buttons. + + Credit to TrustyJAID for the stuff with buttons. Source: + https://github.com/TrustyJAID/Trusty-cogs/blob/f6ceb28ff592f664070a89282288452d615d7dc5/eventposter/eventposter.py#L750-L777 + + Parameters + ---------- + content: Union[str, discord.Embed] + Either text or an embed to send. + timeout: int + Time before timeout. Defaults to 30 seconds. + clear_after: bool + Should the message have its buttons removed? Defaults to True. Set to false if you will + edit later + negative_response: bool + If the bot should send "Cancelled." after a negative response. Defaults to True. + + Returns + ------- + bool + False if the user declined, if the request timed out, or if there are insufficient + permissions, else True. + """ + view = discord.ui.View() + approve_button = discord.ui.Button( + style=discord.ButtonStyle.green, + emoji="\N{HEAVY CHECK MARK}\N{VARIATION SELECTOR-16}", + custom_id=f"yes-{interaction.message.id}", + ) + deny_button = discord.ui.Button( + style=discord.ButtonStyle.red, + emoji="\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}", + custom_id=f"no-{interaction.message.id}", + ) + view.add_item(approve_button) + view.add_item(deny_button) + await interaction.edit_original_message(content=content, embed=embed, view=view) + + def check_same_user(inter): + return inter.user.id == interaction.user.id + + try: + x = await bot.wait_for("interaction", check=check_same_user, timeout=timeout) + except AsyncTimeoutError: + await interaction.edit_original_message(content=_("Request timed out.")) + return False + else: + custom_id = x.data.get("custom_id") + if custom_id == f"yes-{interaction.message.id}": + return True + if negative_response: + await interaction.edit_original_message(content=_("Cancelled."), view=None, embed=None) + return False + finally: + if clear_after: + await interaction.edit_original_message( + content=interaction.message.content, embed=embed, view=None + ) + + +class EditReasonButton(Button): + def __init__( + self, + bot: "Red", + interaction: discord.Interaction, + *, + user: Union[discord.Member, UnavailableMember], + case: dict, + case_index: int, + disabled: bool = False, + row: Optional[int] = None, + ): + self.bot = bot + self.inter = interaction + self.ws = bot.get_cog("WarnSystem") + self.api: "API" = self.ws.api + super().__init__( + style=discord.ButtonStyle.secondary, + label=_("Edit reason"), + disabled=disabled, + emoji="โœ", + row=row or 0, + ) + self.user = user + self.case = case + self.case_index = case_index + + async def callback(self, interaction: discord.Interaction): + await interaction.response.defer() + interaction = self.inter + embed = discord.Embed() + embed.description = _( + "Case #{number} edition.\n\n**Please type the new reason to set**" + ).format(number=self.case_index) + embed.set_footer(text=_("You have two minutes to type your text in the chat.")) + await interaction.edit_original_message(embed=embed, view=None) + try: + response = await self.bot.wait_for( + "message", + check=predicates.MessagePredicate.same_context(interaction, user=interaction.user), + timeout=120, + ) + except AsyncTimeoutError: + await interaction.delete_original_message() + return + new_reason = await self.api.format_reason(interaction.guild, response.content) + embed.description = _("Case #{number} edition.").format(number=self.case_index) + embed.add_field(name=_("Old reason"), value=self.case["reason"], inline=False) + embed.add_field(name=_("New reason"), value=new_reason, inline=False) + embed.set_footer(text=_("Click on โœ… to confirm the changes.")) + response = await prompt_yes_or_no(self.bot, interaction, embed=embed, clear_after=False) + if response is False: + return + await self.api.edit_case(interaction.guild, self.user, self.case_index, new_reason) + await interaction.edit_original_message( + content=_("The reason was successfully edited!\n"), embed=None, view=None + ) + + +class DeleteWarnButton(Button): + def __init__( + self, + bot: "Red", + interaction: discord.Interaction, + *, + user: Union[discord.Member, UnavailableMember], + case: dict, + case_index: int, + disabled: bool = False, + row: Optional[int] = None, + ): + self.bot = bot + self.inter = interaction + self.ws = bot.get_cog("WarnSystem") + self.api: "API" = self.ws.api + self.cache: "MemoryCache" = self.ws.cache + super().__init__( + style=discord.ButtonStyle.danger, + label=_("Delete case"), + disabled=disabled, + emoji="\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}", + row=row or 0, + ) + self.user = user + self.case = case + self.case_index = case_index + + async def callback(self, interaction: discord.Interaction): + await interaction.response.defer() + interaction = self.inter + guild = interaction.guild + embed = discord.Embed() + can_unmute = False + add_roles = False + if self.case["level"] == 2: + mute_role = guild.get_role(await self.cache.get_mute_role(guild)) + member = guild.get_member(self.user) + if member: + if mute_role and mute_role in member.roles: + can_unmute = True + add_roles = await self.ws.data.guild(guild).remove_roles() + description = _( + "Case #{number} deletion.\n**Click on the button to confirm your action.**" + ).format(number=self.case_index) + if can_unmute or add_roles: + description += _("\nNote: Deleting the case will also do the following:") + if can_unmute: + description += _("\n- unmute the member") + if add_roles: + description += _("\n- add all roles back to the member") + embed.description = description + response = await prompt_yes_or_no(self.bot, interaction, embed=embed, clear_after=False) + if response is False: + return + await self.api.delete_case(guild, self.user, self.case_index) + await interaction.edit_original_message( + content=_("The case was successfully deleted!"), embed=None, view=None + ) + + +class WarningsList(Select): + def __init__( + self, + bot: "Red", + user: Union[discord.Member, UnavailableMember], + cases: list, + *, + row: Optional[int] = None, + ): + self.bot = bot + self.ws = bot.get_cog("WarnSystem") + self.api: "API" = self.ws.api + super().__init__( + placeholder=_("Click to view the list of warnings."), + min_values=1, + max_values=1, + options=self.generate_cases(cases), + row=row, + ) + self.user = user + self.cases = cases + + def _get_label(self, level: int): + if level == 1: + return (_("Warning"), "โš ") + elif level == 2: + return (_("Mute"), "๐Ÿ”‡") + elif level == 3: + return (_("Kick"), "๐Ÿ‘ข") + elif level == 4: + return (_("Softban"), "๐Ÿงน") + elif level == 5: + return (_("Ban"), "๐Ÿ”จ") + + def generate_cases(self, cases: list): + options = [] + for i, case in enumerate(cases[:24]): + name, emote = self._get_label(case["level"]) + date = pretty_date(self.api._get_datetime(case["time"])) + if case["reason"] and len(name) + len(case["reason"]) > 25: + reason = case["reason"][:47] + "..." + else: + reason = case["reason"] + option = SelectOption( + label=name + " โ€ข " + date, + value=i, + emoji=emote, + description=reason, + ) + options.append(option) + return options + + async def callback(self, interaction: discord.Interaction): + warning_str = lambda level, plural: { + 1: (_("Warning"), _("Warnings")), + 2: (_("Mute"), _("Mutes")), + 3: (_("Kick"), _("Kicks")), + 4: (_("Softban"), _("Softbans")), + 5: (_("Ban"), _("Bans")), + }.get(level, _("unknown"))[1 if plural else 0] + + guild = interaction.guild + i = int(interaction.data["values"][0]) + case = self.cases[i] + level = case["level"] + moderator = guild.get_member(case["author"]) + moderator = "ID: " + str(case["author"]) if not moderator else moderator.mention + time = self.api._get_datetime(case["time"]) + embed = discord.Embed(description=_("Case #{number} informations").format(number=i + 1)) + embed.set_author(name=f"{self.user} | {self.user.id}", icon_url=self.user.avatar) + embed.add_field( + name=_("Level"), value=f"{warning_str(level, False)} ({level})", inline=True + ) + embed.add_field(name=_("Moderator"), value=moderator, inline=True) + if case["duration"]: + duration = self.api._get_timedelta(case["duration"]) + embed.add_field( + name=_("Duration"), + value=_("{duration}\n(Until {date})").format( + duration=self.api._format_timedelta(duration), + date=self.api._format_datetime(time + duration), + ), + ) + embed.add_field(name=_("Reason"), value=case["reason"], inline=False), + embed.timestamp = time + embed.colour = await self.ws.data.guild(guild).colors.get_raw(level) + is_mod = await mod.is_mod_or_superior(self.bot, interaction.user) + view = View() + view.add_item( + EditReasonButton( + self.bot, + interaction, + user=self.user, + case=case, + case_index=i, + disabled=not is_mod, + ) + ) + view.add_item( + DeleteWarnButton( + self.bot, + interaction, + user=self.user, + case=case, + case_index=i, + disabled=not is_mod, + ) + ) + await interaction.response.send_message(embed=embed, view=view) diff --git a/warnsystem/warnsystem.py b/warnsystem/warnsystem.py index 80902ab9..f2883305 100644 --- a/warnsystem/warnsystem.py +++ b/warnsystem/warnsystem.py @@ -2,13 +2,14 @@ import discord import logging import asyncio -import re from io import BytesIO from typing import Optional +from discord.ui import View from asyncio import TimeoutError as AsyncTimeoutError from abc import ABC -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone + from laggron_utils.logging import close_logger, DisabledConsoleOutput from redbot.core import commands, Config, checks @@ -17,6 +18,8 @@ from redbot.core.utils import predicates, menus, mod from redbot.core.utils.chat_formatting import pagify +from warnsystem.components import WarningsList + from . import errors from .api import API, UnavailableMember from .automod import AutomodMixin @@ -36,62 +39,6 @@ def listener(name=None): return lambda x: x -def pretty_date(time: datetime): - """ - Get a datetime object and return a pretty string like 'an hour ago', - 'Yesterday', '3 months ago', 'just now', etc - - This is based on this answer, modified for i18n compatibility: - https://stackoverflow.com/questions/1551382/user-friendly-time-format-in-python - """ - - def text(amount: float, unit: tuple): - amount = round(amount) - if amount > 1: - unit = unit[1] - else: - unit = unit[0] - return _("{amount} {unit} ago.").format(amount=amount, unit=unit) - - units_name = { - 0: (_("year"), _("years")), - 1: (_("month"), _("months")), - 2: (_("week"), _("weeks")), - 3: (_("day"), _("days")), - 4: (_("hour"), _("hours")), - 5: (_("minute"), _("minutes")), - 6: (_("second"), _("seconds")), - } - now = datetime.now() - diff = now - time - second_diff = diff.seconds - day_diff = diff.days - if day_diff < 0: - return "" - if day_diff == 0: - if second_diff < 10: - return _("Just now") - if second_diff < 60: - return text(second_diff, units_name[6]) - if second_diff < 120: - return _("A minute ago") - if second_diff < 3600: - return text(second_diff / 60, units_name[5]) - if second_diff < 7200: - return _("An hour ago") - if second_diff < 86400: - return text(second_diff / 3600, units_name[4]) - if day_diff == 1: - return _("Yesterday") - if day_diff < 7: - return text(day_diff, units_name[3]) - if day_diff < 31: - return text(day_diff / 7, units_name[2]) - if day_diff < 365: - return text(day_diff / 30, units_name[1]) - return text(day_diff / 365, units_name[0]) - - # Red 3.1 backwards compatibility try: from redbot.core.utils.chat_formatting import text_to_file @@ -227,7 +174,7 @@ def __init__(self, bot): self.task: asyncio.Task - __version__ = "1.4.0" + __version__ = "1.5.0" __author__ = ["retke (El Laggron)"] # helpers @@ -790,7 +737,6 @@ async def masswarn_5(self, ctx, *selection: str): @commands.command() @commands.guild_only() - @commands.bot_has_permissions(add_reactions=True, manage_messages=True) @commands.cooldown(1, 3, commands.BucketType.member) async def warnings( self, ctx: commands.Context, user: UnavailableMember = None, index: int = 0 @@ -830,355 +776,22 @@ async def warnings( 5: (_("Ban"), _("Bans")), }.get(level, _("unknown"))[1 if plural else 0] - embeds = [] msg = [] for i in range(6): total_warns = total(i) if total_warns > 0: msg.append(f"{warning_str(i, total_warns > 1)}: {total_warns}") warn_field = "\n".join(msg) if len(msg) > 1 else msg[0] - warn_list = [] - for case in cases[:-10:-1]: - level = case["level"] - reason = str(case["reason"]).splitlines() - if len(reason) > 1: - reason = reason[0] + "..." - else: - reason = reason[0] - date = pretty_date(self.api._get_datetime(case["time"])) - text = f"**{warning_str(level, False)}:** {reason} โ€ข *{date}*\n" - if len("".join(warn_list + [text])) > 1024: # embed limits - break - else: - warn_list.append(text) embed = discord.Embed(description=_("User modlog summary.")) - embed.set_author(name=f"{user} | {user.id}", icon_url=user.avatar_url) + embed.set_author(name=f"{user} | {user.id}", icon_url=user.avatar) embed.add_field( name=_("Total number of warnings: ") + str(len(cases)), value=warn_field, inline=False ) - embed.add_field( - name=_("{len} last warnings").format(len=len(warn_list)) - if len(warn_list) > 1 - else _("Last warning"), - value="".join(warn_list), - inline=False, - ) - embed.set_footer(text=_("Click on the reactions to scroll through the warnings")) embed.colour = user.top_role.colour - embeds.append(embed) - - for i, case in enumerate(cases): - level = case["level"] - moderator = ctx.guild.get_member(case["author"]) - moderator = "ID: " + str(case["author"]) if not moderator else moderator.mention - - time = self.api._get_datetime(case["time"]) - embed = discord.Embed( - description=_("Case #{number} informations").format(number=i + 1) - ) - embed.set_author(name=f"{user} | {user.id}", icon_url=user.avatar_url) - embed.add_field( - name=_("Level"), value=f"{warning_str(level, False)} ({level})", inline=True - ) - embed.add_field(name=_("Moderator"), value=moderator, inline=True) - if case["duration"]: - duration = self.api._get_timedelta(case["duration"]) - embed.add_field( - name=_("Duration"), - value=_("{duration}\n(Until {date})").format( - duration=self.api._format_timedelta(duration), - date=self.api._format_datetime(time + duration), - ), - ) - embed.add_field(name=_("Reason"), value=case["reason"], inline=False), - embed.timestamp = time - embed.colour = await self.data.guild(ctx.guild).colors.get_raw(level) - embeds.append(embed) - - controls = {"โฌ…": menus.prev_page, "โŒ": menus.close_menu, "โžก": menus.next_page} - if await mod.is_mod_or_superior(self.bot, ctx.author): - controls.update({"โœ": self._edit_case, "๐Ÿ—‘": self._delete_case}) - - await menus.menu( - ctx=ctx, pages=embeds, controls=controls, message=None, page=index, timeout=60 - ) - - async def _edit_case( - self, - ctx: commands.Context, - pages: list, - controls: dict, - message: discord.Message, - page: int, - timeout: float, - emoji: str, - ): - """ - Edit a case, this is linked to the warnings menu system. - """ - - async def edit_message(channel_id: int, message_id: int, new_reason: str): - channel: discord.TextChannel = guild.get_channel(channel_id) - if channel is None: - log.warn( - f"[Guild {guild.id}] Failed to edit modlog message. " - f"Channel {channel_id} not found." - ) - return False - try: - message: discord.Message = await channel.fetch_message(message_id) - except discord.errors.NotFound: - log.warn( - f"[Guild {guild.id}] Failed to edit modlog message. " - f"Message {message_id} in channel {channel.id} not found." - ) - return False - except discord.errors.Forbidden: - log.warn( - f"[Guild {guild.id}] Failed to edit modlog message. " - f"No permissions to fetch messages in channel {channel.id}." - ) - return False - except discord.errors.HTTPException as e: - log.error( - f"[Guild {guild.id}] Failed to edit modlog message. API exception raised.", - exc_info=e, - ) - return False - try: - embed: discord.Embed = message.embeds[0] - embed.set_field_at( - len(embed.fields) - 2, name=_("Reason"), value=new_reason, inline=False - ) - except IndexError as e: - log.error( - f"[Guild {guild.id}] Failed to edit modlog message. Embed is malformed.", - exc_info=e, - ) - return False - try: - await message.edit(embed=embed) - except discord.errors.HTTPException as e: - log.error( - f"[Guild {guild.id}] Failed to edit modlog message. " - "Unknown error when attempting message edition.", - exc_info=e, - ) - return False - return True - - guild = ctx.guild - if page == 0: - # first page, no case to edit - await message.remove_reaction(emoji, ctx.author) - return await menus.menu( - ctx, pages, controls, message=message, page=page, timeout=timeout - ) - await message.clear_reactions() - try: - old_embed = message.embeds[0] - except IndexError: - return - embed = discord.Embed() - member_id = int( - re.match(r"(?:.*#[0-9]{4})(?: \| )([0-9]{15,21})", old_embed.author.name).group(1) - ) - member = self.bot.get_user(member_id) or UnavailableMember( - self.bot, guild._state, member_id - ) - embed.clear_fields() - embed.description = _( - "Case #{number} edition.\n\n**Please type the new reason to set**" - ).format(number=page) - embed.set_footer(text=_("You have two minutes to type your text in the chat.")) - case = (await self.data.custom("MODLOGS", guild.id, member.id).x())[page - 1] - await message.edit(embed=embed) - try: - response = await self.bot.wait_for( - "message", check=predicates.MessagePredicate.same_context(ctx), timeout=120 - ) - except AsyncTimeoutError: - await message.delete() - return - case = (await self.data.custom("MODLOGS", guild.id, member.id).x())[page - 1] - new_reason = await self.api.format_reason(guild, response.content) - embed.description = _("Case #{number} edition.").format(number=page) - embed.add_field(name=_("Old reason"), value=case["reason"], inline=False) - embed.add_field(name=_("New reason"), value=new_reason, inline=False) - embed.set_footer(text=_("Click on โœ… to confirm the changes.")) - await message.edit(embed=embed) - menus.start_adding_reactions(message, predicates.ReactionPredicate.YES_OR_NO_EMOJIS) - pred = predicates.ReactionPredicate.yes_or_no(message, ctx.author) - try: - await ctx.bot.wait_for("reaction_add", check=pred, timeout=30) - except AsyncTimeoutError: - await message.clear_reactions() - await message.edit(content=_("Question timed out."), embed=None) - return - if pred.result: - async with self.data.custom("MODLOGS", guild.id, member.id).x() as logs: - logs[page - 1]["reason"] = new_reason - try: - channel_id, message_id = logs[page - 1]["modlog_message"].values() - except KeyError: - result = None - else: - result = await edit_message(channel_id, message_id, new_reason) - await message.clear_reactions() - text = _("The reason was successfully edited!\n") - if result is False: - text += _("*The modlog message couldn't be edited. Check your logs for details.*") - await message.edit(content=text, embed=None) - else: - await message.clear_reactions() - await message.edit(content=_("The reason was not edited."), embed=None) - - async def _delete_case( - self, - ctx: commands.Context, - pages: list, - controls: dict, - message: discord.Message, - page: int, - timeout: float, - emoji: str, - ): - """ - Remove a case, this is linked to the warning system. - """ - - async def delete_message(channel_id: int, message_id: int): - channel: discord.TextChannel = guild.get_channel(channel_id) - if channel is None: - log.warn( - f"[Guild {guild.id}] Failed to delete modlog message. " - f"Channel {channel_id} not found." - ) - return False - try: - message: discord.Message = await channel.fetch_message(message_id) - except discord.errors.NotFound: - log.warn( - f"[Guild {guild.id}] Failed to delete modlog message. " - f"Message {message_id} in channel {channel.id} not found." - ) - return False - except discord.errors.Forbidden: - log.warn( - f"[Guild {guild.id}] Failed to delete modlog message. " - f"No permissions to fetch messages in channel {channel.id}." - ) - return False - except discord.errors.HTTPException as e: - log.error( - f"[Guild {guild.id}] Failed to delete modlog message. API exception raised.", - exc_info=e, - ) - return False - try: - await message.delete() - except discord.errors.HTTPException as e: - log.error( - f"[Guild {guild.id}] Failed to delete modlog message. " - "Unknown error when attempting message deletion.", - exc_info=e, - ) - return False - return True - guild = ctx.guild - await message.clear_reactions() - try: - old_embed = message.embeds[0] - except IndexError: - return - embed = discord.Embed() - member_id = int( - re.match(r"(?:.*#[0-9]{4})(?: \| )([0-9]{15,21})", old_embed.author.name).group(1) - ) - member = self.bot.get_user(member_id) or UnavailableMember( - self.bot, guild._state, member_id - ) - if page == 0: - # no warning specified, mod wants to completly clear the member - embed.colour = 0xEE2B2B - embed.description = _( - "Member {member}'s clearance. By selecting โŒ on the user modlog summary, you can " - "remove all warnings given to {member}. __All levels and notes are affected.__\n" - "**Click on the reaction to confirm the removal of the entire user's modlog. " - "This cannot be undone.**" - ).format(member=str(member)) - else: - level = int(re.match(r".*\(([0-9]*)\)", old_embed.fields[0].value).group(1)) - can_unmute = False - add_roles = False - if level == 2: - mute_role = guild.get_role(await self.cache.get_mute_role(guild)) - member = guild.get_member(member.id) - if member: - if mute_role and mute_role in member.roles: - can_unmute = True - add_roles = await self.data.guild(guild).remove_roles() - description = _( - "Case #{number} deletion.\n**Click on the reaction to confirm your action.**" - ).format(number=page) - if can_unmute or add_roles: - description += _("\nNote: Deleting the case will also do the following:") - if can_unmute: - description += _("\n- unmute the member") - if add_roles: - description += _("\n- add all roles back to the member") - embed.description = description - await message.edit(embed=embed) - menus.start_adding_reactions(message, predicates.ReactionPredicate.YES_OR_NO_EMOJIS) - pred = predicates.ReactionPredicate.yes_or_no(message, ctx.author) - try: - await ctx.bot.wait_for("reaction_add", check=pred, timeout=30) - except AsyncTimeoutError: - await message.clear_reactions() - await message.edit(content=_("Question timed out."), embed=None) - return - if not pred.result: - await message.clear_reactions() - await message.edit(content=_("Nothing was removed."), embed=None) - return - if page == 0: - # removing entire modlog - await self.data.custom("MODLOGS", guild.id, member.id).x.set([]) - log.debug(f"[Guild {guild.id}] Cleared modlog of member {member} (ID: {member.id}).") - await message.clear_reactions() - await message.edit(content=_("User modlog cleared."), embed=None) - return - async with self.data.custom("MODLOGS", guild.id, member.id).x() as logs: - try: - roles = logs[page - 1]["roles"] - except KeyError: - roles = [] - try: - channel_id, message_id = logs[page - 1]["modlog_message"].values() - except KeyError: - result = None - else: - result = await delete_message(channel_id, message_id) - logs.remove(logs[page - 1]) - log.debug( - f"[Guild {guild.id}] Removed case #{page} from member {member} (ID: {member.id})." - ) - await message.clear_reactions() - if can_unmute: - await member.remove_roles( - mute_role, - reason=_("Warning deleted by {author}").format( - author=f"{str(ctx.author)} (ID: {ctx.author.id})" - ), - ) - if roles: - roles = [guild.get_role(x) for x in roles] - await member.add_roles(*roles, reason=_("Adding removed roles back after unmute.")) - text = _("The case was successfully deleted!") - if result is False: - text += _("*The modlog message couldn't be deleted. Check your logs for details.*") - await message.edit(content=_("The case was successfully deleted!"), embed=None) + view = View() + view.add_item(WarningsList(self.bot, user, cases)) + await ctx.send(embed=embed, view=view) @commands.command() @checks.mod_or_permissions(kick_members=True) @@ -1418,7 +1031,7 @@ async def on_manual_action(self, guild: discord.Guild, member: discord.Member, l await self.api.get_modlog_channel(guild, level) except errors.NotFound: return - when = datetime.utcnow() + when = datetime.now(timezone.utc) before = when + timedelta(minutes=1) after = when - timedelta(minutes=1) await asyncio.sleep(10) # prevent small delays from causing a 5 minute delay on entry