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
*.jpg
*.zip
# gitpod.io
gitpod.yml
.vscode

View File

@ -204,23 +204,23 @@ produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these
conditions:
- a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
- b) The work must carry prominent notices stating that it is
released under this License and any conditions added under
section 7. This requirement modifies the requirement in section 4
to "keep intact all notices".
- c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
- d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
- a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
- b) The work must carry prominent notices stating that it is
released under this License and any conditions added under
section 7. This requirement modifies the requirement in section 4
to "keep intact all notices".
- c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
- d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
@ -239,42 +239,42 @@ sections 4 and 5, provided that you also convey the machine-readable
Corresponding Source under the terms of this License, in one of these
ways:
- a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
- b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the Corresponding
Source from a network server at no charge.
- c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
- d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
- e) Convey the object code using peer-to-peer transmission,
provided you inform other peers where the object code and
Corresponding Source of the work are being offered to the general
public at no charge under subsection 6d.
- a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
- b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the Corresponding
Source from a network server at no charge.
- c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
- d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
- e) Convey the object code using peer-to-peer transmission,
provided you inform other peers where the object code and
Corresponding Source of the work are being offered to the general
public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
@ -350,23 +350,23 @@ Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders
of that material) supplement the terms of this License with terms:
- a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
- b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
- c) Prohibiting misrepresentation of the origin of that material,
or requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
- d) Limiting the use for publicity purposes of names of licensors
or authors of the material; or
- e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
- f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions
of it) with contractual assumptions of liability to the recipient,
for any liability that these contractual assumptions directly
impose on those licensors and authors.
- a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
- b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
- c) Prohibiting misrepresentation of the origin of that material,
or requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
- d) Limiting the use for publicity purposes of names of licensors
or authors of the material; or
- e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
- f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions
of it) with contractual assumptions of liability to the recipient,
for any liability that these contractual assumptions directly
impose on those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you

1
Procfile Normal file
View File

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

122
bot.py
View File

@ -3,11 +3,13 @@
# © lambda#0987 <lambda@lambda.dance>
# SPDX-License-Identifier: AGPL-3.0-or-later
import sys
import asyncio
import base64
import logging
import traceback
import discord
import nextcord
from bot_bin.bot import Bot
from discord.ext import commands
@ -17,74 +19,78 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# SelectorEventLoop on windows doesn't support subprocesses lol
import asyncio
import sys
if sys.platform == 'win32':
loop = asyncio.ProactorEventLoop()
asyncio.set_event_loop(loop)
loop = asyncio.ProactorEventLoop()
asyncio.set_event_loop(loop)
class Bot(Bot):
startup_extensions = (
'cogs.emote',
'cogs.meta',
'bot_bin.debug',
'bot_bin.misc',
'bot_bin.systemd',
'jishaku',
)
startup_extensions = (
'cogs.emote',
'cogs.meta',
'bot_bin.debug',
'bot_bin.misc',
'bot_bin.systemd',
'jishaku',
)
def __init__(self, **kwargs):
with open('data/config.py', encoding='utf-8') as f:
config = eval(f.read(), {})
def __init__(self, **kwargs):
with open('data/config.py', encoding='utf-8') as f:
config = eval(f.read(), {})
super().__init__(config=config, **kwargs)
# allow use of the bot's user ID before ready()
token_part0 = self.config['tokens']['discord'].partition('.')[0].encode()
self.user_id = int(base64.b64decode(token_part0 + b'=' * (3 - len(token_part0) % 3)))
super().__init__(config=config, **kwargs)
# allow use of the bot's user ID before ready()
token_part0 = self.config['tokens']['discord'].partition('.')[
0].encode()
self.user_id = int(base64.b64decode(
token_part0 + b'=' * (3 - len(token_part0) % 3)))
def process_config(self):
"""Load the emojis from the config to be used when a command fails or succeeds
We do it this way so that they can be used anywhere instead of requiring a bot instance.
"""
super().process_config()
import utils.misc
default = ('', '')
utils.SUCCESS_EMOJIS = utils.misc.SUCCESS_EMOJIS = (
self.config.get('response_emojis', {}).get('success', default))
def process_config(self):
"""Load the emojis from the config to be used when a command fails or succeeds
We do it this way so that they can be used anywhere instead of requiring a bot instance.
"""
super().process_config()
import utils.misc
default = ('', '')
utils.SUCCESS_EMOJIS = utils.misc.SUCCESS_EMOJIS = (
self.config.get('response_emojis', {}).get('success', default))
def main():
import sys
import sys
if len(sys.argv) == 1:
shard_count = None
shard_ids = None
elif len(sys.argv) < 3:
print('Usage:', sys.argv[0], '[<shard count> <hyphen-separated list of shard IDs>]', file=sys.stderr)
sys.exit(1)
else:
shard_count = int(sys.argv[1])
shard_ids = list(map(int, sys.argv[2].split('-')))
if len(sys.argv) == 1:
shard_count = None
shard_ids = None
elif len(sys.argv) < 3:
print(
'Usage:', sys.argv[0], '[<shard count> <hyphen-separated list of shard IDs>]', file=sys.stderr)
sys.exit(1)
else:
shard_count = int(sys.argv[1])
shard_ids = list(map(int, sys.argv[2].split('-')))
Bot(
intents=discord.Intents(
guilds=True,
# we hardly need DM support but it's helpful to be able to run the help/support commands in DMs
messages=True,
# we don't need DM reactions because we don't ever paginate in DMs
guild_reactions=True,
emojis=True,
# everything else, including `members` and `presences`, is implicitly false.
),
Bot(
intents=nextcord.Intents(
guilds=True,
# we hardly need DM support but it's helpful to be able to run the help/support commands in DMs
messages=True,
# we don't need DM reactions because we don't ever paginate in DMs
guild_reactions=True,
emojis=True,
# everything else, including `members` and `presences`, is implicitly false.
),
# the least stateful bot you will ever see 😎
chunk_guilds_at_startup=False,
member_cache_flags=discord.MemberCacheFlags.none(),
# disable message cache
max_messages=None,
# the least stateful bot you will ever see 😎
chunk_guilds_at_startup=False,
member_cache_flags=nextcord.MemberCacheFlags.none(),
# disable message cache
max_messages=None,
shard_count=shard_count,
shard_ids=shard_ids,
).run()
shard_count=shard_count,
shard_ids=shard_ids,
).run()
if __name__ == '__main__':
main()
main()

