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
|
||||
*.jpg
|
||||
*.zip
|
||||
|
||||
# gitpod.io
|
||||
gitpod.yml
|
||||
.vscode
|
22
bot.py
22
bot.py
|
@ -3,11 +3,13 @@
|
|||
# © lambda#0987 <lambda@lambda.dance>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
import discord
|
||||
import nextcord
|
||||
from bot_bin.bot import Bot
|
||||
from discord.ext import commands
|
||||
|
||||
|
@ -17,12 +19,11 @@ logger = logging.getLogger(__name__)
|
|||
logger.setLevel(logging.INFO)
|
||||
|
||||
# SelectorEventLoop on windows doesn't support subprocesses lol
|
||||
import asyncio
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
loop = asyncio.ProactorEventLoop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
|
||||
class Bot(Bot):
|
||||
startup_extensions = (
|
||||
'cogs.emote',
|
||||
|
@ -39,8 +40,10 @@ class Bot(Bot):
|
|||
|
||||
super().__init__(config=config, **kwargs)
|
||||
# allow use of the bot's user ID before ready()
|
||||
token_part0 = self.config['tokens']['discord'].partition('.')[0].encode()
|
||||
self.user_id = int(base64.b64decode(token_part0 + b'=' * (3 - len(token_part0) % 3)))
|
||||
token_part0 = self.config['tokens']['discord'].partition('.')[
|
||||
0].encode()
|
||||
self.user_id = int(base64.b64decode(
|
||||
token_part0 + b'=' * (3 - len(token_part0) % 3)))
|
||||
|
||||
def process_config(self):
|
||||
"""Load the emojis from the config to be used when a command fails or succeeds
|
||||
|
@ -52,6 +55,7 @@ class Bot(Bot):
|
|||
utils.SUCCESS_EMOJIS = utils.misc.SUCCESS_EMOJIS = (
|
||||
self.config.get('response_emojis', {}).get('success', default))
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
|
||||
|
@ -59,14 +63,15 @@ def main():
|
|||
shard_count = None
|
||||
shard_ids = None
|
||||
elif len(sys.argv) < 3:
|
||||
print('Usage:', sys.argv[0], '[<shard count> <hyphen-separated list of shard IDs>]', file=sys.stderr)
|
||||
print(
|
||||
'Usage:', sys.argv[0], '[<shard count> <hyphen-separated list of shard IDs>]', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
shard_count = int(sys.argv[1])
|
||||
shard_ids = list(map(int, sys.argv[2].split('-')))
|
||||
|
||||
Bot(
|
||||
intents=discord.Intents(
|
||||
intents=nextcord.Intents(
|
||||
guilds=True,
|
||||
# we hardly need DM support but it's helpful to be able to run the help/support commands in DMs
|
||||
messages=True,
|
||||
|
@ -78,7 +83,7 @@ def main():
|
|||
|
||||
# the least stateful bot you will ever see 😎
|
||||
chunk_guilds_at_startup=False,
|
||||
member_cache_flags=discord.MemberCacheFlags.none(),
|
||||
member_cache_flags=nextcord.MemberCacheFlags.none(),
|
||||
# disable message cache
|
||||
max_messages=None,
|
||||
|
||||
|
@ -86,5 +91,6 @@ def main():
|
|||
shard_ids=shard_ids,
|
||||
).run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
|
@ -18,9 +18,9 @@ import warnings
|
|||
import weakref
|
||||
|
||||
import aiohttp
|
||||
import discord
|
||||
import nextcord
|
||||
import humanize
|
||||
from discord.ext import commands
|
||||
from nextcord.ext import commands
|
||||
|
||||
import utils
|
||||
import utils.image
|
||||
|
@ -32,16 +32,20 @@ from utils.converter import emote_type_filter_default
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
# guilds can have duplicate emotes, so let us create zips to match
|
||||
warnings.filterwarnings('ignore', module='zipfile', category=UserWarning, message=r"^Duplicate name: .*$")
|
||||
warnings.filterwarnings('ignore', module='zipfile',
|
||||
category=UserWarning, message=r"^Duplicate name: .*$")
|
||||
|
||||
|
||||
class UserCancelledError(commands.UserInputError):
|
||||
pass
|
||||
|
||||
|
||||
class Emotes(commands.Cog):
|
||||
IMAGE_MIMETYPES = {'image/png', 'image/jpeg', 'image/gif', 'image/webp'}
|
||||
# TAR_MIMETYPES = {'application/x-tar', 'application/x-xz', 'application/gzip', 'application/x-bzip2'}
|
||||
TAR_MIMETYPES = {'application/x-tar'}
|
||||
ZIP_MIMETYPES = {'application/zip', 'application/octet-stream', 'application/x-zip-compressed', 'multipart/x-zip'}
|
||||
ZIP_MIMETYPES = {'application/zip', 'application/octet-stream',
|
||||
'application/x-zip-compressed', 'multipart/x-zip'}
|
||||
ARCHIVE_MIMETYPES = TAR_MIMETYPES | ZIP_MIMETYPES
|
||||
ZIP_OVERHEAD_BYTES = 30
|
||||
|
||||
|
@ -57,7 +61,8 @@ class Emotes(commands.Cog):
|
|||
self.http = aiohttp.ClientSession(
|
||||
loop=self.bot.loop,
|
||||
read_timeout=self.bot.config.get('http_read_timeout', 60),
|
||||
connector=connector if self.bot.config.get('use_socks5_for_all_connections') else None,
|
||||
connector=connector if self.bot.config.get(
|
||||
'use_socks5_for_all_connections') else None,
|
||||
headers={
|
||||
'User-Agent':
|
||||
self.bot.config['user_agent'] + ' '
|
||||
|
@ -83,7 +88,9 @@ class Emotes(commands.Cog):
|
|||
self.bot.loop.create_task(close())
|
||||
|
||||
public_commands = set()
|
||||
def public(command, public_commands=public_commands): # resolve some kinda scope issue that i don't understand
|
||||
|
||||
# resolve some kinda scope issue that i don't understand
|
||||
def public(command, public_commands=public_commands):
|
||||
public_commands.add(command.qualified_name)
|
||||
return command
|
||||
|
||||
|
@ -137,7 +144,7 @@ class Emotes(commands.Cog):
|
|||
"""Add a bunch of custom emotes."""
|
||||
|
||||
ran = False
|
||||
# we could use *emotes: discord.PartialEmoji here but that would require spaces between each emote.
|
||||
# we could use *emotes: nextcord.PartialEmoji here but that would require spaces between each emote.
|
||||
# and would fail if any arguments were not valid emotes
|
||||
for match in re.finditer(utils.emote.RE_CUSTOM_EMOTE, ''.join(emotes)):
|
||||
ran = True
|
||||
|
@ -183,12 +190,14 @@ class Emotes(commands.Cog):
|
|||
return name, url
|
||||
|
||||
elif not args:
|
||||
raise commands.BadArgument('Your message had no emotes and no name!')
|
||||
raise commands.BadArgument(
|
||||
'Your message had no emotes and no name!')
|
||||
|
||||
@classmethod
|
||||
def parse_add_command_attachment(cls, context, args):
|
||||
attachment = context.message.attachments[0]
|
||||
name = cls.format_emote_filename(''.join(args) if args else attachment.filename)
|
||||
name = cls.format_emote_filename(
|
||||
''.join(args) if args else attachment.filename)
|
||||
url = attachment.url
|
||||
|
||||
return name, url
|
||||
|
@ -238,7 +247,8 @@ class Emotes(commands.Cog):
|
|||
"""
|
||||
emotes = list(filter(image_type, context.guild.emojis))
|
||||
if not emotes:
|
||||
raise commands.BadArgument('No emotes of that type were found in this server.')
|
||||
raise commands.BadArgument(
|
||||
'No emotes of that type were found in this server.')
|
||||
|
||||
async with context.typing():
|
||||
async for zip_file in self.archive_emotes(context, emotes):
|
||||
|
@ -248,6 +258,7 @@ class Emotes(commands.Cog):
|
|||
filesize_limit = context.guild.filesize_limit
|
||||
discrims = collections.defaultdict(int)
|
||||
downloaded = collections.deque()
|
||||
|
||||
async def download(emote):
|
||||
# don't put two files in the zip with the same name
|
||||
discrims[emote.name] += 1
|
||||
|
@ -269,7 +280,8 @@ class Emotes(commands.Cog):
|
|||
est_size_in_zip = est_zip_overhead + len(data)
|
||||
if est_size_in_zip >= filesize_limit:
|
||||
self.bot.loop.create_task(
|
||||
context.send(f'{emote} could not be added because it alone would exceed the file size limit.')
|
||||
context.send(
|
||||
f'{emote} could not be added because it alone would exceed the file size limit.')
|
||||
)
|
||||
return
|
||||
|
||||
|
@ -294,7 +306,8 @@ class Emotes(commands.Cog):
|
|||
downloaded.appendleft(item)
|
||||
break
|
||||
|
||||
zinfo = zipfile.ZipInfo(name, date_time=created_at.timetuple()[:6])
|
||||
zinfo = zipfile.ZipInfo(
|
||||
name, date_time=created_at.timetuple()[:6])
|
||||
zip.writestr(zinfo, image_data)
|
||||
|
||||
if out.tell() == 0:
|
||||
|
@ -302,7 +315,7 @@ class Emotes(commands.Cog):
|
|||
break
|
||||
|
||||
out.seek(0)
|
||||
yield discord.File(out, f'emotes-{context.guild.id}-{count}.zip')
|
||||
yield nextcord.File(out, f'emotes-{context.guild.id}-{count}.zip')
|
||||
count += 1
|
||||
|
||||
@commands.command(name='import', aliases=['add-zip', 'add-tar', 'add-from-zip', 'add-from-tar'])
|
||||
|
@ -314,7 +327,8 @@ class Emotes(commands.Cog):
|
|||
The rest will be ignored.
|
||||
"""
|
||||
if url and context.message.attachments:
|
||||
raise commands.BadArgument('Either a URL or an attachment must be given, not both.')
|
||||
raise commands.BadArgument(
|
||||
'Either a URL or an attachment must be given, not both.')
|
||||
if not url and not context.message.attachments:
|
||||
raise commands.BadArgument('A URL or attachment must be given.')
|
||||
|
||||
|
@ -328,7 +342,7 @@ class Emotes(commands.Cog):
|
|||
return
|
||||
|
||||
await self.add_from_archive(context, archive)
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
with contextlib.suppress(nextcord.HTTPException):
|
||||
# so they know when we're done
|
||||
await context.message.add_reaction(utils.SUCCESS_EMOJIS[True])
|
||||
|
||||
|
@ -383,7 +397,8 @@ class Emotes(commands.Cog):
|
|||
|
||||
If the image is static and there are not enough free static slots, convert the image to a gif instead.
|
||||
"""
|
||||
counts = collections.Counter(map(operator.attrgetter('animated'), context.guild.emojis))
|
||||
counts = collections.Counter(
|
||||
map(operator.attrgetter('animated'), context.guild.emojis))
|
||||
# >= rather than == because there are sneaky ways to exceed the limit
|
||||
if counts[False] >= context.guild.emoji_limit and counts[True] >= context.guild.emoji_limit:
|
||||
# we raise instead of returning a string in order to abort commands that run this function in a loop
|
||||
|
@ -397,10 +412,10 @@ class Emotes(commands.Cog):
|
|||
|
||||
try:
|
||||
emote = await self.create_emote_from_bytes(context, name, image_data, reason=reason)
|
||||
except discord.InvalidArgument:
|
||||
return discord.utils.escape_mentions(f'{name}: The file supplied was not a valid GIF, PNG, JPEG, or WEBP file.')
|
||||
except discord.HTTPException as ex:
|
||||
return discord.utils.escape_mentions(
|
||||
except nextcord.InvalidArgument:
|
||||
return nextcord.utils.escape_mentions(f'{name}: The file supplied was not a valid GIF, PNG, JPEG, or WEBP file.')
|
||||
except nextcord.HTTPException as ex:
|
||||
return nextcord.utils.escape_mentions(
|
||||
f'{name}: An error occurred while creating the the emote:\n'
|
||||
+ utils.format_http_exception(ex))
|
||||
s = f'Emote {emote} successfully created'
|
||||
|
@ -408,10 +423,12 @@ class Emotes(commands.Cog):
|
|||
|
||||
async def fetch(self, url, valid_mimetypes=IMAGE_MIMETYPES, *, validate_headers=True):
|
||||
valid_mimetypes = valid_mimetypes or self.IMAGE_MIMETYPES
|
||||
|
||||
def validate_headers(response):
|
||||
response.raise_for_status()
|
||||
# some dumb servers also send '; charset=UTF-8' which we should ignore
|
||||
mimetype, options = cgi.parse_header(response.headers.get('Content-Type', ''))
|
||||
mimetype, options = cgi.parse_header(
|
||||
response.headers.get('Content-Type', ''))
|
||||
if mimetype not in valid_mimetypes:
|
||||
raise errors.InvalidFileError
|
||||
|
||||
|
@ -423,9 +440,11 @@ class Emotes(commands.Cog):
|
|||
except aiohttp.ClientResponseError:
|
||||
raise
|
||||
except aiohttp.ClientError as exc:
|
||||
raise errors.EmoteManagerError(f'An error occurred while retrieving the file: {exc}')
|
||||
raise errors.EmoteManagerError(
|
||||
f'An error occurred while retrieving the file: {exc}')
|
||||
|
||||
if validate_headers: await validate(self.http.head(url, timeout=self.bot.config.get('http_head_timeout', 10)))
|
||||
if validate_headers:
|
||||
await validate(self.http.head(url, timeout=self.bot.config.get('http_head_timeout', 10)))
|
||||
return await validate(self.http.get(url))
|
||||
|
||||
async def create_emote_from_bytes(self, context, name, image_data: bytes, *, reason=None):
|
||||
|
@ -448,7 +467,7 @@ class Emotes(commands.Cog):
|
|||
else:
|
||||
for emote in (emote,) + emotes:
|
||||
await context.invoke(self.remove, emote)
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
with contextlib.suppress(nextcord.HTTPException):
|
||||
await context.message.add_reaction(utils.SUCCESS_EMOJIS[True])
|
||||
|
||||
@commands.command(aliases=('mv',))
|
||||
|
@ -463,7 +482,7 @@ class Emotes(commands.Cog):
|
|||
await emote.edit(
|
||||
name=new_name,
|
||||
reason=f'Renamed by {utils.format_user(context.author)}')
|
||||
except discord.HTTPException as ex:
|
||||
except nextcord.HTTPException as ex:
|
||||
return await context.send(
|
||||
'An error occurred while renaming the emote:\n'
|
||||
+ utils.format_http_exception(ex))
|
||||
|
@ -539,11 +558,11 @@ class Emotes(commands.Cog):
|
|||
if match:
|
||||
id = int(match['id'])
|
||||
if local:
|
||||
emote = discord.utils.get(context.guild.emojis, id=id)
|
||||
emote = nextcord.utils.get(context.guild.emojis, id=id)
|
||||
if emote:
|
||||
return emote
|
||||
else:
|
||||
return discord.PartialEmoji(
|
||||
return nextcord.PartialEmoji(
|
||||
animated=bool(match['animated']),
|
||||
name=match['name'],
|
||||
id=int(match['id']),
|
||||
|
@ -552,15 +571,18 @@ class Emotes(commands.Cog):
|
|||
return await self.disambiguate(context, name)
|
||||
|
||||
async def disambiguate(self, context, name):
|
||||
name = name.strip(':') # in case the user tries :foo: and foo is animated
|
||||
candidates = [e for e in context.guild.emojis if e.name.lower() == name.lower() and e.require_colons]
|
||||
# in case the user tries :foo: and foo is animated
|
||||
name = name.strip(':')
|
||||
candidates = [e for e in context.guild.emojis if e.name.lower(
|
||||
) == name.lower() and e.require_colons]
|
||||
if not candidates:
|
||||
raise errors.EmoteNotFoundError(name)
|
||||
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
|
||||
message = ['Multiple emotes were found with that name. Which one do you mean?']
|
||||
message = [
|
||||
'Multiple emotes were found with that name. Which one do you mean?']
|
||||
for i, emote in enumerate(candidates, 1):
|
||||
message.append(fr'{i}. {emote} (\:{emote.name}:)')
|
||||
|
||||
|
@ -577,9 +599,11 @@ class Emotes(commands.Cog):
|
|||
try:
|
||||
message = await self.bot.wait_for('message', check=check, timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
raise commands.UserInputError('Sorry, you took too long. Try again.')
|
||||
raise commands.UserInputError(
|
||||
'Sorry, you took too long. Try again.')
|
||||
|
||||
return candidates[int(message.content)-1]
|
||||
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(Emotes(bot))
|
||||
|
|
30
cogs/meta.py
30
cogs/meta.py
|
@ -3,11 +3,12 @@
|
|||
|
||||
import contextlib
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
import nextcord
|
||||
from nextcord.ext import commands
|
||||
|
||||
import utils
|
||||
|
||||
|
||||
class Meta(commands.Cog):
|
||||
# TODO does this need to be configurable?
|
||||
INVITE_DURATION_SECONDS = 60 * 60 * 3
|
||||
|
@ -39,33 +40,24 @@ class Meta(commands.Cog):
|
|||
|
||||
try:
|
||||
await context.author.send(f'Official support server invite: {invite}')
|
||||
except discord.Forbidden:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
except nextcord.Forbidden:
|
||||
with contextlib.suppress(nextcord.HTTPException):
|
||||
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.')
|
||||
else:
|
||||
try:
|
||||
await context.message.add_reaction('📬')
|
||||
except discord.HTTPException:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
except nextcord.HTTPException:
|
||||
with contextlib.suppress(nextcord.HTTPException):
|
||||
await context.send('📬')
|
||||
|
||||
@commands.command(aliases=['inv'])
|
||||
@commands.command(aliases=['inv']) # https://discordapi.com/permissions.html
|
||||
async def invite(self, context):
|
||||
"""Gives you a link to add me to your server."""
|
||||
permissions = discord.Permissions()
|
||||
permissions.update(**dict.fromkeys((
|
||||
'read_messages',
|
||||
'send_messages',
|
||||
'add_reactions',
|
||||
'external_emojis',
|
||||
'manage_emojis',
|
||||
'embed_links',
|
||||
'attach_files',
|
||||
), True))
|
||||
|
||||
await context.send('<%s>' % discord.utils.oauth_url(self.bot.user.id, permissions))
|
||||
await context.send('<%s>' % nextcord.utils.oauth_url(self.bot.user.id, permissions=nextcord.Permissions(permissions=1074056256)))
|
||||
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(Meta(bot))
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
'prefixes': ['em/'],
|
||||
|
||||
'tokens': {
|
||||
'discord': 'sek.rit.token',
|
||||
'discord': 'bot.token',
|
||||
},
|
||||
|
||||
'ignore_bots': {
|
||||
|
@ -27,11 +27,15 @@
|
|||
|
||||
'copyright_license_file': 'data/short-license.txt',
|
||||
|
||||
'socks5_proxy_url': None, # required for connecting to the EC API over a Tor onion service
|
||||
'use_socks5_for_all_connections': False, # whether to use socks5 for all HTTP operations (other than discord.py)
|
||||
'user_agent': 'EmoteManagerBot (https://github.com/iomintz/emote-manager-bot)',
|
||||
'ec_api_base_url': None, # set to None to use the default of https://ec.emote.bot/api/v0
|
||||
'http_head_timeout': 10, # timeout for the initial HEAD request before retrieving any images (up this if using Tor)
|
||||
# required for connecting to the EC API over a Tor onion service
|
||||
'socks5_proxy_url': None,
|
||||
# whether to use socks5 for all HTTP operations (other than discord.py)
|
||||
'use_socks5_for_all_connections': False,
|
||||
'user_agent': 'EmoteManagerBot (https://github.com/uhIgnacio/EmoteManager)',
|
||||
# set to None to use the default of https://ec.emote.bot/api/v0
|
||||
'ec_api_base_url': None,
|
||||
# timeout for the initial HEAD request before retrieving any images (up this if using Tor)
|
||||
'http_head_timeout': 10,
|
||||
'http_read_timeout': 60, # timeout for retrieving an image
|
||||
|
||||
# emotes that the bot may use to respond to you
|
||||
|
@ -42,8 +46,8 @@
|
|||
# so that both the staging and the stable versions of the bot can use them
|
||||
'response_emojis': {
|
||||
'success': { # emotes used to indicate success or failure
|
||||
False: '<:error:478164511879069707>',
|
||||
True: '<:success:478164452261363712>'
|
||||
False: '', # <:EmoteName:ID> or '❌'
|
||||
True: '' # <:EmoteName:ID> or '✅'
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -10,5 +10,5 @@ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You may find a copy of the GNU Affero General Public License at https://github.com/EmoteBot/EmoteManager/blob/master/LICENSE.md.
|
||||
You may find a copy of the GNU Affero General Public License at https://github.com/uhIgnacio/EmoteManager/blob/master/LICENSE.md.
|
||||
The rest of the source code is also there.
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
aiohttp_socks
|
||||
bot_bin>=1.5.0,<2.0.0
|
||||
discord.py>=1.5.0,<2.0.0
|
||||
bot_bin
|
||||
nextcord
|
||||
jishaku
|
||||
wand
|
||||
|
|
|
@ -14,6 +14,7 @@ from . import errors
|
|||
|
||||
ArchiveInfo = collections.namedtuple('ArchiveInfo', 'filename content error')
|
||||
|
||||
|
||||
def extract(archive: typing.io.BinaryIO, *, size_limit=None) \
|
||||
-> Iterable[Tuple[str, Optional[bytes], Optional[BaseException]]]:
|
||||
"""
|
||||
|
@ -39,6 +40,7 @@ def extract(archive: typing.io.BinaryIO, *, size_limit=None) \
|
|||
finally:
|
||||
archive.seek(0)
|
||||
|
||||
|
||||
def extract_zip(archive, *, size_limit=None):
|
||||
with zipfile.ZipFile(archive) as zip:
|
||||
members = [m for m in zip.infolist() if not m.is_dir()]
|
||||
|
@ -57,6 +59,7 @@ def extract_zip(archive, *, size_limit=None):
|
|||
else: # this else is required to avoid UnboundLocalError for some reason
|
||||
yield ArchiveInfo(filename=member.filename, content=content, error=None)
|
||||
|
||||
|
||||
def extract_tar(archive, *, size_limit=None):
|
||||
with tarfile.open(fileobj=archive) as tar:
|
||||
members = [f for f in tar.getmembers() if f.isfile()]
|
||||
|
@ -70,10 +73,12 @@ def extract_tar(archive, *, size_limit=None):
|
|||
|
||||
yield ArchiveInfo(member.name, content=tar.extractfile(member).read(), error=None)
|
||||
|
||||
|
||||
async def extract_async(archive: typing.io.BinaryIO, size_limit=None):
|
||||
for x in extract(archive, size_limit=size_limit):
|
||||
yield await asyncio.sleep(0, x)
|
||||
|
||||
|
||||
def main():
|
||||
import io
|
||||
import sys
|
||||
|
@ -88,5 +93,6 @@ def main():
|
|||
|
||||
print(f'{name}: {humanize.naturalsize(len(data)):>10}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import functools
|
||||
from discord.ext.commands import BadArgument
|
||||
from nextcord.ext.commands import BadArgument
|
||||
|
||||
_emote_type_predicates = {
|
||||
'all': lambda _: True,
|
||||
|
@ -10,6 +10,8 @@ _emote_type_predicates = {
|
|||
'animated': lambda e: e.animated}
|
||||
|
||||
# this is kind of a hack to ensure that the last argument is always converted, even if the default is used.
|
||||
|
||||
|
||||
def emote_type_filter_default(command):
|
||||
old_callback = command.callback
|
||||
|
||||
|
@ -19,7 +21,8 @@ def emote_type_filter_default(command):
|
|||
try:
|
||||
image_type = _emote_type_predicates[image_type]
|
||||
except KeyError:
|
||||
raise BadArgument(f'Invalid emote type. Specify one of "all", "static", or "animated".')
|
||||
raise BadArgument(
|
||||
f'Invalid emote type. Specify one of "all", "static", or "animated".')
|
||||
return await old_callback(self, ctx, *args[:-1], image_type)
|
||||
|
||||
command.callback = callback
|
||||
|
|
|
@ -11,7 +11,9 @@ various utilities related to custom emotes
|
|||
RE_EMOTE = re.compile(r'(:|;)(?P<name>\w{2,32})\1|(?P<newline>\n)', re.ASCII)
|
||||
|
||||
"""Matches only custom server emotes."""
|
||||
RE_CUSTOM_EMOTE = re.compile(r'<(?P<animated>a?):(?P<name>\w{2,32}):(?P<id>\d{17,})>', re.ASCII)
|
||||
RE_CUSTOM_EMOTE = re.compile(
|
||||
r'<(?P<animated>a?):(?P<name>\w{2,32}):(?P<id>\d{17,})>', re.ASCII)
|
||||
|
||||
|
||||
def url(id, *, animated: bool = False):
|
||||
"""Convert an emote ID to the image URL for that emote."""
|
||||
|
|
|
@ -10,13 +10,14 @@ import datetime
|
|||
import urllib.parse
|
||||
from typing import Dict
|
||||
from http import HTTPStatus
|
||||
from discord import PartialEmoji
|
||||
from nextcord import PartialEmoji
|
||||
import utils.image as image_utils
|
||||
from utils.errors import RateLimitedError
|
||||
from discord import HTTPException, Forbidden, NotFound, DiscordServerError
|
||||
from nextcord import HTTPException, Forbidden, NotFound, DiscordServerError
|
||||
|
||||
GuildId = int
|
||||
|
||||
|
||||
async def json_or_text(resp):
|
||||
text = await resp.text(encoding='utf-8')
|
||||
try:
|
||||
|
@ -28,8 +29,9 @@ async def json_or_text(resp):
|
|||
|
||||
return text
|
||||
|
||||
|
||||
class EmoteClient:
|
||||
BASE_URL = 'https://discord.com/api/v7'
|
||||
BASE_URL = 'https://discord.com/api/v9'
|
||||
HTTP_ERROR_CLASSES = {
|
||||
HTTPStatus.FORBIDDEN: Forbidden,
|
||||
HTTPStatus.NOT_FOUND: NotFound,
|
||||
|
@ -51,7 +53,8 @@ class EmoteClient:
|
|||
# Emote Manager shouldn't use walrus op until Debian adopts 3.8 :(
|
||||
reason = kwargs.pop('reason', None)
|
||||
if reason:
|
||||
headers['X-Audit-Log-Reason'] = urllib.parse.quote(reason, safe='/ ')
|
||||
headers['X-Audit-Log-Reason'] = urllib.parse.quote(
|
||||
reason, safe='/ ')
|
||||
kwargs['headers'] = headers
|
||||
|
||||
# TODO handle OSError and 500/502, like dpy does
|
||||
|
@ -83,7 +86,8 @@ class EmoteClient:
|
|||
|
||||
async def _handle_rl(self, resp, method, path, guild_id, **kwargs):
|
||||
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
|
||||
self.guild_rls[guild_id] = retry_at.timestamp()
|
||||
|
@ -100,7 +104,8 @@ class EmoteClient:
|
|||
data = await self.request(
|
||||
'POST', f'/guilds/{guild.id}/emojis',
|
||||
guild.id,
|
||||
json=dict(name=name, image=image_utils.image_to_base64_url(image), roles=role_ids),
|
||||
json=dict(name=name, image=image_utils.image_to_base64_url(
|
||||
image), roles=role_ids),
|
||||
reason=reason,
|
||||
)
|
||||
return PartialEmoji(animated=data.get('animated', False), name=data.get('name'), id=data.get('id'))
|
||||
|
|
|
@ -5,7 +5,8 @@ import utils
|
|||
import asyncio
|
||||
import humanize
|
||||
import datetime
|
||||
from discord.ext import commands
|
||||
from nextcord.ext import commands
|
||||
|
||||
|
||||
class MissingManageEmojisPermission(commands.MissingPermissions):
|
||||
"""The invoker or the bot doesn't have permissions to manage server emojis."""
|
||||
|
@ -16,62 +17,86 @@ class MissingManageEmojisPermission(commands.MissingPermissions):
|
|||
"Sorry, you don't have enough permissions to run this command. "
|
||||
'You and I both need the Manage Emojis permission.')
|
||||
|
||||
|
||||
class EmoteManagerError(commands.CommandError):
|
||||
"""Generic error with the bot. This can be used to catch all bot errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ImageProcessingTimeoutError(EmoteManagerError, asyncio.TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
class ImageResizeTimeoutError(ImageProcessingTimeoutError):
|
||||
"""Resizing the image took too long."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('Error: resizing the image took too long.')
|
||||
|
||||
|
||||
class ImageConversionTimeoutError(ImageProcessingTimeoutError):
|
||||
def __init__(self):
|
||||
super().__init__('Error: converting the image to a GIF took too long.')
|
||||
|
||||
|
||||
class HTTPException(EmoteManagerError):
|
||||
"""The server did not respond with an OK status code. This is only for non-Discord HTTP requests."""
|
||||
|
||||
def __init__(self, status):
|
||||
super().__init__(f'URL error: server returned error code {status}')
|
||||
|
||||
|
||||
class RateLimitedError(EmoteManagerError):
|
||||
def __init__(self, retry_at):
|
||||
if isinstance(retry_at, float):
|
||||
# it took me about an HOUR to realize i had to pass tz because utcfromtimestamp returns a NAÏVE time obj!
|
||||
retry_at = datetime.datetime.fromtimestamp(retry_at, tz=datetime.timezone.utc)
|
||||
retry_at = datetime.datetime.fromtimestamp(
|
||||
retry_at, tz=datetime.timezone.utc)
|
||||
# humanize.naturaltime is annoying to work with due to timezones so we use this
|
||||
delta = humanize.naturaldelta(retry_at, when=datetime.datetime.now(tz=datetime.timezone.utc))
|
||||
super().__init__(f'Error: Discord told me to slow down! Please retry this command in {delta}.')
|
||||
delta = humanize.naturaldelta(
|
||||
retry_at, when=datetime.datetime.now(tz=datetime.timezone.utc))
|
||||
super().__init__(
|
||||
f'Error: Discord told me to slow down! Please retry this command in {delta}.')
|
||||
|
||||
|
||||
class EmoteNotFoundError(EmoteManagerError):
|
||||
"""An emote with that name was not found"""
|
||||
|
||||
def __init__(self, name):
|
||||
super().__init__(f'An emote called `{name}` does not exist in this server.')
|
||||
super().__init__(
|
||||
f'An emote called `{name}` does not exist in this server.')
|
||||
|
||||
|
||||
class FileTooBigError(EmoteManagerError):
|
||||
def __init__(self, size, limit):
|
||||
self.size = size
|
||||
self.limit = limit
|
||||
|
||||
|
||||
class InvalidFileError(EmoteManagerError):
|
||||
"""The file is not a zip, tar, GIF, PNG, JPG, or WEBP file."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('Invalid file given.')
|
||||
|
||||
|
||||
class InvalidImageError(InvalidFileError):
|
||||
"""The image is not a GIF, PNG, or JPG"""
|
||||
|
||||
def __init__(self):
|
||||
super(Exception, self).__init__('The image supplied was not a GIF, PNG, JPG, or WEBP file.')
|
||||
super(Exception, self).__init__(
|
||||
'The image supplied was not a GIF, PNG, JPG, or WEBP file.')
|
||||
|
||||
|
||||
class PermissionDeniedError(EmoteManagerError):
|
||||
"""Raised when a user tries to modify an emote without the Manage Emojis permission"""
|
||||
|
||||
def __init__(self, name):
|
||||
super().__init__(f"You're not authorized to modify `{name}`.")
|
||||
|
||||
|
||||
class DiscordError(Exception):
|
||||
"""Usually raised when the client cache is being baka"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('Discord seems to be having issues right now, please try again later.')
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# © lambda#0987 <lambda@lambda.dance>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from utils import errors
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
|
@ -16,11 +17,11 @@ logger = logging.getLogger(__name__)
|
|||
try:
|
||||
import wand.image
|
||||
except (ImportError, OSError):
|
||||
logger.warn('Failed to import wand.image. Image manipulation functions will be unavailable.')
|
||||
logger.warn(
|
||||
'Failed to import wand.image. Image manipulation functions will be unavailable.')
|
||||
else:
|
||||
import wand.exceptions
|
||||
|
||||
from utils import errors
|
||||
|
||||
def resize_until_small(image_data: io.BytesIO) -> None:
|
||||
"""If the image_data is bigger than 256KB, resize it until it's not."""
|
||||
|
@ -37,10 +38,12 @@ def resize_until_small(image_data: io.BytesIO) -> None:
|
|||
with wand.image.Image(blob=image_data) as original_image:
|
||||
while True:
|
||||
logger.debug('image size too big (%s bytes)', image_size)
|
||||
logger.debug('attempting resize to at most%s*%s pixels', max_resolution, max_resolution)
|
||||
logger.debug('attempting resize to at most%s*%s pixels',
|
||||
max_resolution, max_resolution)
|
||||
|
||||
with original_image.clone() as resized:
|
||||
resized.transform(resize=f'{max_resolution}x{max_resolution}')
|
||||
resized.transform(
|
||||
resize=f'{max_resolution}x{max_resolution}')
|
||||
image_size = len(resized.make_blob())
|
||||
if image_size <= 256 * 2**10 or max_resolution < 32: # don't resize past 256KiB or 32×32
|
||||
image_data.truncate(0)
|
||||
|
@ -53,6 +56,7 @@ def resize_until_small(image_data: io.BytesIO) -> None:
|
|||
except wand.exceptions.CoderError:
|
||||
raise errors.InvalidImageError
|
||||
|
||||
|
||||
def convert_to_gif(image_data: io.BytesIO) -> None:
|
||||
try:
|
||||
with wand.image.Image(blob=image_data) as orig, orig.convert('gif') as converted:
|
||||
|
@ -68,6 +72,7 @@ def convert_to_gif(image_data: io.BytesIO) -> None:
|
|||
except wand.exceptions.CoderError:
|
||||
raise errors.InvalidImageError
|
||||
|
||||
|
||||
def mime_type_for_image(data):
|
||||
if data.startswith(b'\x89PNG\r\n\x1a\n'):
|
||||
return 'image/png'
|
||||
|
@ -79,12 +84,14 @@ def mime_type_for_image(data):
|
|||
return 'image/webp'
|
||||
raise errors.InvalidImageError
|
||||
|
||||
|
||||
def image_to_base64_url(data):
|
||||
fmt = 'data:{mime};base64,{data}'
|
||||
mime = mime_type_for_image(data)
|
||||
b64 = base64.b64encode(data).decode('ascii')
|
||||
return fmt.format(mime=mime, data=b64)
|
||||
|
||||
|
||||
def main() -> typing.NoReturn:
|
||||
"""resize or convert an image from stdin and write the resized or converted version to stdout."""
|
||||
import sys
|
||||
|
@ -114,6 +121,7 @@ def main() -> typing.NoReturn:
|
|||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
async def process_image_in_subprocess(command_name, image_data: bytes):
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable, '-m', __name__, command_name,
|
||||
|
@ -131,12 +139,15 @@ async def process_image_in_subprocess(command_name, image_data: bytes):
|
|||
if proc.returncode == 2:
|
||||
raise errors.InvalidImageError
|
||||
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
|
||||
|
||||
resize_in_subprocess = functools.partial(process_image_in_subprocess, 'resize')
|
||||
convert_to_gif_in_subprocess = functools.partial(process_image_in_subprocess, 'convert')
|
||||
convert_to_gif_in_subprocess = functools.partial(
|
||||
process_image_in_subprocess, 'convert')
|
||||
|
||||
|
||||
def size(fp):
|
||||
"""return the size, in bytes, of the data a file-like object represents"""
|
||||
|
@ -144,6 +155,7 @@ def size(fp):
|
|||
fp.seek(0, io.SEEK_END)
|
||||
return fp.tell()
|
||||
|
||||
|
||||
class preserve_position(contextlib.AbstractContextManager):
|
||||
def __init__(self, fp):
|
||||
self.fp = fp
|
||||
|
@ -152,5 +164,6 @@ class preserve_position(contextlib.AbstractContextManager):
|
|||
def __exit__(self, *excinfo):
|
||||
self.fp.seek(self.old_pos)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
import nextcord
|
||||
|
||||
|
||||
def format_user(user, *, mention=False):
|
||||
"""Format a user object for audit log purposes."""
|
||||
|
@ -18,8 +19,9 @@ def format_user(user, *, mention=False):
|
|||
else:
|
||||
return f'@{user} ({user.id})'
|
||||
|
||||
def format_http_exception(exception: discord.HTTPException):
|
||||
"""Formats a discord.HTTPException for relaying to the user.
|
||||
|
||||
def format_http_exception(exception: nextcord.HTTPException):
|
||||
"""Formats a nextcord.HTTPException for relaying to the user.
|
||||
Sample return value:
|
||||
|
||||
BAD REQUEST (status code: 400):
|
||||
|
@ -30,6 +32,7 @@ def format_http_exception(exception: discord.HTTPException):
|
|||
f'{exception.response.reason} (status code: {exception.response.status}):'
|
||||
f'\n{exception.text}')
|
||||
|
||||
|
||||
def strip_angle_brackets(string):
|
||||
"""Strip leading < and trailing > from a string.
|
||||
Useful if a user sends you a url like <this> to avoid embeds, or to convert emotes to reactions."""
|
||||
|
@ -37,6 +40,7 @@ def strip_angle_brackets(string):
|
|||
return string[1:-1]
|
||||
return string
|
||||
|
||||
|
||||
async def gather_or_cancel(*awaitables, loop=None):
|
||||
"""run the awaitables in the sequence concurrently. If any of them raise an exception,
|
||||
propagate the first exception raised and cancel all other awaitables.
|
||||
|
|
|
@ -5,12 +5,13 @@ import asyncio
|
|||
import contextlib
|
||||
import typing
|
||||
|
||||
import discord
|
||||
from discord.ext.commands import Context
|
||||
import nextcord
|
||||
from nextcord.ext.commands import Context
|
||||
|
||||
# Copyright © 2016-2017 Pandentia and contributors
|
||||
# https://github.com/Thessia/Liara/blob/75fa11948b8b2ea27842d8815a32e51ef280a999/cogs/utils/paginator.py
|
||||
|
||||
|
||||
class Paginator:
|
||||
def __init__(self, ctx: Context, pages: typing.Iterable, *, timeout=300, delete_message=False,
|
||||
delete_message_on_timeout=False, text_message=None):
|
||||
|
@ -39,7 +40,7 @@ class Paginator:
|
|||
|
||||
self._page = None
|
||||
|
||||
def react_check(self, reaction: discord.RawReactionActionEvent):
|
||||
def react_check(self, reaction: nextcord.RawReactionActionEvent):
|
||||
if reaction.user_id != self.author.id:
|
||||
return False
|
||||
|
||||
|
@ -47,12 +48,12 @@ class Paginator:
|
|||
return False
|
||||
|
||||
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):
|
||||
"""Starts pagination"""
|
||||
self._stopped = False
|
||||
self._embed = discord.Embed()
|
||||
self._embed = nextcord.Embed()
|
||||
await self.first_page()
|
||||
for button in self.navigation:
|
||||
await self._message.add_reaction(button)
|
||||
|
@ -69,8 +70,8 @@ class Paginator:
|
|||
await self.navigation[str(reaction.emoji)]()
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await self._message.remove_reaction(reaction.emoji, discord.Object(reaction.user_id))
|
||||
with contextlib.suppress(nextcord.HTTPException):
|
||||
await self._message.remove_reaction(reaction.emoji, nextcord.Object(reaction.user_id))
|
||||
|
||||
async def stop(self, *, delete=None):
|
||||
"""Aborts pagination."""
|
||||
|
@ -78,7 +79,7 @@ class Paginator:
|
|||
delete = self.delete_msg
|
||||
|
||||
if delete:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
with contextlib.suppress(nextcord.HTTPException):
|
||||
await self._message.delete()
|
||||
else:
|
||||
await self._clear_reactions()
|
||||
|
@ -87,16 +88,17 @@ class Paginator:
|
|||
async def _clear_reactions(self):
|
||||
try:
|
||||
await self._message.clear_reactions()
|
||||
except discord.Forbidden:
|
||||
except nextcord.Forbidden:
|
||||
for button in self.navigation:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
with contextlib.suppress(nextcord.HTTPException):
|
||||
await self._message.remove_reaction(button, self._message.author)
|
||||
except discord.HTTPException:
|
||||
except nextcord.HTTPException:
|
||||
pass
|
||||
|
||||
async def format_page(self):
|
||||
self._embed.description = self.pages[self._page]
|
||||
self._embed.set_footer(text=self.footer.format(self._page + 1, len(self.pages)))
|
||||
self._embed.set_footer(text=self.footer.format(
|
||||
self._page + 1, len(self.pages)))
|
||||
|
||||
kwargs = {'embed': self._embed}
|
||||
if self.text_message:
|
||||
|
@ -127,6 +129,7 @@ class Paginator:
|
|||
self._page = len(self.pages) - 1
|
||||
await self.format_page()
|
||||
|
||||
|
||||
class ListPaginator(Paginator):
|
||||
def __init__(self, ctx, _list: list, per_page=10, **kwargs):
|
||||
pages = []
|
||||
|
|
Loading…
Reference in New Issue