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

ChatCommandHandler.__build_local_commands()   C

Complexity

Conditions 11

Size

Total Lines 121
Code Lines 61

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 61
dl 0
loc 121
rs 5.0563
c 0
b 0
f 0
cc 11
nop 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like pincer.commands.chat_command_handler.ChatCommandHandler.__build_local_commands() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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