Passed
Pull Request — main (#389)
by
unknown
02:55 queued 01:11
created

ChatCommandHandler.get_commands()   A

Complexity

Conditions 3

Size

Total Lines 27
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 13
dl 0
loc 27
rs 9.75
c 0
b 0
f 0
cc 3
nop 1
1
# Copyright Pincer 2021-Present
0 ignored issues
show
introduced by
Missing module docstring
Loading history...
2
# Full MIT License can be found in `LICENSE` at the project root.
3
4
from __future__ import annotations
5
6
from typing import TYPE_CHECKING
7
8
import logging
9
from asyncio import gather
10
11
from ..utils.types import MISSING, Singleton
12
13
from ..exceptions import ForbiddenError
14
from ..objects.guild.guild import Guild
15
from ..objects.app.command import AppCommand, AppCommandOption
16
from ..objects.app.command_types import AppCommandOptionType, AppCommandType
17
18
if TYPE_CHECKING:
19
    from typing import List, Dict, Optional, ValuesView, Union
0 ignored issues
show
introduced by
Imports from package typing are not grouped
Loading history...
20
    from .interactable import Interactable
21
    from ..client import Client
22
    from ..utils.snowflake import Snowflake
23
    from ..objects.app.command import InteractableStructure
24
25
_log = logging.getLogger(__name__)
26
27
28
class ChatCommandHandler(metaclass=Singleton):
29
    """Singleton containing methods used to handle various commands
30
31
    The register and built_register
32
    -------------------------------
33
    I found the way Discord expects commands to be registered to be very different than
34
    how you want to think about command registration. i.e. Discord wants nesting but we
35
    don't want any nesting. Nesting makes it hard to think about commands and also will
36
    increase lookup time.
37
    The way this problem is avoided is by storing a version of the commands that we can
38
    deal with as library developers and a version of the command that Discord thinks we
39
    should provide. That is where the register and the built_register help simplify the
40
    design of the library.
41
    The register is simply where the "Pincer version" of commands gets saved to memory.
42
    The built_register is where the version of commands that Discord requires is saved.
43
    The register allows for O(1) lookups by storing commands in a Python dictionary. It
44
    does cost some memory to save two copies in the current iteration of the system but
45
    we should be able to drop the built_register in runtime if we want to. I don't feel
46
    that lost maintainability from this is optimal. We can index by in O(1) by checking
47
    the register but can still use the built_register if we need to do a nested lookup.
48
49
    Attributes
50
    ----------
51
    client: :class:`Client`
52
        The client object
53
    managers: Dict[:class:`str`, :class:`~typing.Any`]
54
        Dictionary of managers
55
    register: Dict[:class:`str`, :class:`~pincer.objects.app.command.InteractableStructure`]
56
        Dictionary of ``InteractableStructure``
57
    built_register: Dict[:class:`str`, :class:`~pincer.objects.app.command.InteractableStructure`]
58
        Dictionary of ``InteractableStructure`` where the commands are converted to
59
        the format that Discord expects for sub commands and sub command groups.
60
    """  # noqa: E501
61
62
    has_been_initialized = False
63
    managers: List[Interactable] = []
64
    register: Dict[str, InteractableStructure] = {}
65
    built_register: Dict[str, AppCommand] = {}
66
67
    # Endpoints:
68
    __get = "/commands"
69
    __delete = "/commands/{command.id}"
70
    __update = "/commands/{command.id}"
71
    __add = "/commands"
72
    __add_guild = "/guilds/{command.guild_id}/commands"
73
    __get_guild = "/guilds/{guild_id}/commands"
74
    __update_guild = "/guilds/{command.guild_id}/commands/{command.id}"
75
    __delete_guild = "/guilds/{command.guild_id}/commands/{command.id}"
76
77
    def __init__(self, client: Client):
78
        self.client = client
79
        self._api_commands: List[AppCommand] = []
80
        _log.debug(
81
            "%i commands registered.", len(ChatCommandHandler.register.items())
82
        )
83
        self.client.throttler.throttle = dict(
84
            map(
85
                lambda cmd: (cmd.call, {}), ChatCommandHandler.register.values()
86
            )
87
        )
88
89
        self.__prefix = f"applications/{self.client.bot.id}"
90
91
    async def get_commands(self) -> List[AppCommand]:
92
        """|coro|
93
94
        Get a list of app commands from Discord
95
96
        Returns
97
        -------
98
        List[:class:`~pincer.objects.app.command.AppCommand`]
99
            List of commands.
100
        """
101
        # TODO: Update if discord adds bulk get guild commands
0 ignored issues
show
Coding Style introduced by
TODO and FIXME comments should generally be avoided.
Loading history...
102
        guild_commands = await gather(
103
            *map(
104
                lambda guild: self.client.http.get(
105
                    self.__prefix
106
                    + self.__get_guild.format(
107
                        guild_id=guild.id if isinstance(guild, Guild) else guild
108
                    )
109
                ),
110
                self.client.guilds,
111
            )
112
        )
113
        return list(
114
            map(
115
                AppCommand.from_dict,
116
                await self.client.http.get(self.__prefix + self.__get)
117
                + [cmd for guild in guild_commands for cmd in guild],
118
            )
119
        )
120
121
    async def remove_command(self, cmd: AppCommand):
122
        """|coro|
123
124
        Remove a specific command
125
126
        Parameters
127
        ----------
128
        cmd : :class:`~pincer.objects.app.command.AppCommand`
129
            What command to delete
130
        """
131
        # TODO: Update if discord adds bulk delete commands
0 ignored issues
show
Coding Style introduced by
TODO and FIXME comments should generally be avoided.
Loading history...
132
        if cmd.guild_id:
133
            _log.info(
134
                "Removing command `%s` with guild id %d from Discord",
135
                cmd.name,
136
                cmd.guild_id,
137
            )
138
        else:
139
            _log.info("Removing global command `%s` from Discord", cmd.name)
140
141
        remove_endpoint = self.__delete_guild if cmd.guild_id else self.__delete
142
143
        await self.client.http.delete(
144
            self.__prefix + remove_endpoint.format(command=cmd)
145
        )
146
147
    async def add_command(self, cmd: AppCommand):
148
        """|coro|
149
150
        Add an app command
151
152
        Parameters
153
        ----------
154
        cmd : :class:`~pincer.objects.app.command.AppCommand`
155
            Command to add
156
        """
157
        _log.info("Updated or registered command `%s` to Discord", cmd.name)
158
159
        add_endpoint = self.__add
160
161
        if cmd.guild_id:
162
            add_endpoint = self.__add_guild.format(command=cmd)
163
164
        await self.client.http.post(
165
            self.__prefix + add_endpoint, data=cmd.to_dict()
166
        )
167
168
    async def add_commands(self, commands: List[AppCommand]):
169
        """|coro|
170
171
        Add a list of app commands
172
173
        Parameters
174
        ----------
175
        commands : List[:class:`~pincer.objects.app.command.AppCommand`]
176
            List of command objects to add
177
        """
178
        await gather(*map(self.add_command, commands))
179
180
    @staticmethod
181
    def __build_local_commands():
182
        """Builds the commands into the format that Discord expects. See class info
183
        for the reasoning.
184
        """
185
        for cmd in ChatCommandHandler.register.values():
186
187
            if cmd.sub_group:
188
                # If a command has a sub_group, it must be nested 2 levels deep.
189
                #
190
                # command
191
                #     subcommand-group
192
                #         subcommand
193
                #
194
                # The children of the subcommand-group object are being set to include
195
                # `cmd` If that subcommand-group object does not exist, it will be
196
                # created here. The same goes for the top-level command.
197
                #
198
                # First make sure the command exists. This command will hold the
199
                # subcommand-group for `cmd`.
200
201
                # `key` represents the hash value for the top-level command that will
202
                # hold the subcommand.
203
                key = _hash_app_command_params(
204
                    cmd.group.name,
205
                    cmd.app.guild_id,
206
                    AppCommandType.CHAT_INPUT,
207
                    None,
208
                    None,
209
                )
210
211
                if key not in ChatCommandHandler.built_register:
212
                    ChatCommandHandler.built_register[key] = AppCommand(
213
                        name=cmd.group.name,
214
                        description=cmd.group.description,
215
                        type=AppCommandType.CHAT_INPUT,
216
                        guild_id=cmd.app.guild_id,
217
                        options=[]
218
                    )
219
220
                # The top-level command now exists. A subcommand group now if placed
221
                # inside the top-level command. This subcommand group will hold `cmd`.
222
223
                children = ChatCommandHandler.built_register[key].options
224
225
                sub_command_group = AppCommandOption(
226
                    name=cmd.sub_group.name,
227
                    description=cmd.sub_group.description,
228
                    type=AppCommandOptionType.SUB_COMMAND_GROUP,
229
                    options=[]
230
                )
231
232
                # This for-else makes sure that sub_command_group will hold a reference
233
                # to the subcommand group that we want to modify to hold `cmd`
234
235
                for cmd_in_children in children:
236
                    if (
237
                        cmd_in_children.name == sub_command_group.name
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
238
                        and cmd_in_children.description == sub_command_group.description
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
239
                        and cmd_in_children.type == sub_command_group.type
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
240
                    ):
241
                        sub_command_group = cmd_in_children
242
                        break
243
                else:
244
                    children.append(sub_command_group)
245
246
                sub_command_group.options.append(AppCommandOption(
247
                    name=cmd.app.name,
248
                    description=cmd.app.description,
249
                    type=AppCommandOptionType.SUB_COMMAND,
250
                    options=cmd.app.options,
251
                ))
252
253
                continue
254
255
            if cmd.group:
256
                # Any command at this point will only have one level of nesting.
257
                #
258
                # Command
259
                #    subcommand
260
                #
261
                # A subcommand object is what is being generated here. If there is no
262
                # top level command, it will be created here.
263
264
                # `key` represents the hash value for the top-level command that will
265
                # hold the subcommand.
266
267
                key = _hash_app_command_params(
268
                    cmd.group.name,
269
                    cmd.app.guild_id,
270
                    AppCommandOptionType.SUB_COMMAND,
271
                    None,
272
                    None
273
                )
274
275
                if key not in ChatCommandHandler.built_register:
276
                    ChatCommandHandler.built_register[key] = AppCommand(
277
                        name=cmd.group.name,
278
                        description=cmd.group.description,
279
                        type=AppCommandOptionType.SUB_COMMAND,
280
                        guild_id=cmd.app.guild_id,
281
                        options=[]
282
                    )
283
284
                # No checking has to be done before appending `cmd` since it is the
285
                # lowest level.
286
                ChatCommandHandler.built_register[key].options.append(
287
                    AppCommandOption(
288
                        name=cmd.app.name,
289
                        description=cmd.app.description,
290
                        type=AppCommandType.CHAT_INPUT,
291
                        options=cmd.app.options
292
                    )
293
                )
294
295
                continue
296
297
            # All single-level commands are registered here.
298
            ChatCommandHandler.built_register[
299
                _hash_app_command(cmd.app, cmd.group, cmd.sub_group)
300
            ] = cmd.app
301
302
    @staticmethod
303
    def get_local_registered_commands() -> ValuesView[AppCommand]:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
304
        return ChatCommandHandler.built_register.values()
305
306
    async def __get_existing_commands(self):
307
        """|coro|
308
309
        Get AppCommand objects for all commands registered to discord.
310
        """
311
        try:
312
            self._api_commands = await self.get_commands()
313
314
        except ForbiddenError:
315
            logging.error("Cannot retrieve slash commands, skipping...")
316
            return
317
318
    async def __remove_unused_commands(self):
319
        """|coro|
320
321
        Remove commands that are registered by discord but not in use
322
        by the current client
323
        """
324
        local_registered_commands = self.get_local_registered_commands()
325
326
        def should_be_removed(target: AppCommand) -> bool:
327
            for reg_cmd in local_registered_commands:
328
                # Commands have endpoints based on their `name` amd `guild_id`. Other
329
                # parameters can be updated instead of deleting and re-registering the
330
                # command.
331
                if (
332
                    target.name == reg_cmd.name
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
333
                    and target.guild_id == reg_cmd.guild_id
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
334
                ):
335
                    return False
336
            return True
337
338
        # NOTE: Cannot be generator since it can't be consumed due to lines 743-745
339
        to_remove = [*filter(should_be_removed, self._api_commands)]
340
341
        await gather(
342
            *map(
343
                lambda cmd: self.remove_command(cmd),
0 ignored issues
show
Unused Code introduced by
This lambda might be unnecessary.
Loading history...
344
                to_remove,
345
            )
346
        )
347
348
        self._api_commands = list(
349
            filter(lambda cmd: cmd not in to_remove, self._api_commands)
350
        )
351
352
    async def __add_commands(self):
353
        """|coro|
354
355
        Add all new commands which have been registered by the decorator to Discord.
356
357
        .. code-block::
358
359
            Because commands have unique names within a type and scope, we treat POST
360
            requests for new commands as upserts. That means making a new command with
361
            an already-used name for your application will update the existing command.
362
            `<https://discord.dev/interactions/application-commands#updating-and-deleting-a-command>`_
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (102/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
363
364
        Therefore, we don't need to use a separate loop for updating and adding
365
        commands.
366
        """
367
        local_registered_commands = self.get_local_registered_commands()
368
369
        def should_be_updated_or_uploaded(target):
370
            for command in self._api_commands:
371
                if target == command:
372
                    return False
373
            return True
374
375
        changed_commands = filter(
376
            should_be_updated_or_uploaded, local_registered_commands
377
        )
378
379
        for command in changed_commands:
380
            await self.add_command(command)
381
382
    async def initialize(self):
383
        """|coro|
384
385
        Call methods of this class to refresh all app commands
386
        """
387
        if ChatCommandHandler.has_been_initialized:
388
            # Only first shard should be initialized.
389
            return
390
391
        ChatCommandHandler.has_been_initialized = True
392
393
        self.__build_local_commands()
394
        await self.__get_existing_commands()
395
        await self.__remove_unused_commands()
396
        await self.__add_commands()
397
398
399
def _hash_app_command(
400
    command: AppCommand,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
401
    group: Optional[str],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
402
    sub_group: Optional[str]
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
403
) -> int:
404
    """
405
    See :func:`~pincer.commands.commands._hash_app_command_params` for information.
406
    """
407
    return _hash_app_command_params(
408
        command.name,
409
        command.guild_id,
410
        command.type,
411
        group,
412
        sub_group
413
    )
414
415
416
def _hash_app_command_params(
417
    name: str,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
418
    guild_id: Union[Snowflake, None, MISSING],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
419
    app_command_type: AppCommandType,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
420
    group: Optional[str],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
421
    sub_group: Optional[str]
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
422
) -> int:
423
    """
424
    The group layout in Pincer is very different from what discord has on their docs.
425
    You can think of the Pincer group layout like this:
426
427
    name: The name of the function that is being called.
428
429
    group: The :class:`~pincer.commands.groups.Group` object that this function is
430
        using.
431
    sub_option: The :class:`~pincer.commands.groups.Subgroup` object that this
432
        functions is using.
433
434
    Abstracting away this part of the Discord API allows for a much cleaner
435
    transformation between what users want to input and what commands Discord
436
    expects.
437
438
    Parameters
439
    ----------
440
    name : str
441
        The name of the function for the command
442
    guild_id : Union[:class:`~pincer.utils.snowflake.Snowflake`, None, MISSING]
443
        The ID of a guild, None, or MISSING.
444
    app_command_type : :class:`~pincer.objects.app.command_types.AppCommandType`
445
        The app command type of the command. NOT THE OPTION TYPE.
446
    group : str
447
        The highest level of organization the command is it. This should always be the
448
        name of the base command. :data:`None` or :data:`MISSING` if not there.
449
    sub_option : str
450
        The name of the group that holds the lowest level of options. :data:`None` or
451
        :data:`MISSING` if not there.
452
    """
453
    return hash((name, guild_id, app_command_type, group, sub_group))
454