Skip to content

Commit ecb9fca

Browse files
AstreaTSSzevaryx
andauthored
feat: add support for user-installable apps (#1647)
* [feat] Initial UserApps support * fix: use factory instead of default * fix: properly process and sync contexts and integration_types * fix: correct typehints of new fields * feat: add integration_types and contexts to decorators * fix: might as well * feat: add context and authorizing_integration_owners fields to context * feat: add interaction_metadata to Message * fix: presumably would be an issue here * fix: export MessageInteractionMetadata * feat: lazly add integration_types_config * feat: hybrid ctx parity * docs: add comments for hybrid ctx channel types * fix: add new enums to __all__ * ci: make pre-commit not complain * fix: properly handle integration_types and contexts for subcommands * feat: add contexts and integration_types decorators Not sure how I feel about the names. * docs: add basic docs for contexts/integration_types * fix: do not compare contexts when guild cmd * refactor: use less hacky logic for dm_permission * fix: make hybrid cmd use prefixed channel when possible * feat: add integration_types/contexts parity for hybrid cmds * refactor: just use already-determined context * fix: parse and use new user field for MessageInteractionMetadata * fix: add custom context logic if context is empty --------- Co-authored-by: zevaryx <[email protected]>
1 parent 8aea2c7 commit ecb9fca

File tree

12 files changed

+385
-33
lines changed

12 files changed

+385
-33
lines changed

docs/src/Guides/03 Creating Commands.md

+61-10
Original file line numberDiff line numberDiff line change
@@ -423,18 +423,69 @@ There are two ways to define permissions.
423423

424424
Multiple permissions are defined with the bitwise OR operator `|`.
425425

426-
### Blocking Commands in DMs
426+
## Usable Contexts
427427

428-
You can also block commands in DMs. To do that, just set `dm_permission` to false.
428+
You can control where slash commands (and other application commands) can be used using - in guilds, in DMs, and/or other private channels. By default, commands can be used in all contexts.
429429

430-
```py
431-
@slash_command(
432-
name="my_guild_only_command",
433-
dm_permission=False,
434-
)
435-
async def my_command_function(ctx: SlashContext):
436-
...
437-
```
430+
As with permissions, there are two ways to define the context.
431+
432+
=== ":one: Decorators"
433+
434+
```python
435+
from interactions import contexts
436+
437+
@slash_command(name="my_guild_only_command")
438+
@contexts(guild=True, bot_dm=False, private_channel=False)
439+
async def my_command_function(ctx: SlashContext):
440+
...
441+
```
442+
443+
=== ":two: Function Definition"
444+
445+
```python
446+
from interactions import ContextType
447+
448+
@slash_command(
449+
name="my_command",
450+
contexts=[ContextType.GUILD],
451+
)
452+
async def my_command_function(ctx: SlashContext):
453+
...
454+
```
455+
456+
## Integration Types
457+
458+
Applications can be installed/integrated in different ways:
459+
- The one you are familiar with is the *guild* integration, where the application is installed in a specific guild, and so the entire guild can use the application.
460+
- You can also install the application to a *user*, where the application can then be used by the user anywhere they desire.
461+
462+
By default, commands can only be used in guild integrations. Like many other properties, this can be changed.
463+
464+
There are two ways to define this:
465+
466+
=== ":one: Decorators"
467+
468+
```python
469+
from interactions import integration_types
470+
471+
@slash_command(name="my_command")
472+
@integration_types(guild=True, user=True)
473+
async def my_command_function(ctx: SlashContext):
474+
...
475+
```
476+
477+
=== ":two: Function Definition"
478+
479+
```python
480+
from interactions import IntegrationType
481+
482+
@slash_command(
483+
name="my_command",
484+
integration_types=[IntegrationType.GUILD_INSTALL, IntegrationType.USER_INSTALL],
485+
)
486+
async def my_command_function(ctx: SlashContext):
487+
...
488+
```
438489

439490
## Checks
440491

interactions/__init__.py

+10
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,11 @@
104104
ComponentContext,
105105
ComponentType,
106106
ConsumeRest,
107+
contexts,
107108
context_menu,
108109
ContextMenu,
109110
ContextMenuContext,
111+
ContextType,
110112
Converter,
111113
cooldown,
112114
Cooldown,
@@ -179,6 +181,8 @@
179181
IDConverter,
180182
InputText,
181183
IntegrationExpireBehaviour,
184+
IntegrationType,
185+
integration_types,
182186
Intents,
183187
InteractionCommand,
184188
InteractionContext,
@@ -214,6 +218,7 @@
214218
MessageConverter,
215219
MessageFlags,
216220
MessageInteraction,
221+
MessageInteractionMetadata,
217222
MessageReference,
218223
MessageType,
219224
MFALevel,
@@ -426,10 +431,12 @@
426431
"ComponentType",
427432
"ConsumeRest",
428433
"const",
434+
"contexts",
429435
"context_menu",
430436
"CONTEXT_MENU_NAME_LENGTH",
431437
"ContextMenu",
432438
"ContextMenuContext",
439+
"ContextType",
433440
"Converter",
434441
"cooldown",
435442
"Cooldown",
@@ -519,6 +526,8 @@
519526
"IDConverter",
520527
"InputText",
521528
"IntegrationExpireBehaviour",
529+
"IntegrationType",
530+
"integration_types",
522531
"Intents",
523532
"InteractionCommand",
524533
"InteractionContext",
@@ -558,6 +567,7 @@
558567
"MessageConverter",
559568
"MessageFlags",
560569
"MessageInteraction",
570+
"MessageInteractionMetadata",
561571
"MessageReference",
562572
"MessageType",
563573
"MFALevel",

interactions/ext/hybrid_commands/context.py

+34-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
to_snowflake,
2323
Attachment,
2424
process_message_payload,
25+
TYPE_MESSAGEABLE_CHANNEL,
2526
)
27+
from interactions.models.discord.enums import ContextType
2628
from interactions.client.mixins.send import SendMixin
2729
from interactions.client.errors import HTTPException
2830
from interactions.ext import prefixed_commands as prefixed
@@ -60,6 +62,9 @@ class HybridContext(BaseContext, SendMixin):
6062
ephemeral: bool
6163
"""Whether the context response is ephemeral."""
6264

