Compare commits
14 Commits
e295b159c5
...
5ce2a12a96
Author | SHA1 | Date |
---|---|---|
in the moon | 5ce2a12a96 | |
in the moon | c5e536875e | |
in the moon | b76b973c01 | |
in the moon | 2184e69667 | |
in the moon | fa118d6880 | |
in the moon | 5c6ced1f73 | |
igna | 3f73309974 | |
igna | 1ff3e93938 | |
igna | e560568a35 | |
igna | 603a7e177a | |
igna | 51086a3e0b | |
igna | 35f019a490 | |
igna | 84c5276076 | |
igna | 3eaac0148d |
|
@ -15,3 +15,7 @@ venv/
|
||||||
*.gif
|
*.gif
|
||||||
*.jpg
|
*.jpg
|
||||||
*.zip
|
*.zip
|
||||||
|
|
||||||
|
# gitpod.io
|
||||||
|
gitpod.yml
|
||||||
|
.vscode
|
140
LICENSE.md
140
LICENSE.md
|
@ -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
|
||||||
|
|
122
bot.py
122
bot.py
|
@ -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()
|
||||||
|
|
1118
cogs/emote.py
1118
cogs/emote.py
File diff suppressed because it is too large
Load Diff
100
cogs/meta.py
100
cogs/meta.py
|
@ -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')
|
||||||
|
|
|
@ -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 '✅'
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
202974
data/ec-emotes-final.json
202974
data/ec-emotes-final.json
File diff suppressed because it is too large
Load Diff
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
120
utils/archive.py
120
utils/archive.py
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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()
|
||||||
|
|
111
utils/errors.py
111
utils/errors.py
|
@ -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.')
|
||||||
|
|
219
utils/image.py
219
utils/image.py
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue