diff options
| -rw-r--r-- | bot/cogs/clickup.py | 8 | ||||
| -rw-r--r-- | bot/cogs/cogs.py | 6 | ||||
| -rw-r--r-- | bot/cogs/tags.py | 6 | ||||
| -rw-r--r-- | bot/pagination.py | 237 | ||||
| -rw-r--r-- | bot/utils.py | 128 |
5 files changed, 248 insertions, 137 deletions
diff --git a/bot/cogs/clickup.py b/bot/cogs/clickup.py index cde0f6a80..aa6fa4a62 100644 --- a/bot/cogs/clickup.py +++ b/bot/cogs/clickup.py @@ -10,7 +10,9 @@ from bot.constants import ( ADMIN_ROLE, CLICKUP_KEY, CLICKUP_SPACE, CLICKUP_TEAM, DEVOPS_ROLE, MODERATOR_ROLE, OWNER_ROLE ) from bot.decorators import with_role -from bot.utils import CaseInsensitiveDict, paginate +from bot.pagination import LinePaginator +from bot.utils import CaseInsensitiveDict + CREATE_TASK_URL = "https://api.clickup.com/api/v1/list/{list_id}/task" EDIT_TASK_URL = "https://api.clickup.com/api/v1/task/{task_id}" @@ -110,7 +112,7 @@ class ClickUp: status = f"{task['status']['status'].title()}" lines.append(f"{id_fragment} ({status})\n\u00BB {task['name']}") - return await paginate(lines, ctx, embed, max_size=750) + return await LinePaginator.paginate(lines, ctx, embed, max_size=750) return await ctx.send(embed=embed) @command(name="clickup.task()", aliases=["clickup.task", "task", "get_task"]) @@ -172,7 +174,7 @@ class ClickUp: f"**Assignees**\n{assignees}" ) - return await paginate(lines, ctx, embed, max_size=750) + return await LinePaginator.paginate(lines, ctx, embed, max_size=750) return await ctx.send(embed=embed) @command(name="clickup.team()", aliases=["clickup.team", "team", "list_team"]) diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index 7ad27656e..774f5a68d 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -10,7 +10,7 @@ from bot.constants import ( WHITE_CHEVRON ) from bot.decorators import with_role -from bot.utils import paginate +from bot.pagination import LinePaginator class Cogs: @@ -202,7 +202,7 @@ class Cogs: for cog, error in failed_loads: lines.append(f"`{cog}` {WHITE_CHEVRON} `{error}`") - return await paginate(lines, ctx, embed, empty=False) + return await LinePaginator.paginate(lines, ctx, embed, empty=False) elif full_cog in self.bot.extensions: try: @@ -262,7 +262,7 @@ class Cogs: lines.append(f"{chevron} {cog}") - await paginate(lines, ctx, embed, max_size=300, empty=False) + await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) def setup(bot): diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index ab46be009..2bb939ff0 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -8,7 +8,7 @@ from discord.ext.commands import AutoShardedBot, Context, command from bot.constants import ADMIN_ROLE, MODERATOR_ROLE, OWNER_ROLE from bot.constants import SITE_API_KEY, SITE_API_TAGS_URL, TAG_COOLDOWN from bot.decorators import with_role -from bot.utils import paginate +from bot.pagination import LinePaginator class Tags: @@ -176,12 +176,12 @@ class Tags: # Paginate if this is a list of all tags if tags: - return await paginate( + return await LinePaginator.paginate( (lines for lines in tags), ctx, embed, footer_text="To show a tag, type bot.tags.get <tagname>.", empty=False, - max_size=200 + max_lines=15 ) return await ctx.send(embed=embed) diff --git a/bot/pagination.py b/bot/pagination.py new file mode 100644 index 000000000..51ddad212 --- /dev/null +++ b/bot/pagination.py @@ -0,0 +1,237 @@ +# coding=utf-8 +import asyncio +from typing import Iterable, Optional + +from discord import Embed, Member, Reaction +from discord.abc import User +from discord.ext.commands import Context, Paginator + +LEFT_EMOJI = "\u2B05" +RIGHT_EMOJI = "\u27A1" +DELETE_EMOJI = "\u274c" +FIRST_EMOJI = "\u23EE" +LAST_EMOJI = "\u23ED" + +PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI] + + +class LinePaginator(Paginator): + """ + A class that aids in paginating code blocks for Discord messages. + + Attributes + ----------- + prefix: :class:`str` + The prefix inserted to every page. e.g. three backticks. + suffix: :class:`str` + The suffix appended at the end of every page. e.g. three backticks. + max_size: :class:`int` + The maximum amount of codepoints allowed in a page. + max_lines: :class:`int` + The maximum amount of lines allowed in a page. + """ + + def __init__(self, prefix='```', suffix='```', + max_size=2000, max_lines=None): + """ + This function overrides the Paginator.__init__ + from inside discord.ext.commands. + It overrides in order to allow us to configure + the maximum number of lines per page. + """ + self.prefix = prefix + self.suffix = suffix + self.max_size = max_size - len(suffix) + self.max_lines = max_lines + self._current_page = [prefix] + self._linecount = 0 + self._count = len(prefix) + 1 # prefix + newline + self._pages = [] + + def add_line(self, line='', *, empty=False): + """Adds a line to the current page. + + If the line exceeds the :attr:`max_size` then an exception + is raised. + + This function overrides the Paginator.add_line + from inside discord.ext.commands. + It overrides in order to allow us to configure + the maximum number of lines per page. + + Parameters + ----------- + line: str + The line to add. + empty: bool + Indicates if another empty line should be added. + + Raises + ------ + RuntimeError + The line was too big for the current :attr:`max_size`. + """ + if len(line) > self.max_size - len(self.prefix) - 2: + raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) + + if self.max_lines is not None: + if self._linecount >= self.max_lines: + self._linecount = 0 + self.close_page() + + self._linecount += 1 + if self._count + len(line) + 1 > self.max_size: + self.close_page() + + self._count += len(line) + 1 + self._current_page.append(line) + + if empty: + self._current_page.append('') + self._count += 1 + + @classmethod + async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed, + prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500, + empty: bool = True, restrict_to_user: User = None, timeout: int=300, + footer_text: str = None): + """ + Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to + switch page, or to finish with pagination. + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may + be used to change page, or to remove pagination from the message. Pagination will also be removed automatically + if no reaction is added for five minutes (300 seconds). + >>> embed = Embed() + >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) + >>> await LinePaginator.paginate( + ... (line for line in lines), + ... ctx, embed + ... ) + :param lines: The lines to be paginated + :param ctx: Current context object + :param embed: A pre-configured embed to be used as a template for each page + :param prefix: Text to place before each page + :param suffix: Text to place after each page + :param max_lines: The maximum number of lines on each page + :param max_size: The maximum number of characters on each page + :param empty: Whether to place an empty line between each given line + :param restrict_to_user: A user to lock pagination operations to for this message, if supplied + :param timeout: The amount of time in seconds to disable pagination of no reaction is added + :param footer_text: Text to prefix the page number in the footer with + """ + + def event_check(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + + no_restrictions = ( + # Pagination is not restricted + not restrict_to_user or + # The reaction was by a whitelisted user + user_.id == restrict_to_user.id + ) + + return ( + # Conditions for a successful pagination: + all(( + # Reaction is on this message + reaction_.message.id == message.id, + # Reaction is one of the pagination emotes + reaction_.emoji in PAGINATION_EMOJI, + # Reaction was not made by the Bot + user_.id != ctx.bot.user.id, + # There were no restrictions + no_restrictions + )) + ) + + paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines) + current_page = 0 + + for line in lines: + paginator.add_line(line, empty=empty) + + embed.description = paginator.pages[current_page] + + if len(paginator.pages) <= 1: + if footer_text: + embed.set_footer(text=footer_text) + + return await ctx.send(embed=embed) + else: + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") + else: + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + + message = await ctx.send(embed=embed) + + for emoji in PAGINATION_EMOJI: + # Add all the applicable emoji to the message + await message.add_reaction(emoji) + + while True: + try: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=event_check) + except asyncio.TimeoutError: + break # We're done, no reactions for the last 5 minutes + + if reaction.emoji == DELETE_EMOJI: + break + + if reaction.emoji == FIRST_EMOJI: + await message.remove_reaction(reaction.emoji, user) + current_page = 0 + embed.description = paginator.pages[current_page] + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") + else: + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + await message.edit(embed=embed) + + if reaction.emoji == LAST_EMOJI: + await message.remove_reaction(reaction.emoji, user) + current_page = len(paginator.pages) - 1 + embed.description = paginator.pages[current_page] + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") + else: + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + await message.edit(embed=embed) + + if reaction.emoji == LEFT_EMOJI: + await message.remove_reaction(reaction.emoji, user) + + if current_page <= 0: + continue + + current_page -= 1 + + embed.description = paginator.pages[current_page] + + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") + else: + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + + await message.edit(embed=embed) + + if reaction.emoji == RIGHT_EMOJI: + await message.remove_reaction(reaction.emoji, user) + + if current_page >= len(paginator.pages) - 1: + continue + + current_page += 1 + + embed.description = paginator.pages[current_page] + + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") + else: + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + + await message.edit(embed=embed) + + await message.clear_reactions() diff --git a/bot/utils.py b/bot/utils.py index c3ee680ed..eac37a4b4 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -1,16 +1,4 @@ # coding=utf-8 -import asyncio -from typing import Iterable - -from discord import Embed, Member, Reaction -from discord.abc import User -from discord.ext.commands import Context, Paginator - -LEFT_EMOJI = "\u2B05" -RIGHT_EMOJI = "\u27A1" -DELETE_EMOJI = "\U0001F5D1" - -PAGINATION_EMOJI = [LEFT_EMOJI, RIGHT_EMOJI, DELETE_EMOJI] class CaseInsensitiveDict(dict): @@ -57,119 +45,3 @@ class CaseInsensitiveDict(dict): for k in list(self.keys()): v = super(CaseInsensitiveDict, self).pop(k) self.__setitem__(k, v) - - -async def paginate(lines: Iterable[str], ctx: Context, embed: Embed, - prefix: str = "", suffix: str = "", max_size: int = 500, empty: bool = True, - restrict_to_user: User = None, timeout: int=300, - footer_text: str = None): - """ - Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to - switch page, or to finish with pagination. - - When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. Pagination will also be removed automatically - if no reaction is added for five minutes (300 seconds). - - >>> embed = Embed() - >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) - >>> await paginate( - ... (line for line in lines), - ... ctx, embed - ... ) - - :param lines: The lines to be paginated - :param ctx: Current context object - :param embed: A pre-configured embed to be used as a template for each page - :param prefix: Text to place before each page - :param suffix: Text to place after each page - :param max_size: The maximum number of characters on each page - :param empty: Whether to place an empty line between each given line - :param restrict_to_user: A user to lock pagination operations to for this message, if supplied - :param timeout: The amount of time in seconds to disable pagination of no reaction is added - :param footer_text: Text to prefix the page number in the footer with - """ - - def event_check(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ - - return ( - reaction_.message.id == message.id and # Reaction on this specific message - reaction_.emoji in PAGINATION_EMOJI and # One of the reactions we handle - user_.id != ctx.bot.user.id and ( # Not applied by the bot itself - not restrict_to_user or # Unrestricted if there's no user to restrict to, or... - user_.id == restrict_to_user.id # Only by the restricted user - ) - ) - - paginator = Paginator(prefix=prefix, suffix=suffix, max_size=max_size) - current_page = 0 - - for line in lines: - paginator.add_line(line, empty=empty) - - embed.description = paginator.pages[current_page] - - if len(paginator.pages) <= 1: - if footer_text: - embed.set_footer(text=footer_text) - - return await ctx.send(embed=embed) - else: - if footer_text: - embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") - else: - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - - message = await ctx.send(embed=embed) - - for emoji in PAGINATION_EMOJI: - # Add all the applicable emoji to the message - await message.add_reaction(emoji) - - while True: - try: - reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=event_check) - except asyncio.TimeoutError: - break # We're done, no reactions for the last 5 minutes - - if reaction.emoji == DELETE_EMOJI: - break - - if reaction.emoji == LEFT_EMOJI: - await message.remove_reaction(reaction.emoji, user) - - if current_page <= 0: - continue - - current_page -= 1 - - embed.description = paginator.pages[current_page] - - if footer_text: - embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") - else: - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - - await message.edit(embed=embed) - - if reaction.emoji == RIGHT_EMOJI: - await message.remove_reaction(reaction.emoji, user) - - if current_page >= len(paginator.pages) - 1: - continue - - current_page += 1 - - embed.description = paginator.pages[current_page] - - if footer_text: - embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") - else: - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - - await message.edit(embed=embed) - - await message.clear_reactions() |