File diff suppressed because it is too large Load Diff

View File

@ -3,72 +3,64 @@
import contextlib
import discord
from discord.ext import commands
import nextcord
from nextcord.ext import commands
import utils
class Meta(commands.Cog):
# TODO does this need to be configurable?
INVITE_DURATION_SECONDS = 60 * 60 * 3
MAX_INVITE_USES = 5
# TODO does this need to be configurable?
INVITE_DURATION_SECONDS = 60 * 60 * 3
MAX_INVITE_USES = 5
def __init__(self, bot):
self.bot = bot
self.support_channel = None
self.task = bot.loop.create_task(self.cache_invite_channel())
def __init__(self, bot):
self.bot = bot
self.support_channel = None
self.task = bot.loop.create_task(self.cache_invite_channel())
def cog_unload(self):
self.task.cancel()
def cog_unload(self):
self.task.cancel()
async def cache_invite_channel(self):
self.support_channel = ch = await self.bot.fetch_channel(self.bot.config['support_server_invite_channel'])
return ch
async def cache_invite_channel(self):
self.support_channel = ch = await self.bot.fetch_channel(self.bot.config['support_server_invite_channel'])
return ch
@commands.command()
async def support(self, context):
"""Directs you to the support server."""
ch = self.support_channel or await self.cache_invite_channel()
@commands.command()
async def support(self, context):
"""Directs you to the support server."""
ch = self.support_channel or await self.cache_invite_channel()
reason = f'Created for {context.author} (ID: {context.author.id})'
invite = await ch.create_invite(
max_age=self.INVITE_DURATION_SECONDS,
max_uses=self.MAX_INVITE_USES,
reason=reason,
)
reason = f'Created for {context.author} (ID: {context.author.id})'
invite = await ch.create_invite(
max_age=self.INVITE_DURATION_SECONDS,
max_uses=self.MAX_INVITE_USES,
reason=reason,
)
try:
await context.author.send(f'Official support server invite: {invite}')
except discord.Forbidden:
with contextlib.suppress(discord.HTTPException):
await context.message.add_reaction(utils.SUCCESS_EMOJIS[True])
with contextlib.suppress(discord.HTTPException):
await context.send('Unable to send invite in DMs. Please allow DMs from server members.')
else:
try:
await context.message.add_reaction('📬')
except discord.HTTPException:
with contextlib.suppress(discord.HTTPException):
await context.send('📬')
try:
await context.author.send(f'Official support server invite: {invite}')
except nextcord.Forbidden:
with contextlib.suppress(nextcord.HTTPException):
await context.message.add_reaction(utils.SUCCESS_EMOJIS[True])
with contextlib.suppress(nextcord.HTTPException):
await context.send('Unable to send invite in DMs. Please allow DMs from server members.')
else:
try:
await context.message.add_reaction('📬')
except nextcord.HTTPException:
with contextlib.suppress(nextcord.HTTPException):
await context.send('📬')
@commands.command(aliases=['inv'])
async def invite(self, context):
"""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))
@commands.command(aliases=['inv']) # https://discordapi.com/permissions.html
async def invite(self, context):
"""Gives you a link to add me to your server."""
await context.send('<%s>' % nextcord.utils.oauth_url(self.bot.user.id, permissions=nextcord.Permissions(permissions=1074056256)))
await context.send('<%s>' % discord.utils.oauth_url(self.bot.user.id, permissions))
def setup(bot):
bot.add_cog(Meta(bot))
bot.add_cog(Meta(bot))
if not bot.config.get('support_server_invite_channel'):
bot.remove_command('support')
if not bot.config.get('support_server_invite_channel'):
bot.remove_command('support')

View File

@ -1,49 +1,53 @@
{
'description':
'Emote Manager lets you manage custom server emotes effortlessly.\n\n'
'NOTE: Most commands will be unavailable until both you and the bot have the '
'"Manage Emojis" permission.',
'description':
'Emote Manager lets you manage custom server emotes effortlessly.\n\n'
'NOTE: Most commands will be unavailable until both you and the bot have the '
'"Manage Emojis" permission.',
# a channel ID to invite people to when they request help with the bot
# the bot must have Create Instant Invite permissions for this channel
# if set to None, the support command will be disabled
'support_server_invite_channel': None,
'prefixes': ['em/'],
# a channel ID to invite people to when they request help with the bot
# the bot must have Create Instant Invite permissions for this channel
# if set to None, the support command will be disabled
'support_server_invite_channel': None,
'tokens': {
'discord': 'sek.rit.token',
},
'prefixes': ['em/'],
'ignore_bots': {
'default': True,
'overrides': {
'channels': [
],
'guilds': [
],
},
},
'tokens': {
'discord': 'bot.token',
},
'copyright_license_file': 'data/short-license.txt',
'ignore_bots': {
'default': True,
'overrides': {
'channels': [
],
'guilds': [
],
},
},
'socks5_proxy_url': None, # 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)
'user_agent': 'EmoteManagerBot (https://github.com/iomintz/emote-manager-bot)',
'ec_api_base_url': None, # set to None to use the default of https://ec.emote.bot/api/v0
'http_head_timeout': 10, # timeout for the initial HEAD request before retrieving any images (up this if using Tor)
'http_read_timeout': 60, # timeout for retrieving an image
'copyright_license_file': 'data/short-license.txt',
# emotes that the bot may use to respond to you
# If not provided, the bot will use '❌', '✅' instead.
#
# You can obtain these ones from the discordbots.org server under the name "tickNo" and "tickYes"
# but I uploaded them to my test server
# so that both the staging and the stable versions of the bot can use them
'response_emojis': {
'success': { # emotes used to indicate success or failure
False: '<:error:478164511879069707>',
True: '<:success:478164452261363712>'
},
},
# required for connecting to the EC API over a Tor onion service
'socks5_proxy_url': None,
# whether to use socks5 for all HTTP operations (other than discord.py)
'use_socks5_for_all_connections': False,
'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
# emotes that the bot may use to respond to you
# If not provided, the bot will use '❌', '✅' instead.
#
# You can obtain these ones from the discordbots.org server under the name "tickNo" and "tickYes"
# but I uploaded them to my test server
# so that both the staging and the stable versions of the bot can use them
'response_emojis': {
'success': { # emotes used to indicate success or failure
False: '', # <:EmoteName:ID> or '❌'
True: '' # <:EmoteName:ID> or '✅'
},
},
}

File diff suppressed because it is too large Load Diff

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
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.

View File

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

View File

@ -14,79 +14,85 @@ from . import errors
ArchiveInfo = collections.namedtuple('ArchiveInfo', 'filename content error')
def extract(archive: typing.io.BinaryIO, *, size_limit=None) \
-> Iterable[Tuple[str, Optional[bytes], Optional[BaseException]]]:
"""
extract a binary file-like object representing a zip or uncompressed tar archive, yielding filenames and contents.
-> Iterable[Tuple[str, Optional[bytes], Optional[BaseException]]]:
"""
extract a binary file-like object representing a zip or uncompressed tar archive, yielding filenames and contents.
yields ArchiveInfo objects: (filename: str, content: typing.Optional[bytes], error: )
if size_limit is not None and the size limit is exceeded, or for any other error, yield None for content
on success, error will be None
"""
yields ArchiveInfo objects: (filename: str, content: typing.Optional[bytes], error: )
if size_limit is not None and the size limit is exceeded, or for any other error, yield None for content
on success, error will be None
"""
try:
yield from extract_zip(archive, size_limit=size_limit)
return
except zipfile.BadZipFile:
pass
finally:
archive.seek(0)
try:
yield from extract_zip(archive, size_limit=size_limit)
return
except zipfile.BadZipFile:
pass
finally:
archive.seek(0)
try:
yield from extract_tar(archive, size_limit=size_limit)
except tarfile.ReadError as exc:
raise ValueError('not a valid zip or tar file') from exc
finally:
archive.seek(0)
try:
yield from extract_tar(archive, size_limit=size_limit)
except tarfile.ReadError as exc:
raise ValueError('not a valid zip or tar file') from exc
finally:
archive.seek(0)
def extract_zip(archive, *, size_limit=None):
with zipfile.ZipFile(archive) as zip:
members = [m for m in zip.infolist() if not m.is_dir()]
for member in members:
if size_limit is not None and member.file_size >= size_limit:
yield ArchiveInfo(
filename=member.filename,
content=None,
error=errors.FileTooBigError(member.file_size, size_limit))
continue
with zipfile.ZipFile(archive) as zip:
members = [m for m in zip.infolist() if not m.is_dir()]
for member in members:
if size_limit is not None and member.file_size >= size_limit:
yield ArchiveInfo(
filename=member.filename,
content=None,
error=errors.FileTooBigError(member.file_size, size_limit))
continue
try:
content = zip.open(member).read()
except RuntimeError as exc: # why no specific exceptions smh
yield ArchiveInfo(filename=member.filename, content=None, error=exc)
else: # this else is required to avoid UnboundLocalError for some reason
yield ArchiveInfo(filename=member.filename, content=content, error=None)
try:
content = zip.open(member).read()
except RuntimeError as exc: # why no specific exceptions smh
yield ArchiveInfo(filename=member.filename, content=None, error=exc)
else: # this else is required to avoid UnboundLocalError for some reason
yield ArchiveInfo(filename=member.filename, content=content, error=None)
def extract_tar(archive, *, size_limit=None):
with tarfile.open(fileobj=archive) as tar:
members = [f for f in tar.getmembers() if f.isfile()]
for member in members:
if size_limit is not None and member.size >= size_limit:
yield ArchiveInfo(
filename=member.name,
content=None,
error=errors.FileTooBigError(member.size, size_limit))
continue
with tarfile.open(fileobj=archive) as tar:
members = [f for f in tar.getmembers() if f.isfile()]
for member in members:
if size_limit is not None and member.size >= size_limit:
yield ArchiveInfo(
filename=member.name,
content=None,
error=errors.FileTooBigError(member.size, size_limit))
continue
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):
for x in extract(archive, size_limit=size_limit):
yield await asyncio.sleep(0, x)
for x in extract(archive, size_limit=size_limit):
yield await asyncio.sleep(0, x)
def main():
import io
import sys
import io
import sys
import humanize
import humanize
arc = io.BytesIO(sys.stdin.detach().read())
for name, data, error in extract(arc):
if error is not None:
print(f'{name}: {error}')
continue
arc = io.BytesIO(sys.stdin.detach().read())
for name, data, error in extract(arc):
if error is not None:
print(f'{name}: {error}')
continue
print(f'{name}: {humanize.naturalsize(len(data)):>10}')
print(f'{name}: {humanize.naturalsize(len(data)):>10}')
if __name__ == '__main__':
main()
main()

View File

@ -2,25 +2,28 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import functools
from discord.ext.commands import BadArgument
from nextcord.ext.commands import BadArgument
_emote_type_predicates = {
'all': lambda _: True,
'static': lambda e: not e.animated,
'animated': lambda e: e.animated}
'all': lambda _: True,
'static': lambda e: not 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.
def emote_type_filter_default(command):
old_callback = command.callback
old_callback = command.callback
@functools.wraps(old_callback)
async def callback(self, ctx, *args):
image_type = args[-1]
try:
image_type = _emote_type_predicates[image_type]
except KeyError:
raise BadArgument(f'Invalid emote type. Specify one of "all", "static", or "animated".')
return await old_callback(self, ctx, *args[:-1], image_type)
@functools.wraps(old_callback)
async def callback(self, ctx, *args):
image_type = args[-1]
try:
image_type = _emote_type_predicates[image_type]
except KeyError:
raise BadArgument(
f'Invalid emote type. Specify one of "all", "static", or "animated".')
return await old_callback(self, ctx, *args[:-1], image_type)
command.callback = callback
return command
command.callback = callback
return command

View File

@ -11,9 +11,11 @@ various utilities related to custom emotes
RE_EMOTE = re.compile(r'(:|;)(?P<name>\w{2,32})\1|(?P<newline>\n)', re.ASCII)
"""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):
"""Convert an emote ID to the image URL for that emote."""
extension = 'gif' if animated else 'png'
return f'https://cdn.discordapp.com/emojis/{id}.{extension}?v=1'
"""Convert an emote ID to the image URL for that emote."""
extension = 'gif' if animated else 'png'
return f'https://cdn.discordapp.com/emojis/{id}.{extension}?v=1'

