1
0
Fork 0
mirror of https://github.com/uhIgnacio/EmoteManager.git synced 2024-08-15 02:23:13 +00:00
This commit is contained in:
igna 2021-09-07 15:14:44 +00:00
parent 1ff3e93938
commit 3f73309974
10 changed files with 584 additions and 523 deletions

View file

@ -14,6 +14,7 @@ from . import errors
ArchiveInfo = collections.namedtuple('ArchiveInfo', 'filename content error') ArchiveInfo = collections.namedtuple('ArchiveInfo', 'filename content error')
def extract(archive: typing.io.BinaryIO, *, size_limit=None) \ def extract(archive: typing.io.BinaryIO, *, size_limit=None) \
-> Iterable[Tuple[str, Optional[bytes], Optional[BaseException]]]: -> Iterable[Tuple[str, Optional[bytes], Optional[BaseException]]]:
""" """
@ -39,6 +40,7 @@ def extract(archive: typing.io.BinaryIO, *, size_limit=None) \
finally: finally:
archive.seek(0) archive.seek(0)
def extract_zip(archive, *, size_limit=None): def extract_zip(archive, *, size_limit=None):
with zipfile.ZipFile(archive) as zip: with zipfile.ZipFile(archive) as zip:
members = [m for m in zip.infolist() if not m.is_dir()] members = [m for m in zip.infolist() if not m.is_dir()]
@ -57,6 +59,7 @@ def extract_zip(archive, *, size_limit=None):
else: # this else is required to avoid UnboundLocalError for some reason else: # this else is required to avoid UnboundLocalError for some reason
yield ArchiveInfo(filename=member.filename, content=content, error=None) yield ArchiveInfo(filename=member.filename, content=content, error=None)
def extract_tar(archive, *, size_limit=None): def extract_tar(archive, *, size_limit=None):
with tarfile.open(fileobj=archive) as tar: with tarfile.open(fileobj=archive) as tar:
members = [f for f in tar.getmembers() if f.isfile()] members = [f for f in tar.getmembers() if f.isfile()]
@ -70,10 +73,12 @@ def extract_tar(archive, *, size_limit=None):
yield ArchiveInfo(member.name, content=tar.extractfile(member).read(), error=None) yield ArchiveInfo(member.name, content=tar.extractfile(member).read(), error=None)
async def extract_async(archive: typing.io.BinaryIO, size_limit=None): async def extract_async(archive: typing.io.BinaryIO, size_limit=None):
for x in extract(archive, size_limit=size_limit): for x in extract(archive, size_limit=size_limit):
yield await asyncio.sleep(0, x) yield await asyncio.sleep(0, x)
def main(): def main():
import io import io
import sys import sys
@ -88,5 +93,6 @@ def main():
print(f'{name}: {humanize.naturalsize(len(data)):>10}') print(f'{name}: {humanize.naturalsize(len(data)):>10}')
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import functools import functools
from discord.ext.commands import BadArgument from nextcord.ext.commands import BadArgument
_emote_type_predicates = { _emote_type_predicates = {
'all': lambda _: True, 'all': lambda _: True,
@ -10,6 +10,8 @@ _emote_type_predicates = {
'animated': lambda e: e.animated} 'animated': lambda e: e.animated}
# this is kind of a hack to ensure that the last argument is always converted, even if the default is used. # this is kind of a hack to ensure that the last argument is always converted, even if the default is used.
def emote_type_filter_default(command): def emote_type_filter_default(command):
old_callback = command.callback old_callback = command.callback
@ -19,7 +21,8 @@ def emote_type_filter_default(command):
try: try:
image_type = _emote_type_predicates[image_type] image_type = _emote_type_predicates[image_type]
except KeyError: except KeyError:
raise BadArgument(f'Invalid emote type. Specify one of "all", "static", or "animated".') raise BadArgument(
f'Invalid emote type. Specify one of "all", "static", or "animated".')
return await old_callback(self, ctx, *args[:-1], image_type) return await old_callback(self, ctx, *args[:-1], image_type)
command.callback = callback command.callback = callback

View file

@ -11,7 +11,9 @@ various utilities related to custom emotes
RE_EMOTE = re.compile(r'(:|;)(?P<name>\w{2,32})\1|(?P<newline>\n)', re.ASCII) RE_EMOTE = re.compile(r'(:|;)(?P<name>\w{2,32})\1|(?P<newline>\n)', re.ASCII)
"""Matches only custom server emotes.""" """Matches only custom server emotes."""
RE_CUSTOM_EMOTE = re.compile(r'<(?P<animated>a?):(?P<name>\w{2,32}):(?P<id>\d{17,})>', re.ASCII) RE_CUSTOM_EMOTE = re.compile(
r'<(?P<animated>a?):(?P<name>\w{2,32}):(?P<id>\d{17,})>', re.ASCII)
def url(id, *, animated: bool = False): def url(id, *, animated: bool = False):
"""Convert an emote ID to the image URL for that emote.""" """Convert an emote ID to the image URL for that emote."""

View file

@ -17,6 +17,7 @@ from nextcord import HTTPException, Forbidden, NotFound, DiscordServerError
GuildId = int GuildId = int
async def json_or_text(resp): async def json_or_text(resp):
text = await resp.text(encoding='utf-8') text = await resp.text(encoding='utf-8')
try: try:
@ -28,6 +29,7 @@ async def json_or_text(resp):
return text return text
class EmoteClient: class EmoteClient:
BASE_URL = 'https://discord.com/api/v9' BASE_URL = 'https://discord.com/api/v9'
HTTP_ERROR_CLASSES = { HTTP_ERROR_CLASSES = {
@ -51,7 +53,8 @@ class EmoteClient:
# Emote Manager shouldn't use walrus op until Debian adopts 3.8 :( # Emote Manager shouldn't use walrus op until Debian adopts 3.8 :(
reason = kwargs.pop('reason', None) reason = kwargs.pop('reason', None)
if reason: if reason:
headers['X-Audit-Log-Reason'] = urllib.parse.quote(reason, safe='/ ') headers['X-Audit-Log-Reason'] = urllib.parse.quote(
reason, safe='/ ')
kwargs['headers'] = headers kwargs['headers'] = headers
# TODO handle OSError and 500/502, like dpy does # TODO handle OSError and 500/502, like dpy does
@ -83,7 +86,8 @@ class EmoteClient:
async def _handle_rl(self, resp, method, path, guild_id, **kwargs): async def _handle_rl(self, resp, method, path, guild_id, **kwargs):
retry_after = (await resp.json())['retry_after'] / 1000.0 retry_after = (await resp.json())['retry_after'] / 1000.0
retry_at = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(seconds=retry_after) retry_at = datetime.datetime.now(
tz=datetime.timezone.utc) + datetime.timedelta(seconds=retry_after)
# cache unconditionally in case request() is called again while we're sleeping # cache unconditionally in case request() is called again while we're sleeping
self.guild_rls[guild_id] = retry_at.timestamp() self.guild_rls[guild_id] = retry_at.timestamp()
@ -100,7 +104,8 @@ class EmoteClient:
data = await self.request( data = await self.request(
'POST', f'/guilds/{guild.id}/emojis', 'POST', f'/guilds/{guild.id}/emojis',
guild.id, guild.id,
json=dict(name=name, image=image_utils.image_to_base64_url(image), roles=role_ids), json=dict(name=name, image=image_utils.image_to_base64_url(
image), roles=role_ids),
reason=reason, reason=reason,
) )
return PartialEmoji(animated=data.get('animated', False), name=data.get('name'), id=data.get('id')) return PartialEmoji(animated=data.get('animated', False), name=data.get('name'), id=data.get('id'))

View file

@ -5,7 +5,8 @@ import utils
import asyncio import asyncio
import humanize import humanize
import datetime import datetime
from discord.ext import commands from nextcord.ext import commands
class MissingManageEmojisPermission(commands.MissingPermissions): class MissingManageEmojisPermission(commands.MissingPermissions):
"""The invoker or the bot doesn't have permissions to manage server emojis.""" """The invoker or the bot doesn't have permissions to manage server emojis."""
@ -16,62 +17,86 @@ class MissingManageEmojisPermission(commands.MissingPermissions):
"Sorry, you don't have enough permissions to run this command. " "Sorry, you don't have enough permissions to run this command. "
'You and I both need the Manage Emojis permission.') 'You and I both need the Manage Emojis permission.')
class EmoteManagerError(commands.CommandError): class EmoteManagerError(commands.CommandError):
"""Generic error with the bot. This can be used to catch all bot errors.""" """Generic error with the bot. This can be used to catch all bot errors."""
pass pass
class ImageProcessingTimeoutError(EmoteManagerError, asyncio.TimeoutError): class ImageProcessingTimeoutError(EmoteManagerError, asyncio.TimeoutError):
pass pass
class ImageResizeTimeoutError(ImageProcessingTimeoutError): class ImageResizeTimeoutError(ImageProcessingTimeoutError):
"""Resizing the image took too long.""" """Resizing the image took too long."""
def __init__(self): def __init__(self):
super().__init__('Error: resizing the image took too long.') super().__init__('Error: resizing the image took too long.')
class ImageConversionTimeoutError(ImageProcessingTimeoutError): class ImageConversionTimeoutError(ImageProcessingTimeoutError):
def __init__(self): def __init__(self):
super().__init__('Error: converting the image to a GIF took too long.') super().__init__('Error: converting the image to a GIF took too long.')
class HTTPException(EmoteManagerError): class HTTPException(EmoteManagerError):
"""The server did not respond with an OK status code. This is only for non-Discord HTTP requests.""" """The server did not respond with an OK status code. This is only for non-Discord HTTP requests."""
def __init__(self, status): def __init__(self, status):
super().__init__(f'URL error: server returned error code {status}') super().__init__(f'URL error: server returned error code {status}')
class RateLimitedError(EmoteManagerError): class RateLimitedError(EmoteManagerError):
def __init__(self, retry_at): def __init__(self, retry_at):
if isinstance(retry_at, float): if isinstance(retry_at, float):
# it took me about an HOUR to realize i had to pass tz because utcfromtimestamp returns a NAÏVE time obj! # it took me about an HOUR to realize i had to pass tz because utcfromtimestamp returns a NAÏVE time obj!
retry_at = datetime.datetime.fromtimestamp(retry_at, tz=datetime.timezone.utc) retry_at = datetime.datetime.fromtimestamp(
retry_at, tz=datetime.timezone.utc)
# humanize.naturaltime is annoying to work with due to timezones so we use this # humanize.naturaltime is annoying to work with due to timezones so we use this
delta = humanize.naturaldelta(retry_at, when=datetime.datetime.now(tz=datetime.timezone.utc)) delta = humanize.naturaldelta(
super().__init__(f'Error: Discord told me to slow down! Please retry this command in {delta}.') retry_at, when=datetime.datetime.now(tz=datetime.timezone.utc))
super().__init__(
f'Error: Discord told me to slow down! Please retry this command in {delta}.')
class EmoteNotFoundError(EmoteManagerError): class EmoteNotFoundError(EmoteManagerError):
"""An emote with that name was not found""" """An emote with that name was not found"""
def __init__(self, name): def __init__(self, name):
super().__init__(f'An emote called `{name}` does not exist in this server.') super().__init__(
f'An emote called `{name}` does not exist in this server.')
class FileTooBigError(EmoteManagerError): class FileTooBigError(EmoteManagerError):
def __init__(self, size, limit): def __init__(self, size, limit):
self.size = size self.size = size
self.limit = limit self.limit = limit
class InvalidFileError(EmoteManagerError): class InvalidFileError(EmoteManagerError):
"""The file is not a zip, tar, GIF, PNG, JPG, or WEBP file.""" """The file is not a zip, tar, GIF, PNG, JPG, or WEBP file."""
def __init__(self): def __init__(self):
super().__init__('Invalid file given.') super().__init__('Invalid file given.')
class InvalidImageError(InvalidFileError): class InvalidImageError(InvalidFileError):
"""The image is not a GIF, PNG, or JPG""" """The image is not a GIF, PNG, or JPG"""
def __init__(self): def __init__(self):
super(Exception, self).__init__('The image supplied was not a GIF, PNG, JPG, or WEBP file.') super(Exception, self).__init__(
'The image supplied was not a GIF, PNG, JPG, or WEBP file.')
class PermissionDeniedError(EmoteManagerError): class PermissionDeniedError(EmoteManagerError):
"""Raised when a user tries to modify an emote without the Manage Emojis permission""" """Raised when a user tries to modify an emote without the Manage Emojis permission"""
def __init__(self, name): def __init__(self, name):
super().__init__(f"You're not authorized to modify `{name}`.") super().__init__(f"You're not authorized to modify `{name}`.")
class DiscordError(Exception): class DiscordError(Exception):
"""Usually raised when the client cache is being baka""" """Usually raised when the client cache is being baka"""
def __init__(self): def __init__(self):
super().__init__('Discord seems to be having issues right now, please try again later.') super().__init__('Discord seems to be having issues right now, please try again later.')

View file

@ -1,6 +1,7 @@
# © lambda#0987 <lambda@lambda.dance> # © lambda#0987 <lambda@lambda.dance>
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
from utils import errors
import asyncio import asyncio
import base64 import base64
import contextlib import contextlib
@ -16,11 +17,11 @@ logger = logging.getLogger(__name__)
try: try:
import wand.image import wand.image
except (ImportError, OSError): except (ImportError, OSError):
logger.warn('Failed to import wand.image. Image manipulation functions will be unavailable.') logger.warn(
'Failed to import wand.image. Image manipulation functions will be unavailable.')
else: else:
import wand.exceptions import wand.exceptions
from utils import errors
def resize_until_small(image_data: io.BytesIO) -> None: def resize_until_small(image_data: io.BytesIO) -> None:
"""If the image_data is bigger than 256KB, resize it until it's not.""" """If the image_data is bigger than 256KB, resize it until it's not."""
@ -37,10 +38,12 @@ def resize_until_small(image_data: io.BytesIO) -> None:
with wand.image.Image(blob=image_data) as original_image: with wand.image.Image(blob=image_data) as original_image:
while True: while True:
logger.debug('image size too big (%s bytes)', image_size) logger.debug('image size too big (%s bytes)', image_size)
logger.debug('attempting resize to at most%s*%s pixels', max_resolution, max_resolution) logger.debug('attempting resize to at most%s*%s pixels',
max_resolution, max_resolution)
with original_image.clone() as resized: with original_image.clone() as resized:
resized.transform(resize=f'{max_resolution}x{max_resolution}') resized.transform(
resize=f'{max_resolution}x{max_resolution}')
image_size = len(resized.make_blob()) image_size = len(resized.make_blob())
if image_size <= 256 * 2**10 or max_resolution < 32: # don't resize past 256KiB or 32×32 if image_size <= 256 * 2**10 or max_resolution < 32: # don't resize past 256KiB or 32×32
image_data.truncate(0) image_data.truncate(0)
@ -53,6 +56,7 @@ def resize_until_small(image_data: io.BytesIO) -> None:
except wand.exceptions.CoderError: except wand.exceptions.CoderError:
raise errors.InvalidImageError raise errors.InvalidImageError
def convert_to_gif(image_data: io.BytesIO) -> None: def convert_to_gif(image_data: io.BytesIO) -> None:
try: try:
with wand.image.Image(blob=image_data) as orig, orig.convert('gif') as converted: with wand.image.Image(blob=image_data) as orig, orig.convert('gif') as converted:
@ -68,6 +72,7 @@ def convert_to_gif(image_data: io.BytesIO) -> None:
except wand.exceptions.CoderError: except wand.exceptions.CoderError:
raise errors.InvalidImageError raise errors.InvalidImageError
def mime_type_for_image(data): def mime_type_for_image(data):
if data.startswith(b'\x89PNG\r\n\x1a\n'): if data.startswith(b'\x89PNG\r\n\x1a\n'):
return 'image/png' return 'image/png'
@ -79,12 +84,14 @@ def mime_type_for_image(data):
return 'image/webp' return 'image/webp'
raise errors.InvalidImageError raise errors.InvalidImageError
def image_to_base64_url(data): def image_to_base64_url(data):
fmt = 'data:{mime};base64,{data}' fmt = 'data:{mime};base64,{data}'
mime = mime_type_for_image(data) mime = mime_type_for_image(data)
b64 = base64.b64encode(data).decode('ascii') b64 = base64.b64encode(data).decode('ascii')
return fmt.format(mime=mime, data=b64) return fmt.format(mime=mime, data=b64)
def main() -> typing.NoReturn: def main() -> typing.NoReturn:
"""resize or convert an image from stdin and write the resized or converted version to stdout.""" """resize or convert an image from stdin and write the resized or converted version to stdout."""
import sys import sys
@ -114,6 +121,7 @@ def main() -> typing.NoReturn:
sys.exit(0) sys.exit(0)
async def process_image_in_subprocess(command_name, image_data: bytes): async def process_image_in_subprocess(command_name, image_data: bytes):
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
sys.executable, '-m', __name__, command_name, sys.executable, '-m', __name__, command_name,
@ -131,12 +139,15 @@ async def process_image_in_subprocess(command_name, image_data: bytes):
if proc.returncode == 2: if proc.returncode == 2:
raise errors.InvalidImageError raise errors.InvalidImageError
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(err.decode('utf-8') + f'Return code: {proc.returncode}') raise RuntimeError(err.decode('utf-8') +
f'Return code: {proc.returncode}')
return image_data return image_data
resize_in_subprocess = functools.partial(process_image_in_subprocess, 'resize') resize_in_subprocess = functools.partial(process_image_in_subprocess, 'resize')
convert_to_gif_in_subprocess = functools.partial(process_image_in_subprocess, 'convert') convert_to_gif_in_subprocess = functools.partial(
process_image_in_subprocess, 'convert')
def size(fp): def size(fp):
"""return the size, in bytes, of the data a file-like object represents""" """return the size, in bytes, of the data a file-like object represents"""
@ -144,6 +155,7 @@ def size(fp):
fp.seek(0, io.SEEK_END) fp.seek(0, io.SEEK_END)
return fp.tell() return fp.tell()
class preserve_position(contextlib.AbstractContextManager): class preserve_position(contextlib.AbstractContextManager):
def __init__(self, fp): def __init__(self, fp):
self.fp = fp self.fp = fp
@ -152,5 +164,6 @@ class preserve_position(contextlib.AbstractContextManager):
def __exit__(self, *excinfo): def __exit__(self, *excinfo):
self.fp.seek(self.old_pos) self.fp.seek(self.old_pos)
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -5,7 +5,8 @@
import asyncio import asyncio
import discord import nextcord
def format_user(user, *, mention=False): def format_user(user, *, mention=False):
"""Format a user object for audit log purposes.""" """Format a user object for audit log purposes."""
@ -18,8 +19,9 @@ def format_user(user, *, mention=False):
else: else:
return f'@{user} ({user.id})' return f'@{user} ({user.id})'
def format_http_exception(exception: discord.HTTPException):
"""Formats a discord.HTTPException for relaying to the user. def format_http_exception(exception: nextcord.HTTPException):
"""Formats a nextcord.HTTPException for relaying to the user.
Sample return value: Sample return value:
BAD REQUEST (status code: 400): BAD REQUEST (status code: 400):
@ -30,6 +32,7 @@ def format_http_exception(exception: discord.HTTPException):
f'{exception.response.reason} (status code: {exception.response.status}):' f'{exception.response.reason} (status code: {exception.response.status}):'
f'\n{exception.text}') f'\n{exception.text}')
def strip_angle_brackets(string): def strip_angle_brackets(string):
"""Strip leading < and trailing > from a string. """Strip leading < and trailing > from a string.
Useful if a user sends you a url like <this> to avoid embeds, or to convert emotes to reactions.""" Useful if a user sends you a url like <this> to avoid embeds, or to convert emotes to reactions."""
@ -37,6 +40,7 @@ def strip_angle_brackets(string):
return string[1:-1] return string[1:-1]
return string return string
async def gather_or_cancel(*awaitables, loop=None): async def gather_or_cancel(*awaitables, loop=None):
"""run the awaitables in the sequence concurrently. If any of them raise an exception, """run the awaitables in the sequence concurrently. If any of them raise an exception,
propagate the first exception raised and cancel all other awaitables. propagate the first exception raised and cancel all other awaitables.

