Compare commits

...

14 Commits

Author SHA1 Message Date
in the moon 5ce2a12a96
Delete runtime.txt 2021-09-11 00:19:32 +00:00
in the moon c5e536875e
a 2021-09-10 14:44:21 -03:00
in the moon b76b973c01
railway support 2021-09-10 14:43:57 -03:00
in the moon 2184e69667 it works™️ 2021-09-09 11:24:28 +00:00
in the moon fa118d6880 minor changes 2021-09-08 21:05:36 +00:00
in the moon 5c6ced1f73 license. 2021-09-08 21:05:26 +00:00
igna 3f73309974 format 2021-09-07 15:14:44 +00:00
igna 1ff3e93938 nextcord™️ 2021-09-07 15:07:51 +00:00
igna e560568a35 token leak 2021-09-07 15:01:53 +00:00
igna 603a7e177a small update 2021-09-07 15:00:25 +00:00
igna 51086a3e0b typo 2021-09-07 14:49:13 +00:00
igna 35f019a490 format 2021-09-07 14:47:30 +00:00
igna 84c5276076 format and nextcord 2021-09-07 14:22:54 +00:00
igna 3eaac0148d nextcord 2021-09-07 14:15:53 +00:00
18 changed files with 102803 additions and 102711 deletions

4
.gitignore vendored
View File

@ -15,3 +15,7 @@ venv/
*.gif *.gif
*.jpg *.jpg
*.zip *.zip
# gitpod.io
gitpod.yml
.vscode

1
Procfile Normal file
View File

@ -0,0 +1 @@
web: python3 bot.py

22
bot.py
View File

