diff --git a/docs/src/API Reference/API Reference/ext/hybrid_commands/context.md b/docs/src/API Reference/API Reference/ext/hybrid_commands/context.md new file mode 100644 index 000000000..769cc00fb --- /dev/null +++ b/docs/src/API Reference/API Reference/ext/hybrid_commands/context.md @@ -0,0 +1 @@ +::: interactions.ext.hybrid_commands.context diff --git a/docs/src/API Reference/API Reference/ext/hybrid_commands/hybrid_slash.md b/docs/src/API Reference/API Reference/ext/hybrid_commands/hybrid_slash.md new file mode 100644 index 000000000..3be2c46c1 --- /dev/null +++ b/docs/src/API Reference/API Reference/ext/hybrid_commands/hybrid_slash.md @@ -0,0 +1 @@ +::: interactions.ext.hybrid_commands.hybrid_slash diff --git a/docs/src/API Reference/API Reference/ext/hybrid_commands/index.md b/docs/src/API Reference/API Reference/ext/hybrid_commands/index.md new file mode 100644 index 000000000..c2721707d --- /dev/null +++ b/docs/src/API Reference/API Reference/ext/hybrid_commands/index.md @@ -0,0 +1,5 @@ +# Hybrid Commands Index + +- [Context](context) +- [Hybrid Slash](hybrid_slash) +- [Manager](manager) diff --git a/docs/src/API Reference/API Reference/ext/hybrid_commands/manager.md b/docs/src/API Reference/API Reference/ext/hybrid_commands/manager.md new file mode 100644 index 000000000..d8181b9f3 --- /dev/null +++ b/docs/src/API Reference/API Reference/ext/hybrid_commands/manager.md @@ -0,0 +1 @@ +::: interactions.ext.hybrid_commands.manager diff --git a/docs/src/API Reference/API Reference/ext/index.md b/docs/src/API Reference/API Reference/ext/index.md index e83a65ca5..9e0753164 100644 --- a/docs/src/API Reference/API Reference/ext/index.md +++ b/docs/src/API Reference/API Reference/ext/index.md @@ -13,3 +13,6 @@ These files contain useful features that help you develop a bot - [Prefixed Commands](prefixed_commands) - An extension to allow prefixed/text commands + +- [Hybrid Commands](hybrid_commands) + - An extension that makes hybrid slash/prefixed commands diff --git a/docs/src/Guides/03 Creating Commands.md b/docs/src/Guides/03 Creating Commands.md index ee5fc3d75..977f1297b 100644 --- a/docs/src/Guides/03 Creating Commands.md +++ b/docs/src/Guides/03 Creating Commands.md @@ -91,6 +91,8 @@ This will show up in discord as `/base group command`. There are more ways to ad === ":three: Class Definition" ```python + from interactions import SlashCommand + base = SlashCommand(name="base", description="My command base") group = base.group(name="group", description="My command group") @@ -125,7 +127,7 @@ Now that you know all the options you have for options, you can opt into adding You do that by using the `@slash_option()` decorator and passing the option name as a function parameter: ```python -from interactions import OptionType +from interactions import OptionType, slash_option @slash_command(name="my_command", ...) @slash_option( @@ -260,20 +262,23 @@ from interactions import AutocompleteContext @my_command.autocomplete("string_option") async def autocomplete(self, ctx: AutocompleteContext): - # make sure this is done within three seconds + string_option_input = ctx.input_text # can be empty + # you can use ctx.kwargs.get("name") for other options - note they can be empty too + + # make sure you respond within three seconds await ctx.send( choices=[ { - "name": f"{ctx.input_text}a", - "value": f"{ctx.input_text}a", + "name": f"{string_option_input}a", + "value": f"{string_option_input}a", }, { - "name": f"{ctx.input_text}b", - "value": f"{ctx.input_text}b", + "name": f"{string_option_input}b", + "value": f"{string_option_input}b", }, { - "name": f"{ctx.input_text}c", - "value": f"{ctx.input_text}c", + "name": f"{string_option_input}c", + "value": f"{string_option_input}c", }, ] ) @@ -495,28 +500,60 @@ The same principle can be used to reuse autocomplete options. ## Simplified Error Handling -If you want error handling for all commands, you can override `Client` and define your own. -Any error from interactions will trigger `on_command_error`. That includes context menus. +If you want error handling for all commands, you can override the default error listener and define your own. +Any error from interactions will trigger `CommandError`. That includes context menus. In this example, we are logging the error and responding to the interaction if not done so yet: ```python -from interactions import Client +import traceback from interactions.api.events import CommandError -class CustomClient(Client): - @listen(disable_default_listeners=True) # tell the dispatcher that this replaces the default listener - async def on_command_error(self, event: CommandError): - logger.error(event.error) - if not event.ctx.responded: - await event.ctx.send("Something went wrong.") - -client = CustomErrorClient(...) +@listen(CommandError, disable_default_listeners=True) # tell the dispatcher that this replaces the default listener +async def on_command_error(self, event: CommandError): + traceback.print_exception(event.error) + if not event.ctx.responded: + await event.ctx.send("Something went wrong.") ``` -There also is `on_command` which you can overwrite too. That fires on every interactions usage. +There also is `CommandCompletion` which you can overwrite too. That fires on every interactions usage. ## I Need A Custom Parameter Type If your bot is complex enough, you might find yourself wanting to use custom models in your commands. -To do this, you'll want to use a string option, and define a converter. Information on how to use converters can be found [on the converter page](/interactions.py/Guides/08 Converters/). +To do this, you'll want to use a string option, and define a converter. Information on how to use converters can be found [on the converter page](/Guides/08 Converters). + +## I Want To Make A Prefixed/Text Command Too + +You're in luck! You can use a hybrid command, which is a slash command that also gets converted to an equivalent prefixed command under the hood. + +Hybrid commands are their own extension, and require [prefixed commands to set up beforehand](/interactions.py/Guides/26 Prefixed Commands). After that, use the `setup` function in the `hybrid_commands` extension in your main bot file. + +Your setup can (but doesn't necessarily have to) look like this: + +```python +import interactions +from interactions.ext import prefixed_commands as prefixed +from interactions.ext import hybrid_commands as hybrid + +bot = interactions.Client(...) # may want to enable the message content intent +prefixed.setup(bot) # normal step for prefixed commands +hybrid.setup(bot) # note its usage AFTER prefixed commands have been set up +``` + +To actually make slash commands, simply replace `@slash_command` with `@hybrid_slash_command`, and `SlashContext` with `HybridContext`, like so: + +```python +from interactions.ext.hybrid_commands import hybrid_slash_command, HybridContext + +@hybrid_slash_command(name="my_command", description="My hybrid command!") +async def my_command_function(ctx: HybridContext): + await ctx.send("Hello World") +``` + +Suggesting you are using the default mention settings for your bot, you should be able to run this command by `@BotPing my_command`. + +As you can see, the only difference between hybrid commands and slash commands, from a developer perspective, is that they use `HybridContext`, which attempts +to seamlessly allow using the same context for slash and prefixed commands. You can always get the underlying context via `inner_context`, though. + +Of course, keep in mind that support two different types of commands is hard - some features may not get represented well in prefixed commands, and autocomplete is not possible at all. diff --git a/docs/src/Guides/10 Events.md b/docs/src/Guides/10 Events.md index b6f28956a..de6a2ce1d 100644 --- a/docs/src/Guides/10 Events.md +++ b/docs/src/Guides/10 Events.md @@ -1,79 +1,138 @@ # Events -Events are dispatched whenever a subscribed event gets sent by Discord. +Events (in interactions.py) are pieces of information that are sent whenever something happens in Discord or in the library itself - this includes channel updates, message sending, the bot starting up, and more. -## What Events Can I Get +## Intents What events you subscribe to are defined at startup by setting your `Intents`. -`Intents.DEFAULT` is a good place to start if your bot is new and small, otherwise, it is recommended to take your time and go through them one by one. -```python -bot = Client(intents=Intents.DEFAULT) -bot.start("Put your token here") -``` +By default, interactions.py automatically uses every intent but privileged intents (discussed in a bit). This means you're receiving data about *a lot* of events - it's nice to have those intents while starting out, but we heavily encourage narrowing them so that your bot uses less memory and isn't slowed down by processing them. -For more information, please visit the API reference [here](/interactions.py/API Reference/API Reference/models/Discord/enums/#internal.models.discord.enums.Intents). +There are two ways of setting them. We'll use the `GUILDS` and `GUILD_INVITES` intents as an example, but you should decide what intents you need yourself. -## Hey Listen!!! +=== ":one: Directly through `Intents`" + ```python + from interactions import Intents + bot = Client(intents=Intents.GUILDS | Intents.GUILD_INVITES) + ``` -Now you can receive events. To respond to them, you need to register a callback. Callbacks should be lower-case, use `_` instead of spaces and start with `on_`. -Depending on how you register your callbacks that's not a requirement, but it is a good habit nonetheless. +=== ":two: `Intents.new`" + ```python + from interactions import Intents + bot = Client(intents=Intents.new(guilds=True, guild_invites=True)) + ``` -For example, the event callback for the `ChannelCreate` event should be called `on_channel_create`. +Some intents are deemed to have sensitive content by Discord and so have extra restrictions on them - these are called **privileged intents.** At the time of writing, these include *message content, guild members, and presences.* These require extra steps to enable them for your bot: -You can find all events and their signatures [here](/interactions.py/API Reference/API Reference/events/discord/). +1. Go to the [Discord developer portal](https://discord.com/developers/applications/). +2. Select your application. +3. In the "Bot" tab, go to the "Privileged Gateway Intents" category and scroll down to the privileged intents you want. +4. Enable the toggle. + - **If your bot is verified or in more than 100 servers, you need to apply for the intent through Discord in order to toggle it.** This may take a couple of weeks. -Be aware that your `Intents` must be set to receive the event you are looking for. +Then, you can specify it in your bot just like the other intents. If you encounter any errors during this process, [referring to the intents page on Discord's documentation](https://discord.com/developers/docs/topics/gateway#gateway-intents) may help. ---- +!!! danger + `Intents.ALL` is a shortcut provided by interactions.py to enable *every single intents, including privileged intents.* This is very useful while testing bots, **but this shortcut is an incredibly bad idea to use when actually running your bots for use.** As well as adding more strain on the bot (as discussed earlier with normal intents), this is just a bad idea privacy wise: your bot likely does not need to know that much data. -There are two ways to register events. **Decorators** are the recommended way to do this. +For more information, please visit the API reference about Intents [at this page](/interactions.py/API Reference/API Reference/models/Discord/enums/#interactions.models.discord.enums.Intents). -=== ":one: Decorators" - ```python - from interactions import listen - from interactions.api.events import ChannelCreate +## Subscribing to Events - @listen() - async def on_channel_create(event: ChannelCreate): - # this event is called when a channel is created in a guild where the bot is +After your intents have been properly configured, you can start to listen to events. Say, if you wanted to listen to channels being created in a guild the bot can see, then all you would have to do is this: - print(f"Channel created with name: {event.channel.name}") - ``` +```python +from interactions import listen +from interactions.api.events import ChannelCreate - You can also use `@listen` with any function names: +@listen(ChannelCreate) +async def an_event_handler(event: ChannelCreate): + print(f"Channel created with name: {event.channel.name}") +``` +As you can see, the `listen` statement marks a function to receive (or, well, listen/subscribe to) a specific event - we specify which event to receive by passing in the *event object*, which an object that contains all information about an event. Whenever that events happens in Discord, it triggers our function to run, passing the event object into it. Here, we get the channel that the event contains and send out its name to the terminal. + +???+ note "Difference from other Python Discord libraries" + If you come from some other Python Discord libraries, or even come from older versions of interactions.py, you might have noticed how the above example uses an *event object* - IE a `ChannelCreate` object - instead of passing the associated object with that event - IE a `Channel` (or similar) object - into the function. This is intentional - by using event objects, we have greater control of what information we can give to you. + + For pretty much every event object, the object associated with that event is still there, just as an attribute. Here, the channel is in `event.channel` - you'll usually find the object in other events in a similar format. + Update events usually use `event.before` and `event.after` too. + +While the above is the recommended format for listening to events (as you can be sure that you specified the right event), there are other methods for specifying what event you're listening to: + +???+ warning "Event name format for some methods" + You may notice how some of these methods require the event name to be `all_in_this_case`. The casing itself is called *snake case* - it uses underscores to indicate either a literal space or a gap between words, and exclusively uses lowercase otherwise. To transform an event object, which is in camel case (more specifically, Pascal case), to snake case, first take a look at the letters that are capital, make them lowercase, and add an underscore before those letters *unless it's the first letter of the name of the object*. + + For example, looking at **C**hannel**C**reate, we can see two capital letters. Making them lowercase makes it **c**hannel**c**reate, and then adding an underscore before them makes them **c**hannel**_c**reate (notice how the first letter does *not* have a lowercase before them). + + You *can* add an `on_` prefixed before the modified event name too. For example, you could use both `on_channel_create` and `channel_create`, depending on your preference. + + If you're confused by any of this, stay away from methods that use this type of name formatting. + +=== ":one: Type Annotation" ```python - @listen(ChannelCreate) - async def my_function(event: ChannelCreate): - # you can pass the event + @listen() + async def an_event_handler(event: ChannelCreate): ... + ``` - @listen("on_channel_create") - async def my_function(event: ChannelCreate): - # you can also pass the event name as a string +=== ":two: String in `listen`" + ```python + @listen("channel_create") + async def an_event_handler(event): ... + ``` +=== ":three: Function name" + ```python @listen() - async def my_function(event: ChannelCreate): - # you can also use the typehint of `event` + async def channel_create(event): ... ``` -=== ":two: Manual Registration" - You can also register the events manually. This gives you the most flexibility, but it's not the cleanest. +## Other Notes About Events - ```python - from interactions import Listener - from interactions.api.events import ChannelCreate +### No Argument Events + +Some events may have no information to pass - the information is the event itself. This happens with some of the internal events - events that are specific to interactions.py, not Discord. - async def on_channel_create(event: ChannelCreate): - # this event is called when a channel is created in a guild where the bot is +Whenever this happens, you can specify the event to simply not pass anything into the function, as can be seen with the startup event: - print(f"Channel created with name: {event.channel.name}") +```python +from interactions.api.events import Startup +@listen(Startup) +async def startup_func(): + ... +``` - bot = Client(intents=Intents.DEFAULT) - bot.add_listener(Listener(func=on_channel_create, event="on_channel_create")) - bot.start("Put your token here") - ``` +If you forget, the library will just pass an empty object to avoid errors. + +### Disabling Default Listeners + +Some internal events, like `ModalCompletion`, have default listeners that perform niceties like logging the command/interaction logged. You may not want this, however, and may want to completely override this behavior without subclassiung `Client`. If so, you can acheive it through `disable_default_listeners`: + +```python +from interactions.api.events import ModalCompletion + +@listen(ModalCompletion, disable_default_listeners=True) +async def my_modal_completion(event: ModalCompletion): + print("I now control ModalCompletion!") +``` + +A lot of times, this behavior is used for custom error tracking. If so, [take a look at the error tracking guide](../25 Error Tracking) for a guide on that. + +## Events to Listen To + +There are a plethora of events that you can listen to. You can find a list of events that are currently supported through the two links below - every class listened on these two pages are available for you, though be aware that your `Intents` must be set appropriately to receive the event you are looking for. + +- [Discord Events](/interactions.py/API Reference/API Reference/events/discord/) +- [Internal Events](/interactions.py/API Reference/API Reference/events/internal/) + +### Frequently Used Events + +- [Startup](/interactions.py/API Reference/API Reference/events/internal/#interactions.api.events.internal.Startup) is an event, as its name implies, that runs when the bot is first started up - more specifically, it runs when the bot is first ready to do actions. This is a good place to set up tools or libraries that require an asynchronous function. +- [Error](/interactions.py/API Reference/API Reference/events/internal/#interactions.api.events.internal.Error) and its many, *many* subclasses about specific types of errors trigger whenever an error occurs while the bot is running. If you want error *tracking* (IE just logging the errors you get to fix them later on), then [take a look at the error tracking guide](../25 Error Tracking). Otherwise, you can do specific error handling using these events (ideally with `disable_default_listeners` turned on) to provide custom messages for command errors. +- [Component](/interactions.py/API Reference/API Reference/events/internal/#interactions.api.events.internal.Component), [ButtonPressed](/interactions.py/API Reference/API Reference/events/internal/#interactions.api.events.internal.ButtonPressed), [Select](/interactions.py/API Reference/API Reference/events/internal/#interactions.api.events.internal.Select), and [ModalCompletion](/interactions.py/API Reference/API Reference/events/internal/#interactions.api.events.internal.ModalCompletion) may be useful for you if you're trying to respond to component or modal interactions - take a look at the [component guide](../05 Components) or the [modal guide](../06 Modals) for more information. +- [MessageCreate](/interactions.py/API Reference/API Reference/discord/#interactions.api.events.discord.MessageCreate) is used whenever anyone sends a message to a channel the bot can see. This can be useful for automoderation, though note *message content is a privileged intent*, as talked about above. For prefixed/text commands in particular, we already have our own implementation - take a look at them [at this page](../26 Prefixed Commands). +- [GuildJoin](/interactions.py/API Reference/API Reference/events/discord/#interactions.api.events.discord.GuildJoin) and [GuildLeft](/interactions.py/API Reference/API Reference/events/discord/#interactions.api.events.discord.GuildLeft) are, as you can expect, events that are sent whenever the bot joins and leaves a guild. Note that for `GuildJoin`, the event triggers for *every guild on startup* - it's best to have a check to see if the bot is ready through `bot.is_ready` and ignore this event if it isn't. diff --git a/interactions/__init__.py b/interactions/__init__.py index 3be1785e8..925377124 100644 --- a/interactions/__init__.py +++ b/interactions/__init__.py @@ -149,6 +149,7 @@ GuildForum, GuildForumPost, GuildIntegration, + GuildMedia, GuildNews, GuildNewsConverter, GuildNewsThread, @@ -476,6 +477,7 @@ "GuildForum", "GuildForumPost", "GuildIntegration", + "GuildMedia", "GuildNews", "GuildNewsConverter", "GuildNewsThread", diff --git a/interactions/api/events/discord.py b/interactions/api/events/discord.py index 31a1aee9b..51b2b17bd 100644 --- a/interactions/api/events/discord.py +++ b/interactions/api/events/discord.py @@ -1,21 +1,20 @@ """ These are events dispatched by Discord. This is intended as a reference so you know what data to expect for each event. -??? Hint "Example Usage:" - The event classes outlined here are in `CamelCase` to comply with Class naming convention, however the event names - are actually in `lower_case_with_underscores` so your listeners should be named as following: +???+ hint "Example Usage" + To listen to an event, use the `listen` decorator: ```python - @listen() - def on_ready(): - # ready events pass no data, so dont have params - print("Im ready!") - - @listen() - def on_guild_join(event): - # guild_create events pass a guild object, expect a single param - print(f"{event.guild.name} created") + from interactions import listen + from interactions.api.events import ChannelCreate # or any other event + + @listen(ChannelCreate) + async def an_event_handler(event: ChannelCreate): + print(f"Channel created with name: {event.channel.name}") ``` + + For more information, including other ways to listen to events, see [the events guide](/interactions.py/Guides/10 Events). + !!! warning While all of these events are documented, not all of them are used, currently. diff --git a/interactions/api/events/internal.py b/interactions/api/events/internal.py index 43b4f5767..c0eb91bdd 100644 --- a/interactions/api/events/internal.py +++ b/interactions/api/events/internal.py @@ -1,21 +1,20 @@ """ These are events dispatched by the client. This is intended as a reference so you know what data to expect for each event. -??? Hint "Example Usage:" - The event classes outlined here are in `CamelCase` to comply with Class naming convention, however the event names - are actually in `lower_case_with_underscores` so your listeners should be named as following: +???+ hint "Example Usage" + To listen to an event, use the `listen` decorator: ```python - @listen() - def on_ready(): - # ready events pass no data, so dont have params - print("Im ready!") - - @listen() - def on_guild_join(event): - # guild_create events pass a guild object, expect a single param - print(f"{event.guild.name} created") + from interactions import listen + from interactions.api.events import ChannelCreate # or any other event + + @listen(ChannelCreate) + async def an_event_handler(event: ChannelCreate): + print(f"Channel created with name: {event.channel.name}") ``` + + For more information, including other ways to listen to events, see [the events guide](/interactions.py/Guides/10 Events). + !!! warning While all of these events are documented, not all of them are used, currently. diff --git a/interactions/api/events/processors/auto_mod.py b/interactions/api/events/processors/auto_mod.py index 8e766e456..31f08476f 100644 --- a/interactions/api/events/processors/auto_mod.py +++ b/interactions/api/events/processors/auto_mod.py @@ -14,7 +14,7 @@ class AutoModEvents(EventMixinTemplate): @Processor.define() async def _raw_auto_moderation_action_execution(self, event: "RawGatewayEvent") -> None: action = AutoModerationAction.from_dict(event.data.copy(), self) - channel = self.get_channel(event.data["channel_id"]) + channel = self.get_channel(event.data.get("channel_id")) guild = self.get_guild(event.data["guild_id"]) self.dispatch(events.AutoModExec(action, channel, guild)) diff --git a/interactions/client/mixins/send.py b/interactions/client/mixins/send.py index d6c7e285a..6fa4a2ff3 100644 --- a/interactions/client/mixins/send.py +++ b/interactions/client/mixins/send.py @@ -111,5 +111,8 @@ async def send( if message_data: message = self.client.cache.place_message_data(message_data) if delete_after: - await message.delete(delay=delete_after) + if kwargs.get("pass_self_into_delete"): # hack to pass in interaction/hybrid context + await message.delete(delay=delete_after, context=self) + else: + await message.delete(delay=delete_after) return message diff --git a/interactions/client/utils/attr_converters.py b/interactions/client/utils/attr_converters.py index 454316115..91c08d18f 100644 --- a/interactions/client/utils/attr_converters.py +++ b/interactions/client/utils/attr_converters.py @@ -1,5 +1,6 @@ import inspect import typing +import interactions from datetime import datetime from typing import Callable, Union, Any @@ -20,13 +21,18 @@ def timestamp_converter(value: Union[datetime, int, float, str]) -> Timestamp: A Timestamp object """ - if isinstance(value, str): - return Timestamp.fromisoformat(value) - if isinstance(value, (float, int)): - return Timestamp.fromtimestamp(float(value)) - if isinstance(value, datetime): - return Timestamp.fromdatetime(value) - raise TypeError("Timestamp must be one of: datetime, int, float, ISO8601 str") + try: + if isinstance(value, str): + return Timestamp.fromisoformat(value) + if isinstance(value, (float, int)): + return Timestamp.fromtimestamp(float(value)) + if isinstance(value, datetime): + return Timestamp.fromdatetime(value) + raise TypeError("Timestamp must be one of: datetime, int, float, ISO8601 str") + except ValueError as e: + interactions.const.get_logger().warning("Failed to convert timestamp", exc_info=e) + # Should only happen if the timestamp is something stupid like 269533-01-01T00:00 - in which case we just return MISSING + return MISSING def list_converter(converter) -> Callable[[list], list]: diff --git a/interactions/ext/hybrid_commands/__init__.py b/interactions/ext/hybrid_commands/__init__.py new file mode 100644 index 000000000..70d33bc59 --- /dev/null +++ b/interactions/ext/hybrid_commands/__init__.py @@ -0,0 +1,12 @@ +from .context import HybridContext +from .hybrid_slash import HybridSlashCommand, hybrid_slash_command, hybrid_slash_subcommand +from .manager import HybridManager, setup + +__all__ = ( + "HybridContext", + "HybridManager", + "HybridSlashCommand", + "hybrid_slash_command", + "hybrid_slash_subcommand", + "setup", +) diff --git a/interactions/ext/hybrid_commands/context.py b/interactions/ext/hybrid_commands/context.py new file mode 100644 index 000000000..678cee838 --- /dev/null +++ b/interactions/ext/hybrid_commands/context.py @@ -0,0 +1,384 @@ +import datetime + +from typing import TYPE_CHECKING, Any, Optional, Union, Iterable, Sequence +from typing_extensions import Self + + +from interactions import ( + BaseContext, + Permissions, + Message, + SlashContext, + Client, + Typing, + Embed, + BaseComponent, + UPLOADABLE_TYPE, + Snowflake_Type, + Sticker, + AllowedMentions, + MessageReference, + MessageFlags, + to_snowflake, + Attachment, + process_message_payload, +) +from interactions.client.mixins.send import SendMixin +from interactions.ext import prefixed_commands as prefixed + +if TYPE_CHECKING: + from .hybrid_slash import HybridSlashCommand + +__all__ = ("HybridContext",) + + +class DeferTyping: + def __init__(self, ctx: "HybridContext", ephermal: bool) -> None: + self.ctx = ctx + self.ephermal = ephermal + + async def __aenter__(self) -> None: + await self.ctx.defer(ephemeral=self.ephermal) + + async def __aexit__(self, *_) -> None: + pass + + +class HybridContext(BaseContext, SendMixin): + prefix: str + "The prefix used to invoke this command." + + app_permissions: Permissions + """The permissions available to this context""" + + deferred: bool + """Whether the context has been deferred.""" + responded: bool + """Whether the context has been responded to.""" + ephemeral: bool + """Whether the context response is ephemeral.""" + + _command_name: str + """The command name.""" + _message: Message | None + + args: list[Any] + """The arguments passed to the command.""" + kwargs: dict[str, Any] + """The keyword arguments passed to the command.""" + + __attachment_index__: int + + _slash_ctx: SlashContext | None + _prefixed_ctx: prefixed.PrefixedContext | None + + def __init__(self, client: Client): + super().__init__(client) + self.prefix = "" + self.app_permissions = Permissions(0) + self.deferred = False + self.responded = False + self.ephemeral = False + self._command_name = "" + self.args = [] + self.kwargs = {} + self._message = None + self.__attachment_index__ = 0 + self._slash_ctx = None + self._prefixed_ctx = None + + @classmethod + def from_dict(cls, client: Client, payload: dict) -> None: + # this doesn't mean anything, so just implement it to make abc happy + raise NotImplementedError + + @classmethod + def from_slash_context(cls, ctx: SlashContext) -> Self: + self = cls(ctx.client) + self.guild_id = ctx.guild_id + self.channel_id = ctx.channel_id + self.author_id = ctx.author_id + self.message_id = ctx.message_id + self.prefix = "/" + self.app_permissions = ctx.app_permissions + self.deferred = ctx.deferred + self.responded = ctx.responded + self.ephemeral = ctx.ephemeral + self._command_name = ctx._command_name + self.args = ctx.args + self.kwargs = ctx.kwargs + self._slash_ctx = ctx + return self + + @classmethod + def from_prefixed_context(cls, ctx: prefixed.PrefixedContext) -> Self: + # this is a "best guess" on what the permissions are + # this may or may not be totally accurate + if hasattr(ctx.channel, "permissions_for"): + app_permissions = ctx.channel.permissions_for(ctx.guild.me) # type: ignore + elif ctx.channel.type in {10, 11, 12}: # it's a thread + app_permissions = ctx.channel.parent_channel.permissions_for(ctx.guild.me) # type: ignore + + self = cls(ctx.client) + self.guild_id = ctx.guild_id + self.channel_id = ctx.channel_id + self.author_id = ctx.author_id + self.message_id = ctx.message_id + self._message = ctx.message + self.prefix = ctx.prefix + self.app_permissions = app_permissions + self._command_name = ctx.command.qualified_name + self.args = ctx.args + self._prefixed_ctx = ctx + return self + + @property + def inner_context(self) -> SlashContext | prefixed.PrefixedContext: + """The inner context that this hybrid context is wrapping.""" + return self._slash_ctx or self._prefixed_ctx # type: ignore + + @property + def command(self) -> "HybridSlashCommand": + return self.client._interaction_lookup[self._command_name] + + @property + def expires_at(self) -> Optional[datetime.datetime]: + """The time at which the interaction expires.""" + if not self._slash_ctx: + return None + + if self.responded: + return self._slash_ctx.id.created_at + datetime.timedelta(minutes=15) + return self._slash_ctx.id.created_at + datetime.timedelta(seconds=3) + + @property + def expired(self) -> bool: + """Whether the interaction has expired.""" + return datetime.datetime.utcnow() > self.expires_at if self._slash_ctx else False + + @property + def deferred_ephemeral(self) -> bool: + """Whether the interaction has been deferred ephemerally.""" + return self.deferred and self.ephemeral + + @property + def message(self) -> Message | None: + """The message that invoked this context.""" + return self._message or self.client.cache.get_message(self.channel_id, self.message_id) + + @property + def typing(self) -> Typing | DeferTyping: + """A context manager to send a _typing/defer state to a given channel as long as long as the wrapped operation takes.""" + if self._slash_ctx: + return DeferTyping(self._slash_ctx, self.ephemeral) + return self.channel.typing + + async def defer(self, ephemeral: bool = False) -> None: + """ + Either defers the response (if used in an interaction) or triggers a typing indicator for 10 seconds (if used for messages). + + Args: + ephemeral: Should the response be ephemeral? Only applies to responses for interactions. + + """ + if self._slash_ctx: + await self._slash_ctx.defer(ephemeral=ephemeral) + else: + await self.channel.trigger_typing() + + self.deferred = True + + async def reply( + self, + content: Optional[str] = None, + embeds: Optional[ + Union[ + Iterable[Union[Embed, dict]], + Union[Embed, dict], + ] + ] = None, + embed: Optional[Union[Embed, dict]] = None, + **kwargs, + ) -> "Message": + """ + Reply to this message, takes all the same attributes as `send`. + + For interactions, this functions the same as `send`. + """ + kwargs = locals() + kwargs.pop("self") + extra_kwargs = kwargs.pop("kwargs") + kwargs |= extra_kwargs + + if self._slash_ctx: + result = await self.send(**kwargs) + else: + kwargs.pop("ephemeral", None) + result = await self._prefixed_ctx.reply(**kwargs) + + self.responded = True + return result + + async def _send_http_request( + self, + message_payload: dict, + files: Iterable["UPLOADABLE_TYPE"] | None = None, + ) -> dict: + if self._slash_ctx: + return await self._slash_ctx._send_http_request(message_payload, files) + return await self._prefixed_ctx._send_http_request(message_payload, files) + + async def send( + self, + content: Optional[str] = None, + *, + embeds: Optional[ + Union[ + Iterable[Union["Embed", dict]], + Union["Embed", dict], + ] + ] = None, + embed: Optional[Union["Embed", dict]] = None, + components: Optional[ + Union[ + Iterable[Iterable[Union["BaseComponent", dict]]], + Iterable[Union["BaseComponent", dict]], + "BaseComponent", + dict, + ] + ] = None, + stickers: Optional[ + Union[ + Iterable[Union["Sticker", "Snowflake_Type"]], + "Sticker", + "Snowflake_Type", + ] + ] = None, + allowed_mentions: Optional[Union["AllowedMentions", dict]] = None, + reply_to: Optional[Union["MessageReference", "Message", dict, "Snowflake_Type"]] = None, + files: Optional[Union["UPLOADABLE_TYPE", Iterable["UPLOADABLE_TYPE"]]] = None, + file: Optional["UPLOADABLE_TYPE"] = None, + tts: bool = False, + suppress_embeds: bool = False, + silent: bool = False, + flags: Optional[Union[int, "MessageFlags"]] = None, + delete_after: Optional[float] = None, + ephemeral: bool = False, + **kwargs: Any, + ) -> "Message": + """ + Send a message. + + Args: + content: Message text content. + embeds: Embedded rich content (up to 6000 characters). + embed: Embedded rich content (up to 6000 characters). + components: The components to include with the message. + stickers: IDs of up to 3 stickers in the server to send in the message. + allowed_mentions: Allowed mentions for the message. + reply_to: Message to reference, must be from the same channel. + files: Files to send, the path, bytes or File() instance, defaults to None. You may have up to 10 files. + file: Files to send, the path, bytes or File() instance, defaults to None. You may have up to 10 files. + tts: Should this message use Text To Speech. + suppress_embeds: Should embeds be suppressed on this send + silent: Should this message be sent without triggering a notification. + flags: Message flags to apply. + delete_after: Delete message after this many seconds. + ephemeral: Should this message be sent as ephemeral (hidden) - only works with interactions + + Returns: + New message object that was sent. + """ + flags = MessageFlags(flags or 0) + if ephemeral and self._slash_ctx: + flags |= MessageFlags.EPHEMERAL + self.ephemeral = True + if suppress_embeds: + flags |= MessageFlags.SUPPRESS_EMBEDS + if silent: + flags |= MessageFlags.SILENT + + return await super().send( + content=content, + embeds=embeds, + embed=embed, + components=components, + stickers=stickers, + allowed_mentions=allowed_mentions, + reply_to=reply_to, + files=files, + file=file, + tts=tts, + flags=flags, + delete_after=delete_after, + pass_self_into_delete=bool(self._slash_ctx), + **kwargs, + ) + + async def delete(self, message: "Snowflake_Type") -> None: + """ + Delete a message sent in response to this context. Must be in the same channel as the context. + + Args: + message: The message to delete + """ + if self._slash_ctx: + return await self._slash_ctx.delete(message) + await self.client.http.delete_message(self.channel_id, to_snowflake(message)) + + async def edit( + self, + message: "Snowflake_Type", + *, + content: Optional[str] = None, + embeds: Optional[ + Union[ + Iterable[Union["Embed", dict]], + Union["Embed", dict], + ] + ] = None, + embed: Optional[Union["Embed", dict]] = None, + components: Optional[ + Union[ + Iterable[Iterable[Union["BaseComponent", dict]]], + Iterable[Union["BaseComponent", dict]], + "BaseComponent", + dict, + ] + ] = None, + attachments: Optional[Sequence[Attachment | dict]] = None, + allowed_mentions: Optional[Union["AllowedMentions", dict]] = None, + files: Optional[Union["UPLOADABLE_TYPE", Iterable["UPLOADABLE_TYPE"]]] = None, + file: Optional["UPLOADABLE_TYPE"] = None, + tts: bool = False, + ) -> "Message": + if self._slash_ctx: + return await self._slash_ctx.edit( + message, + content=content, + embeds=embeds, + embed=embed, + components=components, + attachments=attachments, + allowed_mentions=allowed_mentions, + files=files, + file=file, + tts=tts, + ) + + message_payload = process_message_payload( + content=content, + embeds=embeds or embed, + components=components, + allowed_mentions=allowed_mentions, + attachments=attachments, + tts=tts, + ) + if file: + files = [file, *files] if files else [file] + + message_data = await self.client.http.edit_message( + message_payload, self.channel_id, to_snowflake(message), files=files + ) + if message_data: + return self.client.cache.place_message_data(message_data) diff --git a/interactions/ext/hybrid_commands/hybrid_slash.py b/interactions/ext/hybrid_commands/hybrid_slash.py new file mode 100644 index 000000000..927f057f3 --- /dev/null +++ b/interactions/ext/hybrid_commands/hybrid_slash.py @@ -0,0 +1,588 @@ +import asyncio +import inspect +from typing import Any, Callable, List, Optional, Union, TYPE_CHECKING, Awaitable + +import attrs +from interactions import ( + BaseContext, + Converter, + NoArgumentConverter, + Attachment, + SlashCommandChoice, + OptionType, + BaseChannelConverter, + ChannelType, + BaseChannel, + MemberConverter, + UserConverter, + RoleConverter, + SlashCommand, + SlashContext, + Absent, + LocalisedName, + LocalisedDesc, + MISSING, + SlashCommandOption, + Snowflake_Type, + Permissions, +) +from interactions.client.const import AsyncCallable, GLOBAL_SCOPE +from interactions.client.utils.serializer import no_export_meta +from interactions.client.utils.misc_utils import maybe_coroutine, get_object_name +from interactions.client.errors import BadArgument +from interactions.ext.prefixed_commands import PrefixedCommand, PrefixedContext +from interactions.models.internal.converters import _LiteralConverter +from interactions.models.internal.checks import guild_only + +if TYPE_CHECKING: + from .context import HybridContext + +__all__ = ("HybridSlashCommand", "hybrid_slash_command", "hybrid_slash_subcommand") + + +def _values_wrapper(a_dict: dict | None) -> list: + return list(a_dict.values()) if a_dict else [] + + +def generate_permission_check(permissions: "Permissions") -> Callable[["HybridContext"], Awaitable[bool]]: + async def _permission_check(ctx: "HybridContext") -> bool: + return ctx.author.has_permission(*permissions) if ctx.guild_id else True # type: ignore + + return _permission_check # type: ignore + + +def generate_scope_check(_scopes: list["Snowflake_Type"]) -> Callable[["HybridContext"], Awaitable[bool]]: + scopes = frozenset(int(s) for s in _scopes) + + async def _scope_check(ctx: "HybridContext") -> bool: + return int(ctx.guild_id) in scopes + + return _scope_check # type: ignore + + +class BasicConverter(Converter): + def __init__(self, type_to_convert: Any) -> None: + self.type_to_convert = type_to_convert + + async def convert(self, ctx: BaseContext, arg: str) -> Any: + return self.type_to_convert(arg) + + +class BoolConverter(Converter): + async def convert(self, ctx: BaseContext, argument: str) -> bool: + lowered = argument.lower() + if lowered in {"yes", "y", "true", "t", "1", "enable", "on"}: + return True + elif lowered in {"no", "n", "false", "f", "0", "disable", "off"}: # noqa: RET505 + return False + raise BadArgument(f"{argument} is not a recognised boolean option.") + + +class AttachmentConverter(NoArgumentConverter): + async def convert(self, ctx: "HybridContext", _: Any) -> Attachment: + try: + attachment = ctx.message.attachments[ctx.__attachment_index__] + ctx.__attachment_index__ += 1 + return attachment + except IndexError: + raise BadArgument("No attachment found.") from None + + +class ChoicesConverter(_LiteralConverter): + def __init__(self, choices: list[SlashCommandChoice | dict]) -> None: + standardized_choices = tuple((SlashCommandChoice(**o) if isinstance(o, dict) else o) for o in choices) + + names = tuple(c.name for c in standardized_choices) + self.values = {str(arg): str for arg in names} + self.choice_values = {str(c.name): c.value for c in standardized_choices} + + async def convert(self, ctx: BaseContext, argument: str) -> Any: + val = await super().convert(ctx, argument) + return self.choice_values[val] + + +class RangeConverter(Converter[float | int]): + def __init__( + self, + number_type: int, + min_value: Optional[float | int], + max_value: Optional[float | int], + ) -> None: + self.number_type = number_type + self.min_value = min_value + self.max_value = max_value + + self.number_convert = int if number_type == OptionType.INTEGER else float + + async def convert(self, ctx: BaseContext, argument: str) -> float | int: + try: + converted: float | int = await maybe_coroutine(self.number_convert, ctx, argument) + + if self.min_value and converted < self.min_value: + raise BadArgument(f'Value "{argument}" is less than {self.min_value}.') + if self.max_value and converted > self.max_value: + raise BadArgument(f'Value "{argument}" is greater than {self.max_value}.') + + return converted + except ValueError: + type_name = "number" if self.number_type == OptionType.NUMBER else "integer" + + if type_name.startswith("i"): + raise BadArgument(f'Argument "{argument}" is not an {type_name}.') from None + raise BadArgument(f'Argument "{argument}" is not a {type_name}.') from None + except BadArgument: + raise + + +class StringLengthConverter(Converter[str]): + def __init__(self, min_length: Optional[int], max_length: Optional[int]) -> None: + self.min_length = min_length + self.max_length = max_length + + async def convert(self, ctx: BaseContext, argument: str) -> str: + if self.min_length and len(argument) < self.min_length: + raise BadArgument(f'The string "{argument}" is shorter than {self.min_length} character(s).') + elif self.max_length and len(argument) > self.max_length: # noqa: RET506 + raise BadArgument(f'The string "{argument}" is longer than {self.max_length} character(s).') + + return argument + + +class NarrowedChannelConverter(BaseChannelConverter): + def __init__(self, channel_types: list[ChannelType | int]) -> None: + self.channel_types = channel_types + + def _check(self, result: BaseChannel) -> bool: + return result.type in self.channel_types + + +class HackyUnionConverter(Converter): + def __init__(self, *converters: type[Converter]) -> None: + self.converters = converters + + async def convert(self, ctx: BaseContext, arg: str) -> Any: + for converter in self.converters: + try: + return await converter().convert(ctx, arg) + except Exception: + continue + + union_names = tuple(get_object_name(t).removesuffix("Converter") for t in self.converters) + union_types_str = ", ".join(union_names[:-1]) + f", or {union_names[-1]}" + raise BadArgument(f'Could not convert "{arg}" into {union_types_str}.') + + +class ChainConverter(Converter): + def __init__( + self, + first_converter: Converter, + second_converter: Callable, + name_of_cmd: str, + ) -> None: + self.first_converter = first_converter + self.second_converter = second_converter + self.name_of_cmd = name_of_cmd + + async def convert(self, ctx: BaseContext, arg: str) -> Any: + first = await self.first_converter.convert(ctx, arg) + return await maybe_coroutine(self.second_converter, ctx, first) + + +class ChainNoArgConverter(NoArgumentConverter): + def __init__( + self, + first_converter: NoArgumentConverter, + second_converter: Callable, + name_of_cmd: str, + ) -> None: + self.first_converter = first_converter + self.second_converter = second_converter + self.name_of_cmd = name_of_cmd + + async def convert(self, ctx: "HybridContext", _: Any) -> Any: + first = await self.first_converter.convert(ctx, _) + return await maybe_coroutine(self.second_converter, ctx, first) + + +def type_from_option(option_type: OptionType | int) -> Converter: + if option_type == OptionType.STRING: + return BasicConverter(str) + elif option_type == OptionType.INTEGER: # noqa: RET505 + return BasicConverter(int) + elif option_type == OptionType.NUMBER: + return BasicConverter(float) + elif option_type == OptionType.BOOLEAN: + return BoolConverter() + elif option_type == OptionType.USER: + return HackyUnionConverter(MemberConverter, UserConverter) + elif option_type == OptionType.CHANNEL: + return BaseChannelConverter() + elif option_type == OptionType.ROLE: + return RoleConverter() + elif option_type == OptionType.MENTIONABLE: + return HackyUnionConverter(MemberConverter, UserConverter, RoleConverter) + elif option_type == OptionType.ATTACHMENT: + return AttachmentConverter() + raise NotImplementedError(f"Unknown option type: {option_type}") + + +@attrs.define(eq=False, order=False, hash=False, kw_only=True) +class HybridSlashCommand(SlashCommand): + aliases: list[str] = attrs.field(repr=False, factory=list, metadata=no_export_meta) + _dummy_base: bool = attrs.field(repr=False, default=False, metadata=no_export_meta) + _silence_autocomplete_errors: bool = attrs.field(repr=False, default=False, metadata=no_export_meta) + + async def __call__(self, context: SlashContext, *args, **kwargs) -> None: + new_ctx = context.client.hybrid.hybrid_context.from_slash_context(context) + await super().__call__(new_ctx, *args, **kwargs) + + def group( + self, + name: str = None, + description: str = "No Description Set", + inherit_checks: bool = True, + aliases: list[str] | None = None, + ) -> "HybridSlashCommand": + self._dummy_base = True + return HybridSlashCommand( + name=self.name, + description=self.description, + group_name=name, + group_description=description, + scopes=self.scopes, + default_member_permissions=self.default_member_permissions, + dm_permission=self.dm_permission, + checks=self.checks.copy() if inherit_checks else [], + aliases=aliases or [], + ) + + def subcommand( + self, + sub_cmd_name: Absent[LocalisedName | str] = MISSING, + group_name: LocalisedName | str = None, + sub_cmd_description: Absent[LocalisedDesc | str] = MISSING, + group_description: Absent[LocalisedDesc | str] = MISSING, + options: List[Union[SlashCommandOption, dict]] = None, + nsfw: bool = False, + inherit_checks: bool = True, + aliases: list[str] | None = None, + silence_autocomplete_errors: bool = True, + ) -> Callable[..., "HybridSlashCommand"]: + def wrapper(call: AsyncCallable) -> "HybridSlashCommand": + nonlocal sub_cmd_name, sub_cmd_description + + if not asyncio.iscoroutinefunction(call): + raise TypeError("Subcommand must be coroutine") + + if sub_cmd_description is MISSING: + sub_cmd_description = call.__doc__ or "No Description Set" + if sub_cmd_name is MISSING: + sub_cmd_name = call.__name__ + + self._dummy_base = True + return HybridSlashCommand( + name=self.name, + description=self.description, + group_name=group_name or self.group_name, + group_description=group_description or self.group_description, + sub_cmd_name=sub_cmd_name, + sub_cmd_description=sub_cmd_description, + default_member_permissions=self.default_member_permissions, + dm_permission=self.dm_permission, + options=options, + callback=call, + scopes=self.scopes, + nsfw=nsfw, + checks=self.checks.copy() if inherit_checks else [], + aliases=aliases or [], + silence_autocomplete_errors=silence_autocomplete_errors, + ) + + return wrapper + + +@attrs.define(eq=False, order=False, hash=False, kw_only=True) +class _HybridToPrefixedCommand(PrefixedCommand): + async def __call__(self, context: PrefixedContext, *args, **kwargs) -> None: + new_ctx = context.client.hybrid.hybrid_context.from_prefixed_context(context) + await super().__call__(new_ctx, *args, **kwargs) + + +def slash_to_prefixed(cmd: HybridSlashCommand) -> _HybridToPrefixedCommand: # noqa: C901 there's nothing i can do + prefixed_cmd = _HybridToPrefixedCommand( + name=str(cmd.sub_cmd_name) if cmd.is_subcommand else str(cmd.name), + aliases=list(_values_wrapper(cmd.sub_cmd_name.to_locale_dict())) + if cmd.is_subcommand + else list(_values_wrapper(cmd.name.to_locale_dict())), + help=str(cmd.description), + callback=cmd.callback, + checks=cmd.checks, + cooldown=cmd.cooldown, + max_concurrency=cmd.max_concurrency, + pre_run_callback=cmd.pre_run_callback, + post_run_callback=cmd.post_run_callback, + error_callback=cmd.error_callback, + ) + if cmd.aliases: + prefixed_cmd.aliases.extend(cmd.aliases) + + if not cmd.dm_permission: + prefixed_cmd.add_check(guild_only()) + + if cmd.scopes != [GLOBAL_SCOPE]: + prefixed_cmd.add_check(generate_scope_check(cmd.scopes)) + + if cmd.default_member_permissions: + prefixed_cmd.add_check(generate_permission_check(cmd.default_member_permissions)) + + if not cmd.options: + prefixed_cmd._inspect_signature = inspect.Signature() + return prefixed_cmd + + fake_sig_parameters: list[inspect.Parameter] = [] + + for option in cmd.options: + if isinstance(option, dict): + # makes my life easier + option = SlashCommandOption(**option) + + if option.autocomplete and not cmd._silence_autocomplete_errors: + # there isn't much we can do here + raise ValueError("Autocomplete is unsupported in hybrid commands.") + + name = str(option.name) + annotation = inspect.Parameter.empty + default = inspect.Parameter.empty + kind = inspect.Parameter.POSITIONAL_ONLY if cmd._uses_arg else inspect.Parameter.POSITIONAL_OR_KEYWORD + + if slash_param := cmd.parameters.get(name): + kind = slash_param.kind + + if kind == inspect.Parameter.KEYWORD_ONLY: # work around prefixed cmd parsing + kind = inspect.Parameter.POSITIONAL_OR_KEYWORD + + if slash_param.converter: + annotation = slash_param.converter + if slash_param.default is not MISSING: + default = slash_param.default + + if option.choices: + option_anno = ChoicesConverter(option.choices) + elif option.min_value is not None or option.max_value is not None: + option_anno = RangeConverter(option.type, option.min_value, option.max_value) + elif option.min_length is not None or option.max_length is not None: + option_anno = StringLengthConverter(option.min_length, option.max_length) + elif option.type == OptionType.CHANNEL and option.channel_types: + option_anno = NarrowedChannelConverter(option.channel_types) + else: + option_anno = type_from_option(option.type) + + if annotation is inspect.Parameter.empty: + annotation = option_anno + elif isinstance(option_anno, NoArgumentConverter): + annotation = ChainNoArgConverter(option_anno, annotation, name) + else: + annotation = ChainConverter(option_anno, annotation, name) + + if not option.required and default == inspect.Parameter.empty: + default = None + + actual_param = inspect.Parameter( + name=name, + kind=kind, + default=default, + annotation=annotation, + ) + fake_sig_parameters.append(actual_param) + + prefixed_cmd._inspect_signature = inspect.Signature(parameters=fake_sig_parameters) + return prefixed_cmd + + +def create_subcmd_func(group: bool = False) -> Callable: + async def _subcommand_base(*args, **kwargs) -> None: + if group: + raise BadArgument("Cannot run this subcommand group without a valid subcommand.") + raise BadArgument("Cannot run this command without a valid subcommand.") + + return _subcommand_base + + +def base_subcommand_generator( + name: str, aliases: list[str], description: str, group: bool = False +) -> _HybridToPrefixedCommand: + return _HybridToPrefixedCommand( + callback=create_subcmd_func(group=group), + name=name, + aliases=aliases, + help=description, + ignore_extra=False, + inspect_signature=inspect.Signature(None), # type: ignore + ) + + +def hybrid_slash_command( + name: Absent[str | LocalisedName] = MISSING, + *, + aliases: Optional[list[str]] = None, + description: Absent[str | LocalisedDesc] = MISSING, + scopes: Absent[list["Snowflake_Type"]] = MISSING, + options: Optional[list[Union[SlashCommandOption, dict]]] = None, + default_member_permissions: Optional["Permissions"] = None, + dm_permission: bool = True, + sub_cmd_name: str | LocalisedName = None, + group_name: str | LocalisedName = None, + sub_cmd_description: str | LocalisedDesc = "No Description Set", + group_description: str | LocalisedDesc = "No Description Set", + nsfw: bool = False, + silence_autocomplete_errors: bool = False, +) -> Callable[[AsyncCallable], HybridSlashCommand]: + """ + A decorator to declare a coroutine as a hybrid slash command. + + Hybrid commands are a slash command that can also function as a prefixed command. + These use a HybridContext instead of an SlashContext, but otherwise are mostly identical to normal slash commands. + + Note that hybrid commands do not support autocompletes. + They also only partially support attachments, allowing one attachment option for a command. + + !!! note + While the base and group descriptions arent visible in the discord client, currently. + We strongly advise defining them anyway, if you're using subcommands, as Discord has said they will be visible in + one of the future ui updates. + + Args: + name: 1-32 character name of the command, defaults to the name of the coroutine. + aliases: Aliases for the prefixed command varient of the command. Has no effect on the slash command. + description: 1-100 character description of the command + scopes: The scope this command exists within + options: The parameters for the command, max 25 + default_member_permissions: What permissions members need to have by default to use this command. + dm_permission: Should this command be available in DMs. + sub_cmd_name: 1-32 character name of the subcommand + sub_cmd_description: 1-100 character description of the subcommand + group_name: 1-32 character name of the group + group_description: 1-100 character description of the group + nsfw: This command should only work in NSFW channels + silence_autocomplete_errors: Should autocomplete errors be silenced. Don't use this unless you know what you're doing. + + Returns: + HybridSlashCommand Object + + """ + + def wrapper(func: AsyncCallable) -> HybridSlashCommand: + if not asyncio.iscoroutinefunction(func): + raise ValueError("Commands must be coroutines") + + perm = default_member_permissions + if hasattr(func, "default_member_permissions"): + if perm: + perm = perm | func.default_member_permissions + else: + perm = func.default_member_permissions + + _name = name + if _name is MISSING: + _name = func.__name__ + + _description = description + if _description is MISSING: + _description = func.__doc__ or "No Description Set" + + cmd = HybridSlashCommand( + name=_name, + group_name=group_name, + group_description=group_description, + sub_cmd_name=sub_cmd_name, + sub_cmd_description=sub_cmd_description, + description=_description, + scopes=scopes or [GLOBAL_SCOPE], + default_member_permissions=perm, + dm_permission=dm_permission, + callback=func, + options=options, + nsfw=nsfw, + aliases=aliases or [], + silence_autocomplete_errors=silence_autocomplete_errors, + ) + + return cmd + + return wrapper + + +def hybrid_slash_subcommand( + base: str | LocalisedName, + *, + subcommand_group: Optional[str | LocalisedName] = None, + name: Absent[str | LocalisedName] = MISSING, + aliases: Optional[list[str]] = None, + description: Absent[str | LocalisedDesc] = MISSING, + base_description: Optional[str | LocalisedDesc] = None, + base_desc: Optional[str | LocalisedDesc] = None, + base_default_member_permissions: Optional["Permissions"] = None, + base_dm_permission: bool = True, + subcommand_group_description: Optional[str | LocalisedDesc] = None, + sub_group_desc: Optional[str | LocalisedDesc] = None, + scopes: List["Snowflake_Type"] = None, + options: List[dict] = None, + nsfw: bool = False, + silence_autocomplete_errors: bool = False, +) -> Callable[[AsyncCallable], HybridSlashCommand]: + """ + A decorator specifically tailored for creating hybrid slash subcommands. + + Args: + base: The name of the base command + subcommand_group: The name of the subcommand group, if any. + name: The name of the subcommand, defaults to the name of the coroutine. + aliases: Aliases for the prefixed command varient of the subcommand. Has no effect on the slash command. + description: The description of the subcommand + base_description: The description of the base command + base_desc: An alias of `base_description` + base_default_member_permissions: What permissions members need to have by default to use this command. + base_dm_permission: Should this command be available in DMs. + subcommand_group_description: Description of the subcommand group + sub_group_desc: An alias for `subcommand_group_description` + scopes: The scopes of which this command is available, defaults to GLOBAL_SCOPE + options: The options for this command + nsfw: This command should only work in NSFW channels + silence_autocomplete_errors: Should autocomplete errors be silenced. Don't use this unless you know what you're doing. + + Returns: + A HybridSlashCommand object + + """ + + def wrapper(func: AsyncCallable) -> HybridSlashCommand: + if not asyncio.iscoroutinefunction(func): + raise ValueError("Commands must be coroutines") + + _name = name + if _name is MISSING: + _name = func.__name__ + + _description = description + if _description is MISSING: + _description = func.__doc__ or "No Description Set" + + cmd = HybridSlashCommand( + name=base, + description=(base_description or base_desc) or "No Description Set", + group_name=subcommand_group, + group_description=(subcommand_group_description or sub_group_desc) or "No Description Set", + sub_cmd_name=_name, + sub_cmd_description=_description, + default_member_permissions=base_default_member_permissions, + dm_permission=base_dm_permission, + scopes=scopes or [GLOBAL_SCOPE], + callback=func, + options=options, + nsfw=nsfw, + aliases=aliases or [], + silence_autocomplete_errors=silence_autocomplete_errors, + ) + return cmd + + return wrapper diff --git a/interactions/ext/hybrid_commands/manager.py b/interactions/ext/hybrid_commands/manager.py new file mode 100644 index 000000000..de0127d59 --- /dev/null +++ b/interactions/ext/hybrid_commands/manager.py @@ -0,0 +1,149 @@ +from typing import cast, Callable, Any + +from interactions import Client, BaseContext, listen +from interactions.api.events import CallbackAdded, ExtensionUnload +from interactions.ext import prefixed_commands as prefixed + +from .context import HybridContext +from .hybrid_slash import ( + _values_wrapper, + base_subcommand_generator, + HybridSlashCommand, + _HybridToPrefixedCommand, + slash_to_prefixed, +) + +__all__ = ("HybridManager", "setup") + + +def add_use_slash_command_message( + prefixed_cmd: _HybridToPrefixedCommand, slash_cmd: HybridSlashCommand +) -> _HybridToPrefixedCommand: + if prefixed_cmd.has_binding: + + def wrap_old_callback(func: Callable) -> Any: + async def _msg_callback(self, ctx: prefixed.PrefixedContext, *args, **kwargs): + await ctx.reply(f"This command has been updated. Please use {slash_cmd.mention(ctx.guild_id)} instead.") + await func(ctx, *args, **kwargs) + + return _msg_callback + + else: + + def wrap_old_callback(func: Callable) -> Any: + async def _msg_callback(ctx: prefixed.PrefixedContext, *args, **kwargs): + await ctx.reply(f"This command has been updated. Please use {slash_cmd.mention(ctx.guild_id)} instead.") + await func(ctx, *args, **kwargs) + + return _msg_callback + + prefixed_cmd.callback = wrap_old_callback(prefixed_cmd.callback) + return prefixed_cmd + + +class HybridManager: + """ + The main part of the extension. Deals with injecting itself in the first place. + + Parameters: + client: The client instance. + hybrid_context: The object to instantiate for Hybrid Context + use_slash_command_msg: If enabled, will send out a message encouraging users to use the slash command \ + equivalent whenever they use the prefixed command version. + """ + + def __init__( + self, client: Client, *, hybrid_context: type[BaseContext] = HybridContext, use_slash_command_msg: bool = False + ) -> None: + if not hasattr(client, "prefixed") or not isinstance(client.prefixed, prefixed.PrefixedManager): + raise TypeError("Prefixed commands are not set up for this bot.") + + self.hybrid_context = hybrid_context + self.use_slash_command_msg = use_slash_command_msg + + self.client = cast(prefixed.PrefixedInjectedClient, client) + self.ext_command_list: dict[str, list[str]] = {} + + self.client.add_listener(self.add_hybrid_command.copy_with_binding(self)) + self.client.add_listener(self.handle_ext_unload.copy_with_binding(self)) + + self.client.hybrid = self + + @listen("on_callback_added") + async def add_hybrid_command(self, event: CallbackAdded): + if ( + not isinstance(event.callback, HybridSlashCommand) + or not event.callback.callback + or event.callback._dummy_base + ): + return + + cmd = event.callback + prefixed_transform = slash_to_prefixed(cmd) + + if self.use_slash_command_msg: + prefixed_transform = add_use_slash_command_message(prefixed_transform, cmd) + + if cmd.is_subcommand: + base = None + if not (base := self.client.prefixed.commands.get(str(cmd.name))): + base = base_subcommand_generator( + str(cmd.name), + list(_values_wrapper(cmd.name.to_locale_dict())) + cmd.aliases, + str(cmd.name), + group=False, + ) + self.client.prefixed.add_command(base) + + if cmd.group_name: # group command + group = None + if not (group := base.subcommands.get(str(cmd.group_name))): + group = base_subcommand_generator( + str(cmd.group_name), + list(_values_wrapper(cmd.group_name.to_locale_dict())) + cmd.aliases, + str(cmd.group_name), + group=True, + ) + base.add_command(group) + base = group + + # since this is added *after* the base command has been added to the bot, we need to run + # this function ourselves + prefixed_transform._parse_parameters() + base.add_command(prefixed_transform) + else: + self.client.prefixed.add_command(prefixed_transform) + + if cmd.extension: + self.ext_command_list.setdefault(cmd.extension.extension_name, []).append(cmd.resolved_name) + + @listen("extension_unload") + async def handle_ext_unload(self, event: ExtensionUnload) -> None: + if not self.ext_command_list.get(event.extension.extension_name): + return + + for cmd in self.ext_command_list[event.extension.extension_name]: + self.client.prefixed.remove_command(cmd, delete_parent_if_empty=True) + + del self.ext_command_list[event.extension.extension_name] + + +def setup( + client: Client, *, hybrid_context: type[BaseContext] = HybridContext, use_slash_command_msg: bool = False +) -> HybridManager: + """ + Sets up hybrid commands. It is recommended to use this function directly to do so. + + !!! warning + Prefixed commands need to be set up prior to using this. + + Args: + client: The client instance. + hybrid_context: The object to instantiate for Hybrid Context + use_slash_command_msg: If enabled, will send out a message encouraging users to use the slash command \ + equivalent whenever they use the prefixed command version. + + Returns: + HybridManager: The class that deals with all things hybrid commands. + """ + return HybridManager(client, hybrid_context=hybrid_context, use_slash_command_msg=use_slash_command_msg) diff --git a/interactions/models/__init__.py b/interactions/models/__init__.py index a3e3543aa..e8a7c0058 100644 --- a/interactions/models/__init__.py +++ b/interactions/models/__init__.py @@ -67,6 +67,7 @@ GuildForum, GuildForumPost, GuildIntegration, + GuildMedia, GuildNews, GuildNewsThread, GuildPreview, @@ -411,6 +412,7 @@ "GuildForum", "GuildForumPost", "GuildIntegration", + "GuildMedia", "GuildNews", "GuildNewsConverter", "GuildNewsThread", diff --git a/interactions/models/discord/__init__.py b/interactions/models/discord/__init__.py index 5adf044de..2793aefc1 100644 --- a/interactions/models/discord/__init__.py +++ b/interactions/models/discord/__init__.py @@ -13,6 +13,7 @@ GuildChannel, GuildForum, GuildForumPost, + GuildMedia, GuildNews, GuildNewsThread, GuildPrivateThread, @@ -234,6 +235,7 @@ "GuildForum", "GuildForumPost", "GuildIntegration", + "GuildMedia", "GuildNews", "GuildNewsThread", "GuildPreview", diff --git a/interactions/models/discord/channel.py b/interactions/models/discord/channel.py index 26e8200ac..b13125ab0 100644 --- a/interactions/models/discord/channel.py +++ b/interactions/models/discord/channel.py @@ -2640,6 +2640,11 @@ async def delete_tag(self, tag_id: "Snowflake_Type") -> None: self._client.cache.place_channel_data(data) +@attrs.define(eq=False, order=False, hash=False, kw_only=True) +class GuildMedia(GuildForum): + ... + + def process_permission_overwrites( overwrites: Union[dict, PermissionOverwrite, List[Union[dict, PermissionOverwrite]]] ) -> List[dict]: @@ -2694,6 +2699,7 @@ def process_permission_overwrites( GuildVoice, GuildStageVoice, GuildForum, + GuildMedia, GuildPublicThread, GuildForumPost, GuildPrivateThread, @@ -2731,4 +2737,5 @@ def process_permission_overwrites( ChannelType.DM: DM, ChannelType.GROUP_DM: DMGroup, ChannelType.GUILD_FORUM: GuildForum, + ChannelType.GUILD_MEDIA: GuildMedia, } diff --git a/interactions/models/discord/embed.py b/interactions/models/discord/embed.py index a9ec2e11b..1d6305613 100644 --- a/interactions/models/discord/embed.py +++ b/interactions/models/discord/embed.py @@ -229,6 +229,12 @@ class Embed(DictSerializationMixin): metadata=no_export_meta, ) + @classmethod + def _process_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]: + if image_data := data.pop("image", None): + data["images"] = [image_data] + return data + @property def image(self) -> Optional[EmbedAttachment]: """ diff --git a/interactions/models/discord/enums.py b/interactions/models/discord/enums.py index d8a3d2523..d5b3ec054 100644 --- a/interactions/models/discord/enums.py +++ b/interactions/models/discord/enums.py @@ -611,6 +611,8 @@ class ChannelType(CursedIntEnum): """Voice channel for hosting events with an audience""" GUILD_FORUM = 15 """A Forum channel""" + GUILD_MEDIA = 16 + """Channel that can only contain threads, similar to `GUILD_FORUM` channels""" @property def guild(self) -> bool: @@ -794,6 +796,8 @@ class ChannelFlags(DiscordIntFlag): """ Thread is pinned to the top of its parent forum channel """ CLYDE_THREAD = 1 << 8 """This thread was created by Clyde""" + HIDE_MEDIA_DOWNLOAD_OPTIONS = 1 << 15 + """when set hides the embedded media download options. Available only for media channels""" # Special members NONE = 0 @@ -968,6 +972,7 @@ class AuditLogEventType(CursedIntEnum): AUTO_MODERATION_BLOCK_MESSAGE = 143 AUTO_MODERATION_FLAG_TO_CHANNEL = 144 AUTO_MODERATION_USER_COMMUNICATION_DISABLED = 145 + AUTO_MODERATION_QUARANTINE = 146 CREATOR_MONETIZATION_REQUEST_CREATED = 150 CREATOR_MONETIZATION_TERMS_ACCEPTED = 151 ROLE_PROMPT_CREATE = 160 diff --git a/interactions/models/discord/guild.py b/interactions/models/discord/guild.py index 3941ef124..f166ba540 100644 --- a/interactions/models/discord/guild.py +++ b/interactions/models/discord/guild.py @@ -348,7 +348,8 @@ def threads(self) -> List["models.TYPE_THREAD_CHANNEL"]: @property def members(self) -> List["models.Member"]: """Returns a list of all members within this guild.""" - return [self._client.cache.get_member(self.id, m_id) for m_id in self._member_ids] + members = (self._client.cache.get_member(self.id, m_id) for m_id in self._member_ids) + return [m for m in members if m] @property def premium_subscribers(self) -> List["models.Member"]: @@ -368,7 +369,7 @@ def humans(self) -> List["models.Member"]: @property def roles(self) -> List["models.Role"]: """Returns a list of roles associated with this guild.""" - return sorted([self._client.cache.get_role(r_id) for r_id in self._role_ids], reverse=True) + return sorted((r for r_id in self._role_ids if (r := self._client.cache.get_role(r_id))), reverse=True) @property def me(self) -> "models.Member": @@ -1475,7 +1476,7 @@ async def create_role( payload["permissions"] = str(int(permissions)) if colour := colour or color: - payload["color"] = colour.value + payload["color"] = colour if isinstance(colour, int) else colour.value if hoist: payload["hoist"] = True diff --git a/interactions/models/discord/message.py b/interactions/models/discord/message.py index c84d3dfc0..bccc6a36b 100644 --- a/interactions/models/discord/message.py +++ b/interactions/models/discord/message.py @@ -187,9 +187,10 @@ def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any] data["user_id"] = client.cache.place_user_data(user_data).id return data - async def user(self) -> "models.User": + @property + def user(self) -> "models.User": """Get the user associated with this interaction.""" - return await self.get_user(self._user_id) + return self.client.get_user(self._user_id) @attrs.define(eq=False, order=False, hash=False, kw_only=False) diff --git a/interactions/models/internal/context.py b/interactions/models/internal/context.py index 77500da34..9163d39ee 100644 --- a/interactions/models/internal/context.py +++ b/interactions/models/internal/context.py @@ -237,8 +237,6 @@ class BaseInteractionContext(BaseContext): id: Snowflake """The interaction ID.""" - app_permissions: Permissions - """The permissions available to this interaction""" locale: str """The selected locale of the invoking user (https://discord.com/developers/docs/reference#locales)""" guild_locale: str @@ -261,6 +259,8 @@ class BaseInteractionContext(BaseContext): _command_name: str """The command name of the interaction.""" + permission_map: dict[Snowflake, Permissions] + args: list[typing.Any] """The arguments passed to the interaction.""" kwargs: dict[str, typing.Any] @@ -277,7 +277,7 @@ def from_dict(cls, client: "interactions.Client", payload: dict) -> Self: instance = cls(client=client) instance.token = payload["token"] instance.id = Snowflake(payload["id"]) - instance.app_permissions = Permissions(payload.get("app_permissions", 0)) + instance.permission_map = {client.app.id: Permissions(payload.get("app_permissions", 0))} instance.locale = payload["locale"] instance.guild_locale = payload.get("guild_locale", instance.locale) instance._context_type = payload.get("type", 0) @@ -304,8 +304,23 @@ def from_dict(cls, client: "interactions.Client", payload: dict) -> Self: instance.process_options(payload) + if member := payload.get("member"): + instance.permission_map[Snowflake(member["id"])] = Permissions(member["permissions"]) + return instance + @property + def app_permissions(self) -> Permissions: + """The permissions available to this interaction""" + return self.permission_map.get(self.client.app.id, Permissions(0)) + + @property + def author_permissions(self) -> Permissions: + """The permissions available to the author of this interaction""" + if self.guild_id: + return self.permission_map.get(self.author_id, Permissions(0)) + return Permissions(0) + @property def command(self) -> InteractionCommand: return self.client._interaction_lookup[self._command_name] @@ -523,6 +538,7 @@ async def send( tts=tts, flags=flags, delete_after=delete_after, + pass_self_into_delete=True, **kwargs, )