diff --git a/bot.py b/bot.py index d73cad6..1337033 100755 --- a/bot.py +++ b/bot.py @@ -28,6 +28,7 @@ l_extensions: List[str] = [ 'cogs.utility', 'cogs.logs', 'cogs.poll', + 'cogs.help', 'jishaku', ] @@ -38,8 +39,9 @@ async def _prefix_callable(bot, message: discord.message) -> list: extras = [] if str(message.guild.id) in bot.prefixes: extras.extend( - bot.prefixes.get(str(message.guild.id), "prefixes") - .split('-sep-') + bot.prefixes.get(str(message.guild.id), "prefixes").split( + bot.config.get("misc", "separator") + ) ) return commands.when_mentioned_or(*extras)(bot, message) @@ -67,7 +69,7 @@ class TuxBot(commands.AutoShardedBot): self.prefixes = Config('./configs/prefixes.cfg') self.blacklist = Config('./configs/blacklist.cfg') - self.version = Version(10, 1, 0, pre_release='a0', build=build) + self.version = Version(10, 1, 0, pre_release='a5', build=build) for extension in l_extensions: try: @@ -83,7 +85,8 @@ class TuxBot(commands.AutoShardedBot): + extension, exc_info=e) async def is_owner(self, user: discord.User) -> bool: - return str(user.id) in self.config.get("permissions", "owners").split(',') + return str(user.id) in self.config.get("permissions", "owners").split( + ',') async def on_socket_response(self, msg): self._prev_events.append(msg) diff --git a/cogs/admin.py b/cogs/admin.py index 812e1f7..d8b2b95 100644 --- a/cogs/admin.py +++ b/cogs/admin.py @@ -10,6 +10,7 @@ from discord.ext import commands from bot import TuxBot from .utils.lang import Texts +from .utils.extra import commandExtra, groupExtra from .utils.models import Warn, Lang log = logging.getLogger(__name__) @@ -61,9 +62,10 @@ class Admin(commands.Cog): return e - """---------------------------------------------------------------------""" + ########################################################################### - @commands.group(name='say', invoke_without_command=True) + @groupExtra(name='say', invoke_without_command=True, category='admin', + description=Texts('commands').get('admin._say')) async def _say(self, ctx: commands.Context, *, content: str): if ctx.invoked_subcommand is None: try: @@ -73,7 +75,8 @@ class Admin(commands.Cog): await ctx.send(content) - @_say.command(name='edit') + @_say.command(name='edit', + description=Texts('commands').get('admin._say_edit')) async def _say_edit(self, ctx: commands.Context, message_id: int, *, content: str): try: @@ -90,7 +93,8 @@ class Admin(commands.Cog): Texts('utils', ctx).get("Unable to find the message"), delete_after=5) - @_say.command(name='to') + @_say.command(name='to', + description=Texts('commands').get('admin._say_to')) async def _say_to(self, ctx: commands.Context, channel: Union[discord.TextChannel, discord.User], *, content): @@ -101,9 +105,10 @@ class Admin(commands.Cog): await channel.send(content) - """---------------------------------------------------------------------""" + ########################################################################### - @commands.command(name='ban') + @commandExtra(name='ban', category='admin', + description=Texts('commands').get('admin._ban')) async def _ban(self, ctx: commands.Context, user: discord.Member, *, reason=""): try: @@ -128,9 +133,10 @@ class Admin(commands.Cog): Texts('utils', ctx).get("Unable to find the user..."), delete_after=5) - """---------------------------------------------------------------------""" + ########################################################################### - @commands.command(name='kick') + @commandExtra(name='kick', category='admin', + description=Texts('commands').get('admin._kick')) async def _kick(self, ctx: commands.Context, user: discord.Member, *, reason=""): try: @@ -155,9 +161,10 @@ class Admin(commands.Cog): Texts('utils', ctx).get("Unable to find the user..."), delete_after=5) - """---------------------------------------------------------------------""" + ########################################################################### - @commands.command(name='clear') + @commandExtra(name='clear', category='admin', + description=Texts('commands').get('admin._clear')) async def _clear(self, ctx: commands.Context, count: int): try: await ctx.message.delete() @@ -165,14 +172,16 @@ class Admin(commands.Cog): except discord.errors.Forbidden: pass - """---------------------------------------------------------------------""" + ########################################################################### - @commands.group(name='react') + @groupExtra(name='react', category='admin', + description=Texts('commands').get('admin._react')) async def _react(self, ctx: commands.Context): if ctx.invoked_subcommand is None: - return + await ctx.send_help('react') - @_react.command(name='add') + @_react.command(name='add', + description=Texts('commands').get('admin._react_add')) async def _react_add(self, ctx: commands.Context, message_id: int, *, emojis: str): emojis: list = emojis.split(' ') @@ -188,7 +197,8 @@ class Admin(commands.Cog): Texts('utils', ctx).get("Unable to find the message"), delete_after=5) - @_react.command(name='clear') + @_react.command(name='clear', + description=Texts('commands').get('admin._react_remove')) async def _react_remove(self, ctx: commands.Context, message_id: int): try: message: discord.Message = await ctx.channel.fetch_message( @@ -199,9 +209,11 @@ class Admin(commands.Cog): Texts('utils', ctx).get("Unable to find the message"), delete_after=5) - """---------------------------------------------------------------------""" + ########################################################################### - @commands.group(name='delete', invoke_without_command=True) + @groupExtra(name='delete', invoke_without_command=True, + category='admin', + description=Texts('commands').get('admin._delete')) async def _delete(self, ctx: commands.Context, message_id: int): try: await ctx.message.delete() @@ -217,7 +229,8 @@ class Admin(commands.Cog): Texts('utils', ctx).get("Unable to find the message"), delete_after=5) - @_delete.command(name='from', aliases=['to', 'in']) + @_delete.command(name='from', aliases=['to', 'in'], + description=Texts('commands').get('admin._delete_from')) async def _delete_from(self, ctx: commands.Context, channel: discord.TextChannel, message_id: int): try: @@ -234,7 +247,7 @@ class Admin(commands.Cog): Texts('utils', ctx).get("Unable to find the message"), delete_after=5) - """---------------------------------------------------------------------""" + ########################################################################### async def get_warn(self, ctx: commands.Context, member: discord.Member = False): @@ -279,7 +292,8 @@ class Admin(commands.Cog): self.bot.database.session.add(warn) self.bot.database.session.commit() - @commands.group(name='warn', aliases=['warns']) + @groupExtra(name='warn', aliases=['warns'], category='admin', + description=Texts('commands').get('admin._warn')) async def _warn(self, ctx: commands.Context): await ctx.trigger_typing() if ctx.invoked_subcommand is None: @@ -291,7 +305,8 @@ class Admin(commands.Cog): await ctx.send(embed=e) - @_warn.command(name='add', aliases=['new']) + @_warn.command(name='add', aliases=['new'], + description=Texts('commands').get('admin._warn_new')) async def _warn_new(self, ctx: commands.Context, member: discord.Member, *, reason="N/A"): member = await ctx.guild.fetch_member(member.id) @@ -370,7 +385,8 @@ class Admin(commands.Cog): f"\n**{Texts('admin', ctx).get('Reason')}:** `{reason}`" ) - @_warn.command(name='remove', aliases=['revoke']) + @_warn.command(name='remove', aliases=['revoke'], + description=Texts('commands').get('admin._warn_remove')) async def _warn_remove(self, ctx: commands.Context, warn_id: int): warn = self.bot.database.session \ .query(Warn) \ @@ -382,7 +398,8 @@ class Admin(commands.Cog): await ctx.send(f"{Texts('admin', ctx).get('Warn with id')} `{warn_id}`" f" {Texts('admin', ctx).get('successfully removed')}") - @_warn.command(name='show', aliases=['list']) + @_warn.command(name='show', aliases=['list'], + description=Texts('commands').get('admin._warn_show')) async def _warn_show(self, ctx: commands.Context, member: discord.Member): warns_list, warns = await self.get_warn(ctx, member) @@ -393,7 +410,8 @@ class Admin(commands.Cog): await ctx.send(embed=e) - @_warn.command(name='edit', aliases=['change']) + @_warn.command(name='edit', aliases=['change'], + description=Texts('commands').get('admin._warn_edit')) async def _warn_edit(self, ctx: commands.Context, warn_id: int, *, reason): warn = self.bot.database.session \ .query(Warn) \ @@ -406,9 +424,11 @@ class Admin(commands.Cog): await ctx.send(f"{Texts('admin', ctx).get('Warn with id')} `{warn_id}`" f" {Texts('admin', ctx).get('successfully edited')}") - """---------------------------------------------------------------------""" + ########################################################################### - @commands.command(name='language', aliases=['lang', 'langue', 'langage']) + @commandExtra(name='language', aliases=['lang', 'langue', 'langage'], + category='admin', + description=Texts('commands').get('admin._language')) async def _language(self, ctx: commands.Context, locale: str): available = self.bot.database.session \ .query(Lang.value) \ @@ -436,6 +456,103 @@ class Admin(commands.Cog): await ctx.send( Texts('admin', ctx).get('Language changed successfully')) + ########################################################################### + + @groupExtra(name='prefix', aliases=['prefixes'], category='admin', + description=Texts('commands').get('admin._prefix')) + async def _prefix(self, ctx: commands.Context): + if ctx.invoked_subcommand is None: + await ctx.send_help('prefix') + + @_prefix.command(name='add', aliases=['set', 'new'], + description=Texts('commands').get('admin._prefix_add')) + async def _prefix_add(self, ctx: commands.Context, prefix: str): + if str(ctx.guild.id) in self.bot.prefixes: + prefixes = self.bot.prefixes.get( + str(ctx.guild.id), "prefixes" + ).split( + self.bot.config.get("misc", "separator") + ) + + if prefix in prefixes: + return await ctx.send( + Texts('admin', ctx).get('This prefix already exists') + ) + else: + prefixes.append(prefix) + self.bot.prefixes.set( + str(ctx.guild.id), + "prefixes", + self.bot.config.get("misc", "separator") + .join(prefixes) + ) + with open('./configs/prefixes.cfg', 'w') as configfile: + self.bot.prefixes.write(configfile) + else: + self.bot.prefixes.add_section(str(ctx.guild.id)) + self.bot.prefixes.set(str(ctx.guild.id), "prefixes", prefix) + with open('./configs/prefixes.cfg', 'w') as configfile: + self.bot.prefixes.write(configfile) + + await ctx.send( + Texts('admin', ctx).get('Prefix added successfully') + ) + + @_prefix.command(name='remove', aliases=['drop', 'del', 'delete'], + description=Texts('commands').get('admin._prefix_remove')) + async def _prefix_remove(self, ctx: commands.Context, prefix: str): + if str(ctx.guild.id) in self.bot.prefixes: + prefixes = self.bot.prefixes.get( + str(ctx.guild.id), "prefixes" + ).split( + self.bot.config.get("misc", "separator") + ) + + if prefix in prefixes: + prefixes.remove(prefix) + self.bot.prefixes.set( + str(ctx.guild.id), + "prefixes", + self.bot.config.get("misc", "separator") + .join(prefixes) + ) + with open('./configs/prefixes.cfg', 'w') as configfile: + self.bot.prefixes.write(configfile) + + return await ctx.send( + Texts('admin', ctx).get('Prefix removed successfully') + ) + + await ctx.send( + Texts('admin', ctx).get('This prefix does not exist') + ) + + @_prefix.command(name='list', aliases=['show', 'all'], + description=Texts('commands').get('admin._prefix_list')) + async def _prefix_list(self, ctx: commands.Context): + extras = ['.'] + if ctx.message.guild is not None: + extras = [] + if str(ctx.message.guild.id) in self.bot.prefixes: + extras.extend( + self.bot.prefixes.get(str(ctx.message.guild.id), + "prefixes").split( + self.bot.config.get("misc", "separator") + ) + ) + + prefixes = [self.bot.user.mention] + prefixes.extend(extras) + + if len(prefixes) <= 1: + text = Texts('admin', ctx)\ + .get('The only prefix for this guild is :\n') + else: + text = Texts('admin', ctx)\ + .get('Available prefixes for this guild are :\n') + + await ctx.send(text + "\n • ".join(prefixes)) + def setup(bot: TuxBot): bot.add_cog(Admin(bot)) diff --git a/cogs/basics.py b/cogs/basics.py index ced1a86..86dcd1d 100644 --- a/cogs/basics.py +++ b/cogs/basics.py @@ -25,10 +25,9 @@ class Basics(commands.Cog): return os.popen(cmd).read().strip() - """---------------------------------------------------------------------""" + ########################################################################### - @commandExtra(name='ping', - category='basics', + @commandExtra(name='ping', category='basics', description=Texts('commands').get('basics._ping')) async def _ping(self, ctx: commands.Context): start = time.perf_counter() @@ -45,7 +44,7 @@ class Basics(commands.Cog): e.add_field(name='discordapp.com', value=f'{discordapp}ms') await ctx.send(embed=e) - """---------------------------------------------------------------------""" + ########################################################################### @staticmethod def fetch_info(): @@ -67,8 +66,7 @@ class Basics(commands.Cog): return total, file_amount - @commandExtra(name='info', aliases=['about'], - category='basics', + @commandExtra(name='info', aliases=['about'], category='basics', description=Texts('commands').get('basics._info')) async def _info(self, ctx: commands.Context): proc = psutil.Process() @@ -148,7 +146,7 @@ class Basics(commands.Cog): await ctx.send(embed=e) - """---------------------------------------------------------------------""" + ########################################################################### @commandExtra(name='credits', aliases=['contributors', 'authors'], category='basics', diff --git a/cogs/help.py b/cogs/help.py new file mode 100644 index 0000000..96fa1f9 --- /dev/null +++ b/cogs/help.py @@ -0,0 +1,621 @@ +from discord.ext import commands +from .utils import checks, formats, time +from .utils.paginator import Pages +import discord +from collections import OrderedDict, deque, Counter +import os, datetime +import asyncio +import copy +import unicodedata +import inspect +import itertools +from typing import Union + +class Prefix(commands.Converter): + async def convert(self, ctx, argument): + user_id = ctx.bot.user.id + if argument.startswith((f'<@{user_id}>', f'<@!{user_id}>')): + raise commands.BadArgument('That is a reserved prefix already in use.') + return argument + +class FetchedUser(commands.Converter): + async def convert(self, ctx, argument): + if not argument.isdigit(): + raise commands.BadArgument('Not a valid user ID.') + try: + return await ctx.bot.fetch_user(argument) + except discord.NotFound: + raise commands.BadArgument('User not found.') from None + except discord.HTTPException: + raise commands.BadArgument('An error occurred while fetching the user.') from None + +class HelpPaginator(Pages): + def __init__(self, help_command, ctx, entries, *, per_page=4): + super().__init__(ctx, entries=entries, per_page=per_page) + self.reaction_emojis.append(('\N{WHITE QUESTION MARK ORNAMENT}', self.show_bot_help)) + self.total = len(entries) + self.help_command = help_command + self.prefix = help_command.clean_prefix + self.is_bot = False + + def get_bot_page(self, page): + cog, description, commands = self.entries[page - 1] + self.title = f'{cog} Commands' + self.description = description + return commands + + def prepare_embed(self, entries, page, *, first=False): + self.embed.clear_fields() + self.embed.description = self.description + self.embed.title = self.title + + if self.is_bot: + value ='For more help, join the official bot support server: https://discord.gg/DWEaqMy' + self.embed.add_field(name='Support', value=value, inline=False) + + self.embed.set_footer(text=f'Use "{self.prefix}help command" for more info on a command.') + + for entry in entries: + signature = f'{entry.qualified_name} {entry.signature}' + self.embed.add_field(name=signature, value=entry.short_doc or "No help given", inline=False) + + if self.maximum_pages: + self.embed.set_author(name=f'Page {page}/{self.maximum_pages} ({self.total} commands)') + + async def show_help(self): + """shows this message""" + + self.embed.title = 'Paginator help' + self.embed.description = 'Hello! Welcome to the help page.' + + messages = [f'{emoji} {func.__doc__}' for emoji, func in self.reaction_emojis] + self.embed.clear_fields() + self.embed.add_field(name='What are these reactions for?', value='\n'.join(messages), inline=False) + + self.embed.set_footer(text=f'We were on page {self.current_page} before this message.') + await self.message.edit(embed=self.embed) + + async def go_back_to_current_page(): + await asyncio.sleep(30.0) + await self.show_current_page() + + self.bot.loop.create_task(go_back_to_current_page()) + + async def show_bot_help(self): + """shows how to use the bot""" + + self.embed.title = 'Using the bot' + self.embed.description = 'Hello! Welcome to the help page.' + self.embed.clear_fields() + + entries = ( + ('', 'This means the argument is __**required**__.'), + ('[argument]', 'This means the argument is __**optional**__.'), + ('[A|B]', 'This means the it can be __**either A or B**__.'), + ('[argument...]', 'This means you can have multiple arguments.\n' \ + 'Now that you know the basics, it should be noted that...\n' \ + '__**You do not type in the brackets!**__') + ) + + self.embed.add_field(name='How do I use this bot?', value='Reading the bot signature is pretty simple.') + + for name, value in entries: + self.embed.add_field(name=name, value=value, inline=False) + + self.embed.set_footer(text=f'We were on page {self.current_page} before this message.') + await self.message.edit(embed=self.embed) + + async def go_back_to_current_page(): + await asyncio.sleep(30.0) + await self.show_current_page() + + self.bot.loop.create_task(go_back_to_current_page()) + +class PaginatedHelpCommand(commands.HelpCommand): + def __init__(self): + super().__init__(command_attrs={ + 'cooldown': commands.Cooldown(1, 3.0, commands.BucketType.member), + 'help': 'Shows help about the bot, a command, or a category' + }) + + async def on_help_command_error(self, ctx, error): + if isinstance(error, commands.CommandInvokeError): + await ctx.send(str(error.original)) + + def get_command_signature(self, command): + parent = command.full_parent_name + if len(command.aliases) > 0: + aliases = '|'.join(command.aliases) + fmt = f'[{command.name}|{aliases}]' + if parent: + fmt = f'{parent} {fmt}' + alias = fmt + else: + alias = command.name if not parent else f'{parent} {command.name}' + return f'{alias} {command.signature}' + + async def send_bot_help(self, mapping): + def key(c): + return c.cog_name or '\u200bNo Category' + + bot = self.context.bot + entries = await self.filter_commands(bot.commands, sort=True, key=key) + nested_pages = [] + per_page = 9 + total = 0 + + for cog, commands in itertools.groupby(entries, key=key): + commands = sorted(commands, key=lambda c: c.name) + if len(commands) == 0: + continue + + total += len(commands) + actual_cog = bot.get_cog(cog) + # get the description if it exists (and the cog is valid) or return Empty embed. + description = (actual_cog and actual_cog.description) or discord.Embed.Empty + nested_pages.extend((cog, description, commands[i:i + per_page]) for i in range(0, len(commands), per_page)) + + # a value of 1 forces the pagination session + pages = HelpPaginator(self, self.context, nested_pages, per_page=1) + + # swap the get_page implementation to work with our nested pages. + pages.get_page = pages.get_bot_page + pages.is_bot = True + pages.total = total + await self.context.release() + await pages.paginate() + + async def send_cog_help(self, cog): + entries = await self.filter_commands(cog.get_commands(), sort=True) + pages = HelpPaginator(self, self.context, entries) + pages.title = f'{cog.qualified_name} Commands' + pages.description = cog.description + + await self.context.release() + await pages.paginate() + + def common_command_formatting(self, page_or_embed, command): + page_or_embed.title = self.get_command_signature(command) + if command.description: + page_or_embed.description = f'{command.description}\n\n{command.help}' + else: + page_or_embed.description = command.help or 'No help found...' + + async def send_command_help(self, command): + # No pagination necessary for a single command. + embed = discord.Embed(colour=discord.Colour.blurple()) + self.common_command_formatting(embed, command) + await self.context.send(embed=embed) + + async def send_group_help(self, group): + subcommands = group.commands + if len(subcommands) == 0: + return await self.send_command_help(group) + + entries = await self.filter_commands(subcommands, sort=True) + pages = HelpPaginator(self, self.context, entries) + self.common_command_formatting(pages, group) + + await self.context.release() + await pages.paginate() + +class Meta(commands.Cog): + """Commands for utilities related to Discord or the Bot itself.""" + + def __init__(self, bot): + self.bot = bot + self.old_help_command = bot.help_command + bot.help_command = PaginatedHelpCommand() + bot.help_command.cog = self + + def cog_unload(self): + self.bot.help_command = self.old_help_command + + async def cog_command_error(self, ctx, error): + if isinstance(error, commands.BadArgument): + await ctx.send(error) + + @commands.command(hidden=True) + async def hello(self, ctx): + """Displays my intro message.""" + await ctx.send('Hello! I\'m a robot! Danny#0007 made me.') + + @commands.command() + async def charinfo(self, ctx, *, characters: str): + """Shows you information about a number of characters. + + Only up to 25 characters at a time. + """ + + def to_string(c): + digit = f'{ord(c):x}' + name = unicodedata.name(c, 'Name not found.') + return f'`\\U{digit:>08}`: {name} - {c} \N{EM DASH} ' + msg = '\n'.join(map(to_string, characters)) + if len(msg) > 2000: + return await ctx.send('Output too long to display.') + await ctx.send(msg) + + @commands.group(name='prefix', invoke_without_command=True) + async def prefix(self, ctx): + """Manages the server's custom prefixes. + + If called without a subcommand, this will list the currently set + prefixes. + """ + + prefixes = self.bot.get_guild_prefixes(ctx.guild) + + # we want to remove prefix #2, because it's the 2nd form of the mention + # and to the end user, this would end up making them confused why the + # mention is there twice + del prefixes[1] + + e = discord.Embed(title='Prefixes', colour=discord.Colour.blurple()) + e.set_footer(text=f'{len(prefixes)} prefixes') + e.description = '\n'.join(f'{index}. {elem}' for index, elem in enumerate(prefixes, 1)) + await ctx.send(embed=e) + + @prefix.command(name='add', ignore_extra=False) + @checks.is_mod() + async def prefix_add(self, ctx, prefix: Prefix): + """Appends a prefix to the list of custom prefixes. + + Previously set prefixes are not overridden. + + To have a word prefix, you should quote it and end it with + a space, e.g. "hello " to set the prefix to "hello ". This + is because Discord removes spaces when sending messages so + the spaces are not preserved. + + Multi-word prefixes must be quoted also. + + You must have Manage Server permission to use this command. + """ + + current_prefixes = self.bot.get_raw_guild_prefixes(ctx.guild.id) + current_prefixes.append(prefix) + try: + await self.bot.set_guild_prefixes(ctx.guild, current_prefixes) + except Exception as e: + await ctx.send(f'{ctx.tick(False)} {e}') + else: + await ctx.send(ctx.tick(True)) + + @prefix_add.error + async def prefix_add_error(self, ctx, error): + if isinstance(error, commands.TooManyArguments): + await ctx.send("You've given too many prefixes. Either quote it or only do it one by one.") + + @prefix.command(name='remove', aliases=['delete'], ignore_extra=False) + @checks.is_mod() + async def prefix_remove(self, ctx, prefix: Prefix): + """Removes a prefix from the list of custom prefixes. + + This is the inverse of the 'prefix add' command. You can + use this to remove prefixes from the default set as well. + + You must have Manage Server permission to use this command. + """ + + current_prefixes = self.bot.get_raw_guild_prefixes(ctx.guild.id) + + try: + current_prefixes.remove(prefix) + except ValueError: + return await ctx.send('I do not have this prefix registered.') + + try: + await self.bot.set_guild_prefixes(ctx.guild, current_prefixes) + except Exception as e: + await ctx.send(f'{ctx.tick(False)} {e}') + else: + await ctx.send(ctx.tick(True)) + + @prefix.command(name='clear') + @checks.is_mod() + async def prefix_clear(self, ctx): + """Removes all custom prefixes. + + After this, the bot will listen to only mention prefixes. + + You must have Manage Server permission to use this command. + """ + + await self.bot.set_guild_prefixes(ctx.guild, []) + await ctx.send(ctx.tick(True)) + + @commands.command() + async def source(self, ctx, *, command: str = None): + """Displays my full source code or for a specific command. + + To display the source code of a subcommand you can separate it by + periods, e.g. tag.create for the create subcommand of the tag command + or by spaces. + """ + source_url = 'https://github.com/Rapptz/RoboDanny' + branch = 'rewrite' + if command is None: + return await ctx.send(source_url) + + if command == 'help': + src = type(self.bot.help_command) + module = src.__module__ + filename = inspect.getsourcefile(src) + else: + obj = self.bot.get_command(command.replace('.', ' ')) + if obj is None: + return await ctx.send('Could not find command.') + + # since we found the command we're looking for, presumably anyway, let's + # try to access the code itself + src = obj.callback.__code__ + module = obj.callback.__module__ + filename = src.co_filename + + lines, firstlineno = inspect.getsourcelines(src) + if not module.startswith('discord'): + # not a built-in command + location = os.path.relpath(filename).replace('\\', '/') + else: + location = module.replace('.', '/') + '.py' + source_url = 'https://github.com/Rapptz/discord.py' + branch = 'master' + + final_url = f'<{source_url}/blob/{branch}/{location}#L{firstlineno}-L{firstlineno + len(lines) - 1}>' + await ctx.send(final_url) + + @commands.command(name='quit', hidden=True) + @commands.is_owner() + async def _quit(self, ctx): + """Quits the bot.""" + await self.bot.logout() + + @commands.command() + async def avatar(self, ctx, *, user: Union[discord.Member, FetchedUser] = None): + """Shows a user's enlarged avatar (if possible).""" + embed = discord.Embed() + user = user or ctx.author + avatar = user.avatar_url_as(static_format='png') + embed.set_author(name=str(user), url=avatar) + embed.set_image(url=avatar) + await ctx.send(embed=embed) + + @commands.command() + async def info(self, ctx, *, user: Union[discord.Member, FetchedUser] = None): + """Shows info about a user.""" + + user = user or ctx.author + if ctx.guild and isinstance(user, discord.User): + user = ctx.guild.get_member(user.id) or user + + e = discord.Embed() + roles = [role.name.replace('@', '@\u200b') for role in getattr(user, 'roles', [])] + shared = sum(g.get_member(user.id) is not None for g in self.bot.guilds) + e.set_author(name=str(user)) + + def format_date(dt): + if dt is None: + return 'N/A' + return f'{dt:%Y-%m-%d %H:%M} ({time.human_timedelta(dt, accuracy=3)})' + + e.add_field(name='ID', value=user.id, inline=False) + e.add_field(name='Servers', value=f'{shared} shared', inline=False) + e.add_field(name='Joined', value=format_date(getattr(user, 'joined_at', None)), inline=False) + e.add_field(name='Created', value=format_date(user.created_at), inline=False) + + voice = getattr(user, 'voice', None) + if voice is not None: + vc = voice.channel + other_people = len(vc.members) - 1 + voice = f'{vc.name} with {other_people} others' if other_people else f'{vc.name} by themselves' + e.add_field(name='Voice', value=voice, inline=False) + + if roles: + e.add_field(name='Roles', value=', '.join(roles) if len(roles) < 10 else f'{len(roles)} roles', inline=False) + + colour = user.colour + if colour.value: + e.colour = colour + + if user.avatar: + e.set_thumbnail(url=user.avatar_url) + + if isinstance(user, discord.User): + e.set_footer(text='This member is not in this server.') + + await ctx.send(embed=e) + + @commands.command(aliases=['guildinfo'], usage='') + @commands.guild_only() + async def serverinfo(self, ctx, *, guild_id: int = None): + """Shows info about the current server.""" + + if guild_id is not None and await self.bot.is_owner(ctx.author): + guild = self.bot.get_guild(guild_id) + if guild is None: + return await ctx.send(f'Invalid Guild ID given.') + else: + guild = ctx.guild + + roles = [role.name.replace('@', '@\u200b') for role in guild.roles] + + # we're going to duck type our way here + class Secret: + pass + + secret_member = Secret() + secret_member.id = 0 + secret_member.roles = [guild.default_role] + + # figure out what channels are 'secret' + secret = Counter() + totals = Counter() + for channel in guild.channels: + perms = channel.permissions_for(secret_member) + channel_type = type(channel) + totals[channel_type] += 1 + if not perms.read_messages: + secret[channel_type] += 1 + elif isinstance(channel, discord.VoiceChannel) and (not perms.connect or not perms.speak): + secret[channel_type] += 1 + + member_by_status = Counter(str(m.status) for m in guild.members) + + e = discord.Embed() + e.title = guild.name + e.add_field(name='ID', value=guild.id) + e.add_field(name='Owner', value=guild.owner) + if guild.icon: + e.set_thumbnail(url=guild.icon_url) + + channel_info = [] + key_to_emoji = { + discord.TextChannel: '<:text_channel:586339098172850187>', + discord.VoiceChannel: '<:voice_channel:586339098524909604>', + } + for key, total in totals.items(): + secrets = secret[key] + try: + emoji = key_to_emoji[key] + except KeyError: + continue + + if secrets: + channel_info.append(f'{emoji} {total} ({secrets} locked)') + else: + channel_info.append(f'{emoji} {total}') + + info = [] + features = set(guild.features) + all_features = { + 'PARTNERED': 'Partnered', + 'VERIFIED': 'Verified', + 'DISCOVERABLE': 'Server Discovery', + 'PUBLIC': 'Server Discovery/Public', + 'INVITE_SPLASH': 'Invite Splash', + 'VIP_REGIONS': 'VIP Voice Servers', + 'VANITY_URL': 'Vanity Invite', + 'MORE_EMOJI': 'More Emoji', + 'COMMERCE': 'Commerce', + 'LURKABLE': 'Lurkable', + 'NEWS': 'News Channels', + 'ANIMATED_ICON': 'Animated Icon', + 'BANNER': 'Banner' + } + + for feature, label in all_features.items(): + if feature in features: + info.append(f'{ctx.tick(True)}: {label}') + + if info: + e.add_field(name='Features', value='\n'.join(info)) + + e.add_field(name='Channels', value='\n'.join(channel_info)) + + if guild.premium_tier != 0: + boosts = f'Level {guild.premium_tier}\n{guild.premium_subscription_count} boosts' + last_boost = max(guild.members, key=lambda m: m.premium_since or guild.created_at) + if last_boost.premium_since is not None: + boosts = f'{boosts}\nLast Boost: {last_boost} ({time.human_timedelta(last_boost.premium_since, accuracy=2)})' + e.add_field(name='Boosts', value=boosts, inline=False) + + fmt = f'<:online:316856575413321728> {member_by_status["online"]} ' \ + f'<:idle:316856575098880002> {member_by_status["idle"]} ' \ + f'<:dnd:316856574868193281> {member_by_status["dnd"]} ' \ + f'<:offline:316856575501402112> {member_by_status["offline"]}\n' \ + f'Total: {guild.member_count}' + + e.add_field(name='Members', value=fmt, inline=False) + + # TODO: maybe chunk and stuff for top role members + # requires max-concurrency d.py check to work though. + + e.add_field(name='Roles', value=', '.join(roles) if len(roles) < 10 else f'{len(roles)} roles') + e.set_footer(text='Created').timestamp = guild.created_at + await ctx.send(embed=e) + + async def say_permissions(self, ctx, member, channel): + permissions = channel.permissions_for(member) + e = discord.Embed(colour=member.colour) + allowed, denied = [], [] + for name, value in permissions: + name = name.replace('_', ' ').replace('guild', 'server').title() + if value: + allowed.append(name) + else: + denied.append(name) + + e.add_field(name='Allowed', value='\n'.join(allowed)) + e.add_field(name='Denied', value='\n'.join(denied)) + await ctx.send(embed=e) + + @commands.command() + @commands.guild_only() + async def permissions(self, ctx, member: discord.Member = None, channel: discord.TextChannel = None): + """Shows a member's permissions in a specific channel. + + If no channel is given then it uses the current one. + + You cannot use this in private messages. If no member is given then + the info returned will be yours. + """ + channel = channel or ctx.channel + if member is None: + member = ctx.author + + await self.say_permissions(ctx, member, channel) + + @commands.command() + @commands.guild_only() + @checks.admin_or_permissions(manage_roles=True) + async def botpermissions(self, ctx, *, channel: discord.TextChannel = None): + """Shows the bot's permissions in a specific channel. + + If no channel is given then it uses the current one. + + This is a good way of checking if the bot has the permissions needed + to execute the commands it wants to execute. + + To execute this command you must have Manage Roles permission. + You cannot use this in private messages. + """ + channel = channel or ctx.channel + member = ctx.guild.me + await self.say_permissions(ctx, member, channel) + + @commands.command(aliases=['invite']) + async def join(self, ctx): + """Joins a server.""" + perms = discord.Permissions.none() + perms.read_messages = True + perms.external_emojis = True + perms.send_messages = True + perms.manage_roles = True + perms.manage_channels = True + perms.ban_members = True + perms.kick_members = True + perms.manage_messages = True + perms.embed_links = True + perms.read_message_history = True + perms.attach_files = True + perms.add_reactions = True + await ctx.send(f'<{discord.utils.oauth_url(self.bot.client_id, perms)}>') + + @commands.command(rest_is_raw=True, hidden=True) + @commands.is_owner() + async def echo(self, ctx, *, content): + await ctx.send(content) + + @commands.command(hidden=True) + async def cud(self, ctx): + """pls no spam""" + + for i in range(3): + await ctx.send(3 - i) + await asyncio.sleep(1) + + await ctx.send('go') + +def setup(bot): + bot.add_cog(Meta(bot)) \ No newline at end of file diff --git a/cogs/logs.py b/cogs/logs.py index 4706b00..27b45fc 100644 --- a/cogs/logs.py +++ b/cogs/logs.py @@ -19,6 +19,8 @@ import psutil from discord.ext import commands, tasks from bot import TuxBot +from .utils import Texts +from .utils.extra import commandExtra log = logging.getLogger(__name__) @@ -238,7 +240,8 @@ class Logs(commands.Cog): msg = f'{emoji} `[{dt:%Y-%m-%d %H:%M:%S}] {record.message}`' await self.webhook.send(msg) - @commands.command(name='commandstats', hidden=True) + @commandExtra(name='commandstats', hidden=True, category='logs', + description=Texts('commands').get('logs._commandstats')) @commands.is_owner() async def _commandstats(self, ctx, limit=20): counter = self.bot.command_stats @@ -253,7 +256,8 @@ class Logs(commands.Cog): await ctx.send(f'```\n{output}\n```') - @commands.command(name='socketstats', hidden=True) + @commandExtra(name='socketstats', hidden=True, category='logs', + description=Texts('commands').get('logs._socketstats')) @commands.is_owner() async def _socketstats(self, ctx): delta = datetime.datetime.utcnow() - self.bot.uptime @@ -263,7 +267,8 @@ class Logs(commands.Cog): await ctx.send( f'{total} socket events observed ({cpm:.2f}/minute):\n{self.bot.socket_stats}') - @commands.command(name='uptime') + @commandExtra(name='uptime', category='logs', + description=Texts('commands').get('logs._uptime')) async def _uptime(self, ctx): """Tells you how long the bot has been up for.""" uptime = humanize.naturaltime( diff --git a/cogs/poll.py b/cogs/poll.py index 1904a63..72391a6 100644 --- a/cogs/poll.py +++ b/cogs/poll.py @@ -72,7 +72,7 @@ class Polls(commands.Cog): await self.update_poll(poll.id) - """---------------------------------------------------------------------""" + ########################################################################### async def create_poll(self, ctx: commands.Context, poll: str, anonymous): question = (poll.split('|')[0]).strip() @@ -92,7 +92,7 @@ class Polls(commands.Cog): ) for i, response in enumerate(responses): e.add_field( - name=f"{emotes[i]} __{response.capitalize()}__", + name=f"__```{emotes[i]} - {response.capitalize()}```__", value="**0** vote" ) e.set_footer(text=f"ID: #{poll_row.id}") @@ -184,10 +184,9 @@ class Polls(commands.Cog): description=Texts('commands').get('poll._poll')) async def _poll(self, ctx: commands.Context): if ctx.invoked_subcommand is None: - pass + await ctx.send_help('sondage') @_poll.group(name='create', aliases=['new', 'nouveau'], - category='poll', description=Texts('commands').get('poll._poll_create')) async def _poll_create(self, ctx: commands.Context, *, poll: str): is_anonymous = '--anonyme' in poll diff --git a/cogs/utility.py b/cogs/utility.py index c090675..510197c 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -7,7 +7,10 @@ from bot import TuxBot import socket from socket import AF_INET6 +from .admin import Admin + from .utils.lang import Texts +from .utils.extra import commandExtra class Utility(commands.Cog): @@ -15,9 +18,10 @@ class Utility(commands.Cog): def __init__(self, bot: TuxBot): self.bot = bot - """---------------------------------------------------------------------""" + ########################################################################### - @commands.command(name='iplocalise') + @commandExtra(name='iplocalise', category='utility', + description=Texts('commands').get('utility._iplocalise')) async def _iplocalise(self, ctx: commands.Context, addr, ip_type=''): addr = re.sub(r'http(s?)://', '', addr) addr = addr[:-1] if addr.endswith('/') else addr @@ -79,9 +83,10 @@ class Utility(commands.Cog): f"{Texts('utility', ctx).get('Cannot connect to host')} {addr}" ) - """---------------------------------------------------------------------""" + ########################################################################### - @commands.command(name='getheaders') + @commandExtra(name='getheaders', category='utility', + description=Texts('commands').get('utility._getheaders')) async def _getheaders(self, ctx: commands.Context, addr: str): if (addr.startswith('http') or addr.startswith('ftp')) is not True: addr = f"http://{addr}" @@ -109,9 +114,11 @@ class Utility(commands.Cog): f"{Texts('utility', ctx).get('Cannot connect to host')} {addr}" ) - """---------------------------------------------------------------------""" + ########################################################################### - @commands.command(name='git', aliases=['sources', 'source', 'github']) + @commandExtra(name='git', aliases=['sources', 'source', 'github'], + category='utility', + description=Texts('commands').get('utility._git')) async def _git(self, ctx): e = discord.Embed( title=Texts('utility', ctx).get('git repo'), @@ -124,9 +131,10 @@ class Utility(commands.Cog): ) await ctx.send(embed=e) - """---------------------------------------------------------------------""" + ########################################################################### - @commands.command(name='quote') + @commandExtra(name='quote', category='utility', + description=Texts('commands').get('utility._quote')) async def _quote(self, ctx, message_id: discord.Message): e = discord.Embed( colour=message_id.author.colour, diff --git a/cogs/utils/config.py b/cogs/utils/config.py index bae139f..4c07ec8 100644 --- a/cogs/utils/config.py +++ b/cogs/utils/config.py @@ -1,23 +1,15 @@ import configparser -class Config: +class Config(configparser.RawConfigParser): __slots__ = ('name', '_db') def __init__(self, name): + super().__init__() self.name = name - self._db: configparser.ConfigParser = configparser.ConfigParser() + self._db = super() self._db.read(self.name) - def __contains__(self, item): - return item in self._db - - def __getitem__(self, item): - return self._db[item] - def all(self) -> list: return self._db.sections() - - def get(self, *args, **kwargs) -> str: - return self._db.get(*args, **kwargs) diff --git a/cogs/utils/extra.py b/cogs/utils/extra.py index 6cecb38..395e2c4 100644 --- a/cogs/utils/extra.py +++ b/cogs/utils/extra.py @@ -4,7 +4,7 @@ from discord.ext import commands class commandsPlus(commands.Command): def __init__(self, func, **kwargs): super().__init__(func, **kwargs) - self.category = kwargs.pop("category") + self.category = kwargs.get("category", 'other') def commandExtra(*args, **kwargs): @@ -14,7 +14,7 @@ def commandExtra(*args, **kwargs): class GroupPlus(commands.Group): def __init__(self, func, **kwargs): super().__init__(func, **kwargs) - self.category = kwargs.pop("category") + self.category = kwargs.get("category", 'other') def groupExtra(*args, **kwargs): diff --git a/cogs/utils/paginator.py b/cogs/utils/paginator.py new file mode 100644 index 0000000..07c92de --- /dev/null +++ b/cogs/utils/paginator.py @@ -0,0 +1,316 @@ +import asyncio +import discord +from discord.ext.commands import Paginator as CommandPaginator + + +class CannotPaginate(Exception): + pass + + +class Pages: + """Implements a paginator that queries the user for the + pagination interface. + + Pages are 1-index based, not 0-index based. + + If the user does not reply within 2 minutes then the pagination + interface exits automatically. + + Parameters + ------------ + ctx: Context + The context of the command. + entries: List[str] + A list of entries to paginate. + per_page: int + How many entries show up per page. + show_entry_count: bool + Whether to show an entry count in the footer. + + Attributes + ----------- + embed: discord.Embed + The embed object that is being used to send pagination info. + Feel free to modify this externally. Only the description, + footer fields, and colour are internally modified. + permissions: discord.Permissions + Our permissions for the channel. + """ + + def __init__(self, ctx, *, entries, per_page=12, show_entry_count=True): + self.bot = ctx.bot + self.entries = entries + self.message = ctx.message + self.channel = ctx.channel + self.author = ctx.author + self.per_page = per_page + pages, left_over = divmod(len(self.entries), self.per_page) + if left_over: + pages += 1 + self.maximum_pages = pages + self.embed = discord.Embed(colour=discord.Colour.blurple()) + self.paginating = len(entries) > per_page + self.show_entry_count = show_entry_count + self.reaction_emojis = [ + ('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', + self.first_page), + ('\N{BLACK LEFT-POINTING TRIANGLE}', self.previous_page), + ('\N{BLACK RIGHT-POINTING TRIANGLE}', self.next_page), + ('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', + self.last_page), + ('\N{INPUT SYMBOL FOR NUMBERS}', self.numbered_page), + ('\N{BLACK SQUARE FOR STOP}', self.stop_pages), + ('\N{INFORMATION SOURCE}', self.show_help), + ] + + if ctx.guild is not None: + self.permissions = self.channel.permissions_for(ctx.guild.me) + else: + self.permissions = self.channel.permissions_for(ctx.bot.user) + + if not self.permissions.embed_links: + raise CannotPaginate('Bot does not have embed links permission.') + + if not self.permissions.send_messages: + raise CannotPaginate('Bot cannot send messages.') + + if self.paginating: + # verify we can actually use the pagination session + if not self.permissions.add_reactions: + raise CannotPaginate( + 'Bot does not have add reactions permission.') + + if not self.permissions.read_message_history: + raise CannotPaginate( + 'Bot does not have Read Message History permission.') + + def get_page(self, page): + base = (page - 1) * self.per_page + return self.entries[base:base + self.per_page] + + def get_content(self, entries, page, *, first=False): + return None + + def get_embed(self, entries, page, *, first=False): + self.prepare_embed(entries, page, first=first) + return self.embed + + def prepare_embed(self, entries, page, *, first=False): + p = [] + for index, entry in enumerate(entries, + 1 + ((page - 1) * self.per_page)): + p.append(f'{index}. {entry}') + + if self.maximum_pages > 1: + if self.show_entry_count: + text = f'Page {page}/{self.maximum_pages} ({len(self.entries)} entries)' + else: + text = f'Page {page}/{self.maximum_pages}' + + self.embed.set_footer(text=text) + + if self.paginating and first: + p.append('') + p.append( + 'Confused? React with \N{INFORMATION SOURCE} for more info.') + + self.embed.description = '\n'.join(p) + + async def show_page(self, page, *, first=False): + self.current_page = page + entries = self.get_page(page) + content = self.get_content(entries, page, first=first) + embed = self.get_embed(entries, page, first=first) + + if not self.paginating: + return await self.channel.send(content=content, embed=embed) + + if not first: + await self.message.edit(content=content, embed=embed) + return + + self.message = await self.channel.send(content=content, embed=embed) + for (reaction, _) in self.reaction_emojis: + if self.maximum_pages == 2 and reaction in ('\u23ed', '\u23ee'): + # no |<< or >>| buttons if we only have two pages + # we can't forbid it if someone ends up using it but remove + # it from the default set + continue + + await self.message.add_reaction(reaction) + + async def checked_show_page(self, page): + if page != 0 and page <= self.maximum_pages: + await self.show_page(page) + + async def first_page(self): + """goes to the first page""" + await self.show_page(1) + + async def last_page(self): + """goes to the last page""" + await self.show_page(self.maximum_pages) + + async def next_page(self): + """goes to the next page""" + await self.checked_show_page(self.current_page + 1) + + async def previous_page(self): + """goes to the previous page""" + await self.checked_show_page(self.current_page - 1) + + async def show_current_page(self): + if self.paginating: + await self.show_page(self.current_page) + + async def numbered_page(self): + """lets you type a page number to go to""" + to_delete = [] + to_delete.append( + await self.channel.send('What page do you want to go to?')) + + def message_check(m): + return m.author == self.author and \ + self.channel == m.channel and \ + m.content.isdigit() + + try: + msg = await self.bot.wait_for('message', check=message_check, + timeout=30.0) + except asyncio.TimeoutError: + to_delete.append(await self.channel.send('Took too long.')) + await asyncio.sleep(5) + else: + page = int(msg.content) + to_delete.append(msg) + if page != 0 and page <= self.maximum_pages: + await self.show_page(page) + else: + to_delete.append(await self.channel.send( + f'Invalid page given. ({page}/{self.maximum_pages})')) + await asyncio.sleep(5) + + try: + await self.channel.delete_messages(to_delete) + except Exception: + pass + + async def show_help(self): + """shows this message""" + messages = ['Welcome to the interactive paginator!\n'] + messages.append( + 'This interactively allows you to see pages of text by navigating with ' \ + 'reactions. They are as follows:\n') + + for (emoji, func) in self.reaction_emojis: + messages.append(f'{emoji} {func.__doc__}') + + embed = self.embed.copy() + embed.clear_fields() + embed.description = '\n'.join(messages) + embed.set_footer( + text=f'We were on page {self.current_page} before this message.') + await self.message.edit(content=None, embed=embed) + + async def go_back_to_current_page(): + await asyncio.sleep(60.0) + await self.show_current_page() + + self.bot.loop.create_task(go_back_to_current_page()) + + async def stop_pages(self): + """stops the interactive pagination session""" + await self.message.delete() + self.paginating = False + + def react_check(self, payload): + if payload.user_id != self.author.id: + return False + + if payload.message_id != self.message.id: + return False + + to_check = str(payload.emoji) + for (emoji, func) in self.reaction_emojis: + if to_check == emoji: + self.match = func + return True + return False + + async def paginate(self): + """Actually paginate the entries and run the interactive loop if necessary.""" + first_page = self.show_page(1, first=True) + if not self.paginating: + await first_page + else: + # allow us to react to reactions right away if we're paginating + self.bot.loop.create_task(first_page) + + while self.paginating: + try: + payload = await self.bot.wait_for('raw_reaction_add', + check=self.react_check, + timeout=120.0) + except asyncio.TimeoutError: + self.paginating = False + try: + await self.message.clear_reactions() + except: + pass + finally: + break + + try: + await self.message.remove_reaction(payload.emoji, + discord.Object( + id=payload.user_id)) + except: + pass # can't remove it so don't bother doing so + + await self.match() + + +class FieldPages(Pages): + """Similar to Pages except entries should be a list of + tuples having (key, value) to show as embed fields instead. + """ + + def prepare_embed(self, entries, page, *, first=False): + self.embed.clear_fields() + self.embed.description = discord.Embed.Empty + + for key, value in entries: + self.embed.add_field(name=key, value=value, inline=False) + + if self.maximum_pages > 1: + if self.show_entry_count: + text = f'Page {page}/{self.maximum_pages} ({len(self.entries)} entries)' + else: + text = f'Page {page}/{self.maximum_pages}' + + self.embed.set_footer(text=text) + + +class TextPages(Pages): + """Uses a commands.Paginator internally to paginate some text.""" + + def __init__(self, ctx, text, *, prefix='```', suffix='```', + max_size=2000): + paginator = CommandPaginator(prefix=prefix, suffix=suffix, + max_size=max_size - 200) + for line in text.split('\n'): + paginator.add_line(line) + + super().__init__(ctx, entries=paginator.pages, per_page=1, + show_entry_count=False) + + def get_page(self, page): + return self.entries[page - 1] + + def get_embed(self, entries, page, *, first=False): + return None + + def get_content(self, entry, page, *, first=False): + if self.maximum_pages > 1: + return f'{entry}\nPage {page}/{self.maximum_pages}' + return entry diff --git a/configs/prefixes.cfg b/configs/prefixes.cfg index 0d6fd82..25e9c42 100644 --- a/configs/prefixes.cfg +++ b/configs/prefixes.cfg @@ -1,2 +1,12 @@ [280805240977227776] -prefixes = b. +prefixes = b1.|Imo07fZY9ogan7ank1n3UERg|b2. + +[303633056944881686] +prefixes = b1. + +[373881878471770112] +prefixes = b1. + +[336642139381301249] +prefixes = + diff --git a/requirements.txt b/requirements.txt index 3414aa6..6782f79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +humanize discord.py[voice] jishaku gitpython @@ -5,4 +6,5 @@ sqlalchemy psycopg2 configparser psutil -tcp_latency \ No newline at end of file +tcp_latency +yarl \ No newline at end of file