aboutsummaryrefslogtreecommitdiffstats
path: root/bot/cogs/help.py
blob: 3d1d6fd1057d792d58d832e0487d8345b946cb66 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
import itertools
import logging
from asyncio import TimeoutError
from collections import namedtuple
from contextlib import suppress
from typing import List, Union

from discord import Colour, Embed, Member, Message, NotFound, Reaction, User
from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand
from fuzzywuzzy import fuzz, process
from fuzzywuzzy.utils import full_process

from bot import constants
from bot.constants import Channels, Emojis, STAFF_ROLES
from bot.decorators import redirect_output
from bot.pagination import LinePaginator

log = logging.getLogger(__name__)

COMMANDS_PER_PAGE = 8
DELETE_EMOJI = Emojis.trashcan
PREFIX = constants.Bot.prefix

Category = namedtuple("Category", ["name", "description", "cogs"])


async def help_cleanup(bot: Bot, author: Member, message: Message) -> None:
    """
    Runs the cleanup for the help command.

    Adds the :trashcan: reaction that, when clicked, will delete the help message.
    After a 300 second timeout, the reaction will be removed.
    """
    def check(reaction: Reaction, user: User) -> bool:
        """Checks the reaction is :trashcan:, the author is original author and messages are the same."""
        return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id

    await message.add_reaction(DELETE_EMOJI)

    with suppress(NotFound):
        try:
            await bot.wait_for("reaction_add", check=check, timeout=300)
            await message.delete()
        except TimeoutError:
            await message.remove_reaction(DELETE_EMOJI, bot.user)


class HelpQueryNotFound(ValueError):
    """
    Raised when a HelpSession Query doesn't match a command or cog.

    Contains the custom attribute of ``possible_matches``.

    Instances of this object contain a dictionary of any command(s) that were close to matching the
    query, where keys are the possible matched command names and values are the likeness match scores.
    """

    def __init__(self, arg: str, possible_matches: dict = None):
        super().__init__(arg)
        self.possible_matches = possible_matches