View File

@ -10,107 +10,112 @@ import datetime
import urllib.parse
from typing import Dict
from http import HTTPStatus
from discord import PartialEmoji
from nextcord import PartialEmoji
import utils.image as image_utils
from utils.errors import RateLimitedError
from discord import HTTPException, Forbidden, NotFound, DiscordServerError
from nextcord import HTTPException, Forbidden, NotFound, DiscordServerError
GuildId = int
async def json_or_text(resp):
text = await resp.text(encoding='utf-8')
try:
if resp.headers['content-type'] == 'application/json':
return json.loads(text)
except KeyError:
# Thanks Cloudflare
pass
return text
async def json_or_text(resp):
text = await resp.text(encoding='utf-8')
try:
if resp.headers['content-type'] == 'application/json':
return json.loads(text)
except KeyError:
# Thanks Cloudflare
pass
return text
class EmoteClient:
BASE_URL = 'https://discord.com/api/v7'
HTTP_ERROR_CLASSES = {
HTTPStatus.FORBIDDEN: Forbidden,
HTTPStatus.NOT_FOUND: NotFound,
HTTPStatus.SERVICE_UNAVAILABLE: DiscordServerError,
}
BASE_URL = 'https://discord.com/api/v9'
HTTP_ERROR_CLASSES = {
HTTPStatus.FORBIDDEN: Forbidden,
HTTPStatus.NOT_FOUND: NotFound,
HTTPStatus.SERVICE_UNAVAILABLE: DiscordServerError,
}
def __init__(self, bot):
self.guild_rls: Dict[GuildId, float] = {}
self.http = aiohttp.ClientSession(headers={
'User-Agent': bot.config['user_agent'] + ' ' + bot.http.user_agent,
'Authorization': 'Bot ' + bot.config['tokens']['discord'],
'X-Ratelimit-Precision': 'millisecond',
})
def __init__(self, bot):
self.guild_rls: Dict[GuildId, float] = {}
self.http = aiohttp.ClientSession(headers={
'User-Agent': bot.config['user_agent'] + ' ' + bot.http.user_agent,
'Authorization': 'Bot ' + bot.config['tokens']['discord'],
'X-Ratelimit-Precision': 'millisecond',
})
async def request(self, method, path, guild_id, **kwargs):
self.check_rl(guild_id)
async def request(self, method, path, guild_id, **kwargs):
self.check_rl(guild_id)
headers = {}
# Emote Manager shouldn't use walrus op until Debian adopts 3.8 :(
reason = kwargs.pop('reason', None)
if reason:
headers['X-Audit-Log-Reason'] = urllib.parse.quote(reason, safe='/ ')
kwargs['headers'] = headers
headers = {}
# Emote Manager shouldn't use walrus op until Debian adopts 3.8 :(
reason = kwargs.pop('reason', None)
if reason:
headers['X-Audit-Log-Reason'] = urllib.parse.quote(
reason, safe='/ ')
kwargs['headers'] = headers
# TODO handle OSError and 500/502, like dpy does
async with self.http.request(method, self.BASE_URL + path, **kwargs) as resp:
if resp.status == HTTPStatus.TOO_MANY_REQUESTS:
return await self._handle_rl(resp, method, path, guild_id, **kwargs)
# TODO handle OSError and 500/502, like dpy does
async with self.http.request(method, self.BASE_URL + path, **kwargs) as resp:
if resp.status == HTTPStatus.TOO_MANY_REQUESTS:
return await self._handle_rl(resp, method, path, guild_id, **kwargs)
data = await json_or_text(resp)
if resp.status in range(200, 300):
return data
data = await json_or_text(resp)
if resp.status in range(200, 300):
return data
error_cls = self.HTTP_ERROR_CLASSES.get(resp.status, HTTPException)
raise error_cls(resp, data)
error_cls = self.HTTP_ERROR_CLASSES.get(resp.status, HTTPException)
raise error_cls(resp, data)
# optimization method that lets us check the RL before downloading the user's image.
# also lets us preemptively check the RL before doing a request
def check_rl(self, guild_id):
try:
retry_at = self.guild_rls[guild_id]
except KeyError:
return
# optimization method that lets us check the RL before downloading the user's image.
# also lets us preemptively check the RL before doing a request
def check_rl(self, guild_id):
try:
retry_at = self.guild_rls[guild_id]
except KeyError:
return
now = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
if retry_at < now:
del self.guild_rls[guild_id]
return
now = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
if retry_at < now:
del self.guild_rls[guild_id]
return
raise RateLimitedError(retry_at)
raise RateLimitedError(retry_at)
async def _handle_rl(self, resp, method, path, guild_id, **kwargs):
retry_after = (await resp.json())['retry_after'] / 1000.0
retry_at = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(seconds=retry_after)
async def _handle_rl(self, resp, method, path, guild_id, **kwargs):
retry_after = (await resp.json())['retry_after'] / 1000.0
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
self.guild_rls[guild_id] = retry_at.timestamp()
# cache unconditionally in case request() is called again while we're sleeping
self.guild_rls[guild_id] = retry_at.timestamp()
if retry_after < 10.0:
await asyncio.sleep(retry_after)
# woo mutual recursion
return await self.request(method, path, guild_id, **kwargs)
if retry_after < 10.0:
await asyncio.sleep(retry_after)
# woo mutual recursion
return await self.request(method, path, guild_id, **kwargs)
# we've been hit with one of those crazy high rate limits, which only occur for specific methods
raise RateLimitedError(retry_at)
# we've been hit with one of those crazy high rate limits, which only occur for specific methods
raise RateLimitedError(retry_at)
async def create(self, *, guild, name, image: bytes, role_ids=(), reason=None):
data = await self.request(
'POST', f'/guilds/{guild.id}/emojis',
guild.id,
json=dict(name=name, image=image_utils.image_to_base64_url(image), roles=role_ids),
reason=reason,
)
return PartialEmoji(animated=data.get('animated', False), name=data.get('name'), id=data.get('id'))
async def create(self, *, guild, name, image: bytes, role_ids=(), reason=None):
data = await self.request(
'POST', f'/guilds/{guild.id}/emojis',
guild.id,
json=dict(name=name, image=image_utils.image_to_base64_url(
image), roles=role_ids),
reason=reason,
)
return PartialEmoji(animated=data.get('animated', False), name=data.get('name'), id=data.get('id'))
async def __aenter__(self):
self.http = await self.http.__aenter__()
return self
async def __aenter__(self):
self.http = await self.http.__aenter__()
return self
async def __aexit__(self, *excinfo):
return await self.http.__aexit__(*excinfo)
async def __aexit__(self, *excinfo):
return await self.http.__aexit__(*excinfo)
async def close(self):
return await self.http.close()
async def close(self):
return await self.http.close()

View File

@ -5,73 +5,98 @@ import utils
import asyncio
import humanize
import datetime
from discord.ext import commands
from nextcord.ext import commands
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."""
def __init__(self):
super(Exception, self).__init__(
f'{utils.SUCCESS_EMOJIS[False]} '
"Sorry, you don't have enough permissions to run this command. "
'You and I both need the Manage Emojis permission.')
def __init__(self):
super(Exception, self).__init__(
f'{utils.SUCCESS_EMOJIS[False]} '
"Sorry, you don't have enough permissions to run this command. "
'You and I both need the Manage Emojis permission.')
class EmoteManagerError(commands.CommandError):
"""Generic error with the bot. This can be used to catch all bot errors."""
pass
"""Generic error with the bot. This can be used to catch all bot errors."""
pass
class ImageProcessingTimeoutError(EmoteManagerError, asyncio.TimeoutError):
pass
pass
class ImageResizeTimeoutError(ImageProcessingTimeoutError):
"""Resizing the image took too long."""
def __init__(self):
super().__init__('Error: resizing the image took too long.')
"""Resizing the image took too long."""
def __init__(self):
super().__init__('Error: resizing the image took too long.')
class ImageConversionTimeoutError(ImageProcessingTimeoutError):
def __init__(self):
super().__init__('Error: converting the image to a GIF took too long.')
def __init__(self):
super().__init__('Error: converting the image to a GIF took too long.')
class HTTPException(EmoteManagerError):
"""The server did not respond with an OK status code. This is only for non-Discord HTTP requests."""
def __init__(self, status):
super().__init__(f'URL error: server returned error code {status}')
"""The server did not respond with an OK status code. This is only for non-Discord HTTP requests."""
def __init__(self, status):
super().__init__(f'URL error: server returned error code {status}')
class RateLimitedError(EmoteManagerError):
def __init__(self, retry_at):
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!
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
delta = humanize.naturaldelta(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}.')
def __init__(self, retry_at):
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!
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
delta = humanize.naturaldelta(
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):
"""An emote with that name was not found"""
def __init__(self, name):
super().__init__(f'An emote called `{name}` does not exist in this server.')
"""An emote with that name was not found"""
def __init__(self, name):
super().__init__(
f'An emote called `{name}` does not exist in this server.')
class FileTooBigError(EmoteManagerError):
def __init__(self, size, limit):
self.size = size
self.limit = limit
def __init__(self, size, limit):
self.size = size
self.limit = limit
class InvalidFileError(EmoteManagerError):
"""The file is not a zip, tar, GIF, PNG, JPG, or WEBP file."""
def __init__(self):
super().__init__('Invalid file given.')
"""The file is not a zip, tar, GIF, PNG, JPG, or WEBP file."""
def __init__(self):
super().__init__('Invalid file given.')
class InvalidImageError(InvalidFileError):
"""The image is not a GIF, PNG, or JPG"""
def __init__(self):
super(Exception, self).__init__('The image supplied was not a GIF, PNG, JPG, or WEBP file.')
"""The image is not a GIF, PNG, or JPG"""
def __init__(self):
super(Exception, self).__init__(
'The image supplied was not a GIF, PNG, JPG, or WEBP file.')
class PermissionDeniedError(EmoteManagerError):
"""Raised when a user tries to modify an emote without the Manage Emojis permission"""
def __init__(self, name):
super().__init__(f"You're not authorized to modify `{name}`.")
"""Raised when a user tries to modify an emote without the Manage Emojis permission"""
def __init__(self, name):
super().__init__(f"You're not authorized to modify `{name}`.")
class DiscordError(Exception):
"""Usually raised when the client cache is being baka"""
def __init__(self):
super().__init__('Discord seems to be having issues right now, please try again later.')
"""Usually raised when the client cache is being baka"""
def __init__(self):
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>
# SPDX-License-Identifier: AGPL-3.0-or-later
from utils import errors
import asyncio
import base64
import contextlib
@ -14,143 +15,155 @@ import typing
logger = logging.getLogger(__name__)
try:
import wand.image
import wand.image
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:
import wand.exceptions
import wand.exceptions
from utils import errors
def resize_until_small(image_data: io.BytesIO) -> None:
"""If the image_data is bigger than 256KB, resize it until it's not."""
# It's important that we only attempt to resize the image when we have to,
# ie when it exceeds the Discord limit of 256KiB.
# Apparently some <256KiB images become larger when we attempt to resize them,
# so resizing sometimes does more harm than good.
max_resolution = 128 # pixels
image_size = size(image_data)
if image_size <= 256 * 2**10:
return
"""If the image_data is bigger than 256KB, resize it until it's not."""
# It's important that we only attempt to resize the image when we have to,
# ie when it exceeds the Discord limit of 256KiB.
# Apparently some <256KiB images become larger when we attempt to resize them,
# so resizing sometimes does more harm than good.
max_resolution = 128 # pixels
image_size = size(image_data)
if image_size <= 256 * 2**10:
return
try:
with wand.image.Image(blob=image_data) as original_image:
while True:
logger.debug('image size too big (%s bytes)', image_size)
logger.debug('attempting resize to at most%s*%s pixels', max_resolution, max_resolution)
try:
with wand.image.Image(blob=image_data) as original_image:
while True:
logger.debug('image size too big (%s bytes)', image_size)
logger.debug('attempting resize to at most%s*%s pixels',
max_resolution, max_resolution)
with original_image.clone() as resized:
resized.transform(resize=f'{max_resolution}x{max_resolution}')
image_size = len(resized.make_blob())
if image_size <= 256 * 2**10 or max_resolution < 32: # don't resize past 256KiB or 32×32
image_data.truncate(0)
image_data.seek(0)
resized.save(file=image_data)
image_data.seek(0)
break
with original_image.clone() as resized:
resized.transform(
resize=f'{max_resolution}x{max_resolution}')
image_size = len(resized.make_blob())
if image_size <= 256 * 2**10 or max_resolution < 32: # don't resize past 256KiB or 32×32
image_data.truncate(0)
image_data.seek(0)
resized.save(file=image_data)
image_data.seek(0)
break
max_resolution //= 2
except wand.exceptions.CoderError:
raise errors.InvalidImageError
max_resolution //= 2
except wand.exceptions.CoderError:
raise errors.InvalidImageError
def convert_to_gif(image_data: io.BytesIO) -> None:
try:
with wand.image.Image(blob=image_data) as orig, orig.convert('gif') as converted:
# discord tries to stop us from abusing animated gif slots by detecting single frame gifs
# so make it two frames
converted.sequence[0].delay = 0 # show the first frame forever
converted.sequence.append(wand.image.Image(width=1, height=1))
try:
with wand.image.Image(blob=image_data) as orig, orig.convert('gif') as converted:
# discord tries to stop us from abusing animated gif slots by detecting single frame gifs
# so make it two frames
converted.sequence[0].delay = 0 # show the first frame forever
converted.sequence.append(wand.image.Image(width=1, height=1))
image_data.truncate(0)
image_data.seek(0)
converted.save(file=image_data)
image_data.seek(0)
except wand.exceptions.CoderError:
raise errors.InvalidImageError
image_data.truncate(0)
image_data.seek(0)
converted.save(file=image_data)
image_data.seek(0)
except wand.exceptions.CoderError:
raise errors.InvalidImageError
def mime_type_for_image(data):
if data.startswith(b'\x89PNG\r\n\x1a\n'):
return 'image/png'
if data.startswith(b'\xFF\xD8') and data.rstrip(b'\0').endswith(b'\xFF\xD9'):
return 'image/jpeg'
if data.startswith((b'GIF87a', b'GIF89a')):
return 'image/gif'
if data.startswith(b'RIFF') and data[8:12] == b'WEBP':
return 'image/webp'
raise errors.InvalidImageError
if data.startswith(b'\x89PNG\r\n\x1a\n'):
return 'image/png'
if data.startswith(b'\xFF\xD8') and data.rstrip(b'\0').endswith(b'\xFF\xD9'):
return 'image/jpeg'
if data.startswith((b'GIF87a', b'GIF89a')):
return 'image/gif'
if data.startswith(b'RIFF') and data[8:12] == b'WEBP':
return 'image/webp'
raise errors.InvalidImageError
def image_to_base64_url(data):
fmt = 'data:{mime};base64,{data}'
mime = mime_type_for_image(data)
b64 = base64.b64encode(data).decode('ascii')
return fmt.format(mime=mime, data=b64)
fmt = 'data:{mime};base64,{data}'
mime = mime_type_for_image(data)
b64 = base64.b64encode(data).decode('ascii')
return fmt.format(mime=mime, data=b64)
def main() -> typing.NoReturn:
"""resize or convert an image from stdin and write the resized or converted version to stdout."""
import sys
"""resize or convert an image from stdin and write the resized or converted version to stdout."""
import sys
if sys.argv[1] == 'resize':
f = resize_until_small
elif sys.argv[1] == 'convert':
f = convert_to_gif
else:
sys.exit(1)
if sys.argv[1] == 'resize':
f = resize_until_small
elif sys.argv[1] == 'convert':
f = convert_to_gif
else:
sys.exit(1)
data = io.BytesIO(sys.stdin.buffer.read())
try:
f(data)
except errors.InvalidImageError:
# 2 is used because 1 is already used by python's default error handler
sys.exit(2)
data = io.BytesIO(sys.stdin.buffer.read())
try:
f(data)
except errors.InvalidImageError:
# 2 is used because 1 is already used by python's default error handler
sys.exit(2)
stdout_write = sys.stdout.buffer.write # getattr optimization
stdout_write = sys.stdout.buffer.write # getattr optimization
while True:
buf = data.read(16 * 1024)
if not buf:
break
while True:
buf = data.read(16 * 1024)
if not buf:
break
stdout_write(buf)
stdout_write(buf)
sys.exit(0)
sys.exit(0)
async def process_image_in_subprocess(command_name, image_data: bytes):
proc = await asyncio.create_subprocess_exec(
sys.executable, '-m', __name__, command_name,
proc = await asyncio.create_subprocess_exec(
sys.executable, '-m', __name__, command_name,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE)
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE)
try:
image_data, err = await asyncio.wait_for(proc.communicate(image_data), timeout=float('inf'))
except asyncio.TimeoutError:
proc.send_signal(signal.SIGINT)
raise errors.ImageResizeTimeoutError if command_name == 'resize' else errors.ImageConversionTimeoutError
else:
if proc.returncode == 2:
raise errors.InvalidImageError
if proc.returncode != 0:
raise RuntimeError(err.decode('utf-8') + f'Return code: {proc.returncode}')
try:
image_data, err = await asyncio.wait_for(proc.communicate(image_data), timeout=float('inf'))
except asyncio.TimeoutError:
proc.send_signal(signal.SIGINT)
raise errors.ImageResizeTimeoutError if command_name == 'resize' else errors.ImageConversionTimeoutError
else:
if proc.returncode == 2:
raise errors.InvalidImageError
if proc.returncode != 0:
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')
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):
"""return the size, in bytes, of the data a file-like object represents"""
with preserve_position(fp):
fp.seek(0, io.SEEK_END)
return fp.tell()
"""return the size, in bytes, of the data a file-like object represents"""
with preserve_position(fp):
fp.seek(0, io.SEEK_END)
return fp.tell()
class preserve_position(contextlib.AbstractContextManager):
def __init__(self, fp):
self.fp = fp
self.old_pos = fp.tell()
def __init__(self, fp):
self.fp = fp
self.old_pos = fp.tell()
def __exit__(self, *excinfo):
self.fp.seek(self.old_pos)
def __exit__(self, *excinfo):
self.fp.seek(self.old_pos)
if __name__ == '__main__':
main()
main()

