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( |
81
|
|
|
"%i commands registered.", len(ChatCommandHandler.register) |
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 |
|
|
|
|
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 |
|
|
|
|
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
|
|
|
|
177
|
|
|
# Reset the built register |
178
|
|
|
ChatCommandHandler.built_register = {} |
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.metadata.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.metadata.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 |
|
|
|
|
233
|
|
|
and cmd_in_children.description == sub_command_group.description |
|
|
|
|
234
|
|
|
and cmd_in_children.type == sub_command_group.type |
|
|
|
|
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.metadata.name, |
243
|
|
|
description=cmd.metadata.description, |
244
|
|
|
type=AppCommandOptionType.SUB_COMMAND, |
245
|
|
|
options=cmd.metadata.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.metadata.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.metadata.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.metadata.name, |
284
|
|
|
description=cmd.metadata.description, |
285
|
|
|
type=AppCommandType.CHAT_INPUT, |
286
|
|
|
options=cmd.metadata.options |
287
|
|
|
) |
288
|
|
|
) |
289
|
|
|
|
290
|
|
|
continue |
291
|
|
|
|
292
|
|
|
# All single-level commands are registered here. |
293
|
|
|
ChatCommandHandler.built_register[ |
294
|
|
|
_hash_interactable_structure(cmd) |
295
|
|
|
] = cmd.metadata |
296
|
|
|
|
297
|
|
|
@staticmethod |
298
|
|
|
def get_local_registered_commands() -> ValuesView[AppCommand]: |
|
|
|
|
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
|
|
|
except ForbiddenError: |
309
|
|
|
logging.error("Cannot retrieve slash commands, skipping...") |
310
|
|
|
return |
311
|
|
|
|
312
|
|
|
async def __remove_unused_commands(self): |
313
|
|
|
"""|coro| |
314
|
|
|
|
315
|
|
|
Remove commands that are registered by discord but not in use |
316
|
|
|
by the current client |
317
|
|
|
""" |
318
|
|
|
local_registered_commands = self.get_local_registered_commands() |
319
|
|
|
|
320
|
|
|
def should_be_removed(target: AppCommand) -> bool: |
321
|
|
|
# Commands have endpoints based on their `name` amd `guild_id`. Other |
322
|
|
|
# parameters can be updated instead of deleting and re-registering the |
323
|
|
|
# command. |
324
|
|
|
return all( |
325
|
|
|
target.name != reg_cmd.name |
326
|
|
|
and target.guild_id != reg_cmd.guild_id |
327
|
|
|
for reg_cmd in local_registered_commands |
328
|
|
|
) |
329
|
|
|
|
330
|
|
|
# NOTE: Cannot be generator since it can't be consumed due to lines 743-745 |
331
|
|
|
to_remove = [*filter(should_be_removed, self._api_commands)] |
332
|
|
|
|
333
|
|
|
await gather( |
334
|
|
|
*(self.remove_command(cmd) for cmd in to_remove) |
335
|
|
|
) |
336
|
|
|
|
337
|
|
|
self._api_commands = [ |
338
|
|
|
cmd for cmd in self._api_commands |
339
|
|
|
if cmd not in to_remove |
340
|
|
|
] |
341
|
|
|
|
342
|
|
|
async def __add_commands(self): |
343
|
|
|
"""|coro| |
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>`_ |
|
|
|
|
352
|
|
|
|
353
|
|
|
Therefore, we don't need to use a separate loop for updating and adding |
354
|
|
|
commands. |
355
|
|
|
""" |
356
|
|
|
for command in self.get_local_registered_commands(): |
357
|
|
|
if command not in self._api_commands: |
358
|
|
|
await self.add_command(command) |
359
|
|
|
|
360
|
|
|
async def initialize(self): |
361
|
|
|
"""|coro| |
362
|
|
|
|
363
|
|
|
Call methods of this class to refresh all app commands |
364
|
|
|
""" |
365
|
|
|
if ChatCommandHandler.has_been_initialized: |
366
|
|
|
# Only first shard should be initialized. |
367
|
|
|
return |
368
|
|
|
|
369
|
|
|
ChatCommandHandler.has_been_initialized = True |
370
|
|
|
|
371
|
|
|
self.__build_local_commands() |
372
|
|
|
await self.__get_existing_commands() |
373
|
|
|
await self.__remove_unused_commands() |
374
|
|
|
await self.__add_commands() |
375
|
|
|
|
376
|
|
|
|
377
|
|
|
def _hash_interactable_structure(interactable: InteractableStructure[AppCommand]): |
378
|
|
|
return _hash_app_command( |
379
|
|
|
interactable.metadata, |
380
|
|
|
interactable.group, |
381
|
|
|
interactable.sub_group |
382
|
|
|
) |
383
|
|
|
|
384
|
|
|
|
385
|
|
|
def _hash_app_command( |
386
|
|
|
command: AppCommand, |
|
|
|
|
387
|
|
|
group: Optional[str], |
|
|
|
|
388
|
|
|
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, |
395
|
|
|
command.guild_id, |
396
|
|
|
command.type, |
397
|
|
|
group, |
398
|
|
|
sub_group |
399
|
|
|
) |
400
|
|
|
|
401
|
|
|
|
402
|
|
|
def _hash_app_command_params( |
403
|
|
|
name: str, |
|
|
|
|
404
|
|
|
guild_id: Union[Snowflake, None, MISSING], |
|
|
|
|
405
|
|
|
app_command_type: AppCommandType, |
|
|
|
|
406
|
|
|
group: Optional[str], |
|
|
|
|
407
|
|
|
sub_group: Optional[str] |
|
|
|
|
408
|
|
|
) -> int: |
409
|
|
|
""" |
410
|
|
|
The group layout in Pincer is very different from what discord has on their docs. |
411
|
|
|
You can think of the Pincer group layout like this: |
412
|
|
|
|
413
|
|
|
name: The name of the function that is being called. |
414
|
|
|
|
415
|
|
|
group: The :class:`~pincer.commands.groups.Group` object that this function is |
416
|
|
|
using. |
417
|
|
|
sub_option: The :class:`~pincer.commands.groups.Subgroup` object that this |
418
|
|
|
functions is using. |
419
|
|
|
|
420
|
|
|
Abstracting away this part of the Discord API allows for a much cleaner |
421
|
|
|
transformation between what users want to input and what commands Discord |
422
|
|
|
expects. |
423
|
|
|
|
424
|
|
|
Parameters |
425
|
|
|
---------- |
426
|
|
|
name : str |
427
|
|
|
The name of the function for the command |
428
|
|
|
guild_id : Union[:class:`~pincer.utils.snowflake.Snowflake`, None, MISSING] |
429
|
|
|
The ID of a guild, None, or MISSING. |
430
|
|
|
app_command_type : :class:`~pincer.objects.app.command_types.AppCommandType` |
431
|
|
|
The app command type of the command. NOT THE OPTION TYPE. |
432
|
|
|
group : str |
433
|
|
|
The highest level of organization the command is it. This should always be the |
434
|
|
|
name of the base command. :data:`None` or :data:`MISSING` if not there. |
435
|
|
|
sub_option : str |
436
|
|
|
The name of the group that holds the lowest level of options. :data:`None` or |
437
|
|
|
:data:`MISSING` if not there. |
438
|
|
|
""" |
439
|
|
|
return hash((name, guild_id, app_command_type, group, sub_group)) |
440
|
|
|
|