From c8ac92912a8fca54d175d2c5dc9018436df1a631 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Fri, 14 Jul 2023 10:29:48 +0100 Subject: add missing pagination_emojis in `LinePaginator.paginate` This also renames the moduel to `pagination` --- pydis_core/utils/pagination.py | 374 +++++++++++++++++++++++++++++++++++++++++ pydis_core/utils/paginator.py | 372 ---------------------------------------- 2 files changed, 374 insertions(+), 372 deletions(-) create mode 100644 pydis_core/utils/pagination.py delete mode 100644 pydis_core/utils/paginator.py diff --git a/pydis_core/utils/pagination.py b/pydis_core/utils/pagination.py new file mode 100644 index 00000000..fb5b1d28 --- /dev/null +++ b/pydis_core/utils/pagination.py @@ -0,0 +1,374 @@ +import asyncio +from contextlib import suppress +from functools import partial +from typing import Sequence + +from pydis_core.utils.logging import get_logger + +import discord +from discord.abc import User +from discord.ext.commands import Context, Paginator + + +from pydis_core.settings import _PaginationEmojis +from pydis_core.utils.messages import reaction_check + +log = get_logger(__name__) + + +class EmptyPaginatorEmbedError(Exception): + """Raised when attempting to paginate with empty contents.""" + + +class LinePaginator(Paginator): + """ + A class that aids in paginating code blocks for Discord messages. + + Available attributes include: + * prefix: `str` + The prefix inserted to every page. e.g. three backticks. + * suffix: `str` + The suffix appended at the end of every page. e.g. three backticks. + * max_size: `int` + The maximum amount of codepoints allowed in a page. + * scale_to_size: `int` + The maximum amount of characters a single line can scale up to. + * max_lines: `int` + The maximum amount of lines allowed in a page. + """ + + def __init__( + self, + pagination_emojis: _PaginationEmojis, + prefix: str = "```", + suffix: str = "```", + max_size: int = 4000, + scale_to_size: int = 4000, + max_lines: int | None = None, + linesep: str = "\n" + ) -> 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. + """ + # Embeds that exceed 4096 characters will result in an HTTPException + # (Discord API limit), so we've set a limit of 4000 + if max_size > 4000: + raise ValueError(f"max_size must be <= 4,000 characters. ({max_size} > 4000)") + + super().__init__( + prefix, + suffix, + max_size - len(suffix), + linesep + ) + + if scale_to_size < max_size: + raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") + + if scale_to_size > 4000: + raise ValueError(f"scale_to_size must be <= 4,000 characters. ({scale_to_size} > 4000)") + + self.scale_to_size = scale_to_size - len(suffix) + self.max_lines = max_lines + self._current_page = [prefix] + self._linecount = 0 + self._count = len(prefix) + 1 # prefix + newline + self._pages = [] + self.pagination_emojis = pagination_emojis + + def add_line(self, line: str = "", *, empty: bool = False) -> None: + """ + Adds a line to the current page. + + If a line on a page exceeds `max_size` characters, then `max_size` will go up to + `scale_to_size` for a single line before creating a new page for the overflow words. If it + is still exceeded, the excess characters are stored and placed on the next pages unti + there are none remaining (by word boundary). The line is truncated if `scale_to_size` is + still exceeded after attempting to continue onto the next page. + + In the case that the page already contains one or more lines and the new lines would cause + `max_size` to be exceeded, a new page is created. This is done in order to make a best + effort to avoid breaking up single lines across pages, while keeping the total length of the + page at a reasonable size. + + 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. + """ + remaining_words = None + if len(line) > (max_chars := self.max_size - len(self.prefix) - 2): + if len(line) > self.scale_to_size: + line, remaining_words = self._split_remaining_words(line, max_chars) + if len(line) > self.scale_to_size: + log.debug("Could not continue to next page, truncating line.") + line = line[:self.scale_to_size] + + # Check if we should start a new page or continue the line on the current one + if self.max_lines is not None and self._linecount >= self.max_lines: + log.debug("max_lines exceeded, creating new page.") + self._new_page() + elif self._count + len(line) + 1 > self.max_size and self._linecount > 0: + log.debug("max_size exceeded on page with lines, creating new page.") + self._new_page() + + self._linecount += 1 + + self._count += len(line) + 1 + self._current_page.append(line) + + if empty: + self._current_page.append("") + self._count += 1 + + # Start a new page if there were any overflow words + if remaining_words: + self._new_page() + self.add_line(remaining_words) + + def _new_page(self) -> None: + """ + Internal: start a new page for the paginator. + + This closes the current page and resets the counters for the new page's line count and + character count. + """ + self._linecount = 0 + self._count = len(self.prefix) + 1 + self.close_page() + + + def _split_remaining_words(self, line: str, max_chars: int) -> tuple[str, str | None]: + """ + Internal: split a line into two strings -- reduced_words and remaining_words. + + reduced_words: the remaining words in `line`, after attempting to remove all words that + exceed `max_chars` (rounding down to the nearest word boundary). + + remaining_words: the words in `line` which exceed `max_chars`. This value is None if + no words could be split from `line`. + + If there are any remaining_words, an ellipses is appended to reduced_words and a + continuation header is inserted before remaining_words to visually communicate the line + continuation. + + Return a tuple in the format (reduced_words, remaining_words). + """ + reduced_words = [] + remaining_words = [] + + # "(Continued)" is used on a line by itself to indicate the continuation of last page + continuation_header = "(Continued)\n-----------\n" + reduced_char_count = 0 + is_full = False + + for word in line.split(" "): + if not is_full: + if len(word) + reduced_char_count <= max_chars: + reduced_words.append(word) + reduced_char_count += len(word) + 1 + else: + # If reduced_words is empty, we were unable to split the words across pages + if not reduced_words: + return line, None + is_full = True + remaining_words.append(word) + else: + remaining_words.append(word) + + return ( + " ".join(reduced_words) + "..." if remaining_words else "", + continuation_header + " ".join(remaining_words) if remaining_words else None + ) + + @classmethod + async def paginate( + cls, + pagination_emojis: _PaginationEmojis, + lines: list[str], + ctx: Context | discord.Interaction, + embed: discord.Embed, + prefix: str = "", + suffix: str = "", + max_lines: int | None = None, + max_size: int = 500, + scale_to_size: int = 4000, + empty: bool = True, + restrict_to_user: User | None = None, + timeout: int = 300, + footer_text: str | None = None, + url: str | None = None, + exception_on_empty_embed: bool = False, + reply: bool = False, + allowed_roles: Sequence[int] | None = None + ) -> discord.Message | 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). + + The interaction will be limited to `restrict_to_user` (ctx.author by default) or + to any user with a moderation role. + + Example: + >>> embed = discord.Embed() + >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) + >>> await LinePaginator.paginate([line for line in lines], ctx, embed) + """ + paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines, + scale_to_size=scale_to_size, pagination_emojis=pagination_emojis) + + current_page = 0 + + if not restrict_to_user: + if isinstance(ctx, discord.Interaction): + restrict_to_user = ctx.user + else: + restrict_to_user = ctx.author + + if not lines: + if exception_on_empty_embed: + log.exception("Pagination asked for empty lines iterable") + raise EmptyPaginatorEmbedError("No lines to paginate") + + log.debug("No lines to add to paginator, adding '(nothing to display)' message") + lines.append("*(nothing to display)*") + + for line in lines: + try: + paginator.add_line(line, empty=empty) + except Exception: + log.exception(f"Failed to add line to paginator: '{line}'") + raise # Should propagate + else: + log.trace(f"Added line to paginator: '{line}'") + + log.debug(f"Paginator created with {len(paginator.pages)} pages") + + embed.description = paginator.pages[current_page] + + reference = ctx.message if reply else None + + if len(paginator.pages) <= 1: + if footer_text: + embed.set_footer(text=footer_text) + log.trace(f"Setting embed footer to '{footer_text}'") + + if url: + embed.url = url + log.trace(f"Setting embed url to '{url}'") + + log.debug("There's less than two pages, so we won't paginate - sending single page on its own") + + if isinstance(ctx, discord.Interaction): + return await ctx.response.send_message(embed=embed) + return await ctx.send(embed=embed, reference=reference) + + 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)}") + log.trace(f"Setting embed footer to '{embed.footer.text}'") + + if url: + embed.url = url + log.trace(f"Setting embed url to '{url}'") + + log.debug("Sending first page to channel...") + + if isinstance(ctx, discord.Interaction): + await ctx.response.send_message(embed=embed) + message = await ctx.original_response() + else: + message = await ctx.send(embed=embed, reference=reference) + + log.debug("Adding emoji reactions to message...") + + pagination_emoji_list = list(pagination_emojis.dict().values()) + + for emoji in pagination_emoji_list: + # Add all the applicable emoji to the message + log.trace(f"Adding reaction: {emoji!r}") + await message.add_reaction(emoji) + + check = partial( + reaction_check, + message_id=message.id, + allowed_emoji=pagination_emoji_list, + allowed_users=(restrict_to_user.id,), + allowed_roles=allowed_roles, + ) + + while True: + try: + if isinstance(ctx, discord.Interaction): + reaction, user = await ctx.client.wait_for("reaction_add", timeout=timeout, check=check) + else: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check) + log.trace(f"Got reaction: {reaction}") + except asyncio.TimeoutError: + log.debug("Timed out waiting for a reaction") + break # We're done, no reactions for the last 5 minutes + + if str(reaction.emoji) == paginator.pagination_emojis.delete: + log.debug("Got delete reaction") + return await message.delete() + if reaction.emoji in pagination_emoji_list: + total_pages = len(paginator.pages) + try: + await message.remove_reaction(reaction.emoji, user) + except discord.HTTPException as e: + # Suppress if trying to act on an archived thread. + if e.code != 50083: + raise e + + if reaction.emoji == paginator.pagination_emojis.first: + current_page = 0 + log.debug(f"Got first page reaction - changing to page 1/{total_pages}") + elif reaction.emoji == paginator.pagination_emojis.last: + current_page = len(paginator.pages) - 1 + log.debug(f"Got last page reaction - changing to page {current_page + 1}/{total_pages}") + elif reaction.emoji == paginator.pagination_emojis.left: + if current_page <= 0: + log.debug("Got previous page reaction, but we're on the first page - ignoring") + continue + + current_page -= 1 + log.debug(f"Got previous page reaction - changing to page {current_page + 1}/{total_pages}") + elif reaction.emoji == paginator.pagination_emojis.right: + if current_page >= len(paginator.pages) - 1: + log.debug("Got next page reaction, but we're on the last page - ignoring") + continue + + current_page += 1 + log.debug(f"Got next page reaction - changing to page {current_page + 1}/{total_pages}") + + 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)}") + + try: + await message.edit(embed=embed) + except discord.HTTPException as e: + if e.code == 50083: + # Trying to act on an archived thread, just ignore and abort + break + raise e + + log.debug("Ending pagination and clearing reactions.") + with suppress(discord.NotFound): + try: + await message.clear_reactions() + except discord.HTTPException as e: + # Suppress if trying to act on an archived thread. + if e.code != 50083: + raise e diff --git a/pydis_core/utils/paginator.py b/pydis_core/utils/paginator.py deleted file mode 100644 index 1b336fce..00000000 --- a/pydis_core/utils/paginator.py +++ /dev/null @@ -1,372 +0,0 @@ -import asyncio -from contextlib import suppress -from functools import partial -from typing import Sequence - -from pydis_core.utils.logging import get_logger - -import discord -from discord.abc import User -from discord.ext.commands import Context, Paginator - - -from pydis_core.settings import _PaginationEmojis -from pydis_core.utils.messages import reaction_check - -log = get_logger(__name__) - - -class EmptyPaginatorEmbedError(Exception): - """Raised when attempting to paginate with empty contents.""" - - -class LinePaginator(Paginator): - """ - A class that aids in paginating code blocks for Discord messages. - - Available attributes include: - * prefix: `str` - The prefix inserted to every page. e.g. three backticks. - * suffix: `str` - The suffix appended at the end of every page. e.g. three backticks. - * max_size: `int` - The maximum amount of codepoints allowed in a page. - * scale_to_size: `int` - The maximum amount of characters a single line can scale up to. - * max_lines: `int` - The maximum amount of lines allowed in a page. - """ - - def __init__( - self, - pagination_emojis: _PaginationEmojis, - prefix: str = "```", - suffix: str = "```", - max_size: int = 4000, - scale_to_size: int = 4000, - max_lines: int | None = None, - linesep: str = "\n" - ) -> 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. - """ - # Embeds that exceed 4096 characters will result in an HTTPException - # (Discord API limit), so we've set a limit of 4000 - if max_size > 4000: - raise ValueError(f"max_size must be <= 4,000 characters. ({max_size} > 4000)") - - super().__init__( - prefix, - suffix, - max_size - len(suffix), - linesep - ) - - if scale_to_size < max_size: - raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") - - if scale_to_size > 4000: - raise ValueError(f"scale_to_size must be <= 4,000 characters. ({scale_to_size} > 4000)") - - self.scale_to_size = scale_to_size - len(suffix) - self.max_lines = max_lines - self._current_page = [prefix] - self._linecount = 0 - self._count = len(prefix) + 1 # prefix + newline - self._pages = [] - self.pagination_emojis = pagination_emojis - - def add_line(self, line: str = "", *, empty: bool = False) -> None: - """ - Adds a line to the current page. - - If a line on a page exceeds `max_size` characters, then `max_size` will go up to - `scale_to_size` for a single line before creating a new page for the overflow words. If it - is still exceeded, the excess characters are stored and placed on the next pages unti - there are none remaining (by word boundary). The line is truncated if `scale_to_size` is - still exceeded after attempting to continue onto the next page. - - In the case that the page already contains one or more lines and the new lines would cause - `max_size` to be exceeded, a new page is created. This is done in order to make a best - effort to avoid breaking up single lines across pages, while keeping the total length of the - page at a reasonable size. - - 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. - """ - remaining_words = None - if len(line) > (max_chars := self.max_size - len(self.prefix) - 2): - if len(line) > self.scale_to_size: - line, remaining_words = self._split_remaining_words(line, max_chars) - if len(line) > self.scale_to_size: - log.debug("Could not continue to next page, truncating line.") - line = line[:self.scale_to_size] - - # Check if we should start a new page or continue the line on the current one - if self.max_lines is not None and self._linecount >= self.max_lines: - log.debug("max_lines exceeded, creating new page.") - self._new_page() - elif self._count + len(line) + 1 > self.max_size and self._linecount > 0: - log.debug("max_size exceeded on page with lines, creating new page.") - self._new_page() - - self._linecount += 1 - - self._count += len(line) + 1 - self._current_page.append(line) - - if empty: - self._current_page.append("") - self._count += 1 - - # Start a new page if there were any overflow words - if remaining_words: - self._new_page() - self.add_line(remaining_words) - - def _new_page(self) -> None: - """ - Internal: start a new page for the paginator. - - This closes the current page and resets the counters for the new page's line count and - character count. - """ - self._linecount = 0 - self._count = len(self.prefix) + 1 - self.close_page() - - - def _split_remaining_words(self, line: str, max_chars: int) -> tuple[str, str | None]: - """ - Internal: split a line into two strings -- reduced_words and remaining_words. - - reduced_words: the remaining words in `line`, after attempting to remove all words that - exceed `max_chars` (rounding down to the nearest word boundary). - - remaining_words: the words in `line` which exceed `max_chars`. This value is None if - no words could be split from `line`. - - If there are any remaining_words, an ellipses is appended to reduced_words and a - continuation header is inserted before remaining_words to visually communicate the line - continuation. - - Return a tuple in the format (reduced_words, remaining_words). - """ - reduced_words = [] - remaining_words = [] - - # "(Continued)" is used on a line by itself to indicate the continuation of last page - continuation_header = "(Continued)\n-----------\n" - reduced_char_count = 0 - is_full = False - - for word in line.split(" "): - if not is_full: - if len(word) + reduced_char_count <= max_chars: - reduced_words.append(word) - reduced_char_count += len(word) + 1 - else: - # If reduced_words is empty, we were unable to split the words across pages - if not reduced_words: - return line, None - is_full = True - remaining_words.append(word) - else: - remaining_words.append(word) - - return ( - " ".join(reduced_words) + "..." if remaining_words else "", - continuation_header + " ".join(remaining_words) if remaining_words else None - ) - - @classmethod - async def paginate( - cls, - lines: list[str], - ctx: Context | discord.Interaction, - embed: discord.Embed, - prefix: str = "", - suffix: str = "", - max_lines: int | None = None, - max_size: int = 500, - scale_to_size: int = 4000, - empty: bool = True, - restrict_to_user: User | None = None, - timeout: int = 300, - footer_text: str | None = None, - url: str | None = None, - exception_on_empty_embed: bool = False, - reply: bool = False, - allowed_roles: Sequence[int] | None = None - ) -> discord.Message | 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). - - The interaction will be limited to `restrict_to_user` (ctx.author by default) or - to any user with a moderation role. - - Example: - >>> embed = discord.Embed() - >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) - >>> await LinePaginator.paginate([line for line in lines], ctx, embed) - """ - paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines, - scale_to_size=scale_to_size) - current_page = 0 - - if not restrict_to_user: - if isinstance(ctx, discord.Interaction): - restrict_to_user = ctx.user - else: - restrict_to_user = ctx.author - - if not lines: - if exception_on_empty_embed: - log.exception("Pagination asked for empty lines iterable") - raise EmptyPaginatorEmbedError("No lines to paginate") - - log.debug("No lines to add to paginator, adding '(nothing to display)' message") - lines.append("*(nothing to display)*") - - for line in lines: - try: - paginator.add_line(line, empty=empty) - except Exception: - log.exception(f"Failed to add line to paginator: '{line}'") - raise # Should propagate - else: - log.trace(f"Added line to paginator: '{line}'") - - log.debug(f"Paginator created with {len(paginator.pages)} pages") - - embed.description = paginator.pages[current_page] - - reference = ctx.message if reply else None - - if len(paginator.pages) <= 1: - if footer_text: - embed.set_footer(text=footer_text) - log.trace(f"Setting embed footer to '{footer_text}'") - - if url: - embed.url = url - log.trace(f"Setting embed url to '{url}'") - - log.debug("There's less than two pages, so we won't paginate - sending single page on its own") - - if isinstance(ctx, discord.Interaction): - return await ctx.response.send_message(embed=embed) - return await ctx.send(embed=embed, reference=reference) - - 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)}") - log.trace(f"Setting embed footer to '{embed.footer.text}'") - - if url: - embed.url = url - log.trace(f"Setting embed url to '{url}'") - - log.debug("Sending first page to channel...") - - if isinstance(ctx, discord.Interaction): - await ctx.response.send_message(embed=embed) - message = await ctx.original_response() - else: - message = await ctx.send(embed=embed, reference=reference) - - log.debug("Adding emoji reactions to message...") - - pagination_emoji = list(paginator.pagination_emojis.dict().values()) - - for emoji in pagination_emoji: - # Add all the applicable emoji to the message - log.trace(f"Adding reaction: {emoji!r}") - await message.add_reaction(emoji) - - check = partial( - reaction_check, - message_id=message.id, - allowed_emoji=pagination_emoji, - allowed_users=(restrict_to_user.id,), - allowed_roles=allowed_roles, - ) - - while True: - try: - if isinstance(ctx, discord.Interaction): - reaction, user = await ctx.client.wait_for("reaction_add", timeout=timeout, check=check) - else: - reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check) - log.trace(f"Got reaction: {reaction}") - except asyncio.TimeoutError: - log.debug("Timed out waiting for a reaction") - break # We're done, no reactions for the last 5 minutes - - if str(reaction.emoji) == paginator.pagination_emojis.delete: - log.debug("Got delete reaction") - return await message.delete() - if reaction.emoji in pagination_emoji: - total_pages = len(paginator.pages) - try: - await message.remove_reaction(reaction.emoji, user) - except discord.HTTPException as e: - # Suppress if trying to act on an archived thread. - if e.code != 50083: - raise e - - if reaction.emoji == paginator.pagination_emojis.first: - current_page = 0 - log.debug(f"Got first page reaction - changing to page 1/{total_pages}") - elif reaction.emoji == paginator.pagination_emojis.last: - current_page = len(paginator.pages) - 1 - log.debug(f"Got last page reaction - changing to page {current_page + 1}/{total_pages}") - elif reaction.emoji == paginator.pagination_emojis.left: - if current_page <= 0: - log.debug("Got previous page reaction, but we're on the first page - ignoring") - continue - - current_page -= 1 - log.debug(f"Got previous page reaction - changing to page {current_page + 1}/{total_pages}") - elif reaction.emoji == paginator.pagination_emojis.right: - if current_page >= len(paginator.pages) - 1: - log.debug("Got next page reaction, but we're on the last page - ignoring") - continue - - current_page += 1 - log.debug(f"Got next page reaction - changing to page {current_page + 1}/{total_pages}") - - 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)}") - - try: - await message.edit(embed=embed) - except discord.HTTPException as e: - if e.code == 50083: - # Trying to act on an archived thread, just ignore and abort - break - raise e - - log.debug("Ending pagination and clearing reactions.") - with suppress(discord.NotFound): - try: - await message.clear_reactions() - except discord.HTTPException as e: - # Suppress if trying to act on an archived thread. - if e.code != 50083: - raise e -- cgit v1.2.3