65+
context: Optional[ContextType]
66+
"""Context where the command was triggered from"""
67+
6368
_command_name: str
6469
"""The command name."""
6570
_message: Message | None
@@ -81,6 +86,7 @@ def __init__(self, client: Client):
8186
self.deferred = False
8287
self.responded = False
8388
self.ephemeral = False
89+
self.context = None
8490
self._command_name = ""
8591
self.args = []
8692
self.kwargs = {}
@@ -106,6 +112,7 @@ def from_slash_context(cls, ctx: SlashContext) -> Self:
106112
self.deferred = ctx.deferred
107113
self.responded = ctx.responded
108114
self.ephemeral = ctx.ephemeral
115+
self.context = ctx.context
109116
self._command_name = ctx._command_name
110117
self.args = ctx.args
111118
self.kwargs = ctx.kwargs
@@ -121,9 +128,27 @@ def from_prefixed_context(cls, ctx: prefixed.PrefixedContext) -> Self:
121128
elif ctx.channel.type in {10, 11, 12}: # it's a thread
122129
app_permissions = ctx.channel.parent_channel.permissions_for(ctx.guild.me) # type: ignore
123130
else:
124-
app_permissions = Permissions(0)
131+
# likely a dm, give a sane default
132+
app_permissions = (
133+
Permissions.VIEW_CHANNEL
134+
| Permissions.SEND_MESSAGES
135+
| Permissions.READ_MESSAGE_HISTORY
136+
| Permissions.EMBED_LINKS
137+
| Permissions.ATTACH_FILES
138+
| Permissions.MENTION_EVERYONE
139+
| Permissions.USE_EXTERNAL_EMOJIS
140+
)
125141

126142
self = cls(ctx.client)
143+
144+
if ctx.channel.type == 1: # dm
145+
# note that prefixed cmds for dms cannot be used outside of bot dms
146+
self.context = ContextType.BOT_DM
147+
elif ctx.channel.type == 3: # group dm - technically not possible but just in case
148+
self.context = ContextType.PRIVATE_CHANNEL
149+
else:
150+
self.context = ContextType.GUILD
151+
127152
self.guild_id = ctx.guild_id
128153
self.channel_id = ctx.channel_id
129154
self.author_id = ctx.author_id
@@ -165,6 +190,14 @@ def deferred_ephemeral(self) -> bool:
165190
"""Whether the interaction has been deferred ephemerally."""
166191
return self.deferred and self.ephemeral
167192