class CustomHelpCommand(HelpCommand):
    """
    An interactive instance for the bot help command.

    Cogs can be grouped into custom categories. All cogs with the same category will be displayed
    under a single category name in the help output. Custom categories are defined inside the cogs
    as a class attribute named `category`. A description can also be specified with the attribute
    `category_description`. If a description is not found in at least one cog, the default will be
    the regular description (class docstring) of the first cog found in the category.
    """

    def __init__(self):
        super().__init__(command_attrs={"help": "Shows help for bot commands"})

    @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
    async def command_callback(self, ctx: Context, *, command: str = None) -> None:
        """Attempts to match the provided query with a valid command or cog."""
        # the only reason we need to tamper with this is because d.py does not support "categories",
        # so we need to deal with them ourselves.

        bot = ctx.bot

        if command is None:
            # quick and easy, send bot help if command is none
            mapping = self.get_bot_mapping()
            await self.send_bot_help(mapping)
            return

        cog_matches = []
        description = None
        for cog in bot.cogs.values():
            if hasattr(cog, "category") and cog.category == command:
                cog_matches.append(cog)
                if hasattr(cog, "category_description"):
                    description = cog.category_description

        if cog_matches:
            category = Category(name=command, description=description, cogs=cog_matches)
            await self.send_category_help(category)
            return

        # it's either a cog, group, command or subcommand; let the parent class deal with it
        await super().command_callback(ctx, command=command)

    async def get_all_help_choices(self) -> set:
        """
        Get all the possible options for getting help in the bot.

        This will only display commands the author has permission to run.

        These include:
        - Category names
        - Cog names
        - Group command names (and aliases)
        - Command names (and aliases)
        - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group)

        Options and choices are case sensitive.
        """
        # first get all commands including subcommands and full command name aliases
        choices = set()
        for command in await self.filter_commands(self.context.bot.walk_commands()):
            # the the command or group name
            choices.add(str(command))

            if isinstance(command, Command):
                # all aliases if it's just a command
                choices.update(command.aliases)
            else:
                # otherwise we need to add the parent name in
                choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases)

        # all cog names
        choices.update(self.context.bot.cogs)

        # all category names
        choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category"))
        return choices

    async def command_not_found(self, string: str) -> "HelpQueryNotFound":
        """
        Handles when a query does not match a valid command, group, cog or category.

        Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches.
        """
        choices = await self.get_all_help_choices()

        # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty
        # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters
        if (processed := full_process(string)):
            result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None)
        else:
            result = []

        return HelpQueryNotFound(f'Query "{string}" not found.', dict(result))

    async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound":
        """
        Redirects the error to `command_not_found`.

        `command_not_found` deals with searching and getting best choices for both commands and subcommands.
        """
        return await self.command_not_found(f"{command.qualified_name} {string}")

    async def send_error_message(self, error: HelpQueryNotFound) -> None:
        """Send the error message to the channel."""
        embed = Embed(colour=Colour.red(), title=str(error))

        if getattr(error, "possible_matches", None):
            matches = "\n".join(f"`{match}`" for match in error.possible_matches)
            embed.description = f"**Did you mean:**\n{matches}"

        await self.context.send(embed=embed)

    async def command_formatting(self, command: Command) -> Embed:
        """
        Takes a command and turns it into an embed.

        It will add an author, command signature + help, aliases and a note if the user can't run the command.
        """
        embed = Embed()
        embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)

        parent = command.full_parent_name

        name = str(command) if not parent else f"{parent} {command.name}"
        command_details = f"**```{PREFIX}{name} {command.signature}```**\n"

        # show command aliases
        aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases)
        if aliases:
            command_details += f"**Can also use:** {aliases}\n\n"

        # check if the user is allowed to run this command
        if not await command.can_run(self.context):
            command_details += "***You cannot run this command.***\n\n"

        command_details += f"*{command.help or 'No details provided.'}*\n"
        embed.description = command_details

        return embed

    async def send_command_help(self, command: Command) -> None:
        """Send help for a single command."""
        embed = await self.command_formatting(command)
        message = await self.context.send(embed=embed)
        await help_cleanup(self.context.bot, self.context.author, message)

    @staticmethod
    def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]:
        """
        Formats the prefix, command name and signature, and short doc for an iterable of commands.

        return_as_list is helpful for passing these command details into the paginator as a list of command details.
        """
        details = []
        for command in commands_:
            signature = f" {command.signature}" if command.signature else ""
            details.append(
                f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*"
            )
        if return_as_list:
            return details
        else:
            return "".join(details)

    async def send_group_help(self, group: Group) -> None:
        """Sends help for a group command."""
        subcommands = group.commands

        if len(subcommands) == 0:
            # no subcommands, just treat it like a regular command
            await self.send_command_help(group)
            return

        # remove commands that the user can't run and are hidden, and sort by name
        commands_ = await self.filter_commands(subcommands, sort=True)

        embed = await self.command_formatting(group)

        command_details = self.get_commands_brief_details(commands_)
        if command_details:
            embed.description += f"\n**Subcommands:**\n{command_details}"

        message = await self.context.send(embed=embed)
        await help_cleanup(self.context.bot, self.context.author, message)

    async def send_cog_help(self, cog: Cog) -> None:
        """Send help for a cog."""
        # sort commands by name, and remove any the user cant run or are hidden.
        commands_ = await self.filter_commands(cog.get_commands(), sort=True)

        embed = Embed()
        embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
        embed.description = f"**{cog.qualified_name}**\n*{cog.description}*"

        command_details = self.get_commands_brief_details(commands_)
        if command_details:
            embed.description += f"\n\n**Commands:**\n{command_details}"

        message = await self.context.send(embed=embed)
        await help_cleanup(self.context.bot, self.context.author, message)

    @staticmethod
    def _category_key(command: Command) -> str:
        """
        Returns a cog name of a given command for use as a key for `sorted` and `groupby`.

        A zero width space is used as a prefix for results with no cogs to force them last in ordering.
        """
        if command.cog:
            with suppress(AttributeError):
                if command.cog.category:
                    return f"**{command.cog.category}**"
            return f"**{command.cog_name}**"
        else:
            return "**\u200bNo Category:**"

    async def send_category_help(self, category: Category) -> None:
        """
        Sends help for a bot category.

        This sends a brief help for all commands in all cogs registered to the category.
        """
        embed = Embed()
        embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)

        all_commands = []
        for cog in category.cogs:
            all_commands.extend(cog.get_commands())

        filtered_commands = await self.filter_commands(all_commands, sort=True)

        command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True)
        description = f"**{category.name}**\n*{category.description}*"

        if command_detail_lines:
            description += "\n\n**Commands:**"

        await LinePaginator.paginate(
            command_detail_lines,
            self.context,
            embed,
            prefix=description,
            max_lines=COMMANDS_PER_PAGE,
            max_size=2000,
        )

    async def send_bot_help(self, mapping: dict) -> None:
        """Sends help for all bot commands and cogs."""
        bot = self.context.bot

        embed = Embed()
        embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)

        filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key)

        cog_or_category_pages = []

        for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key):
            sorted_commands = sorted(_commands, key=lambda c: c.name)

            if len(sorted_commands) == 0:
                continue

            command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True)

            # Split cogs or categories which have too many commands to fit in one page.
            # The length of commands is included for later use when aggregating into pages for the paginator.
            for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE):
                truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE]
                joined_lines = "".join(truncated_lines)
                cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines)))

        pages = []
        counter = 0
        page = ""
        for page_details, length in cog_or_category_pages:
            counter += length
            if counter > COMMANDS_PER_PAGE:
                # force a new page on paginator even if it falls short of the max pages
                # since we still want to group categories/cogs.
                counter = length
                pages.append(page)
                page = f"{page_details}\n\n"
            else:
                page += f"{page_details}\n\n"

        if page:
            # add any remaining command help that didn't get added in the last iteration above.
            pages.append(page)

        await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2000)


class Help(Cog):
    """Custom Embed Pagination Help feature."""

    def __init__(self, bot: Bot) -> None:
        self.bot = bot
        self.old_help_command = bot.help_command
        bot.help_command = CustomHelpCommand()
        bot.help_command.cog = self

    def cog_unload(self) -> None:
        """Reset the help command when the cog is unloaded."""
        self.bot.help_command = self.old_help_command


def setup(bot: Bot) -> None:
    """Load the Help cog."""
    bot.add_cog(Help(bot))
    log.info("Cog loaded: Help")