format and nextcord

This commit is contained in:
igna 2021-09-07 14:22:54 +00:00
parent 3eaac0148d
commit 84c5276076
3 changed files with 679 additions and 645 deletions

20
bot.py
View File

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

View File

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

View File

@ -27,11 +27,15 @@
'copyright_license_file': 'data/short-license.txt', 'copyright_license_file': 'data/short-license.txt',
'socks5_proxy_url': None, # required for connecting to the EC API over a Tor onion service # required for connecting to the EC API over a Tor onion service
'use_socks5_for_all_connections': False, # whether to use socks5 for all HTTP operations (other than discord.py) 'socks5_proxy_url': None,
# whether to use socks5 for all HTTP operations (other than discord.py)
'use_socks5_for_all_connections': False,
'user_agent': 'EmoteManagerBot (https://github.com/iomintz/emote-manager-bot)', '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 # 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) 'ec_api_base_url': None,
# timeout for the initial HEAD request before retrieving any images (up this if using Tor)
'http_head_timeout': 10,
'http_read_timeout': 60, # timeout for retrieving an image 'http_read_timeout': 60, # timeout for retrieving an image
# emotes that the bot may use to respond to you # emotes that the bot may use to respond to you