View File

@ -5,47 +5,51 @@
import asyncio
import discord
import nextcord
def format_user(user, *, mention=False):
"""Format a user object for audit log purposes."""
# not mention: @null byte#8191 (140516693242937345)
# mention: <@140516693242937345> (null byte#8191)
# this allows people to still see the username and discrim
# if they don't share a server with that user
if mention:
return f'{user.mention} (@{user})'
else:
return f'@{user} ({user.id})'
"""Format a user object for audit log purposes."""
# not mention: @null byte#8191 (140516693242937345)
# mention: <@140516693242937345> (null byte#8191)
# this allows people to still see the username and discrim
# if they don't share a server with that user
if mention:
return f'{user.mention} (@{user})'
else:
return f'@{user} ({user.id})'
def format_http_exception(exception: discord.HTTPException):
"""Formats a discord.HTTPException for relaying to the user.
Sample return value:
BAD REQUEST (status code: 400):
Invalid Form Body
In image: File cannot be larger than 256 kb.
"""
return (
f'{exception.response.reason} (status code: {exception.response.status}):'
f'\n{exception.text}')
def format_http_exception(exception: nextcord.HTTPException):
"""Formats a nextcord.HTTPException for relaying to the user.
Sample return value:
BAD REQUEST (status code: 400):
Invalid Form Body
In image: File cannot be larger than 256 kb.
"""
return (
f'{exception.response.reason} (status code: {exception.response.status}):'
f'\n{exception.text}')
def strip_angle_brackets(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."""
if string.startswith('<') and string.endswith('>'):
return string[1:-1]
return 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."""
if string.startswith('<') and string.endswith('>'):
return string[1:-1]
return string
async def gather_or_cancel(*awaitables, loop=None):
"""run the awaitables in the sequence concurrently. If any of them raise an exception,
propagate the first exception raised and cancel all other awaitables.
"""
gather_task = asyncio.gather(*awaitables, loop=loop)
try:
return await gather_task
except asyncio.CancelledError:
raise
except:
gather_task.cancel()
raise
"""run the awaitables in the sequence concurrently. If any of them raise an exception,
propagate the first exception raised and cancel all other awaitables.
"""
gather_task = asyncio.gather(*awaitables, loop=loop)
try:
return await gather_task
except asyncio.CancelledError:
raise
except:
gather_task.cancel()
raise

View File

@ -5,145 +5,148 @@ import asyncio
import contextlib
import typing
import discord
from discord.ext.commands import Context
import nextcord
from nextcord.ext.commands import Context
# Copyright © 2016-2017 Pandentia and contributors
# https://github.com/Thessia/Liara/blob/75fa11948b8b2ea27842d8815a32e51ef280a999/cogs/utils/paginator.py
class Paginator:
def __init__(self, ctx: Context, pages: typing.Iterable, *, timeout=300, delete_message=False,
delete_message_on_timeout=False, text_message=None):
def __init__(self, ctx: Context, pages: typing.Iterable, *, timeout=300, delete_message=False,
delete_message_on_timeout=False, text_message=None):
self.pages = list(pages)
self.timeout = timeout
self.author = ctx.author
self.target = ctx.channel
self.delete_msg = delete_message
self.delete_msg_timeout = delete_message_on_timeout
self.text_message = text_message
self.pages = list(pages)
self.timeout = timeout
self.author = ctx.author
self.target = ctx.channel
self.delete_msg = delete_message
self.delete_msg_timeout = delete_message_on_timeout
self.text_message = text_message
self._stopped = None # we use this later
self._embed = None
self._message = None
self._client = ctx.bot
self._stopped = None # we use this later
self._embed = None
self._message = None
self._client = ctx.bot
self.footer = 'Page {} of {}'
self.navigation = {
'\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}': self.first_page,
'\N{BLACK LEFT-POINTING TRIANGLE}': self.previous_page,
'\N{BLACK RIGHT-POINTING TRIANGLE}': self.next_page,
'\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}': self.last_page,
'\N{BLACK SQUARE FOR STOP}': self.stop
}
self.footer = 'Page {} of {}'
self.navigation = {
'\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}': self.first_page,
'\N{BLACK LEFT-POINTING TRIANGLE}': self.previous_page,
'\N{BLACK RIGHT-POINTING TRIANGLE}': self.next_page,
'\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}': self.last_page,
'\N{BLACK SQUARE FOR STOP}': self.stop
}
self._page = None
self._page = None
def react_check(self, reaction: discord.RawReactionActionEvent):
if reaction.user_id != self.author.id:
return False
def react_check(self, reaction: nextcord.RawReactionActionEvent):
if reaction.user_id != self.author.id:
return False
if reaction.message_id != self._message.id:
return False
if reaction.message_id != self._message.id:
return False
target_emoji = str(reaction.emoji)
return bool(discord.utils.find(lambda emoji: target_emoji == emoji, self.navigation))
target_emoji = str(reaction.emoji)
return bool(nextcord.utils.find(lambda emoji: target_emoji == emoji, self.navigation))
async def begin(self):
"""Starts pagination"""
self._stopped = False
self._embed = discord.Embed()
await self.first_page()
for button in self.navigation:
await self._message.add_reaction(button)
while not self._stopped:
try:
reaction: RawReactionActionEvent = await self._client.wait_for(
'raw_reaction_add',
check=self.react_check,
timeout=self.timeout)
except asyncio.TimeoutError:
await self.stop(delete=self.delete_msg_timeout)
continue
async def begin(self):
"""Starts pagination"""
self._stopped = False
self._embed = nextcord.Embed()
await self.first_page()
for button in self.navigation:
await self._message.add_reaction(button)
while not self._stopped:
try:
reaction: RawReactionActionEvent = await self._client.wait_for(
'raw_reaction_add',
check=self.react_check,
timeout=self.timeout)
except asyncio.TimeoutError:
await self.stop(delete=self.delete_msg_timeout)
continue
await self.navigation[str(reaction.emoji)]()
await self.navigation[str(reaction.emoji)]()
await asyncio.sleep(0.2)
with contextlib.suppress(discord.HTTPException):
await self._message.remove_reaction(reaction.emoji, discord.Object(reaction.user_id))
await asyncio.sleep(0.2)
with contextlib.suppress(nextcord.HTTPException):
await self._message.remove_reaction(reaction.emoji, nextcord.Object(reaction.user_id))
async def stop(self, *, delete=None):
"""Aborts pagination."""
if delete is None:
delete = self.delete_msg
async def stop(self, *, delete=None):
"""Aborts pagination."""
if delete is None:
delete = self.delete_msg
if delete:
with contextlib.suppress(discord.HTTPException):
await self._message.delete()
else:
await self._clear_reactions()
self._stopped = True
if delete:
with contextlib.suppress(nextcord.HTTPException):
await self._message.delete()
else:
await self._clear_reactions()
self._stopped = True
async def _clear_reactions(self):
try:
await self._message.clear_reactions()
except discord.Forbidden:
for button in self.navigation:
with contextlib.suppress(discord.HTTPException):
await self._message.remove_reaction(button, self._message.author)
except discord.HTTPException:
pass
async def _clear_reactions(self):
try:
await self._message.clear_reactions()
except nextcord.Forbidden:
for button in self.navigation:
with contextlib.suppress(nextcord.HTTPException):
await self._message.remove_reaction(button, self._message.author)
except nextcord.HTTPException:
pass
async def format_page(self):
self._embed.description = self.pages[self._page]
self._embed.set_footer(text=self.footer.format(self._page + 1, len(self.pages)))
async def format_page(self):
self._embed.description = self.pages[self._page]
self._embed.set_footer(text=self.footer.format(
self._page + 1, len(self.pages)))
kwargs = {'embed': self._embed}
if self.text_message:
kwargs['content'] = self.text_message
kwargs = {'embed': self._embed}
if self.text_message:
kwargs['content'] = self.text_message
if self._message:
await self._message.edit(**kwargs)
else:
self._message = await self.target.send(**kwargs)
if self._message:
await self._message.edit(**kwargs)
else:
self._message = await self.target.send(**kwargs)
async def first_page(self):
self._page = 0
await self.format_page()
async def first_page(self):
self._page = 0
await self.format_page()
async def next_page(self):
self._page += 1
if self._page == len(self.pages): # avoid the inevitable IndexError
self._page = 0
await self.format_page()
async def next_page(self):
self._page += 1
if self._page == len(self.pages): # avoid the inevitable IndexError
self._page = 0
await self.format_page()
async def previous_page(self):
self._page -= 1
if self._page < 0: # ditto
self._page = len(self.pages) - 1
await self.format_page()
async def previous_page(self):
self._page -= 1
if self._page < 0: # ditto
self._page = len(self.pages) - 1
await self.format_page()
async def last_page(self):
self._page = len(self.pages) - 1
await self.format_page()
async def last_page(self):
self._page = len(self.pages) - 1
await self.format_page()
class ListPaginator(Paginator):
def __init__(self, ctx, _list: list, per_page=10, **kwargs):
pages = []
page = ''
c = 0
l = len(_list)
for i in _list:
if c > l:
break
if c % per_page == 0 and page:
pages.append(page.strip())
page = ''
page += '{}. {}\n'.format(c+1, i)
def __init__(self, ctx, _list: list, per_page=10, **kwargs):
pages = []
page = ''
c = 0
l = len(_list)
for i in _list:
if c > l:
break
if c % per_page == 0 and page:
pages.append(page.strip())
page = ''
page += '{}. {}\n'.format(c+1, i)
c += 1
pages.append(page.strip())
# shut up, IDEA
# noinspection PyArgumentList
super().__init__(ctx, pages, **kwargs)
self.footer += ' ({} entries)'.format(l)
c += 1
pages.append(page.strip())
# shut up, IDEA
# noinspection PyArgumentList
super().__init__(ctx, pages, **kwargs)
self.footer += ' ({} entries)'.format(l)