ChatCommandHandler.add_command()   A
last analyzed

Complexity

Conditions 2

Size

Total Lines 19
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 7
dl 0
loc 19
rs 10
c 0
b 0
f 0
cc 2
nop 2
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`[:class:`~pincer.objects.app.command.AppCommand`]]
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (141/100).

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

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