193+
@property
194+
def channel(self) -> "TYPE_MESSAGEABLE_CHANNEL":
195+
"""The channel this context was invoked in."""
196+
if self._prefixed_ctx:
197+
return self._prefixed_ctx.channel
198+
199+
return self._slash_ctx.channel
200+
168201
@property
169202
def message(self) -> Message | None:
170203
"""The message that invoked this context."""

interactions/ext/hybrid_commands/hybrid_slash.py

+35-7
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@
2626
SlashCommandOption,
2727
Snowflake_Type,
2828
Permissions,
29+
ContextType,
30+
IntegrationType,
2931
)
3032
from interactions.client.const import AsyncCallable, GLOBAL_SCOPE
3133
from interactions.client.utils.serializer import no_export_meta
3234
from interactions.client.utils.misc_utils import maybe_coroutine, get_object_name
3335
from interactions.client.errors import BadArgument
3436
from interactions.ext.prefixed_commands import PrefixedCommand, PrefixedContext
3537
from interactions.models.internal.converters import _LiteralConverter, CONSUME_REST_MARKER
36-
from interactions.models.internal.checks import guild_only
3738

3839
if TYPE_CHECKING:
3940
from .context import HybridContext
@@ -45,6 +46,22 @@ def _values_wrapper(a_dict: dict | None) -> list:
4546
return list(a_dict.values()) if a_dict else []
4647

4748

49+
def generate_contexts_check(contexts: list[ContextType | int]) -> Callable[["HybridContext"], Awaitable[bool]]:
50+
set_contexts = frozenset(contexts)
51+
52+
async def _contexts_check(ctx: "HybridContext") -> bool:
53+
if ctx.context:
54+
return ctx.context in set_contexts
55+
56+
if ctx.guild_id:
57+
return ContextType.GUILD in set_contexts
58+
if ctx.channel.type == 1 and ctx.channel.recipient.id == ctx.bot.user.id:
59+
return ContextType.BOT_DM in set_contexts
60+
return ContextType.PRIVATE_CHANNEL in set_contexts
61+
62+
return _contexts_check # type: ignore
63+
64+
4865
def generate_permission_check(permissions: "Permissions") -> Callable[["HybridContext"], Awaitable[bool]]:
4966
async def _permission_check(ctx: "HybridContext") -> bool:
5067
return ctx.author.has_permission(*permissions) if ctx.guild_id else True # type: ignore
@@ -333,10 +350,9 @@ def slash_to_prefixed(cmd: HybridSlashCommand) -> _HybridToPrefixedCommand: # n
333350
# can't be done in init due to how _binding works
334351
prefixed_cmd._binding = cmd._binding
335352

336-
if not cmd.dm_permission:
337-
prefixed_cmd.add_check(guild_only())
338-
339-
if cmd.scopes != [GLOBAL_SCOPE]:
353+
if cmd.scopes == [GLOBAL_SCOPE]:
354+
prefixed_cmd.add_check(generate_contexts_check(cmd.contexts))
355+
else:
340356
prefixed_cmd.add_check(generate_scope_check(cmd.scopes))
341357