View file

@ -5,12 +5,13 @@ import asyncio
import contextlib import contextlib
import typing import typing
import discord import nextcord
from discord.ext.commands import Context from nextcord.ext.commands import Context
# Copyright © 2016-2017 Pandentia and contributors # Copyright © 2016-2017 Pandentia and contributors
# https://github.com/Thessia/Liara/blob/75fa11948b8b2ea27842d8815a32e51ef280a999/cogs/utils/paginator.py # https://github.com/Thessia/Liara/blob/75fa11948b8b2ea27842d8815a32e51ef280a999/cogs/utils/paginator.py
class Paginator: class Paginator:
def __init__(self, ctx: Context, pages: typing.Iterable, *, timeout=300, delete_message=False, def __init__(self, ctx: Context, pages: typing.Iterable, *, timeout=300, delete_message=False,
delete_message_on_timeout=False, text_message=None): delete_message_on_timeout=False, text_message=None):
@ -39,7 +40,7 @@ class Paginator:
self._page = None self._page = None
def react_check(self, reaction: discord.RawReactionActionEvent): def react_check(self, reaction: nextcord.RawReactionActionEvent):
if reaction.user_id != self.author.id: if reaction.user_id != self.author.id:
return False return False
@ -47,12 +48,12 @@ class Paginator:
return False return False
target_emoji = str(reaction.emoji) target_emoji = str(reaction.emoji)
return bool(discord.utils.find(lambda emoji: target_emoji == emoji, self.navigation)) return bool(nextcord.utils.find(lambda emoji: target_emoji == emoji, self.navigation))
async def begin(self): async def begin(self):
"""Starts pagination""" """Starts pagination"""
self._stopped = False self._stopped = False
self._embed = discord.Embed() self._embed = nextcord.Embed()
await self.first_page() await self.first_page()
for button in self.navigation: for button in self.navigation:
await self._message.add_reaction(button) await self._message.add_reaction(button)
@ -69,8 +70,8 @@ class Paginator:
await self.navigation[str(reaction.emoji)]() await self.navigation[str(reaction.emoji)]()
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(nextcord.HTTPException):
await self._message.remove_reaction(reaction.emoji, discord.Object(reaction.user_id)) await self._message.remove_reaction(reaction.emoji, nextcord.Object(reaction.user_id))
async def stop(self, *, delete=None): async def stop(self, *, delete=None):
"""Aborts pagination.""" """Aborts pagination."""
@ -78,7 +79,7 @@ class Paginator:
delete = self.delete_msg delete = self.delete_msg
if delete: if delete:
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(nextcord.HTTPException):
await self._message.delete() await self._message.delete()
else: else:
await self._clear_reactions() await self._clear_reactions()
@ -87,16 +88,17 @@ class Paginator:
async def _clear_reactions(self): async def _clear_reactions(self):
try: try:
await self._message.clear_reactions() await self._message.clear_reactions()
except discord.Forbidden: except nextcord.Forbidden:
for button in self.navigation: for button in self.navigation:
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(nextcord.HTTPException):
await self._message.remove_reaction(button, self._message.author) await self._message.remove_reaction(button, self._message.author)
except discord.HTTPException: except nextcord.HTTPException:
pass pass
async def format_page(self): async def format_page(self):
self._embed.description = self.pages[self._page] self._embed.description = self.pages[self._page]
self._embed.set_footer(text=self.footer.format(self._page + 1, len(self.pages))) self._embed.set_footer(text=self.footer.format(
self._page + 1, len(self.pages)))
kwargs = {'embed': self._embed} kwargs = {'embed': self._embed}
if self.text_message: if self.text_message:
@ -127,6 +129,7 @@ class Paginator:
self._page = len(self.pages) - 1 self._page = len(self.pages) - 1
await self.format_page() await self.format_page()
class ListPaginator(Paginator): class ListPaginator(Paginator):
def __init__(self, ctx, _list: list, per_page=10, **kwargs): def __init__(self, ctx, _list: list, per_page=10, **kwargs):
pages = [] pages = []