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

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 terms of section 4, provided that you also meet all of these
conditions: conditions:
- a) The work must carry prominent notices stating that you modified - a) The work must carry prominent notices stating that you modified
it, and giving a relevant date. it, and giving a relevant date.
- b) The work must carry prominent notices stating that it is - b) The work must carry prominent notices stating that it is
released under this License and any conditions added under released under this License and any conditions added under
section 7. This requirement modifies the requirement in section 4 section 7. This requirement modifies the requirement in section 4
to "keep intact all notices". to "keep intact all notices".
- c) You must license the entire work, as a whole, under this - c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7 License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts, additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it. invalidate such permission if you have separately received it.
- d) If the work has interactive user interfaces, each must display - d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your interfaces that do not display Appropriate Legal Notices, your
work need not make them do so. work need not make them do so.
A compilation of a covered work with other separate and independent A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work, 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 Corresponding Source under the terms of this License, in one of these
ways: ways:
- a) Convey the object code in, or embodied in, a physical product - a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the (including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium Corresponding Source fixed on a durable physical medium
customarily used for software interchange. customarily used for software interchange.
- b) Convey the object code in, or embodied in, a physical product - b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a (including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the Corresponding conveying of source, or (2) access to copy the Corresponding
Source from a network server at no charge. Source from a network server at no charge.
- c) Convey individual copies of the object code with a copy of the - c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord only if you received the object code with such an offer, in accord
with subsection 6b. with subsection 6b.
- d) Convey the object code by offering access from a designated - d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party) may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements. available for as long as needed to satisfy these requirements.
- e) Convey the object code using peer-to-peer transmission, - e) Convey the object code using peer-to-peer transmission,
provided you inform other peers where the object code and provided you inform other peers where the object code and
Corresponding Source of the work are being offered to the general Corresponding Source of the work are being offered to the general
public at no charge under subsection 6d. public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be 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 add to a covered work, you may (if authorized by the copyright holders
of that material) supplement the terms of this License with terms: of that material) supplement the terms of this License with terms:
- a) Disclaiming warranty or limiting liability differently from the - a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or terms of sections 15 and 16 of this License; or
- b) Requiring preservation of specified reasonable legal notices or - b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or Notices displayed by works containing it; or
- c) Prohibiting misrepresentation of the origin of that material, - c) Prohibiting misrepresentation of the origin of that material,
or requiring that modified versions of such material be marked in or requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or reasonable ways as different from the original version; or
- d) Limiting the use for publicity purposes of names of licensors - d) Limiting the use for publicity purposes of names of licensors
or authors of the material; or or authors of the material; or
- e) Declining to grant rights under trademark law for use of some - e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or trade names, trademarks, or service marks; or
- f) Requiring indemnification of licensors and authors of that - f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions material by anyone who conveys the material (or modified versions
of it) with contractual assumptions of liability to the recipient, of it) with contractual assumptions of liability to the recipient,
for any liability that these contractual assumptions directly for any liability that these contractual assumptions directly
impose on those licensors and authors. impose on those licensors and authors.
All other non-permissive additional terms are considered "further All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you 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> # © 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,74 +19,78 @@ 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',
'cogs.meta', 'cogs.meta',
'bot_bin.debug', 'bot_bin.debug',
'bot_bin.misc', 'bot_bin.misc',
'bot_bin.systemd', 'bot_bin.systemd',
'jishaku', 'jishaku',
) )
def __init__(self, **kwargs): def __init__(self, **kwargs):
with open('data/config.py', encoding='utf-8') as f: with open('data/config.py', encoding='utf-8') as f:
config = eval(f.read(), {}) config = eval(f.read(), {})
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):
"""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(): def main():
import sys import sys
if len(sys.argv) == 1: if len(sys.argv) == 1:
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(
sys.exit(1) 'Usage:', sys.argv[0], '[<shard count> <hyphen-separated list of shard IDs>]', file=sys.stderr)
else: sys.exit(1)
shard_count = int(sys.argv[1]) else:
shard_ids = list(map(int, sys.argv[2].split('-'))) shard_count = int(sys.argv[1])
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,
# we don't need DM reactions because we don't ever paginate in DMs # we don't need DM reactions because we don't ever paginate in DMs
guild_reactions=True, guild_reactions=True,
emojis=True, emojis=True,
# everything else, including `members` and `presences`, is implicitly false. # everything else, including `members` and `presences`, is implicitly false.
), ),
# 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,
shard_count=shard_count,
shard_ids=shard_ids,
).run()
shard_count=shard_count,
shard_ids=shard_ids,
).run()
if __name__ == '__main__': if __name__ == '__main__':
main() main()

File diff suppressed because it is too large Load Diff

View File

@ -3,72 +3,64 @@
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
MAX_INVITE_USES = 5 MAX_INVITE_USES = 5
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
self.support_channel = None self.support_channel = None
self.task = bot.loop.create_task(self.cache_invite_channel()) self.task = bot.loop.create_task(self.cache_invite_channel())
def cog_unload(self): def cog_unload(self):
self.task.cancel() self.task.cancel()
async def cache_invite_channel(self): async def cache_invite_channel(self):
self.support_channel = ch = await self.bot.fetch_channel(self.bot.config['support_server_invite_channel']) self.support_channel = ch = await self.bot.fetch_channel(self.bot.config['support_server_invite_channel'])
return ch return ch
@commands.command() @commands.command()
async def support(self, context): async def support(self, context):
"""Directs you to the support server.""" """Directs you to the support server."""
ch = self.support_channel or await self.cache_invite_channel() ch = self.support_channel or await self.cache_invite_channel()
reason = f'Created for {context.author} (ID: {context.author.id})' reason = f'Created for {context.author} (ID: {context.author.id})'
invite = await ch.create_invite( invite = await ch.create_invite(
max_age=self.INVITE_DURATION_SECONDS, max_age=self.INVITE_DURATION_SECONDS,
max_uses=self.MAX_INVITE_USES, max_uses=self.MAX_INVITE_USES,
reason=reason, reason=reason,
) )
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(( await context.send('<%s>' % nextcord.utils.oauth_url(self.bot.user.id, permissions=nextcord.Permissions(permissions=1074056256)))
'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))
def setup(bot): def setup(bot):
bot.add_cog(Meta(bot)) bot.add_cog(Meta(bot))
if not bot.config.get('support_server_invite_channel'): if not bot.config.get('support_server_invite_channel'):
bot.remove_command('support') bot.remove_command('support')

View File

@ -1,49 +1,53 @@
{ {
'description': 'description':
'Emote Manager lets you manage custom server emotes effortlessly.\n\n' '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 ' 'NOTE: Most commands will be unavailable until both you and the bot have the '
'"Manage Emojis" permission.', '"Manage Emojis" permission.',
# a channel ID to invite people to when they request help with the bot # 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 # the bot must have Create Instant Invite permissions for this channel
# if set to None, the support command will be disabled # if set to None, the support command will be disabled
'support_server_invite_channel': None, 'support_server_invite_channel': None,
'prefixes': ['em/'],
'tokens': { 'prefixes': ['em/'],
'discord': 'sek.rit.token',
},
'ignore_bots': { 'tokens': {
'default': True, 'discord': 'bot.token',
'overrides': { },
'channels': [
],
'guilds': [
],
},
},
'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 'copyright_license_file': 'data/short-license.txt',
'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
# emotes that the bot may use to respond to you # required for connecting to the EC API over a Tor onion service
# If not provided, the bot will use '❌', '✅' instead. 'socks5_proxy_url': None,
# # whether to use socks5 for all HTTP operations (other than discord.py)
# You can obtain these ones from the discordbots.org server under the name "tickNo" and "tickYes" 'use_socks5_for_all_connections': False,
# but I uploaded them to my test server 'user_agent': 'EmoteManagerBot (https://github.com/uhIgnacio/EmoteManager)',
# so that both the staging and the stable versions of the bot can use them # set to None to use the default of https://ec.emote.bot/api/v0
'response_emojis': { 'ec_api_base_url': None,
'success': { # emotes used to indicate success or failure # timeout for the initial HEAD request before retrieving any images (up this if using Tor)
False: '<:error:478164511879069707>', 'http_head_timeout': 10,
True: '<:success:478164452261363712>' '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 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,79 +14,85 @@ 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]]]:
""" """
extract a binary file-like object representing a zip or uncompressed tar archive, yielding filenames and contents. 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: ) 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 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 on success, error will be None
""" """
try: try:
yield from extract_zip(archive, size_limit=size_limit) yield from extract_zip(archive, size_limit=size_limit)
return return
except zipfile.BadZipFile: except zipfile.BadZipFile:
pass pass
finally: finally:
archive.seek(0) 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): 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()]
for member in members: for member in members:
if size_limit is not None and member.file_size >= size_limit: if size_limit is not None and member.file_size >= size_limit:
yield ArchiveInfo( yield ArchiveInfo(
filename=member.filename, filename=member.filename,
content=None, content=None,
error=errors.FileTooBigError(member.file_size, size_limit)) error=errors.FileTooBigError(member.file_size, size_limit))
continue 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): 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()]
for member in members: for member in members:
if size_limit is not None and member.size >= size_limit: if size_limit is not None and member.size >= size_limit:
yield ArchiveInfo( yield ArchiveInfo(
filename=member.name, filename=member.name,
content=None, content=None,
error=errors.FileTooBigError(member.size, size_limit)) error=errors.FileTooBigError(member.size, size_limit))
continue 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): 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
import humanize import humanize
arc = io.BytesIO(sys.stdin.detach().read()) arc = io.BytesIO(sys.stdin.detach().read())
for name, data, error in extract(arc): for name, data, error in extract(arc):
if error is not None: if error is not None:
print(f'{name}: {error}') print(f'{name}: {error}')
continue continue
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,25 +2,28 @@
# 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,
'static': lambda e: not e.animated, 'static': lambda e: not e.animated,
'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
@functools.wraps(old_callback) @functools.wraps(old_callback)
async def callback(self, ctx, *args): async def callback(self, ctx, *args):
image_type = args[-1] image_type = args[-1]
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(
return await old_callback(self, ctx, *args[:-1], image_type) f'Invalid emote type. Specify one of "all", "static", or "animated".')
return await old_callback(self, ctx, *args[:-1], image_type)
command.callback = callback command.callback = callback
return command 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) 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."""
extension = 'gif' if animated else 'png' extension = 'gif' if animated else 'png'
return f'https://cdn.discordapp.com/emojis/{id}.{extension}?v=1' return f'https://cdn.discordapp.com/emojis/{id}.{extension}?v=1'