342358
if cmd.default_member_permissions:
@@ -451,6 +467,8 @@ def hybrid_slash_command(
451467
scopes: Absent[list["Snowflake_Type"]] = MISSING,
452468
options: Optional[list[Union[SlashCommandOption, dict]]] = None,
453469
default_member_permissions: Optional["Permissions"] = None,
470+
integration_types: Optional[List[Union[IntegrationType, int]]] = None,
471+
contexts: Optional[List[Union[ContextType, int]]] = None,
454472
dm_permission: bool = True,
455473
sub_cmd_name: str | LocalisedName = None,
456474
group_name: str | LocalisedName = None,
@@ -480,7 +498,9 @@ def hybrid_slash_command(
480498
scopes: The scope this command exists within
481499
options: The parameters for the command, max 25
482500
default_member_permissions: What permissions members need to have by default to use this command.
483-
dm_permission: Should this command be available in DMs.
501+
integration_types: Installation context(s) where the slash command is available, only for globally-scoped commands.
502+
contexts: Interaction context(s) where the command can be used, only for globally-scoped commands.
503+
dm_permission: Should this command be available in DMs (deprecated).
484504
sub_cmd_name: 1-32 character name of the subcommand
485505
sub_cmd_description: 1-100 character description of the subcommand
486506
group_name: 1-32 character name of the group
@@ -521,6 +541,8 @@ def wrapper(func: AsyncCallable) -> HybridSlashCommand:
521541
description=_description,
522542
scopes=scopes or [GLOBAL_SCOPE],
523543
default_member_permissions=perm,
544+
integration_types=integration_types or [IntegrationType.GUILD_INSTALL],
545+
contexts=contexts or [ContextType.GUILD, ContextType.BOT_DM, ContextType.PRIVATE_CHANNEL],
524546
dm_permission=dm_permission,
525547
callback=func,
526548
options=options,
@@ -544,6 +566,8 @@ def hybrid_slash_subcommand(
544566
base_description: Optional[str | LocalisedDesc] = None,
545567
base_desc: Optional[str | LocalisedDesc] = None,
546568
base_default_member_permissions: Optional["Permissions"] = None,
569+
base_integration_types: Optional[List[Union[IntegrationType, int]]] = None,
570+
base_contexts: Optional[List[Union[ContextType, int]]] = None,
547571
base_dm_permission: bool = True,
548572
subcommand_group_description: Optional[str | LocalisedDesc] = None,
549573
sub_group_desc: Optional[str | LocalisedDesc] = None,
@@ -564,7 +588,9 @@ def hybrid_slash_subcommand(
564588
base_description: The description of the base command
565589
base_desc: An alias of `base_description`
566590
base_default_member_permissions: What permissions members need to have by default to use this command.
567-
base_dm_permission: Should this command be available in DMs.
591+
base_integration_types: Installation context(s) where the slash command is available, only for globally-scoped commands.
592+
base_contexts: Interaction context(s) where the command can be used, only for globally-scoped commands.
593+
base_dm_permission: Should this command be available in DMs (deprecated).
568594
subcommand_group_description: Description of the subcommand group
569595
sub_group_desc: An alias for `subcommand_group_description`
570596
scopes: The scopes of which this command is available, defaults to GLOBAL_SCOPE
@@ -597,6 +623,8 @@ def wrapper(func: AsyncCallable) -> HybridSlashCommand:
597623
sub_cmd_name=_name,
598624
sub_cmd_description=_description,
599625
default_member_permissions=base_default_member_permissions,
626+
integration_types=base_integration_types or [IntegrationType.GUILD_INSTALL],
627+
contexts=base_contexts or [ContextType.GUILD, ContextType.BOT_DM, ContextType.PRIVATE_CHANNEL],
600628
dm_permission=base_dm_permission,
601629
scopes=scopes or [GLOBAL_SCOPE],
602630
callback=func,

interactions/models/__init__.py

+10
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
Colour,
4343
CommandType,
4444
ComponentType,
45+
ContextType,
4546
CustomEmoji,
4647
DefaultNotificationLevel,
4748
DefaultReaction,
@@ -84,6 +85,7 @@
8485
GuildWidgetSettings,
8586
InputText,
8687
IntegrationExpireBehaviour,
88+
IntegrationType,
8789
Intents,
8890
InteractionPermissionTypes,
8991
InteractionType,
@@ -103,6 +105,7 @@
103105
MessageActivityType,
104106
MessageFlags,
105107
MessageInteraction,
108+
MessageInteractionMetadata,
106109
MessageReference,
107110
MessageType,
108111
MFALevel,
@@ -216,6 +219,7 @@
216219
component_callback,
217220
ComponentCommand,
218221
ComponentContext,
222+
contexts,
219223
context_menu,
220224
user_context_menu,
221225
message_context_menu,
@@ -256,6 +260,7 @@
256260
has_id,
257261
has_role,
258262
IDConverter,
263+
integration_types,
259264
InteractionCommand,
260265
InteractionContext,
261266
IntervalTrigger,
@@ -374,9 +379,11 @@
374379
"ComponentContext",
375380
"ComponentType",
376381
"ConsumeRest",
382+
"contexts",
377383
"context_menu",
378384
"ContextMenu",
379385
"ContextMenuContext",
386+
"ContextType",
380387
"Converter",
381388
"cooldown",
382389
"Cooldown",
@@ -454,6 +461,8 @@
454461
"IDConverter",
455462
"InputText",
456463
"IntegrationExpireBehaviour",
464+
"IntegrationType",
465+
"integration_types",
457466
"Intents",
458467
"InteractionCommand",
459468
"InteractionContext",
@@ -489,6 +498,7 @@
489498
"MessageConverter",
490499
"MessageFlags",
491500
"MessageInteraction",
501+
"MessageInteractionMetadata",
492502
"MessageReference",
493503
"MessageType",
494504
"MFALevel",

0 commit comments

Comments
 (0)