diff --git a/docs/changelog.md b/docs/changelog.md index 40f75bb8..d883f211 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added + +- Interaction Paginator that uses discord buttons (#50) - docker-compose.yml (#13) - Running the bot after configuring the env vars is now as simple as `docker-compose up` - Automatic docker image creation: `ghcr.io/discord-modmail/modmail` (#19) diff --git a/modmail/extensions/extension_manager.py b/modmail/extensions/extension_manager.py index 1bac02b6..aacee78e 100644 --- a/modmail/extensions/extension_manager.py +++ b/modmail/extensions/extension_manager.py @@ -15,6 +15,7 @@ from modmail.log import ModmailLogger from modmail.utils.cogs import BotModes, ExtMetadata, ModmailCog from modmail.utils.extensions import EXTENSIONS, NO_UNLOAD, unqualify, walk_extensions +from modmail.utils.pagination import ButtonPaginator log: ModmailLogger = logging.getLogger(__name__) @@ -84,6 +85,7 @@ class ExtensionManager(ModmailCog, name="Extension Manager"): """ type = "extension" + module_name = "extensions" # modmail/extensions def __init__(self, bot: ModmailBot): self.bot = bot @@ -179,14 +181,16 @@ async def list_extensions(self, ctx: Context) -> None: for category, extensions in sorted(categories.items()): # Treat each category as a single line by concatenating everything. # This ensures the paginator will not cut off a page in the middle of a category. + log.trace(f"Extensions in category {category}: {extensions}") category = category.replace("_", " ").title() extensions = "\n".join(sorted(extensions)) lines.append(f"**{category}**\n{extensions}\n") log.debug(f"{ctx.author} requested a list of all {self.type}s. " "Returning a paginated list.") - # TODO: since we currently don't have a paginator. - await ctx.send("".join(lines) or f"There are no {self.type}s installed.") + await ButtonPaginator.paginate( + lines or f"There are no {self.type}s installed.", ctx.message, embed=embed + ) @extensions_group.command(name="refresh", aliases=("rewalk", "rescan")) async def resync_extensions(self, ctx: Context) -> None: @@ -222,7 +226,10 @@ def group_extension_statuses(self) -> t.Mapping[str, str]: status = ":red_circle:" root, name = ext.rsplit(".", 1) - category = " - ".join(root.split(".")) + if root.split(".", 1)[1] == self.module_name: + category = f"General {self.type}s" + else: + category = " - ".join(root.split(".")[2:]) categories[category].append(f"{status} {name}") return dict(categories) diff --git a/modmail/extensions/plugin_manager.py b/modmail/extensions/plugin_manager.py index 47e30a21..57cd1950 100644 --- a/modmail/extensions/plugin_manager.py +++ b/modmail/extensions/plugin_manager.py @@ -25,6 +25,7 @@ class PluginManager(ExtensionManager, name="Plugin Manager"): """Plugin management commands.""" type = "plugin" + module_name = "plugins" # modmail/plugins def __init__(self, bot: ModmailBot) -> None: super().__init__(bot) diff --git a/modmail/extensions/utils/__init__.py b/modmail/extensions/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modmail/extensions/utils/paginator_manager.py b/modmail/extensions/utils/paginator_manager.py new file mode 100644 index 00000000..fc6efbf0 --- /dev/null +++ b/modmail/extensions/utils/paginator_manager.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from discord import InteractionType + +from modmail.utils.cogs import ModmailCog + +if TYPE_CHECKING: + from discord import Interaction + + from modmail.bot import ModmailBot + from modmail.log import ModmailLogger + +logger: ModmailLogger = logging.getLogger(__name__) + + +class PaginatorManager(ModmailCog): + """Handles paginators that were still active when the bot shut down.""" + + def __init__(self, bot: ModmailBot): + self.bot = bot + + @ModmailCog.listener() + async def on_interaction(self, interaction: Interaction) -> None: + """ + Remove components from paginator messages if they fail. + + The paginator handles all interactions while it is active, but if the bot is restarted, + those interactions stop being dealt with. + + This handles all paginator interactions that fail, which should only happen if + the paginator was unable to delete its message. + """ + # paginator only has component interactions + if not interaction.type == InteractionType.component: + return + logger.debug(f"Interaction sent by {interaction.user}.") + logger.trace(f"Interaction data: {interaction.data}") + if ( + interaction.data["custom_id"].startswith("pag_") + and interaction.message.author.id == self.bot.user.id + ): + # sleep for two seconds to give the paginator time to respond. + # this is due to discord requiring a response within 3 seconds, + # and we don't want to let the paginator fail. + await asyncio.sleep(2) + if not interaction.response.is_done(): + await interaction.response.send_message(content="This paginator has expired.", ephemeral=True) + await asyncio.sleep(0.1) # sleep for just a moment so we don't jar the user + await interaction.message.edit(view=None) + + +def setup(bot: ModmailBot) -> None: + """Add the paginator cleaner to the bot.""" + bot.add_cog(PaginatorManager(bot)) diff --git a/modmail/utils/__init__.py b/modmail/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modmail/utils/errors.py b/modmail/utils/errors.py new file mode 100644 index 00000000..69a2fda7 --- /dev/null +++ b/modmail/utils/errors.py @@ -0,0 +1,10 @@ +class MissingAttributeError(Exception): + """Missing attribute.""" + + pass + + +class InvalidArgumentError(Exception): + """Improper argument.""" + + pass diff --git a/modmail/utils/pagination.py b/modmail/utils/pagination.py new file mode 100644 index 00000000..f2f98fa8 --- /dev/null +++ b/modmail/utils/pagination.py @@ -0,0 +1,297 @@ +""" +Paginator. + +Originally adapated from: https://github.com/khk4912/EZPaginator/tree/84b5213741a78de266677b805c6f694ad94fedd6 +""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + +import discord +from discord import ButtonStyle, ui +from discord.embeds import Embed, EmbedProxy +from discord.ext.commands import Paginator as DpyPaginator + +from modmail.utils.errors import InvalidArgumentError, MissingAttributeError + +if TYPE_CHECKING: + from discord import Interaction + from discord.ui import Button + + from modmail.log import ModmailLogger + + +# Labels +# NOTE: the characters are similar to what is printed, but not exact. This is to limit encoding issues. +JUMP_FIRST_LABEL = " \u276e\u276e " # << +BACK_LABEL = " \u276e " # < +FORWARD_LABEL = " \u276f " # > +JUMP_LAST_LABEL = " \u276f\u276f " # >> +STOP_PAGINATE_EMOJI = "\u274c" # [:x:] This is an emoji, which is treated differently from the above + +logger: ModmailLogger = logging.getLogger(__name__) + + +class ButtonPaginator(ui.View, DpyPaginator): + """ + A class that helps in paginating long messages/embeds, which can be interacted via discord buttons. + + Attributes + ---------- + ctx: commands.Context + Context of the message. + contents : List[str] + List of contents. + timeout : float, default 180 + A timeout of receiving Interactions. + only : discord.abc.User, optional + If a parameter is given, the paginator will respond only to the selected user. + auto_delete : bool, default False + Whether to delete message after timeout. + """ + + def __init__( + self, + contents: Union[List[str], str], + /, + source_message: Optional[discord.Message] = None, + embed: Embed = None, + timeout: float = 180, + *, + footer_text: str = None, + prefix: str = "```", + suffix: str = "```", + max_size: int = 2000, + linesep: str = "\n", + only_users: Optional[List[Union[discord.Object, discord.abc.User]]] = None, + only_roles: Optional[List[Union[discord.Object, discord.Role]]] = None, + ) -> None: + """ + Creates a new Paginator instance. + + If source_message or only_users/only_roles are not provided, the paginator will respond to all users. + If source message is provided and only_users is NOT provided, the paginator will respond + to the author of the source message. To override this, pass an empty list to `only_users`. + + """ + self.index = 0 + self._pages: List[str] = [] + self.prefix = prefix + self.suffix = suffix + self.max_size = max_size + self.linesep = linesep + self.embed = embed or Embed() + + # temporary to support strings as contents. This will be changed when we added wrapping. + if isinstance(contents, str): + contents = [contents] + + # ensure that only_users are all users + if only_users is not None: + if isinstance(only_users, list): + if not all(isinstance(user, (discord.Object, discord.abc.User)) for user in only_users): + raise InvalidArgumentError( + "only_users must be a list of discord.Object or discord.abc.User objects." + ) + elif source_message is not None: + logger.debug("Only users not provided, using source message author.") + only_users = [source_message.author] + + if only_roles is not None: + if isinstance(only_roles, list): + if not all(isinstance(role, (discord.Object, discord.Role)) for role in only_roles): + raise InvalidArgumentError( + "only_roles must be a list of discord.Object or discord.Role objects." + ) + + self.only_users = only_users + self.only_roles = only_roles + + if not isinstance(timeout, (int, float)): + raise InvalidArgumentError("timeout must be a float") + + self.timeout = float(timeout) + + # set footer to embed.footer if embed is set + # this is because we will be modifying the footer of this embed + if embed is not None: + if not isinstance(embed.footer, EmbedProxy) and footer_text is None: + footer_text = embed.footer + self.footer_text = footer_text + self.clear() + for line in contents: + self.add_line(line) + self.close_page() + # create the super so the children attributes are set + super().__init__() + + # store component states for disabling + self.states: Dict[str, Dict[str, Any]] = dict() + for child in self.children: + attrs = child.to_component_dict() + self.states[attrs["custom_id"]] = attrs + + @classmethod + async def paginate( + cls, + contents: Optional[List[str]] = None, + source_message: discord.Message = None, + /, + timeout: float = 180, + embed: Embed = None, + *, + footer_text: str = None, + only: Optional[discord.abc.User] = None, + channel: discord.abc.Messageable = None, + show_jump_buttons_min_pages: int = 3, + prefix: str = "", + suffix: str = "", + max_size: int = 4000, + linesep: str = "\n", + only_users: Optional[List[Union[discord.Object, discord.abc.User]]] = None, + only_roles: Optional[List[Union[discord.Object, discord.abc.Role]]] = None, + ) -> None: + """ + Create a paginator, and paginate the provided lines. + + One of source message or channel is required. + """ + paginator = cls( + contents, + source_message=source_message, + timeout=timeout, + embed=embed, + footer_text=footer_text, + prefix=prefix, + suffix=suffix, + max_size=max_size, + linesep=linesep, + only_users=only_users, + only_roles=only_roles, + ) + + if channel is None and source_message is None: + raise MissingAttributeError("Both channel and source_message are None.") + elif channel is None: + channel = source_message.channel + + paginator.update_states() + paginator.embed.description = paginator.pages[paginator.index] + # if there's only one page, don't send the view + if len(paginator.pages) < 2: + await channel.send(embeds=[paginator.embed]) + return + + if len(paginator.pages) < (show_jump_buttons_min_pages or 3): + for item in paginator.children: + if getattr(item, "custom_id", None) in ["pag_jump_first", "pag_jump_last"]: + paginator.remove_item(item) + + msg: discord.Message = await channel.send(embeds=[paginator.embed], view=paginator) + + await paginator.wait() + await msg.edit(view=None) + + async def interaction_check(self, interaction: Interaction) -> bool: + """Check if the interaction is by the author of the paginator.""" + if self.only_users is not None: + logger.trace(f"All allowed users: {self.only_users}") + if any(user.id == interaction.user.id for user in self.only_users): + logger.debug("User is in allowed users") + return True + if self.only_roles is not None: + logger.trace(f"All allowed roles: {self.only_roles}") + user_roles = [role.id for role in interaction.user.roles] + if any(role.id in user_roles for role in self.only_roles): + logger.debug("User is in allowed roles") + return True + await interaction.response.send_message( + content="You are not authorised to use this paginator.", ephemeral=True + ) + return False + + def get_footer(self) -> str: + """Returns the footer text.""" + self.embed.description = self._pages[self.index] + page_indicator = f"Page {self.index+1}/{len(self._pages)}" + footer_txt = ( + f"{self.footer_text} ({page_indicator})" if self.footer_text is not None else page_indicator + ) + return footer_txt + + def update_states(self) -> None: + """ + Disable specific components depending on paginator page and length. + + If the paginator has less than two pages, the jump buttons will be disabled. + If the paginator is on the first page, the jump first/move back buttons will be disabled. + if the paginator is on the last page, the jump last/move forward buttons will be disabled. + """ + # update the footer + self.embed.set_footer(text=self.get_footer()) + + # determine if the jump buttons should be enabled + more_than_two_pages = len(self._pages) > 2 + components = { + "pag_jump_first": more_than_two_pages, + "pag_prev": True, + "pag_next": True, + "pag_jump_last": more_than_two_pages, + } + + if self.index == 0: + # on the first page, disable buttons that would go to this page. + logger.trace("Paginator is on the first page, disabling jump to first and previous buttons.") + components["pag_jump_first"] = False + components["pag_prev"] = False + + elif self.index == len(self._pages) - 1: + # on the last page, disable buttons that would go to this page. + logger.trace("Paginator is on the last page, disabling jump to last and next buttons.") + components["pag_next"] = False + components["pag_jump_last"] = False + + for child in self.children: + # since its possible custom_id and disabled are not an attribute + # we need to get them with getattr + if getattr(child, "custom_id", None) in components.keys(): + if getattr(child, "disabled", None) is not None: + child.disabled = not components[child.custom_id] + + async def send_page(self, interaction: Interaction) -> None: + """Send new page to discord, after updating the view to have properly disabled buttons.""" + self.update_states() + + await interaction.message.edit(embed=self.embed, view=self) + + @ui.button(label=JUMP_FIRST_LABEL, custom_id="pag_jump_first", style=ButtonStyle.primary) + async def go_first(self, _: Button, interaction: Interaction) -> None: + """Move the paginator to the first page.""" + self.index = 0 + await self.send_page(interaction) + + @ui.button(label=BACK_LABEL, custom_id="pag_prev", style=ButtonStyle.primary) + async def go_previous(self, _: Button, interaction: Interaction) -> None: + """Move the paginator to the previous page.""" + self.index -= 1 + await self.send_page(interaction) + + @ui.button(label=FORWARD_LABEL, custom_id="pag_next", style=ButtonStyle.primary) + async def go_next(self, _: Button, interaction: Interaction) -> None: + """Move the paginator to the next page.""" + self.index += 1 + await self.send_page(interaction) + + @ui.button(label=JUMP_LAST_LABEL, custom_id="pag_jump_last", style=ButtonStyle.primary) + async def go_last(self, _: Button, interaction: Interaction) -> None: + """Move the paginator to the last page.""" + self.index = len(self._pages) - 1 + await self.send_page(interaction) + + # NOTE: This method cannot be named `stop`, due to inheriting the method named stop from ui.View + @ui.button(emoji=STOP_PAGINATE_EMOJI, custom_id="pag_stop_paginate", style=ButtonStyle.grey) + async def _stop(self, _: Button, interaction: Interaction) -> None: + """Stop the paginator early.""" + await interaction.response.defer() + self.stop() diff --git a/pyproject.toml b/pyproject.toml index 967b5fed..86da8637 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ source_pkgs = ["modmail"] omit = ["modmail/plugins/**.*"] [tool.pytest.ini_options] -addopts = "--cov" +addopts = "--cov= " minversion = "6.0" testpaths = ["tests"] diff --git a/tests/docs.md b/tests/docs.md index 1719ff47..1b8e0ac4 100644 --- a/tests/docs.md +++ b/tests/docs.md @@ -67,3 +67,28 @@ Test creating an embed with extra parameters errors properly. **Markers:** - dependency (depends_on=patch_embed) +# tests.modmail.utils.test_pagination +## +### test_paginator_init +Test that we can safely create a paginator. + +**Markers:** +- asyncio +### test_paginator_footer +Test the paginator footer matches what is passed. + +**Markers:** +- parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world'), ('There are no plugins installed.', None)]) +- asyncio +### test_paginator_footer +Test the paginator footer matches what is passed. + +**Markers:** +- parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world'), ('There are no plugins installed.', None)]) +- asyncio +### test_paginator_footer +Test the paginator footer matches what is passed. + +**Markers:** +- parametrize (content, footer_text[(['5'], 'Snap, crackle, pop'), (['Earthly'], 'world'), ('There are no plugins installed.', None)]) +- asyncio diff --git a/tests/modmail/utils/test_pagination.py b/tests/modmail/utils/test_pagination.py new file mode 100644 index 00000000..1993c6ee --- /dev/null +++ b/tests/modmail/utils/test_pagination.py @@ -0,0 +1,40 @@ +from typing import List, Union + +import pytest + +from modmail.utils.pagination import ButtonPaginator + + +@pytest.mark.asyncio +async def test_paginator_init() -> None: + """Test that we can safely create a paginator.""" + content = ["content"] + paginator = ButtonPaginator(content, prefix="", suffix="", linesep="") + assert paginator.pages == content + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "content, footer_text", + [ + (["5"], "Snap, crackle, pop"), + (["Earthly"], "world"), + ("There are no plugins installed.", None), + ], +) +async def test_paginator_footer(content: Union[str, List[str]], footer_text: str) -> None: + """Test the paginator footer matches what is passed.""" + pag = ButtonPaginator(content, footer_text=footer_text) + print("index:", pag.index) + print("page len: ", len(pag.pages)) + assert pag.footer_text == footer_text + if isinstance(content, str): + content = [content] + + print(pag.get_footer()) + if footer_text is not None: + assert pag.get_footer().endswith(f"{len(content)})") + assert pag.get_footer().startswith(footer_text) + + else: + assert pag.get_footer().endswith(f"{len(content)}")