1
|
|
|
# Copyright Pincer 2021-Present |
|
|
|
|
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 |
|
|
|
|
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`]] |
|
|
|
|
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 |
|
|
|
|
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 |
|
|
|
|
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 |
|
|
|
|
235
|
|
|
and cmd_in_children.description |
|
|
|
|
236
|
|
|
== sub_command_group.description |
|
|
|
|
237
|
|
|
and cmd_in_children.type == sub_command_group.type |
|
|
|
|
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]: |
|
|
|
|
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>`_ |
|
|
|
|
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], |
|
|
|
|
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] |
|
|
|
|
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, |
|
|
|
|
400
|
|
|
guild_id: Union[Snowflake, None, MISSING], |
|
|
|
|
401
|
|
|
app_command_type: AppCommandType, |
|
|
|
|
402
|
|
|
group: Optional[str], |
|
|
|
|
403
|
|
|
sub_group: Optional[str], |
|
|
|
|
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
|
|
|
|