Passed
Pull Request — main (#389)
by
unknown
01:42
created

ChatCommandHandler.get_commands()   A

Complexity

Conditions 2

Size

Total Lines 23
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 11
dl 0
loc 23
rs 9.85
c 0
b 0
f 0
cc 2
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
84
        self.__prefix = f"applications/{self.client.bot.id}"
85
86
    async def get_commands(self) -> List[AppCommand]:
87
        """|coro|
88
89
        Get a list of app commands from Discord
90
91
        Returns
92
        -------
93
        List[:class:`~pincer.objects.app.command.AppCommand`]
94
            List of commands.
95
        """
96
        # 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...
97
        guild_commands = await gather(*(
98
            self.client.http.get(
99
                self.__prefix + self.__get_guild.format(
100
                    guild_id=guild.id if isinstance(guild, Guild) else guild
101
                )
102
            ) for guild in self.client.guilds
103
        ))
104
        return list(
105
            map(
106
                AppCommand.from_dict,
107
                await self.client.http.get(self.__prefix + self.__get)
108
                + [cmd for guild in guild_commands for cmd in guild],
109
            )
110
        )
111
112
    async def remove_command(self, cmd: AppCommand):
113
        """|coro|
114
115
        Remove a specific command
116
117
        Parameters
118
        ----------
119
        cmd : :class:`~pincer.objects.app.command.AppCommand`
120
            What command to delete
121
        """
122
        # 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...
123
        if cmd.guild_id:
124
            _log.info(
125
                "Removing command `%s` with guild id %d from Discord",
126
                cmd.name,
127
                cmd.guild_id,
128
            )
129
        else:
130
            _log.info("Removing global command `%s` from Discord", cmd.name)
131
132
        remove_endpoint = self.__delete_guild if cmd.guild_id else self.__delete
133
134
        await self.client.http.delete(
135
            self.__prefix + remove_endpoint.format(command=cmd)
136
        )
137
138
    async def add_command(self, cmd: AppCommand):
139
        """|coro|
140
141
        Add an app command
142
143
        Parameters
144
        ----------
145
        cmd : :class:`~pincer.objects.app.command.AppCommand`
146
            Command to add
147
        """
148
        _log.info("Updated or registered command `%s` to Discord", cmd.name)
149
150
        add_endpoint = self.__add
151
152
        if cmd.guild_id:
153
            add_endpoint = self.__add_guild.format(command=cmd)
154
155
        await self.client.http.post(
156
            self.__prefix + add_endpoint, data=cmd.to_dict()
157
        )
158
159
    async def add_commands(self, commands: List[AppCommand]):
160
        """|coro|
161
162
        Add a list of app commands
163
164
        Parameters
165
        ----------
166
        commands : List[:class:`~pincer.objects.app.command.AppCommand`]
167
            List of command objects to add
168
        """
169
        await gather(*map(self.add_command, commands))
170
171
    @staticmethod
172
    def __build_local_commands():
173
        """Builds the commands into the format that Discord expects. See class info
174
        for the reasoning.
175
        """
176
        for cmd in ChatCommandHandler.register.values():
177
178
            if cmd.sub_group:
179
                # If a command has a sub_group, it must be nested 2 levels deep.
180
                #
181
                # command
182
                #     subcommand-group
183
                #         subcommand
184
                #
185
                # The children of the subcommand-group object are being set to include
186
                # `cmd` If that subcommand-group object does not exist, it will be
187
                # created here. The same goes for the top-level command.
188
                #
189
                # First make sure the command exists. This command will hold the
190
                # subcommand-group for `cmd`.
191
192
                # `key` represents the hash value for the top-level command that will
193
                # hold the subcommand.
194
                key = _hash_app_command_params(
195
                    cmd.group.name,
196
                    cmd.app.guild_id,
197
                    AppCommandType.CHAT_INPUT,
198
                    None,
199
                    None,
200
                )
201
202
                if key not in ChatCommandHandler.built_register:
203
                    ChatCommandHandler.built_register[key] = AppCommand(
204
                        name=cmd.group.name,
205
                        description=cmd.group.description,
206
                        type=AppCommandType.CHAT_INPUT,
207
                        guild_id=cmd.app.guild_id,
208
                        options=[]
209
                    )
210
211
                # The top-level command now exists. A subcommand group now if placed
212
                # inside the top-level command. This subcommand group will hold `cmd`.
213
214
                children = ChatCommandHandler.built_register[key].options
215
216
                sub_command_group = AppCommandOption(
217
                    name=cmd.sub_group.name,
218
                    description=cmd.sub_group.description,
219
                    type=AppCommandOptionType.SUB_COMMAND_GROUP,
220
                    options=[]
221
                )
222
223
                # This for-else makes sure that sub_command_group will hold a reference
224
                # to the subcommand group that we want to modify to hold `cmd`
225
226
                for cmd_in_children in children:
227
                    if (
228
                        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...
229
                        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...
230
                        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...
231
                    ):
232
                        sub_command_group = cmd_in_children
233
                        break
234
                else:
235
                    children.append(sub_command_group)
236
237
                sub_command_group.options.append(AppCommandOption(
238
                    name=cmd.app.name,
239
                    description=cmd.app.description,
240
                    type=AppCommandOptionType.SUB_COMMAND,
241
                    options=cmd.app.options,
242
                ))
243
244
                continue
245
246
            if cmd.group:
247
                # Any command at this point will only have one level of nesting.
248
                #
249
                # Command
250
                #    subcommand
251
                #
252
                # A subcommand object is what is being generated here. If there is no
253
                # top level command, it will be created here.
254
255
                # `key` represents the hash value for the top-level command that will
256
                # hold the subcommand.
257
258
                key = _hash_app_command_params(
259
                    cmd.group.name,
260
                    cmd.app.guild_id,
261
                    AppCommandOptionType.SUB_COMMAND,
262
                    None,
263
                    None
264
                )
265
266
                if key not in ChatCommandHandler.built_register:
267
                    ChatCommandHandler.built_register[key] = AppCommand(
268
                        name=cmd.group.name,
269
                        description=cmd.group.description,
270
                        type=AppCommandOptionType.SUB_COMMAND,
271
                        guild_id=cmd.app.guild_id,
272
                        options=[]
273
                    )
274
275
                # No checking has to be done before appending `cmd` since it is the
276
                # lowest level.
277
                ChatCommandHandler.built_register[key].options.append(
278
                    AppCommandOption(
279
                        name=cmd.app.name,
280
                        description=cmd.app.description,
281
                        type=AppCommandType.CHAT_INPUT,
282
                        options=cmd.app.options
283
                    )
284
                )
285
286
                continue
287
288
            # All single-level commands are registered here.
289
            ChatCommandHandler.built_register[
290
                _hash_app_command(cmd.app, cmd.group, cmd.sub_group)
291
            ] = cmd.app
292
293
    @staticmethod
294
    def get_local_registered_commands() -> ValuesView[AppCommand]:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
295
        return ChatCommandHandler.built_register.values()
296
297
    async def __get_existing_commands(self):
298
        """|coro|
299
300
        Get AppCommand objects for all commands registered to discord.
301
        """
302
        try:
303
            self._api_commands = await self.get_commands()
304
305
        except ForbiddenError:
306
            logging.error("Cannot retrieve slash commands, skipping...")
307
            return
308
309
    async def __remove_unused_commands(self):
310
        """|coro|
311
312
        Remove commands that are registered by discord but not in use
313
        by the current client
314
        """
315
        local_registered_commands = self.get_local_registered_commands()
316
317
        def should_be_removed(target: AppCommand) -> bool:
318
            for reg_cmd in local_registered_commands:
319
                # Commands have endpoints based on their `name` amd `guild_id`. Other
320
                # parameters can be updated instead of deleting and re-registering the
321
                # command.
322
                if (
323
                    target.name == reg_cmd.name
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
324
                    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...
325
                ):
326
                    return False
327
            return True
328
329
        # NOTE: Cannot be generator since it can't be consumed due to lines 743-745
330
        to_remove = [*filter(should_be_removed, self._api_commands)]
331
332
        await gather(
333
            *(self.remove_command(cmd) for cmd in to_remove)
334
        )
335
336
        self._api_commands = [
337
            cmd for cmd in self._api_commands
338
            if cmd not in to_remove
339
        ]
340
341
    async def __add_commands(self):
342
        """|coro|
343
344
        Add all new commands which have been registered by the decorator to Discord.
345
346
        .. code-block::
347
348
            Because commands have unique names within a type and scope, we treat POST
349
            requests for new commands as upserts. That means making a new command with
350
            an already-used name for your application will update the existing command.
351
            `<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...
352
353
        Therefore, we don't need to use a separate loop for updating and adding
354
        commands.
355
        """
356
        local_registered_commands = self.get_local_registered_commands()
357
358
        def should_be_updated_or_uploaded(target):
359
            for command in self._api_commands:
360
                if target == command:
361
                    return False
362
            return True
363
364
        changed_commands = filter(
365
            should_be_updated_or_uploaded, local_registered_commands
366
        )
367
368
        for command in changed_commands:
369
            await self.add_command(command)
370
371
    async def initialize(self):
372
        """|coro|
373
374
        Call methods of this class to refresh all app commands
375
        """
376
        if ChatCommandHandler.has_been_initialized:
377
            # Only first shard should be initialized.
378
            return
379
380
        ChatCommandHandler.has_been_initialized = True
381
382
        self.__build_local_commands()
383
        await self.__get_existing_commands()
384
        await self.__remove_unused_commands()
385
        await self.__add_commands()
386
387
388
def _hash_app_command(
389
    command: AppCommand,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
390
    group: Optional[str],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
391
    sub_group: Optional[str]
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
392
) -> int:
393
    """
394
    See :func:`~pincer.commands.commands._hash_app_command_params` for information.
395
    """
396
    return _hash_app_command_params(
397
        command.name,
398
        command.guild_id,
399
        command.type,
400
        group,
401
        sub_group
402
    )
403
404
405
def _hash_app_command_params(
406
    name: str,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
407
    guild_id: Union[Snowflake, None, MISSING],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
408
    app_command_type: AppCommandType,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
409
    group: Optional[str],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
410
    sub_group: Optional[str]
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
411
) -> int:
412
    """
413
    The group layout in Pincer is very different from what discord has on their docs.
414
    You can think of the Pincer group layout like this:
415
416
    name: The name of the function that is being called.
417
418
    group: The :class:`~pincer.commands.groups.Group` object that this function is
419
        using.
420
    sub_option: The :class:`~pincer.commands.groups.Subgroup` object that this
421
        functions is using.
422
423
    Abstracting away this part of the Discord API allows for a much cleaner
424
    transformation between what users want to input and what commands Discord
425
    expects.
426
427
    Parameters
428
    ----------
429
    name : str
430
        The name of the function for the command
431
    guild_id : Union[:class:`~pincer.utils.snowflake.Snowflake`, None, MISSING]
432
        The ID of a guild, None, or MISSING.
433
    app_command_type : :class:`~pincer.objects.app.command_types.AppCommandType`
434
        The app command type of the command. NOT THE OPTION TYPE.
435
    group : str
436
        The highest level of organization the command is it. This should always be the
437
        name of the base command. :data:`None` or :data:`MISSING` if not there.
438
    sub_option : str
439
        The name of the group that holds the lowest level of options. :data:`None` or
440
        :data:`MISSING` if not there.
441
    """
442
    return hash((name, guild_id, app_command_type, group, sub_group))
443