aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar shtlrs <[email protected]>2024-01-04 14:25:04 +0100
committerGravatar shtlrs <[email protected]>2024-01-31 14:41:19 +0100
commit64fe940e43495d5720057e91b648920dae63dc49 (patch)
tree0b909054b3131190a2965465c686ee9c240f0f04
parentbump pydis_core version (diff)
use LinePaginator from pydis_core
This works simply by wrapping pydis_core's LinePaginator.paginate with a method that passes the emojis to the underlying paginate method This allows us to 1. (Most importantly) Reuse the botcore paginator 2. Preserve the same imports 3. Provide a sane default for the trashcan emoji
-rw-r--r--bot/pagination.py372
1 files changed, 31 insertions, 341 deletions
diff --git a/bot/pagination.py b/bot/pagination.py
index fd9dd8af6..6b2f76715 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -1,190 +1,19 @@
-import asyncio
-from contextlib import suppress
-from functools import partial
+from collections.abc import Sequence
import discord
-from discord.abc import User
-from discord.ext.commands import Context, Paginator
+from discord.ext.commands import Context
+from pydis_core.utils.pagination import LinePaginator as _LinePaginator, PaginationEmojis
-from bot import constants
-from bot.log import get_logger
-from bot.utils import messages
+from bot.constants import Emojis
-FIRST_EMOJI = "\u23EE" # [:track_previous:]
-LEFT_EMOJI = "\u2B05" # [:arrow_left:]
-RIGHT_EMOJI = "\u27A1" # [:arrow_right:]
-LAST_EMOJI = "\u23ED" # [:track_next:]
-DELETE_EMOJI = constants.Emojis.trashcan # [:trashcan:]
-PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI)
-
-log = get_logger(__name__)
-
-
-class EmptyPaginatorEmbedError(Exception):
- """Raised when attempting to paginate with empty contents."""
-
-
-
-class LinePaginator(Paginator):
+class LinePaginator(_LinePaginator):
"""
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.
+ See the super class's docs for more info.
"""
- def __init__(
- self,
- 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 = []
-
- 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,
@@ -197,175 +26,36 @@ class LinePaginator(Paginator):
max_size: int = 500,
scale_to_size: int = 4000,
empty: bool = True,
- restrict_to_user: User | None = None,
+ restrict_to_user: discord.User | None = None,
timeout: int = 300,
- footer_text: str | None = None,
- url: str | None = None,
+ footer_text: str | None = None, url: str | None = None,
exception_on_empty_embed: bool = False,
reply: bool = False,
+ allowed_roles: Sequence[int] | None = None, **kwargs
) -> 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...")
-
- 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(
- messages.reaction_check,
- message_id=message.id,
- allowed_emoji=PAGINATION_EMOJI,
- allowed_users=(restrict_to_user.id,),
+ Acts as a wrapper for the super class' `paginate` method to provide the pagination emojis by default.
+
+ Consult the super class's `paginate` method for detailed information.
+ """
+ return await super().paginate(
+ pagination_emojis=PaginationEmojis(delete=Emojis.trashcan),
+ lines=lines,
+ ctx=ctx,
+ embed=embed,
+ prefix=prefix,
+ suffix=suffix,
+ max_lines=max_lines,
+ max_size=max_size,
+ scale_to_size=scale_to_size,
+ empty=empty,
+ restrict_to_user=restrict_to_user,
+ timeout=timeout,
+ footer_text=footer_text,
+ url=url,
+ exception_on_empty_embed=exception_on_empty_embed,
+ reply=reply,
+ 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) == DELETE_EMOJI:
- 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 == FIRST_EMOJI:
- current_page = 0
- log.debug(f"Got first page reaction - changing to page 1/{total_pages}")
- elif reaction.emoji == LAST_EMOJI:
- current_page = len(paginator.pages) - 1
- log.debug(f"Got last page reaction - changing to page {current_page + 1}/{total_pages}")
- elif reaction.emoji == LEFT_EMOJI:
- 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 == RIGHT_EMOJI:
- 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