@ -3,11 +3,13 @@
# © 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
import sys
import asyncio
import base64 import base64
import logging import logging
import traceback import traceback
import discord import nextcord
from bot_bin.bot import Bot from bot_bin.bot import Bot
from discord.ext import commands from discord.ext import commands
@ -17,12 +19,11 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
# SelectorEventLoop on windows doesn't support subprocesses lol # SelectorEventLoop on windows doesn't support subprocesses lol
import asyncio
import sys
if sys.platform == 'win32': if sys.platform == 'win32':
loop = asyncio.ProactorEventLoop() loop = asyncio.ProactorEventLoop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
class Bot(Bot): class Bot(Bot):
startup_extensions = ( startup_extensions = (
'cogs.emote', 'cogs.emote',
@ -39,8 +40,10 @@ class Bot(Bot):
super().__init__(config=config, **kwargs) super().__init__(config=config, **kwargs)
# allow use of the bot's user ID before ready() # allow use of the bot's user ID before ready()
token_part0 = self.config['tokens']['discord'].partition('.')[0].encode() token_part0 = self.config['tokens']['discord'].partition('.')[
self.user_id = int(base64.b64decode(token_part0 + b'=' * (3 - len(token_part0) % 3))) 0].encode()
self.user_id = int(base64.b64decode(
token_part0 + b'=' * (3 - len(token_part0) % 3)))
def process_config(self): def process_config(self):
"""Load the emojis from the config to be used when a command fails or succeeds """Load the emojis from the config to be used when a command fails or succeeds
@ -52,6 +55,7 @@ class Bot(Bot):
utils.SUCCESS_EMOJIS = utils.misc.SUCCESS_EMOJIS = ( utils.SUCCESS_EMOJIS = utils.misc.SUCCESS_EMOJIS = (
self.config.get('response_emojis', {}).get('success', default)) self.config.get('response_emojis', {}).get('success', default))
def main(): def main():
import sys import sys
@ -59,14 +63,15 @@ def main():
shard_count = None shard_count = None
shard_ids = None shard_ids = None
elif len(sys.argv) < 3: elif len(sys.argv) < 3:
print('Usage:', sys.argv[0], '[<shard count> <hyphen-separated list of shard IDs>]', file=sys.stderr) print(
'Usage:', sys.argv[0], '[<shard count> <hyphen-separated list of shard IDs>]', file=sys.stderr)
sys.exit(1) sys.exit(1)
else: else:
shard_count = int(sys.argv[1]) shard_count = int(sys.argv[1])
shard_ids = list(map(int, sys.argv[2].split('-'))) shard_ids = list(map(int, sys.argv[2].split('-')))
Bot( Bot(
intents=discord.Intents( intents=nextcord.Intents(
guilds=True, guilds=True,
# we hardly need DM support but it's helpful to be able to run the help/support commands in DMs # we hardly need DM support but it's helpful to be able to run the help/support commands in DMs
messages=True, messages=True,
@ -78,7 +83,7 @@ def main():
# the least stateful bot you will ever see 😎 # the least stateful bot you will ever see 😎
chunk_guilds_at_startup=False, chunk_guilds_at_startup=False,
member_cache_flags=discord.MemberCacheFlags.none(), member_cache_flags=nextcord.MemberCacheFlags.none(),
# disable message cache # disable message cache
max_messages=None, max_messages=None,
@ -86,5 +91,6 @@ def main():
shard_ids=shard_ids, shard_ids=shard_ids,
).run() ).run()
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@ -18,9 +18,9 @@ import warnings
import weakref import weakref
import aiohttp import aiohttp
import discord import nextcord
import humanize import humanize
from discord.ext import commands from nextcord.ext import commands
import utils import utils
import utils.image import utils.image
@ -32,16 +32,20 @@ from utils.converter import emote_type_filter_default
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# guilds can have duplicate emotes, so let us create zips to match # guilds can have duplicate emotes, so let us create zips to match
warnings.filterwarnings('ignore', module='zipfile', category=UserWarning, message=r"^Duplicate name: .*$") warnings.filterwarnings('ignore', module='zipfile',
category=UserWarning, message=r"^Duplicate name: .*$")
class UserCancelledError(commands.UserInputError): class UserCancelledError(commands.UserInputError):
pass pass
class Emotes(commands.Cog): class Emotes(commands.Cog):
IMAGE_MIMETYPES = {'image/png', 'image/jpeg', 'image/gif', 'image/webp'} IMAGE_MIMETYPES = {'image/png', 'image/jpeg', 'image/gif', 'image/webp'}
# TAR_MIMETYPES = {'application/x-tar', 'application/x-xz', 'application/gzip', 'application/x-bzip2'} # TAR_MIMETYPES = {'application/x-tar', 'application/x-xz', 'application/gzip', 'application/x-bzip2'}
TAR_MIMETYPES = {'application/x-tar'} TAR_MIMETYPES = {'application/x-tar'}
ZIP_MIMETYPES = {'application/zip', 'application/octet-stream', 'application/x-zip-compressed', 'multipart/x-zip'} ZIP_MIMETYPES = {'application/zip', 'application/octet-stream',
'application/x-zip-compressed', 'multipart/x-zip'}
ARCHIVE_MIMETYPES = TAR_MIMETYPES | ZIP_MIMETYPES ARCHIVE_MIMETYPES = TAR_MIMETYPES | ZIP_MIMETYPES
ZIP_OVERHEAD_BYTES = 30 ZIP_OVERHEAD_BYTES = 30
@ -57,7 +61,8 @@ class Emotes(commands.Cog):
self.http = aiohttp.ClientSession( self.http = aiohttp.ClientSession(
loop=self.bot.loop, loop=self.bot.loop,
read_timeout=self.bot.config.get('http_read_timeout', 60), read_timeout=self.bot.config.get('http_read_timeout', 60),
connector=connector if self.bot.config.get('use_socks5_for_all_connections') else None, connector=connector if self.bot.config.get(
'use_socks5_for_all_connections') else None,
headers={ headers={
'User-Agent': 'User-Agent':
self.bot.config['user_agent'] + ' ' self.bot.config['user_agent'] + ' '
@ -83,7 +88,9 @@ class Emotes(commands.Cog):
self.bot.loop.create_task(close()) self.bot.loop.create_task(close())
public_commands = set() public_commands = set()
def public(command, public_commands=public_commands): # resolve some kinda scope issue that i don't understand
# resolve some kinda scope issue that i don't understand
def public(command, public_commands=public_commands):
public_commands.add(command.qualified_name) public_commands.add(command.qualified_name)
return command return command
@ -137,7 +144,7 @@ class Emotes(commands.Cog):
"""Add a bunch of custom emotes.""" """Add a bunch of custom emotes."""
ran = False ran = False
# we could use *emotes: discord.PartialEmoji here but that would require spaces between each emote. # we could use *emotes: nextcord.PartialEmoji here but that would require spaces between each emote.
# and would fail if any arguments were not valid emotes # and would fail if any arguments were not valid emotes
for match in re.finditer(utils.emote.RE_CUSTOM_EMOTE, ''.join(emotes)): for match in re.finditer(utils.emote.RE_CUSTOM_EMOTE, ''.join(emotes)):
ran = True ran = True
@ -183,12 +190,14 @@ class Emotes(commands.Cog):
return name, url return name, url
elif not args: elif not args:
raise commands.BadArgument('Your message had no emotes and no name!') raise commands.BadArgument(
'Your message had no emotes and no name!')
@classmethod @classmethod
def parse_add_command_attachment(cls, context, args): def parse_add_command_attachment(cls, context, args):
attachment = context.message.attachments[0] attachment = context.message.attachments[0]
name = cls.format_emote_filename(''.join(args) if args else attachment.filename) name = cls.format_emote_filename(
''.join(args) if args else attachment.filename)
url = attachment.url url = attachment.url
return name, url return name, url
@ -238,7 +247,8 @@ class Emotes(commands.Cog):
""" """
emotes = list(filter(image_type, context.guild.emojis)) emotes = list(filter(image_type, context.guild.emojis))
if not emotes: if not emotes:
raise commands.BadArgument('No emotes of that type were found in this server.') raise commands.BadArgument(
'No emotes of that type were found in this server.')
async with context.typing(): async with context.typing():
async for zip_file in self.archive_emotes(context, emotes): async for zip_file in self.archive_emotes(context, emotes):
@ -248,6 +258,7 @@ class Emotes(commands.Cog):
filesize_limit = context.guild.filesize_limit filesize_limit = context.guild.filesize_limit
discrims = collections.defaultdict(int) discrims = collections.defaultdict(int)
downloaded = collections.deque() downloaded = collections.deque()
async def download(emote): async def download(emote):
# don't put two files in the zip with the same name # don't put two files in the zip with the same name
discrims[emote.name] += 1 discrims[emote.name] += 1
@ -269,7 +280,8 @@ class Emotes(commands.Cog):
est_size_in_zip = est_zip_overhead + len(data) est_size_in_zip = est_zip_overhead + len(data)
if est_size_in_zip >= filesize_limit: if est_size_in_zip >= filesize_limit:
self.bot.loop.create_task( self.bot.loop.create_task(
context.send(f'{emote} could not be added because it alone would exceed the file size limit.') context.send(
f'{emote} could not be added because it alone would exceed the file size limit.')
) )
return return
@ -294,7 +306,8 @@ class Emotes(commands.Cog):
downloaded.appendleft(item) downloaded.appendleft(item)
break break
zinfo = zipfile.ZipInfo(name, date_time=created_at.timetuple()[:6]) zinfo = zipfile.ZipInfo(
name, date_time=created_at.timetuple()[:6])
zip.writestr(zinfo, image_data) zip.writestr(zinfo, image_data)
if out.tell() == 0: if out.tell() == 0:
@ -302,7 +315,7 @@ class Emotes(commands.Cog):
break break
out.seek(0) out.seek(0)
yield discord.File(out, f'emotes-{context.guild.id}-{count}.zip') yield nextcord.File(out, f'emotes-{context.guild.id}-{count}.zip')
count += 1 count += 1
@commands.command(name='import', aliases=['add-zip', 'add-tar', 'add-from-zip', 'add-from-tar']) @commands.command(name='import', aliases=['add-zip', 'add-tar', 'add-from-zip', 'add-from-tar'])
@ -314,7 +327,8 @@ class Emotes(commands.Cog):
The rest will be ignored. The rest will be ignored.
""" """
if url and context.message.attachments: if url and context.message.attachments:
raise commands.BadArgument('Either a URL or an attachment must be given, not both.') raise commands.BadArgument(
'Either a URL or an attachment must be given, not both.')
if not url and not context.message.attachments: if not url and not context.message.attachments:
raise commands.BadArgument('A URL or attachment must be given.') raise commands.BadArgument('A URL or attachment must be given.')
@ -328,7 +342,7 @@ class Emotes(commands.Cog):
return return
await self.add_from_archive(context, archive) await self.add_from_archive(context, archive)
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(nextcord.HTTPException):
# so they know when we're done # so they know when we're done
await context.message.add_reaction(utils.SUCCESS_EMOJIS[True]) await context.message.add_reaction(utils.SUCCESS_EMOJIS[True])
@ -383,7 +397,8 @@ class Emotes(commands.Cog):
If the image is static and there are not enough free static slots, convert the image to a gif instead. If the image is static and there are not enough free static slots, convert the image to a gif instead.
""" """
counts = collections.Counter(map(operator.attrgetter('animated'), context.guild.emojis)) counts = collections.Counter(
map(operator.attrgetter('animated'), context.guild.emojis))
# >= rather than == because there are sneaky ways to exceed the limit # >= rather than == because there are sneaky ways to exceed the limit
if counts[False] >= context.guild.emoji_limit and counts[True] >= context.guild.emoji_limit: if counts[False] >= context.guild.emoji_limit and counts[True] >= context.guild.emoji_limit:
# we raise instead of returning a string in order to abort commands that run this function in a loop # we raise instead of returning a string in order to abort commands that run this function in a loop
@ -397,10 +412,10 @@ class Emotes(commands.Cog):
try: try:
emote = await self.create_emote_from_bytes(context, name, image_data, reason=reason) emote = await self.create_emote_from_bytes(context, name, image_data, reason=reason)
except discord.InvalidArgument: except nextcord.InvalidArgument:
return discord.utils.escape_mentions(f'{name}: The file supplied was not a valid GIF, PNG, JPEG, or WEBP file.') return nextcord.utils.escape_mentions(f'{name}: The file supplied was not a valid GIF, PNG, JPEG, or WEBP file.')
except discord.HTTPException as ex: except nextcord.HTTPException as ex:
return discord.utils.escape_mentions( return nextcord.utils.escape_mentions(
f'{name}: An error occurred while creating the the emote:\n' f'{name}: An error occurred while creating the the emote:\n'
+ utils.format_http_exception(ex)) + utils.format_http_exception(ex))
s = f'Emote {emote} successfully created' s = f'Emote {emote} successfully created'
@ -408,10 +423,12 @@ class Emotes(commands.Cog):
async def fetch(self, url, valid_mimetypes=IMAGE_MIMETYPES, *, validate_headers=True): async def fetch(self, url, valid_mimetypes=IMAGE_MIMETYPES, *, validate_headers=True):
valid_mimetypes = valid_mimetypes or self.IMAGE_MIMETYPES valid_mimetypes = valid_mimetypes or self.IMAGE_MIMETYPES
def validate_headers(response): def validate_headers(response):
response.raise_for_status() response.raise_for_status()
# some dumb servers also send '; charset=UTF-8' which we should ignore # some dumb servers also send '; charset=UTF-8' which we should ignore
mimetype, options = cgi.parse_header(response.headers.get('Content-Type', '')) mimetype, options = cgi.parse_header(
response.headers.get('Content-Type', ''))
if mimetype not in valid_mimetypes: if mimetype not in valid_mimetypes:
raise errors.InvalidFileError raise errors.InvalidFileError
@ -423,9 +440,11 @@ class Emotes(commands.Cog):
except aiohttp.ClientResponseError: except aiohttp.ClientResponseError:
raise raise
except aiohttp.ClientError as exc: except aiohttp.ClientError as exc:
raise errors.EmoteManagerError(f'An error occurred while retrieving the file: {exc}') raise errors.EmoteManagerError(
f'An error occurred while retrieving the file: {exc}')
if validate_headers: await validate(self.http.head(url, timeout=self.bot.config.get('http_head_timeout', 10))) if validate_headers:
await validate(self.http.head(url, timeout=self.bot.config.get('http_head_timeout', 10)))
return await validate(self.http.get(url)) return await validate(self.http.get(url))
async def create_emote_from_bytes(self, context, name, image_data: bytes, *, reason=None): async def create_emote_from_bytes(self, context, name, image_data: bytes, *, reason=None):
@ -448,7 +467,7 @@ class Emotes(commands.Cog):
else: else:
for emote in (emote,) + emotes: for emote in (emote,) + emotes:
await context.invoke(self.remove, emote) await context.invoke(self.remove, emote)
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(nextcord.HTTPException):
await context.message.add_reaction(utils.SUCCESS_EMOJIS[True]) await context.message.add_reaction(utils.SUCCESS_EMOJIS[True])
@commands.command(aliases=('mv',)) @commands.command(aliases=('mv',))
@ -463,7 +482,7 @@ class Emotes(commands.Cog):
await emote.edit( await emote.edit(
name=new_name, name=new_name,
reason=f'Renamed by {utils.format_user(context.author)}') reason=f'Renamed by {utils.format_user(context.author)}')
except discord.HTTPException as ex: except nextcord.HTTPException as ex:
return await context.send( return await context.send(
'An error occurred while renaming the emote:\n' 'An error occurred while renaming the emote:\n'
+ utils.format_http_exception(ex)) + utils.format_http_exception(ex))
@ -539,11 +558,11 @@ class Emotes(commands.Cog):
if match: if match:
id = int(match['id']) id = int(match['id'])
if local: if local:
emote = discord.utils.get(context.guild.emojis, id=id) emote = nextcord.utils.get(context.guild.emojis, id=id)
if emote: if emote:
return emote return emote
else: else:
return discord.PartialEmoji( return nextcord.PartialEmoji(
animated=bool(match['animated']), animated=bool(match['animated']),
name=match['name'], name=match['name'],
id=int(match['id']), id=int(match['id']),
@ -552,15 +571,18 @@ class Emotes(commands.Cog):
return await self.disambiguate(context, name) return await self.disambiguate(context, name)
async def disambiguate(self, context, name): async def disambiguate(self, context, name):
name = name.strip(':') # in case the user tries :foo: and foo is animated # in case the user tries :foo: and foo is animated
candidates = [e for e in context.guild.emojis if e.name.lower() == name.lower() and e.require_colons] name = name.strip(':')
candidates = [e for e in context.guild.emojis if e.name.lower(
) == name.lower() and e.require_colons]
if not candidates: if not candidates:
raise errors.EmoteNotFoundError(name) raise errors.EmoteNotFoundError(name)
if len(candidates) == 1: if len(candidates) == 1:
return candidates[0] return candidates[0]
message = ['Multiple emotes were found with that name. Which one do you mean?'] message = [
'Multiple emotes were found with that name. Which one do you mean?']
for i, emote in enumerate(candidates, 1): for i, emote in enumerate(candidates, 1):
message.append(fr'{i}. {emote} (\:{emote.name}:)') message.append(fr'{i}. {emote} (\:{emote.name}:)')
@ -577,9 +599,11 @@ class Emotes(commands.Cog):
try: try:
message = await self.bot.wait_for('message', check=check, timeout=30) message = await self.bot.wait_for('message', check=check, timeout=30)
except asyncio.TimeoutError: except asyncio.TimeoutError:
raise commands.UserInputError('Sorry, you took too long. Try again.') raise commands.UserInputError(
'Sorry, you took too long. Try again.')
return candidates[int(message.content)-1] return candidates[int(message.content)-1]
def setup(bot): def setup(bot):
bot.add_cog(Emotes(bot)) bot.add_cog(Emotes(bot))

View File

@ -3,11 +3,12 @@
import contextlib import contextlib
import discord import nextcord
from discord.ext import commands from nextcord.ext import commands
import utils import utils
class Meta(commands.Cog): class Meta(commands.Cog):
# TODO does this need to be configurable? # TODO does this need to be configurable?
INVITE_DURATION_SECONDS = 60 * 60 * 3 INVITE_DURATION_SECONDS = 60 * 60 * 3
@ -39,33 +40,24 @@ class Meta(commands.Cog):
try: try:
await context.author.send(f'Official support server invite: {invite}') await context.author.send(f'Official support server invite: {invite}')
except discord.Forbidden: except nextcord.Forbidden:
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(nextcord.HTTPException):
await context.message.add_reaction(utils.SUCCESS_EMOJIS[True]) await context.message.add_reaction(utils.SUCCESS_EMOJIS[True])
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(nextcord.HTTPException):
await context.send('Unable to send invite in DMs. Please allow DMs from server members.') await context.send('Unable to send invite in DMs. Please allow DMs from server members.')
else: else:
try: try:
await context.message.add_reaction('📬') await context.message.add_reaction('📬')
except discord.HTTPException: except nextcord.HTTPException:
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(nextcord.HTTPException):
await context.send('📬') await context.send('📬')
@commands.command(aliases=['inv']) @commands.command(aliases=['inv']) # https://discordapi.com/permissions.html
async def invite(self, context): async def invite(self, context):
"""Gives you a link to add me to your server.""" """Gives you a link to add me to your server."""
permissions = discord.Permissions()
permissions.update(**dict.fromkeys((
'read_messages',
'send_messages',
'add_reactions',
'external_emojis',
'manage_emojis',
'embed_links',
'attach_files',
), True))
await context.send('<%s>' % discord.utils.oauth_url(self.bot.user.id, permissions)) await context.send('<%s>' % nextcord.utils.oauth_url(self.bot.user.id, permissions=nextcord.Permissions(permissions=1074056256)))
def setup(bot): def setup(bot):
bot.add_cog(Meta(bot)) bot.add_cog(Meta(bot))

View File

@ -12,7 +12,7 @@
'prefixes': ['em/'], 'prefixes': ['em/'],
'tokens': { 'tokens': {
'discord': 'sek.rit.token', 'discord': 'bot.token',
}, },
'ignore_bots': { 'ignore_bots': {
@ -27,11 +27,15 @@
'copyright_license_file': 'data/short-license.txt', 'copyright_license_file': 'data/short-license.txt',
'socks5_proxy_url': None, # required for connecting to the EC API over a Tor onion service # required for connecting to the EC API over a Tor onion service
'use_socks5_for_all_connections': False, # whether to use socks5 for all HTTP operations (other than discord.py) 'socks5_proxy_url': None,
'user_agent': 'EmoteManagerBot (https://github.com/iomintz/emote-manager-bot)', # whether to use socks5 for all HTTP operations (other than discord.py)
'ec_api_base_url': None, # set to None to use the default of https://ec.emote.bot/api/v0 'use_socks5_for_all_connections': False,
'http_head_timeout': 10, # timeout for the initial HEAD request before retrieving any images (up this if using Tor) 'user_agent': 'EmoteManagerBot (https://github.com/uhIgnacio/EmoteManager)',
# set to None to use the default of https://ec.emote.bot/api/v0
'ec_api_base_url': None,
# timeout for the initial HEAD request before retrieving any images (up this if using Tor)
'http_head_timeout': 10,
'http_read_timeout': 60, # timeout for retrieving an image 'http_read_timeout': 60, # timeout for retrieving an image
# emotes that the bot may use to respond to you # emotes that the bot may use to respond to you
@ -42,8 +46,8 @@
# so that both the staging and the stable versions of the bot can use them # so that both the staging and the stable versions of the bot can use them
'response_emojis': { 'response_emojis': {
'success': { # emotes used to indicate success or failure 'success': { # emotes used to indicate success or failure
False: '<:error:478164511879069707>', False: '', # <:EmoteName:ID> or '❌'
True: '<:success:478164452261363712>' True: '' # <:EmoteName:ID> or '✅'
}, },
}, },
} }

View File

@ -10,5 +10,5 @@ but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You may find a copy of the GNU Affero General Public License at https://github.com/EmoteBot/EmoteManager/blob/master/LICENSE.md. You may find a copy of the GNU Affero General Public License at https://github.com/uhIgnacio/EmoteManager/blob/master/LICENSE.md.
The rest of the source code is also there. The rest of the source code is also there.

View File

@ -1,5 +1,5 @@
aiohttp_socks aiohttp_socks
bot_bin>=1.5.0,<2.0.0 bot_bin
discord.py>=1.5.0,<2.0.0 nextcord
jishaku jishaku
wand wand

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

@ -10,13 +10,14 @@ import datetime
import urllib.parse import urllib.parse
from typing import Dict from typing import Dict
from http import HTTPStatus from http import HTTPStatus
from discord import PartialEmoji from nextcord import PartialEmoji
import utils.image as image_utils import utils.image as image_utils
from utils.errors import RateLimitedError from utils.errors import RateLimitedError
from discord import HTTPException, Forbidden, NotFound, DiscordServerError 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,8 +29,9 @@ async def json_or_text(resp):
return text return text
class EmoteClient: class EmoteClient:
BASE_URL = 'https://discord.com/api/v7' BASE_URL = 'https://discord.com/api/v9'
HTTP_ERROR_CLASSES = { HTTP_ERROR_CLASSES = {
HTTPStatus.FORBIDDEN: Forbidden, HTTPStatus.FORBIDDEN: Forbidden,
HTTPStatus.NOT_FOUND: NotFound, HTTPStatus.NOT_FOUND: NotFound,
@ -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 = []