From c2650ace52c521ca304f88c802e1b73377f3f95a Mon Sep 17 00:00:00 2001 From: phxntxm Date: Thu, 4 Oct 2018 14:14:09 -0500 Subject: [PATCH] Update paginator/help command --- cogs/misc.py | 98 ++------- cogs/utils/__init__.py | 2 +- cogs/utils/paginator.py | 451 ++++++++++++++++++++++++++++++---------- 3 files changed, 362 insertions(+), 189 deletions(-) diff --git a/cogs/misc.py b/cogs/misc.py index 9bc13d5..9287db3 100644 --- a/cogs/misc.py +++ b/cogs/misc.py @@ -16,97 +16,31 @@ class Miscallaneous: def __init__(self, bot): self.bot = bot - self.help_embeds = [] - self.results_per_page = 10 - self.commands = None self.process = psutil.Process() self.process.cpu_percent() @commands.command() @utils.can_run(send_messages=True) - async def help(self, ctx, *, command=None): - """This command is used to provide a link to the help URL. - This can be called on a command to provide more information about that command - You can also provide a page number to pull up that page instead of the first page + async def help(self, ctx, *, command: str = None): + """Shows help about a command or the bot""" - EXAMPLE: !help help - RESULT: This information""" - groups = {} - entries = [] - - if command is not None: - command = self.bot.get_command(command) - - if command is None: - for cmd in utils.get_all_commands(self.bot): - try: - if not await cmd.can_run(ctx) or not cmd.enabled: - continue - except commands.errors.MissingPermissions: - continue - - cog = cmd.cog_name - if cog in groups: - groups[cog].append(cmd) - else: - groups[cog] = [cmd] - - for cog, cmds in groups.items(): - entry = {'title': "{} Commands".format(cog), - 'fields': []} - - for cmd in cmds: - if not cmd.help: - # Assume if there's no description for a command, it's not supposed to be used - # I.e. the !command command. It's just a parent - continue - - description = cmd.help.partition('\n')[0] - name_fmt = "{ctx.prefix}**{cmd.qualified_name}** {aliases}".format( - ctx=ctx, - cmd=cmd, - aliases=cmd.aliases if len(cmd.aliases) > 0 else "" - ) - entry['fields'].append({ - 'name': name_fmt, - 'value': description, - 'inline': False - }) - entries.append(entry) - - entries = sorted(entries, key=lambda x: x['title']) - try: - pages = utils.DetailedPages(self.bot, message=ctx.message, entries=entries) - pages.embed.set_thumbnail(url=self.bot.user.avatar_url) - await pages.paginate() - except utils.CannotPaginate as e: - await ctx.send(str(e)) - else: - # Get the description for a command - description = command.help - if description is not None: - # Split into examples, results, and the description itself based on the string - description, _, rest = command.help.partition('EXAMPLE:') - example, _, result = rest.partition('RESULT:') + try: + if command is None: + p = await utils.HelpPaginator.from_bot(ctx) else: - example = None - result = None - # Also get the subcommands for this command, if they exist - subcommands = [x.qualified_name for x in utils.get_all_subcommands(command) if x != command] + entity = self.bot.get_cog(command) or self.bot.get_command(command) - # The rest is simple, create the embed, set the thumbail to me, add all fields if they exist - embed = discord.Embed(title=command.qualified_name) - embed.set_thumbnail(url=self.bot.user.avatar_url) - if description: - embed.add_field(name="Description", value=description.strip(), inline=False) - if example: - embed.add_field(name="Example", value=example.strip(), inline=False) - if result: - embed.add_field(name="Result", value=result.strip(), inline=False) - if subcommands: - embed.add_field(name='Subcommands', value="\n".join(subcommands), inline=False) + if entity is None: + clean = command.replace('@', '@\u200b') + return await ctx.send(f'Command or category "{clean}" not found.') + elif isinstance(entity, commands.Command): + p = await utils.HelpPaginator.from_command(ctx, entity) + else: + p = await utils.HelpPaginator.from_cog(ctx, entity) - await ctx.send(embed=embed) + await p.paginate() + except Exception as e: + await ctx.send(e) @commands.command(aliases=["coin"]) @utils.can_run(send_messages=True) diff --git a/cogs/utils/__init__.py b/cogs/utils/__init__.py index acfc18d..5b91b73 100644 --- a/cogs/utils/__init__.py +++ b/cogs/utils/__init__.py @@ -3,5 +3,5 @@ from .checks import can_run, db_check from .config import * from .utilities import * from .images import create_banner -from .paginator import Pages, CannotPaginate, DetailedPages +from .paginator import Pages, CannotPaginate, HelpPaginator from .database import DB diff --git a/cogs/utils/paginator.py b/cogs/utils/paginator.py index 56c25ad..922b924 100644 --- a/cogs/utils/paginator.py +++ b/cogs/utils/paginator.py @@ -1,5 +1,8 @@ import asyncio import discord +import itertools +import inspect +import re class CannotPaginate(Exception): @@ -12,24 +15,44 @@ class Pages: Pages are 1-index based, not 0-index based. - If the user does not reply within 2 minutes, the pagination + 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, bot, *, message, entries, per_page=10): - self.bot = bot + def __init__(self, ctx, *, entries, per_page=12, show_entry_count=True): + self.bot = ctx.bot self.entries = entries - self.message = message - self.author = message.author + 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() + self.embed = discord.Embed(colour=discord.Colour.blurple()) self.paginating = len(entries) > per_page - self.current_page = 0 - self.match = None + 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), @@ -40,15 +63,25 @@ class Pages: ('\N{INFORMATION SOURCE}', self.show_help), ] - server = self.message.guild - if server is not None: - self.permissions = self.message.channel.permissions_for(server.me) + if ctx.guild is not None: + self.permissions = self.channel.permissions_for(ctx.guild.me) else: - self.permissions = self.message.channel.permissions_for(self.bot.user) + 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] @@ -57,45 +90,38 @@ class Pages: self.current_page = page entries = self.get_page(page) p = [] - for t in enumerate(entries, 1 + ((page - 1) * self.per_page)): - p.append('%s. %s' % t) + for index, entry in enumerate(entries, 1 + ((page - 1) * self.per_page)): + p.append(f'{index}. {entry}') - self.embed.set_footer(text='Page %s/%s (%s entries)' % (page, self.maximum_pages, len(self.entries))) + 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 not self.paginating: self.embed.description = '\n'.join(p) - return await self.message.channel.send(embed=self.embed) + return await self.channel.send(embed=self.embed) if not first: self.embed.description = '\n'.join(p) - try: - await self.message.edit(embed=self.embed) - except discord.NotFound: - self.paginating = False + await self.message.edit(embed=self.embed) return - # 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.') - p.append('') p.append('Confused? React with \N{INFORMATION SOURCE} for more info.') self.embed.description = '\n'.join(p) - self.message = await self.message.channel.send(embed=self.embed) + self.message = await self.channel.send(embed=self.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 - try: - await self.message.add_reaction(reaction) - except discord.NotFound: - # If the message isn't found, we don't care about clearing anything - return + + await self.message.add_reaction(reaction) async def checked_show_page(self, page): if page != 0 and page <= self.maximum_pages: @@ -123,51 +149,44 @@ class Pages: async def numbered_page(self): """lets you type a page number to go to""" - start_message = await self.message.channel.send('What page do you want to go to?') - to_delete = [start_message] + to_delete = [] + to_delete.append(await self.channel.send('What page do you want to go to?')) - def check(m): - if m.author == self.author and m.channel == self.message.channel: - return m.content.isdigit() - else: - return False + 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=check, timeout=30.0) + msg = await self.bot.wait_for('message', check=message_check, timeout=30.0) except asyncio.TimeoutError: - msg = None - if msg is not None: + 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.message.channel.send( - 'Invalid page given. (%s/%s)' % (page, self.maximum_pages))) + to_delete.append(await self.channel.send(f'Invalid page given. ({page}/{self.maximum_pages})')) await asyncio.sleep(5) - else: - to_delete.append(await self.message.channel.send('Took too long.')) - await asyncio.sleep(5) try: - await self.message.channel.delete_messages(to_delete) + await self.channel.delete_messages(to_delete) except Exception: pass async def show_help(self): """shows this message""" - e = discord.Embed() - messages = ['Welcome to the interactive paginator!\n', - 'This interactively allows you to see pages of text by navigating with ' - 'reactions. They are as follows:\n'] + 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('%s %s' % (emoji, func.__doc__)) + messages.append(f'{emoji} {func.__doc__}') - e.description = '\n'.join(messages) - e.colour = 0x738bd7 # blurple - e.set_footer(text='We were on page %s before this message.' % self.current_page) - await self.message.edit(embed=e) + self.embed.description = '\n'.join(messages) + self.embed.clear_fields() + 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(60.0) @@ -181,7 +200,10 @@ class Pages: self.paginating = False def react_check(self, reaction, user): - if user is None or user.id != self.author.id or reaction.message.id != self.message.id: + if user is None or user.id != self.author.id: + return False + + if reaction.message.id != self.message.id: return False for (emoji, func) in self.reaction_emojis: @@ -190,87 +212,304 @@ class Pages: return True return False - async def paginate(self, start_page=1): + async def paginate(self): """Actually paginate the entries and run the interactive loop if necessary.""" - await self.show_page(start_page, first=True) + 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: - react, user = await self.bot.wait_for('reaction_add', check=self.react_check, timeout=120.0) + reaction, user = await self.bot.wait_for('reaction_add', check=self.react_check, timeout=120.0) except asyncio.TimeoutError: self.paginating = False try: await self.message.clear_reactions() - except Exception: + except: pass finally: break try: - await self.message.remove_reaction(react.emoji, user) - except Exception: + await self.message.remove_reaction(reaction, user) + except: pass # can't remove it so don't bother doing so await self.match() -class DetailedPages(Pages): - """A class built on the normal Paginator, except with the idea that you want one 'thing' per page - This allows the ability to have more data on a page, more fields, etc. and page through each 'thing'""" - - def __init__(self, *args, **kwargs): - kwargs['per_page'] = 1 - super().__init__(*args, **kwargs) - - def get_page(self, page): - return self.entries[page - 1] - +class FieldPages(Pages): + """Similar to Pages except entries should be a list of + tuples having (key, value) to show as embed fields instead. + """ async def show_page(self, page, *, first=False): self.current_page = page entries = self.get_page(page) - self.embed.set_footer(text='Page %s/%s (%s entries)' % (page, self.maximum_pages, len(self.entries))) self.embed.clear_fields() - self.embed.description = "" + self.embed.description = discord.Embed.Empty - for key, value in entries.items(): - if key == 'fields': - for f in value: - self.embed.add_field(name=f.get('name'), value=f.get('value'), inline=f.get('inline', True)) + 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: - setattr(self.embed, key, value) + text = f'Page {page}/{self.maximum_pages}' + + self.embed.set_footer(text=text) if not self.paginating: - return await self.message.channel.send(embed=self.embed) + return await self.channel.send(embed=self.embed) if not first: - try: - await self.message.edit(embed=self.embed) - except discord.NotFound: - self.paginating = False + await self.message.edit(embed=self.embed) return - # 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.') - - if self.embed.description: - self.embed.description += '\nConfused? React with \N{INFORMATION SOURCE} for more info.' - else: - self.embed.description = '\nConfused? React with \N{INFORMATION SOURCE} for more info.' - - self.message = await self.message.channel.send(embed=self.embed) + self.message = await self.channel.send(embed=self.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 - try: - await self.message.add_reaction(reaction) - except discord.NotFound: - # If the message isn't found, we don't care about clearing anything - return + + await self.message.add_reaction(reaction) + + +# ?help +# ?help Cog +# ?help command +# -> could be a subcommand + +_mention = re.compile(r'<@\!?([0-9]{1,19})>') + + +def cleanup_prefix(bot, prefix): + m = _mention.match(prefix) + if m: + user = bot.get_user(int(m.group(1))) + if user: + return f'@{user.name} ' + return prefix + + +async def _can_run(cmd, ctx): + try: + return await cmd.can_run(ctx) + except: + return False + + +def _command_signature(cmd): + # this is modified from discord.py source + # which I wrote myself lmao + + result = [cmd.qualified_name] + if cmd.usage: + result.append(cmd.usage) + return ' '.join(result) + + params = cmd.clean_params + if not params: + return ' '.join(result) + + for name, param in params.items(): + if param.default is not param.empty: + # We don't want None or '' to trigger the [name=value] case and instead it should + # do [name] since [name=None] or [name=] are not exactly useful for the user. + should_print = param.default if isinstance(param.default, str) else param.default is not None + if should_print: + result.append(f'[{name}={param.default!r}]') + else: + result.append(f'[{name}]') + elif param.kind == param.VAR_POSITIONAL: + result.append(f'[{name}...]') + else: + result.append(f'<{name}>') + + return ' '.join(result) + + +class HelpPaginator(Pages): + def __init__(self, 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) + + @classmethod + async def from_cog(cls, ctx, cog): + cog_name = cog.__class__.__name__ + + # get the commands + entries = sorted(ctx.bot.get_cog_commands(cog_name), key=lambda c: c.name) + + # remove the ones we can't run + entries = [cmd for cmd in entries if (await _can_run(cmd, ctx)) and not cmd.hidden] + + self = cls(ctx, entries) + self.title = f'{cog_name} Commands' + self.description = inspect.getdoc(cog) + self.prefix = cleanup_prefix(ctx.bot, ctx.prefix) + + # no longer need the database + await ctx.release() + + return self + + @classmethod + async def from_command(cls, ctx, command): + try: + entries = sorted(command.commands, key=lambda c: c.name) + except AttributeError: + entries = [] + else: + entries = [cmd for cmd in entries if (await _can_run(cmd, ctx)) and not cmd.hidden] + + self = cls(ctx, entries) + self.title = command.signature + + if command.description: + self.description = f'{command.description}\n\n{command.help}' + else: + self.description = command.help or 'No help given.' + + self.prefix = cleanup_prefix(ctx.bot, ctx.prefix) + await ctx.release() + return self + + @classmethod + async def from_bot(cls, ctx): + def key(c): + return c.cog_name or '\u200bMisc' + + entries = sorted(ctx.bot.commands, key=key) + nested_pages = [] + per_page = 9 + + # 0: (cog, desc, commands) (max len == 9) + # 1: (cog, desc, commands) (max len == 9) + # ... + + for cog, commands in itertools.groupby(entries, key=key): + plausible = [cmd for cmd in commands if (await _can_run(cmd, ctx)) and not cmd.hidden] + if len(plausible) == 0: + continue + + description = ctx.bot.get_cog(cog) + if description is None: + description = discord.Embed.Empty + else: + description = inspect.getdoc(description) or discord.Embed.Empty + + nested_pages.extend((cog, description, plausible[i:i + per_page]) for i in range(0, len(plausible), per_page)) + + self = cls(ctx, nested_pages, per_page=1) # this forces the pagination session + self.prefix = cleanup_prefix(ctx.bot, ctx.prefix) + await ctx.release() + + # swap the get_page implementation with one that supports our style of pagination + self.get_page = self.get_bot_page + self._is_bot = True + + # replace the actual total + self.total = sum(len(o) for _, _, o in nested_pages) + return self + + def get_bot_page(self, page): + cog, description, commands = self.entries[page - 1] + self.title = f'{cog} Commands' + self.description = description + return commands + + async def show_page(self, page, *, first=False): + self.current_page = page + entries = self.get_page(page) + + self.embed.clear_fields() + self.embed.description = self.description + self.embed.title = self.title + + if hasattr(self, '_is_bot'): + value = 'For more help, join the official bot support server: https://discord.gg/f6uzJEj' + 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.') + + signature = _command_signature + + for entry in entries: + self.embed.add_field(name=signature(entry), 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)') + + if not self.paginating: + return await self.channel.send(embed=self.embed) + + if not first: + await self.message.edit(embed=self.embed) + return + + self.message = await self.channel.send(embed=self.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 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())