View File

@ -10,107 +10,112 @@ 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):
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: 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,
HTTPStatus.SERVICE_UNAVAILABLE: DiscordServerError, HTTPStatus.SERVICE_UNAVAILABLE: DiscordServerError,
} }
def __init__(self, bot): def __init__(self, bot):
self.guild_rls: Dict[GuildId, float] = {} self.guild_rls: Dict[GuildId, float] = {}
self.http = aiohttp.ClientSession(headers={ self.http = aiohttp.ClientSession(headers={
'User-Agent': bot.config['user_agent'] + ' ' + bot.http.user_agent, 'User-Agent': bot.config['user_agent'] + ' ' + bot.http.user_agent,
'Authorization': 'Bot ' + bot.config['tokens']['discord'], 'Authorization': 'Bot ' + bot.config['tokens']['discord'],
'X-Ratelimit-Precision': 'millisecond', 'X-Ratelimit-Precision': 'millisecond',
}) })
async def request(self, method, path, guild_id, **kwargs): async def request(self, method, path, guild_id, **kwargs):
self.check_rl(guild_id) self.check_rl(guild_id)
headers = {} headers = {}
# 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(
kwargs['headers'] = headers reason, safe='/ ')
kwargs['headers'] = headers
# TODO handle OSError and 500/502, like dpy does # TODO handle OSError and 500/502, like dpy does
async with self.http.request(method, self.BASE_URL + path, **kwargs) as resp: async with self.http.request(method, self.BASE_URL + path, **kwargs) as resp:
if resp.status == HTTPStatus.TOO_MANY_REQUESTS: if resp.status == HTTPStatus.TOO_MANY_REQUESTS:
return await self._handle_rl(resp, method, path, guild_id, **kwargs) return await self._handle_rl(resp, method, path, guild_id, **kwargs)
data = await json_or_text(resp) data = await json_or_text(resp)
if resp.status in range(200, 300): if resp.status in range(200, 300):
return data return data
error_cls = self.HTTP_ERROR_CLASSES.get(resp.status, HTTPException) error_cls = self.HTTP_ERROR_CLASSES.get(resp.status, HTTPException)
raise error_cls(resp, data) raise error_cls(resp, data)
# optimization method that lets us check the RL before downloading the user's image. # 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 # also lets us preemptively check the RL before doing a request
def check_rl(self, guild_id): def check_rl(self, guild_id):
try: try:
retry_at = self.guild_rls[guild_id] retry_at = self.guild_rls[guild_id]
except KeyError: except KeyError:
return return
now = datetime.datetime.now(tz=datetime.timezone.utc).timestamp() now = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
if retry_at < now: if retry_at < now:
del self.guild_rls[guild_id] del self.guild_rls[guild_id]
return return
raise RateLimitedError(retry_at) raise RateLimitedError(retry_at)
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()
if retry_after < 10.0: if retry_after < 10.0:
await asyncio.sleep(retry_after) await asyncio.sleep(retry_after)
# woo mutual recursion # woo mutual recursion
return await self.request(method, path, guild_id, **kwargs) 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 # we've been hit with one of those crazy high rate limits, which only occur for specific methods
raise RateLimitedError(retry_at) raise RateLimitedError(retry_at)
async def create(self, *, guild, name, image: bytes, role_ids=(), reason=None): async def create(self, *, guild, name, image: bytes, role_ids=(), reason=None):
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(
reason=reason, image), roles=role_ids),
) 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'))
async def __aenter__(self): async def __aenter__(self):
self.http = await self.http.__aenter__() self.http = await self.http.__aenter__()
return self return self
async def __aexit__(self, *excinfo): async def __aexit__(self, *excinfo):
return await self.http.__aexit__(*excinfo) return await self.http.__aexit__(*excinfo)
async def close(self): async def close(self):
return await self.http.close() return await self.http.close()

View File

@ -5,73 +5,98 @@ 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."""
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): 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):
super().__init__('Error: resizing the image took too long.') def __init__(self):
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):
super().__init__(f'URL error: server returned error code {status}') def __init__(self, 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(
# humanize.naturaltime is annoying to work with due to timezones so we use this retry_at, tz=datetime.timezone.utc)
delta = humanize.naturaldelta(retry_at, when=datetime.datetime.now(tz=datetime.timezone.utc)) # humanize.naturaltime is annoying to work with due to timezones so we use this
super().__init__(f'Error: Discord told me to slow down! Please retry this command in {delta}.') 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): class EmoteNotFoundError(EmoteManagerError):
"""An emote with that name was not found""" """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.') def __init__(self, name):
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):
super().__init__('Invalid file given.') def __init__(self):
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):
super(Exception, self).__init__('The image supplied was not a GIF, PNG, JPG, or WEBP file.') def __init__(self):
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):
super().__init__(f"You're not authorized to modify `{name}`.") def __init__(self, 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):
super().__init__('Discord seems to be having issues right now, please try again later.') 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> # © 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
@ -14,143 +15,155 @@ import typing
logger = logging.getLogger(__name__) 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."""
# It's important that we only attempt to resize the image when we have to, # It's important that we only attempt to resize the image when we have to,
# ie when it exceeds the Discord limit of 256KiB. # ie when it exceeds the Discord limit of 256KiB.
# Apparently some <256KiB images become larger when we attempt to resize them, # Apparently some <256KiB images become larger when we attempt to resize them,
# so resizing sometimes does more harm than good. # so resizing sometimes does more harm than good.
max_resolution = 128 # pixels max_resolution = 128 # pixels
image_size = size(image_data) image_size = size(image_data)
if image_size <= 256 * 2**10: if image_size <= 256 * 2**10:
return return
try: try:
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(
image_size = len(resized.make_blob()) resize=f'{max_resolution}x{max_resolution}')
if image_size <= 256 * 2**10 or max_resolution < 32: # don't resize past 256KiB or 32×32 image_size = len(resized.make_blob())
image_data.truncate(0) if image_size <= 256 * 2**10 or max_resolution < 32: # don't resize past 256KiB or 32×32
image_data.seek(0) image_data.truncate(0)
resized.save(file=image_data) image_data.seek(0)
image_data.seek(0) resized.save(file=image_data)
break 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: 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:
# discord tries to stop us from abusing animated gif slots by detecting single frame gifs # discord tries to stop us from abusing animated gif slots by detecting single frame gifs
# so make it two frames # so make it two frames
converted.sequence[0].delay = 0 # show the first frame forever converted.sequence[0].delay = 0 # show the first frame forever
converted.sequence.append(wand.image.Image(width=1, height=1)) 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): 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'
if data.startswith(b'\xFF\xD8') and data.rstrip(b'\0').endswith(b'\xFF\xD9'): if data.startswith(b'\xFF\xD8') and data.rstrip(b'\0').endswith(b'\xFF\xD9'):
return 'image/jpeg' return 'image/jpeg'
if data.startswith((b'GIF87a', b'GIF89a')): if data.startswith((b'GIF87a', b'GIF89a')):
return 'image/gif' return 'image/gif'
if data.startswith(b'RIFF') and data[8:12] == b'WEBP': if data.startswith(b'RIFF') and data[8:12] == b'WEBP':
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
if sys.argv[1] == 'resize': if sys.argv[1] == 'resize':
f = resize_until_small f = resize_until_small
elif sys.argv[1] == 'convert': elif sys.argv[1] == 'convert':
f = convert_to_gif f = convert_to_gif
else: else:
sys.exit(1) sys.exit(1)
data = io.BytesIO(sys.stdin.buffer.read()) data = io.BytesIO(sys.stdin.buffer.read())
try: try:
f(data) f(data)
except errors.InvalidImageError: except errors.InvalidImageError:
# 2 is used because 1 is already used by python's default error handler # 2 is used because 1 is already used by python's default error handler
sys.exit(2) sys.exit(2)
stdout_write = sys.stdout.buffer.write # getattr optimization stdout_write = sys.stdout.buffer.write # getattr optimization
while True: while True:
buf = data.read(16 * 1024) buf = data.read(16 * 1024)
if not buf: if not buf:
break break
stdout_write(buf) stdout_write(buf)
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,
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE) stderr=asyncio.subprocess.PIPE)
try: try:
image_data, err = await asyncio.wait_for(proc.communicate(image_data), timeout=float('inf')) image_data, err = await asyncio.wait_for(proc.communicate(image_data), timeout=float('inf'))
except asyncio.TimeoutError: except asyncio.TimeoutError:
proc.send_signal(signal.SIGINT) proc.send_signal(signal.SIGINT)
raise errors.ImageResizeTimeoutError if command_name == 'resize' else errors.ImageConversionTimeoutError raise errors.ImageResizeTimeoutError if command_name == 'resize' else errors.ImageConversionTimeoutError
else: else:
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"""
with preserve_position(fp): with preserve_position(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
self.old_pos = fp.tell() 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__': if __name__ == '__main__':
main() main()

View File

@ -5,47 +5,51 @@
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."""
# not mention: @null byte#8191 (140516693242937345) # not mention: @null byte#8191 (140516693242937345)
# mention: <@140516693242937345> (null byte#8191) # mention: <@140516693242937345> (null byte#8191)
# this allows people to still see the username and discrim # this allows people to still see the username and discrim
# if they don't share a server with that user # if they don't share a server with that user
if mention: if mention:
return f'{user.mention} (@{user})' return f'{user.mention} (@{user})'
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.
Sample return value:
BAD REQUEST (status code: 400): def format_http_exception(exception: nextcord.HTTPException):
Invalid Form Body """Formats a nextcord.HTTPException for relaying to the user.
In image: File cannot be larger than 256 kb. Sample return value:
"""
return ( BAD REQUEST (status code: 400):
f'{exception.response.reason} (status code: {exception.response.status}):' Invalid Form Body
f'\n{exception.text}') 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): 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."""
if string.startswith('<') and string.endswith('>'): if string.startswith('<') and string.endswith('>'):
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.
""" """
gather_task = asyncio.gather(*awaitables, loop=loop) gather_task = asyncio.gather(*awaitables, loop=loop)
try: try:
return await gather_task return await gather_task
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except: except:
gather_task.cancel() gather_task.cancel()
raise raise

View File

@ -5,145 +5,148 @@ 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):
self.pages = list(pages) self.pages = list(pages)
self.timeout = timeout self.timeout = timeout
self.author = ctx.author self.author = ctx.author
self.target = ctx.channel self.target = ctx.channel
self.delete_msg = delete_message self.delete_msg = delete_message
self.delete_msg_timeout = delete_message_on_timeout self.delete_msg_timeout = delete_message_on_timeout
self.text_message = text_message self.text_message = text_message
self._stopped = None # we use this later self._stopped = None # we use this later
self._embed = None self._embed = None
self._message = None self._message = None
self._client = ctx.bot self._client = ctx.bot
self.footer = 'Page {} of {}' self.footer = 'Page {} of {}'
self.navigation = { self.navigation = {
'\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}': self.first_page, '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}': self.first_page,
'\N{BLACK LEFT-POINTING TRIANGLE}': self.previous_page, '\N{BLACK LEFT-POINTING TRIANGLE}': self.previous_page,
'\N{BLACK RIGHT-POINTING TRIANGLE}': self.next_page, '\N{BLACK RIGHT-POINTING TRIANGLE}': self.next_page,
'\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}': self.last_page, '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}': self.last_page,
'\N{BLACK SQUARE FOR STOP}': self.stop '\N{BLACK SQUARE FOR STOP}': self.stop
} }
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
if reaction.message_id != self._message.id: if reaction.message_id != self._message.id:
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)
while not self._stopped: while not self._stopped:
try: try:
reaction: RawReactionActionEvent = await self._client.wait_for( reaction: RawReactionActionEvent = await self._client.wait_for(
'raw_reaction_add', 'raw_reaction_add',
check=self.react_check, check=self.react_check,
timeout=self.timeout) timeout=self.timeout)
except asyncio.TimeoutError: except asyncio.TimeoutError:
await self.stop(delete=self.delete_msg_timeout) await self.stop(delete=self.delete_msg_timeout)
continue continue
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."""
if delete is None: if delete is None:
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()
self._stopped = True self._stopped = True
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:
kwargs['content'] = self.text_message kwargs['content'] = self.text_message
if self._message: if self._message:
await self._message.edit(**kwargs) await self._message.edit(**kwargs)
else: else:
self._message = await self.target.send(**kwargs) self._message = await self.target.send(**kwargs)
async def first_page(self): async def first_page(self):
self._page = 0 self._page = 0
await self.format_page() await self.format_page()
async def next_page(self): async def next_page(self):
self._page += 1 self._page += 1
if self._page == len(self.pages): # avoid the inevitable IndexError if self._page == len(self.pages): # avoid the inevitable IndexError
self._page = 0 self._page = 0
await self.format_page() await self.format_page()
async def previous_page(self): async def previous_page(self):
self._page -= 1 self._page -= 1
if self._page < 0: # ditto if self._page < 0: # ditto
self._page = len(self.pages) - 1 self._page = len(self.pages) - 1
await self.format_page() 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): 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 = []
page = '' page = ''
c = 0 c = 0
l = len(_list) l = len(_list)
for i in _list: for i in _list:
if c > l: if c > l:
break break
if c % per_page == 0 and page: if c % per_page == 0 and page:
pages.append(page.strip()) pages.append(page.strip())
page = '' page = ''
page += '{}. {}\n'.format(c+1, i) page += '{}. {}\n'.format(c+1, i)
c += 1 c += 1
pages.append(page.strip()) pages.append(page.strip())
# shut up, IDEA # shut up, IDEA
# noinspection PyArgumentList # noinspection PyArgumentList
super().__init__(ctx, pages, **kwargs) super().__init__(ctx, pages, **kwargs)
self.footer += ' ({} entries)'.format(l) self.footer += ' ({} entries)'.format(l)