diff --git a/docs/src/Guides/03 Creating Commands.md b/docs/src/Guides/03 Creating Commands.md index 977f1297b..abe04237a 100644 --- a/docs/src/Guides/03 Creating Commands.md +++ b/docs/src/Guides/03 Creating Commands.md @@ -1,4 +1,4 @@ -# Creating Slash Commands +# Slash Commands So you want to make a slash command (or interaction, as they are officially called), but don't know how to get started? Then this is the right place for you. @@ -107,7 +107,7 @@ For all of these, the "group" parts are optional, allowing you to do `/base comm You cannot mix group subcommands and non-group subcommands into one base command - you must either use all group subcommands or normal subcommands. -## But I Need More Options +## Options Interactions can also have options. There are a bunch of different [types of options](/interactions.py/API Reference/API Reference/models/Internal/application_commands/#interactions.models.internal.application_commands.OptionType): @@ -157,19 +157,21 @@ async def my_command_function(ctx: SlashContext, integer_option: int = 5): For more information, please visit the API reference [here](/interactions.py/API Reference/API Reference/models/Internal/application_commands/#interactions.models.internal.application_commands.slash_option). -## Restricting Options +### Restricting Options If you are using an `OptionType.CHANNEL` option, you can restrict the channel a user can choose by setting `channel_types`: ```python -@slash_command(name="my_command", ...) +from interactions import ChannelType, GuildText, OptionType, SlashContext, slash_command, slash_option + +@slash_command(name="my_command") @slash_option( name="channel_option", description="Channel Option", required=True, opt_type=OptionType.CHANNEL, - channel_types=[ChannelType.GUILD_TEXT] + channel_types=[ChannelType.GUILD_TEXT], ) -async def my_command_function(ctx: SlashContext, channel_option: GUILD_TEXT): +async def my_command_function(ctx: SlashContext, channel_option: GuildText): await channel_option.send("This is a text channel in a guild") await ctx.send("...") @@ -209,12 +211,12 @@ async def my_command_function(ctx: SlashContext, string_option: str): Be aware that the option `name` and the function parameter need to be the same (In this example both are `integer_option`). -## But I Want A Choice +## Option Choices If your users ~~are dumb~~ constantly misspell specific strings, it might be wise to set up choices. -With choices, the user can no longer freely input whatever they want, instead, they must choose from a curated list. +With choices, the user can no longer freely input whatever they want, instead, they must choose from a pre-defined list. -To create a choice, simply fill `choices` in `@slash_option()`. An option can have up to 25 choices: +To create a choice, simply fill `choices` in `@slash_option()`. An option can have up to 25 choices. The name of a choice is what will be shown in the Discord client of the user, while the value is what the bot will receive in its callback. Both can be the same. ```python from interactions import SlashCommandChoice @@ -235,9 +237,9 @@ async def my_command_function(ctx: SlashContext, integer_option: int): For more information, please visit the API reference [here](/interactions.py/API Reference/API Reference/models/Internal/application_commands/#interactions.models.internal.application_commands.SlashCommandChoice). -## I Need More Than 25 Choices +## Autocomplete / More than 25 choices needed -Looks like you want autocomplete options. These dynamically show users choices based on their input. +If you have more than 25 choices the user can choose from, or you want to give a dynamic list of choices depending on what the user is currently typing, then you will need autocomplete options. The downside is that you need to supply the choices on request, making this a bit more tricky to set up. To use autocomplete options, set `autocomplete=True` in `@slash_option()`: @@ -260,10 +262,10 @@ In there, you have three seconds to return whatever choices you want to the user ```python from interactions import AutocompleteContext -@my_command.autocomplete("string_option") +@my_command_function.autocomplete("string_option") async def autocomplete(self, ctx: AutocompleteContext): 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 + # you can use ctx.kwargs.get("name") to get the current state of other options - note they can be empty too # make sure you respond within three seconds await ctx.send( @@ -284,11 +286,12 @@ async def autocomplete(self, ctx: AutocompleteContext): ) ``` -## But I Don't Like Decorators +## Command definition without decorators -You are in luck. There are currently four different ways to create interactions, one does not need any decorators at all. +There are currently four different ways to define interactions, one does not need any decorators at all. === ":one: Multiple Decorators" + ```python @slash_command(name="my_command", description="My first command :)") @slash_option( @@ -302,6 +305,7 @@ You are in luck. There are currently four different ways to create interactions, ``` === ":two: Single Decorator" + ```python from interactions import SlashCommandOption @@ -322,6 +326,7 @@ You are in luck. There are currently four different ways to create interactions, ``` === ":three: Function Annotations" + ```python from interactions import slash_int_option @@ -331,6 +336,7 @@ You are in luck. There are currently four different ways to create interactions, ``` === ":four: Manual Registration" + ```python from interactions import SlashCommandOption @@ -353,34 +359,34 @@ You are in luck. There are currently four different ways to create interactions, ) ``` -## I Don't Want My Friends Using My Commands +## Restrict commands using permissions -How rude. +It is possible to disable interactions (slash commands as well as context menus) for users that do not have a set of permissions. -Anyway, this is somewhat possible with command permissions. -While you cannot explicitly block / allow certain roles / members / channels to use your commands on the bot side, you can define default permissions which members need to have to use the command. +This functionality works for **permissions**, not to confuse with roles. If you want to restrict some command if the user does not have a certain role, this cannot be done on the bot side. However, it can be done on the Discord server side, in the Server Settings > Integrations page. -However, these default permissions can be overwritten by server admins, so this system is not safe for stuff like owner only eval commands. -This system is designed to limit access to admin commands after a bot is added to a server, before admins have a chance to customise the permissions they want. +!!!warning Administrators + Remember that administrators of a Discord server have all permissions and therefore will always see the commands. -If you do not want admins to be able to overwrite your permissions, or the permissions are not flexible enough for you, you should use [checks][check-this-out]. + If you do not want admins to be able to overwrite your permissions, or the permissions are not flexible enough for you, you should use [checks][checks]. In this example, we will limit access to the command to members with the `MANAGE_EVENTS` and `MANAGE_THREADS` permissions. There are two ways to define permissions. === ":one: Decorators" - ```py + + ```python from interactions import Permissions, slash_default_member_permission @slash_command(name="my_command") - @slash_default_member_permission(Permissions.MANAGE_EVENTS) - @slash_default_member_permission(Permissions.MANAGE_THREADS) + @slash_default_member_permission(Permissions.MANAGE_EVENTS | Permissions.MANAGE_THREADS) async def my_command_function(ctx: SlashContext): ... ``` === ":two: Function Definition" - ```py + + ```python from interactions import Permissions @slash_command( @@ -406,47 +412,43 @@ async def my_command_function(ctx: SlashContext): ... ``` -### Context Menus - -Both default permissions and DM blocking can be used the same way for context menus, since they are normal slash commands under the hood. - -### Check This Out +## Checks Checks allow you to define who can use your commands however you want. There are a few pre-made checks for you to use, and you can simply create your own custom checks. -=== "Build-In Check" +=== ":one: Built-In Check" Check that the author is the owner of the bot: - ```py - from interactions import is_owner + ```python + from interactions import SlashContext, check, is_owner, slash_command - @is_owner() @slash_command(name="my_command") - async def my_command_function(ctx: SlashContext): - ... + @check(is_owner()) + async def command(ctx: SlashContext): + await ctx.send("You are the owner of the bot!", ephemeral=True) ``` -=== "Custom Check" - Check that the author's name starts with `a`: +=== ":two: Custom Check" + Check that the author's username starts with `a`: - ```py - from interactions import check + ```python + from interactions import BaseContext, SlashContext, check, slash_command - async def my_check(ctx: Context): - return ctx.author.name.startswith("a") + async def my_check(ctx: BaseContext): + return ctx.author.username.startswith("a") - @check(check=my_check) @slash_command(name="my_command") - async def my_command_function(ctx: SlashContext): - ... + @check(my_check) + async def command(ctx: SlashContext): + await ctx.send("Your username starts with an 'a'!", ephemeral=True) ``` -=== "Reusing Checks" +=== ":three: Reusing Checks" You can reuse checks in extensions by adding them to the extension check list - ```py + ```python from interactions import Extension class MyExtension(Extension): @@ -461,18 +463,14 @@ There are a few pre-made checks for you to use, and you can simply create your o @slash_command(name="my_command2") async def my_command_function2(ctx: SlashContext): ... - - def setup(bot) -> None: - MyExtension(bot) ``` The check will be checked for every command in the extension. +## Avoid redefining the same option everytime -## I Don't Want To Define The Same Option Every Time - -If you are like me, you find yourself reusing options in different commands and having to redefine them every time which is both annoying and bad programming. +If you have multiple commands that all use the same option, it might be both annoying and bad programming to redefine it multiple times. Luckily, you can simply make your own decorators that themselves call `@slash_option()`: ```python @@ -517,17 +515,17 @@ async def on_command_error(self, event: CommandError): There also is `CommandCompletion` which you can overwrite too. That fires on every interactions usage. -## I Need A Custom Parameter Type +## 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](/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](../08 Converters). -## I Want To Make A Prefixed/Text Command Too +## Prefixed/Text Commands -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. +To use prefixed commands, instead of typing `/my_command`, you will need to type instead `!my_command`, provided that the prefix you set is `!`. -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. +Hybrid commands are are slash commands that also get converted to an equivalent prefixed command under the hood. They are their own extension, and require [prefixed commands to be 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: @@ -556,4 +554,4 @@ Suggesting you are using the default mention settings for your bot, you should b 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. +Of course, keep in mind that supporting 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/04 Context Menus.md b/docs/src/Guides/04 Context Menus.md index 4e7da783c..7d9aa63f7 100644 --- a/docs/src/Guides/04 Context Menus.md +++ b/docs/src/Guides/04 Context Menus.md @@ -1,4 +1,4 @@ -# Creating Context Menus +# Context Menus Context menus are interactions under the hood. Defining them is very similar. Context menus work off `ctx.target` which contains the object the user interacted with. diff --git a/docs/src/Guides/05 Components.md b/docs/src/Guides/05 Components.md index 2a20f6af9..763d170f1 100644 --- a/docs/src/Guides/05 Components.md +++ b/docs/src/Guides/05 Components.md @@ -1,16 +1,34 @@ # Components -While interactions are cool and all, they are still missing a vital component. -Introducing components, aka Buttons, Selects, soon Text Input Fields. -Components can be added to any message by passing them to `components` in any `.send()` method. +Components (Buttons, Select Menus and soon Text Input Fields) can be added to any message by passing them to the `components` argument in any `.send()` method. + +## Layout + +All types of components must be part of Action Rows, which are containers that a message can have. Each message can have up to 5 action rows, with each action row containing a maximum of 5 buttons OR a single select menu. + +If you don't really care of the layout in which your components are set, you can pass in directly the components without actually creating the Action Rows - the library will handle it itself. However, if the layout is important for you (let's say, you want a row with 3 buttons, then a row with a select menu and finally another row with 2 buttons), then you will need to specify the layout yourself by either defining the action rows or using the `spread_to_rows()` function. They are organised in a 5x5 grid, so you either have to manage the layout yourself, use `spread_to_rows()` where we organise them for you, or have a single component. If you want to define the layout yourself, you have to put them in an `ActionRow()`. The `components` parameter need a list of up to five `ActionRow()`. -=== ":one: `ActionRow()`" +=== ":one: No special layout" + Your list of components will be transformed into `ActionRow`s behind the scenes. + + ```python + from interactions import Button, ButtonStyle + + components = Button( + style=ButtonStyle.GREEN, + label="Click Me", + ) + + await channel.send("Look, Buttons!", components=components) + ``` + +=== ":two: `ActionRow()`" ```python - from interactions import ActionRow, Button + from interactions import ActionRow, Button, ButtonStyle components: list[ActionRow] = [ ActionRow( @@ -28,9 +46,9 @@ If you want to define the layout yourself, you have to put them in an `ActionRow await channel.send("Look, Buttons!", components=components) ``` -=== ":two: `spread_to_rows()`" +=== ":three: `spread_to_rows()`" ```python - from interactions import ActionRow, Button, spread_to_rows + from interactions import ActionRow, Button, ButtonStyle, spread_to_rows components: list[ActionRow] = spread_to_rows( Button( @@ -46,28 +64,13 @@ If you want to define the layout yourself, you have to put them in an `ActionRow await channel.send("Look, Buttons!", components=components) ``` -=== ":three: With only one component" - If you only have **one** component, you do not need to worry about the layout at all and can simply pass it. - We will put it in an `ActionRow` behind the scenes. - - ```python - from interactions import Button - - components = Button( - style=ButtonStyle.GREEN, - label="Click Me", - ) - - await channel.send("Look, Buttons!", components=components) - ``` - -For simplicity's sake, example three will be used for all examples going forward. +For simplicity's sake, example one will be used for all examples going forward. If you want to delete components, you need to pass `components=[]` to `.edit()`. -## You Have To Button Up +## Buttons -Buttons are, you guessed right, buttons. Users can click them, and they can be disabled if you wish. That's all really. +Buttons can be clicked on, or be set as disabled if you wish. ```python components = Button( @@ -81,14 +84,14 @@ await channel.send("Look a Button!", components=components) For more information, please visit the API reference [here](/interactions.py/API Reference/API Reference/models/Discord/components/#interactions.models.discord.components.Button). -### I Need More Style +### Button Styles -You are in luck, there are a bunch of colours you can choose from. +There are a bunch of colours and styles you can choose from.
![Button Colours](../images/Components/buttons.png "Button Colours") The colours correspond to the styles found in `ButtonStyle`. Click [here](/interactions.py/API Reference/API Reference/models/Discord/enums/#interactions.models.discord.enums.ButtonStyle) for more information. -If you use `ButtonStyle.URL`, you can pass an url to the button with `url`. Users who click the button will get redirected to your url. +If you use `ButtonStyle.URL`, you can pass a URL to the button with the `url` argument. Users who click the button will get redirected to your URL. ```python from interactions import ButtonStyle @@ -103,13 +106,13 @@ await channel.send("Look a Button!", components=components) `ButtonStyle.URL` does not receive events, or work with callbacks. -## Select Your Favorite +## Select Menus -Sometimes there might be more than a handful options which users need to decide between. That's when a `Select` should probably be used. +Sometimes there might be more than a handful options which users need to decide between. That's when a `SelectMenu` should probably be used. -Selects are very similar to Buttons. The main difference is that you get a list of options to choose from. +Select Menus are very similar to Buttons. The main difference is that you get a list of options to choose from. -If you want to use string options, then you use `StringSelect`. Simply pass a list of strings to `options` and you are good to go. You can also explicitly pass `SelectOptions` to control the value attribute. +If you want to use string options, then you use the `StringSelectMenu`. Simply pass a list of strings to `options` and you are good to go. You can also explicitly pass `SelectOptions` to control the value attribute. You can also define how many options users can choose by setting `min_values` and `max_values`. @@ -126,7 +129,7 @@ components = StringSelectMenu( await channel.send("Look a Select!", components=components) ``` ???+ note - You can only have upto 25 options in a Select + You can only have up to 25 options in a Select Alternatively, you can use `RoleSelectMenu`, `UserSelectMenu` and `ChannelSelectMenu` to select roles, users and channels respectively. These select menus are very similar to `StringSelectMenu`, but they don't allow you to pass a list of options; it's all done behind the scenes. @@ -134,46 +137,50 @@ For more information, please visit the API reference [here](/interactions.py/API ## Responding -Okay now you can make components, but how do you interact with users? +Now that we know how to send components, we need to learn how to respond to a user when a component is interacted with. There are three ways to respond to components. -If you add your component to a temporary message asking for additional user input, just should probably use `bot.wait_for_component()`. -These have the downside that, for example, they won't work anymore after restarting your bot. +If you add your component to a temporary message asking for additional user input, you should probably use `bot.wait_for_component()`. +These have the downside that, for example, they won't work anymore after restarting your bot. On the positive side, they are defined in the same function where your button is sent, so you can easily use variables that you defined *before* the user used the component. -Otherwise, you are looking for a persistent callback. For that, you want to define `custom_id` in your component creation. +Otherwise, you are looking for a persistent callback. For that, you want to define a `custom_id` when creating your component. -When responding to a component you need to satisfy discord either by responding to the context with `ctx.send()` or by editing the component with `ctx.edit_origin()`. You get access to the context with `component.ctx`. +When responding to a component you need to satisfy Discord either by responding to the context with `ctx.send()` or by editing the component with `ctx.edit_origin()`. === ":one: `bot.wait_for_component()`" - As with discord.py, this supports checks and timeouts. + This function supports checks and timeouts. + + In this example, we are checking that the username starts with "a" and clicks the button within 30 seconds. If his username doesn't start with an "a", then we send it an ephemeral message to notify him. If the button times out, we edit the message so that the button is disabled and cannot be clicked anymore. - In this example, we are checking that the username starts with "a" and clicks the button within 30 seconds. - If it times out, we're just gonna disable it ```python - from asyncio import TimeoutError + from interactions import Button, ButtonStyle + from interactions.api.events import Component - components = Button( + # defining and sending the button + button = Button( custom_id="my_button_id", style=ButtonStyle.GREEN, label="Click Me", ) - - message = await channel.send("Look a Button!", components=components) + message = await channel.send("Look a Button!", components=button) # define the check - def check(component: Button) -> bool: - return component.ctx.author.startswith("a") + async def check(component: Component) -> bool: + if component.ctx.author.username.startswith("a"): + return True + else: + await component.ctx.send("Your name does not start with an 'a'!", ephemeral=True) try: # you need to pass the component you want to listen for here # you can also pass an ActionRow, or a list of ActionRows. Then a press on any component in there will be listened for - used_component = await bot.wait_for_component(components=components, check=check, timeout=30) + used_component: Component = await bot.wait_for_component(components=button, check=check, timeout=30) except TimeoutError: print("Timed Out!") - components[0].components[0].disabled = True - await message.edit(components=components) + button.disabled = True + await message.edit(components=button) else: await used_component.ctx.send("Your name starts with 'a'") @@ -181,25 +188,25 @@ When responding to a component you need to satisfy discord either by responding You can also use this to check for a normal message instead of a component interaction. - For more information, please visit the API reference [here](/interactions.py/API Reference/API Reference/client/). + For more information, please visit the API reference [here](/interactions.py/API Reference/API Reference/client/#interactions.client.client.Client.wait_for_component). -=== ":two: Persistent Callback Option 1" +=== ":two: Persistent Callback: `@listen()`" You can listen to the `on_component()` event and then handle your callback. This works even after restarts! ```python + from interactions import Button, ButtonStyle from interactions.api.events import Component - async def my_command(...): - components = Button( - custom_id="my_button_id", - style=ButtonStyle.GREEN, - label="Click Me", - ) - - await channel.send("Look a Button!", components=components) + # defining and sending the button + button = Button( + custom_id="my_button_id", + style=ButtonStyle.GREEN, + label="Click Me", + ) + await channel.send("Look a Button!", components=button) - @listen() + @listen(Component) async def on_component(event: Component): ctx = event.ctx @@ -208,7 +215,7 @@ When responding to a component you need to satisfy discord either by responding await ctx.send("You clicked it!") ``` -=== ":two: Persistent Callback Option 2" +=== ":three: Persistent Callback: `@component_callback()`" If you have a lot of components, putting everything in the `on_component()` event can get messy really quick. Similarly to Option 1, you can define `@component_callback` listeners. This works after restarts too. @@ -216,16 +223,15 @@ When responding to a component you need to satisfy discord either by responding You have to pass your `custom_id` to `@component_callback(custom_id)` for the library to be able to register the callback function to the wanted component. ```python - from interactions import component_callback, ComponentContext + from interactions import Button, ButtonStyle, ComponentContext, component_callback - async def my_command(...): - components = Button( - custom_id="my_button_id", - style=ButtonStyle.GREEN, - label="Click Me", - ) - - await channel.send("Look a Button!", components=components) + # defining and sending the button + button = Button( + custom_id="my_button_id", + style=ButtonStyle.GREEN, + label="Click Me", + ) + await channel.send("Look a Button!", components=button) # you need to pass your custom_id to this decorator @component_callback("my_button_id") @@ -233,53 +239,42 @@ When responding to a component you need to satisfy discord either by responding await ctx.send("You clicked it!") ``` -=== ":three: Persistent Callback Option 3" - Personally, I put my callbacks into different files, which is why I use this method, because the usage of different files can make using decorators challenging. +=== ":four: Persistent Callbacks, with regex" + Regex (regular expressions) can be a great way to pass information from the component creation directly to the component response callback, in a persistent manner. - For this example to work, the function name needs to be the same as the `custom_id` of the component. + Below is an example of how regex can be used to create a button and how to respond to it. ```python - from interactions import ComponentCommand, ComponentContext + import re + from interactions import Button, ButtonStyle, ComponentContext, SlashContext, component_callback, slash_command - async def my_command(...): - components = Button( - custom_id="my_button_id", + @slash_command(name="test") + async def command(ctx: SlashContext): + id = "123456789" # random ID, could be anything (a member ID, a message ID...) + button = Button( + custom_id=f"button_{id}", style=ButtonStyle.GREEN, label="Click Me", ) + await ctx.send(components=button) - await channel.send("Look a Button!", components=components) - - # my callbacks go in here or I subclass this if I want to split it up - class MyComponentCallbacks: - @staticmethod - async def my_button_id(ctx: ComponentContext): - await ctx.send("You clicked it!") - - # magically register all functions from the class - for custom_id in [k for k in MyComponentCallbacks.__dict__ if not k.startswith("__")]: - bot.add_component_callback( - ComponentCommand( - name=f"ComponentCallback::{custom_id}", - callback=getattr(ComponentCallbacks, custom_id), - listeners=[custom_id], - ) - ) - ``` - -=== ":four: Persistent Callbacks, with regex" - Ah, I see you are a masochist. You want to use regex to match your custom_ids. Well who am I to stop you? - ```python - import re - from interactions import component_callback, ComponentContext + # define the pattern of the button custom_id + regex_pattern = re.compile(r"button_([0-9]+)") - @component_callback(re.compile(r"\w*")) - async def test_callback(ctx: ComponentContext): - await ctx.send(f"Clicked {ctx.custom_id}") + @component_callback(regex_pattern) + async def button_callback(ctx: ComponentContext): + match = regex_pattern.match(ctx.custom_id) + if match: + id = match.group(1) # extract the ID from the custom ID + await ctx.send(f"Custom ID: {ctx.custom_id}. ID: {id}") # will return: "Custom ID: button_123456789. ID: 123456789" ``` Just like normal `@component_callback`, you can specify a regex pattern to match your custom_ids, instead of explicitly passing strings. This is useful if you have a lot of components with similar custom_ids, and you want to handle them all in the same callback. Please do bare in mind that using regex patterns can be a bit slower than using strings, especially if you have a lot of components. + +???+ note + As explained previously, the main difference between a Button and a Select Menu is that you can retrieve a list of options that were chosen by the user for a Select Menu. + In this case, this list of options can be found in `ctx.values`. diff --git a/docs/src/Guides/06 Modals.md b/docs/src/Guides/06 Modals.md index 5dbbb5c38..2960bdf8f 100644 --- a/docs/src/Guides/06 Modals.md +++ b/docs/src/Guides/06 Modals.md @@ -1,15 +1,14 @@ # Modals -As everyone knows from surfing the web, popups are really great. Everyone loves them and they make for a great UX. -Luckily for you, you have the option to regale your users love for them by using modals. +Modals are basically popups which a user can use to send text information to your bot. As of the writing of this guide, you can use two components in a modal: -Modals are made of modular *(hah)* components, similar to `ActionRow` from the last Chapter. -Importantly, both modals themselves and modal components also have a `custom_id` which you can supply. -If you want to do anything with the data the users input, it is ***highly*** recommended that you set a `custom_id` for your components. +- Short Text Input (single-line) +- Paragraph Text Input (multi-line) -You cannot not use the same components you can use in `ActionRow` for modals. +Each component that you define in your modal must have its own `custom_id` so that you can easily retrieve the data that a user sent later. -## Making Modular Modals + +## Creating a Modal Modals are one of the ways you can respond to interactions. They are intended for when you need to query a lot of information from a user. @@ -18,7 +17,7 @@ You **cannot** respond to a modal with a modal. Use `ctx.send_modal()` to send a modal. ```python -from interactions import slash_command, SlashContext, Modal, ShortText, ParagraphText +from interactions import Modal, ParagraphText, ShortText, SlashContext, slash_command @slash_command(name="my_modal_command", description="Playing with Modals") async def my_command_function(ctx: SlashContext): @@ -28,63 +27,92 @@ async def my_command_function(ctx: SlashContext): title="My Modal", ) await ctx.send_modal(modal=my_modal) - ... ``` This example leads to the following modal:
![example_modal.png](../images/Modals/modal_example.png "The Add bot button and text") -## Reading Responses - -Okay now the users can input and submit information, but we cannot view their response yet. -To wait for a user to fill out a modal and then get the data, use `bot.wait_for_modal(my_modal)`. - -As with `bot.wait_for_component()`, `bot.wait_for_modal()` supports timeouts. Checks are not supported, since modals are not persistent like Components, and only visible to the Interaction invoker. - -```python -... -from interactions import ModalContext - -modal_ctx: ModalContext = await ctx.bot.wait_for_modal(my_modal) -await modal_ctx.send(f"""You input {modal_ctx.responses["short_text"]} and {modal_ctx.responses["long_text"]}""") -``` - -`bot.wait_for_modal()` returns `ModalContext` which, to nobodies surprise, you need to respond to as well. Respond to it by utilising `modal_ctx.send()`, as you are probably already used to. - -To get the data the user input, you can use `modal_ctx.responses`. This returns a dictionary with the `custom_id` you set for your components as keys, and the users inputs as values. -As previously mentioned, you really want to set your own `custom_id` otherwise you will have problems figuring out which input belongs to which component. - -## Customising Components +### Text Inputs Customisation Modal components are customisable in their appearance. You can set a placeholder, pre-fill them, restrict what users can input, or make them optional. ```python -from interactions import slash_command, Modal, ShortText, SlashContext - +from interactions import Modal, ShortText, SlashContext, slash_command @slash_command(name="my_modal_command", description="Playing with Modals") async def my_command_function(ctx: SlashContext): my_modal = Modal( ShortText( - label="Short Input Text", - custom_id="short_text", - value="Pre-filled text", - min_length=10, - ), - ShortText( - label="Short Input Text", - custom_id="optional_short_text", - required=False, - placeholder="Please be concise", - max_length=10, - ), + label="Short Input Text", + custom_id="short_text", + value="Pre-filled text", + min_length=10, + ), + ShortText( + label="Short Input Text", + custom_id="optional_short_text", + required=False, + placeholder="Please be concise", + max_length=10, + ), title="My Modal", ) - - await ctx.send_modal(modal=my_modal) -... ``` This example leads to the following modal:
![example_modal.png](../images/Modals/modal_example_customisiblity.png "The Add bot button and text") + +## Responding + +Now that users have input some information, the bot needs to process it and answer back the user. Similarly to the Components guide, there is a persistent and non-persistent way to listen to a modal answer. + +The data that the user has input can be found in `ctx.responses`, which is a dictionary with the keys being the custom IDs of your text inputs and the values being the answers the user has entered. + +=== ":one: `@bot.wait_for_modal()`" + As with `bot.wait_for_component()`, `bot.wait_for_modal()` supports timeouts. However, checks are not supported, since modals are not persistent like Components, and only visible to the interaction invoker. + + ```python + from interactions import Modal, ModalContext, ParagraphText, ShortText, SlashContext, slash_command + + @slash_command(name="test") + async def command(ctx: SlashContext): + my_modal = Modal( + ShortText(label="Short Input Text", custom_id="short_text"), + ParagraphText(label="Long Input Text", custom_id="long_text"), + title="My Modal", + custom_id="my_modal", + ) + await ctx.send_modal(modal=my_modal) + modal_ctx: ModalContext = await ctx.bot.wait_for_modal(my_modal) + + # extract the answers from the responses dictionary + short_text = modal_ctx.responses["short_text"] + long_text = modal_ctx.responses["long_text"] + + await modal_ctx.send(f"Short text: {short_text}, Paragraph text: {long_text}", ephemeral=True) + ``` + + !!!warning + In this example, make sure to not mix the two Contexts `ctx` and `modal_ctx`! If the last line of the code is replaced by `ctx.send()`, the text would not be sent because you have already answered the `ctx` variable previously, when sending the modal (`ctx.send_modal()`). + +=== ":two: Persistent Callback: `@modal_callback()`" + In the case of a persistent callback, your callback function must have the names of the custom IDs of your text inputs as its arguments, similar to how you define a callback for a slash command. + + ```python + from interactions import Modal, ModalContext, ParagraphText, ShortText, SlashContext, modal_callback, slash_command + + @slash_command(name="test") + async def command(ctx: SlashContext): + my_modal = Modal( + ShortText(label="Short Input Text", custom_id="short_text"), + ParagraphText(label="Long Input Text", custom_id="long_text"), + title="My Modal", + custom_id="my_modal", + ) + await ctx.send_modal(modal=my_modal) + + @modal_callback("my_modal") + async def on_modal_answer(ctx: ModalContext, short_text: str, long_text: str): + await ctx.send(f"Short text: {short_text}, Paragraph text: {long_text}", ephemeral=True) + ``` diff --git a/docs/src/Guides/10 Events.md b/docs/src/Guides/10 Events.md index de6a2ce1d..1253eee59 100644 --- a/docs/src/Guides/10 Events.md +++ b/docs/src/Guides/10 Events.md @@ -12,13 +12,13 @@ There are two ways of setting them. We'll use the `GUILDS` and `GUILD_INVITES` i === ":one: Directly through `Intents`" ```python - from interactions import Intents + from interactions import Client, Intents bot = Client(intents=Intents.GUILDS | Intents.GUILD_INVITES) ``` === ":two: `Intents.new`" ```python - from interactions import Intents + from interactions import Client, Intents bot = Client(intents=Intents.new(guilds=True, guild_invites=True)) ``` @@ -33,7 +33,7 @@ Some intents are deemed to have sensitive content by Discord and so have extra r 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. + `Intents.ALL` is a shortcut provided by interactions.py to enable *every single intent, 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. 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). @@ -50,7 +50,7 @@ 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. +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 is 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. @@ -110,7 +110,7 @@ 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`: +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 subclassing `Client`. If so, you can achieve it through `disable_default_listeners`: ```python from interactions.api.events import ModalCompletion diff --git a/docs/src/Guides/100 Migration From D.py.md b/docs/src/Guides/97 Migration From D.py.md similarity index 98% rename from docs/src/Guides/100 Migration From D.py.md rename to docs/src/Guides/97 Migration From D.py.md index 10655f316..c5d67fff6 100644 --- a/docs/src/Guides/100 Migration From D.py.md +++ b/docs/src/Guides/97 Migration From D.py.md @@ -1,4 +1,4 @@ -# Migration From discord.py +# Migrating from discord.py 1. interactions.py requires python 3.10 (as compared to dpy's 3.5), you may need to upgrade python. - If you see `ERROR: Could not find a version that satisfies the requirement discord-py-interactions (from versions: none)` when trying to `pip install discord-py-interactions`, this is your problem. diff --git a/docs/src/Guides/99 2.x Migration_NAFF.md b/docs/src/Guides/99 2.x Migration_NAFF.md index f81178835..9170a1156 100644 --- a/docs/src/Guides/99 2.x Migration_NAFF.md +++ b/docs/src/Guides/99 2.x Migration_NAFF.md @@ -1,4 +1,4 @@ -# NAFF Migration +# Migrating from NAFF Oh hey! So you're migrating from NAFF to interactions.py? Well lets get you sorted. diff --git a/docs/src/Guides/index.md b/docs/src/Guides/index.md index 4daf2dd0c..a02ced783 100644 --- a/docs/src/Guides/index.md +++ b/docs/src/Guides/index.md @@ -99,7 +99,7 @@ These guides are meant to help you get started with the library and offer a poin Oh damn, your bot is getting pretty big, huh? Well I guess its time we discuss sharding. -- [__:material-frequently-asked-questions: Migration from discord.py__](100 Migration From D.py.md) +- [__:material-frequently-asked-questions: Migration from discord.py__](97 Migration From D.py.md) --- @@ -111,7 +111,7 @@ These guides are meant to help you get started with the library and offer a poin How do I migrate from interactions.py v4 to v5? -- [__:material-package-up: NAFF Migration Guide__](99 2.x Migration_NAFF.md) +- [__:material-package-up: Migration from NAFF__](99 2.x Migration_NAFF.md) --- diff --git a/interactions/__init__.py b/interactions/__init__.py index e85e5c0b6..bb9b70b2a 100644 --- a/interactions/__init__.py +++ b/interactions/__init__.py @@ -310,7 +310,9 @@ to_optional_snowflake, to_snowflake, to_snowflake_list, + TYPE_ALL_ACTION, TYPE_ALL_CHANNEL, + TYPE_ALL_TRIGGER, TYPE_CHANNEL_MAPPING, TYPE_COMPONENT_MAPPING, TYPE_DM_CHANNEL, @@ -659,7 +661,9 @@ "to_optional_snowflake", "to_snowflake", "to_snowflake_list", + "TYPE_ALL_ACTION", "TYPE_ALL_CHANNEL", + "TYPE_ALL_TRIGGER", "TYPE_CHANNEL_MAPPING", "TYPE_COMPONENT_MAPPING", "TYPE_DM_CHANNEL", diff --git a/interactions/api/events/discord.py b/interactions/api/events/discord.py index cc4f5192c..c621fd01f 100644 --- a/interactions/api/events/discord.py +++ b/interactions/api/events/discord.py @@ -20,12 +20,12 @@ async def an_event_handler(event: ChannelCreate): """ -from typing import TYPE_CHECKING, List, Sequence, Union, Optional +from typing import TYPE_CHECKING, List, Optional, Sequence, Union import attrs import interactions.models -from interactions.api.events.base import GuildEvent, BaseEvent +from interactions.api.events.base import BaseEvent, GuildEvent from interactions.client.const import Absent from interactions.client.utils.attr_utils import docs from interactions.models.discord.snowflake import to_snowflake @@ -99,22 +99,26 @@ async def an_event_handler(event: ChannelCreate): if TYPE_CHECKING: - from interactions.models.discord.guild import Guild, GuildIntegration - from interactions.models.discord.channel import BaseChannel, TYPE_THREAD_CHANNEL, VoiceChannel - from interactions.models.discord.message import Message - from interactions.models.discord.timestamp import Timestamp - from interactions.models.discord.user import Member, User, BaseUser - from interactions.models.discord.snowflake import Snowflake_Type from interactions.models.discord.activity import Activity + from interactions.models.discord.app_perms import ApplicationCommandPermission + from interactions.models.discord.auto_mod import AutoModerationAction, AutoModRule + from interactions.models.discord.channel import ( + TYPE_ALL_CHANNEL, + TYPE_THREAD_CHANNEL, + VoiceChannel, + ) from interactions.models.discord.emoji import CustomEmoji, PartialEmoji + from interactions.models.discord.guild import Guild, GuildIntegration + from interactions.models.discord.message import Message + from interactions.models.discord.reaction import Reaction from interactions.models.discord.role import Role + from interactions.models.discord.scheduled_event import ScheduledEvent + from interactions.models.discord.snowflake import Snowflake_Type + from interactions.models.discord.stage_instance import StageInstance from interactions.models.discord.sticker import Sticker + from interactions.models.discord.timestamp import Timestamp + from interactions.models.discord.user import BaseUser, Member, User from interactions.models.discord.voice_state import VoiceState - from interactions.models.discord.stage_instance import StageInstance - from interactions.models.discord.auto_mod import AutoModerationAction, AutoModRule - from interactions.models.discord.reaction import Reaction - from interactions.models.discord.app_perms import ApplicationCommandPermission - from interactions.models.discord.scheduled_event import ScheduledEvent @attrs.define(eq=False, order=False, hash=False, kw_only=False) @@ -122,12 +126,14 @@ class AutoModExec(BaseEvent): """Dispatched when an auto modation action is executed""" execution: "AutoModerationAction" = attrs.field(repr=False, metadata=docs("The executed auto mod action")) - channel: "BaseChannel" = attrs.field(repr=False, metadata=docs("The channel the action was executed in")) + channel: "TYPE_ALL_CHANNEL" = attrs.field(repr=False, metadata=docs("The channel the action was executed in")) guild: "Guild" = attrs.field(repr=False, metadata=docs("The guild the action was executed in")) @attrs.define(eq=False, order=False, hash=False, kw_only=False) class AutoModCreated(BaseEvent): + """Dispatched when an auto mod rule is created""" + guild: "Guild" = attrs.field(repr=False, metadata=docs("The guild the rule was modified in")) rule: "AutoModRule" = attrs.field(repr=False, metadata=docs("The rule that was modified")) @@ -164,18 +170,18 @@ class ApplicationCommandPermissionsUpdate(BaseEvent): class ChannelCreate(BaseEvent): """Dispatched when a channel is created.""" - channel: "BaseChannel" = attrs.field(repr=False, metadata=docs("The channel this event is dispatched from")) + channel: "TYPE_ALL_CHANNEL" = attrs.field(repr=False, metadata=docs("The channel this event is dispatched from")) @attrs.define(eq=False, order=False, hash=False, kw_only=False) class ChannelUpdate(BaseEvent): """Dispatched when a channel is updated.""" - before: "BaseChannel" = attrs.field( + before: "TYPE_ALL_CHANNEL" = attrs.field( repr=False, ) """Channel before this event. MISSING if it was not cached before""" - after: "BaseChannel" = attrs.field( + after: "TYPE_ALL_CHANNEL" = attrs.field( repr=False, ) """Channel after this event""" @@ -226,7 +232,7 @@ class ThreadListSync(BaseEvent): repr=False, ) """The parent channel ids whose threads are being synced. If omitted, then threads were synced for the entire guild. This array may contain channel_ids that have no active threads as well, so you know to clear that data.""" - threads: List["BaseChannel"] = attrs.field( + threads: List["TYPE_ALL_CHANNEL"] = attrs.field( repr=False, ) """all active threads in the given channels that the current user can access""" @@ -618,7 +624,7 @@ class TypingStart(BaseEvent): repr=False, ) """The user who started typing""" - channel: "BaseChannel" = attrs.field( + channel: "TYPE_ALL_CHANNEL" = attrs.field( repr=False, ) """The channel typing is in""" diff --git a/interactions/api/events/processors/auto_mod.py b/interactions/api/events/processors/auto_mod.py index 31f08476f..dfee26c2d 100644 --- a/interactions/api/events/processors/auto_mod.py +++ b/interactions/api/events/processors/auto_mod.py @@ -1,8 +1,9 @@ from typing import TYPE_CHECKING from interactions.models.discord.auto_mod import AutoModerationAction, AutoModRule -from ._template import EventMixinTemplate, Processor + from ... import events +from ._template import EventMixinTemplate, Processor if TYPE_CHECKING: from interactions.api.events import RawGatewayEvent @@ -25,13 +26,13 @@ async def raw_auto_moderation_rule_create(self, event: "RawGatewayEvent") -> Non self.dispatch(events.AutoModCreated(guild, rule)) @Processor.define() - async def raw_auto_moderation_rule_delete(self, event: "RawGatewayEvent") -> None: + async def raw_auto_moderation_rule_update(self, event: "RawGatewayEvent") -> None: rule = AutoModRule.from_dict(event.data, self) guild = self.get_guild(event.data["guild_id"]) self.dispatch(events.AutoModUpdated(guild, rule)) @Processor.define() - async def raw_auto_moderation_rule_update(self, event: "RawGatewayEvent") -> None: + async def raw_auto_moderation_rule_delete(self, event: "RawGatewayEvent") -> None: rule = AutoModRule.from_dict(event.data, self) guild = self.get_guild(event.data["guild_id"]) self.dispatch(events.AutoModDeleted(guild, rule)) diff --git a/interactions/api/events/processors/integrations.py b/interactions/api/events/processors/integrations.py index e6dd43cf8..12615dcdb 100644 --- a/interactions/api/events/processors/integrations.py +++ b/interactions/api/events/processors/integrations.py @@ -1,9 +1,13 @@ from typing import TYPE_CHECKING -from interactions.models.discord.app_perms import ApplicationCommandPermission, CommandPermissions +from interactions.models.discord.app_perms import ( + ApplicationCommandPermission, + CommandPermissions, +) from interactions.models.discord.snowflake import to_snowflake -from ._template import EventMixinTemplate, Processor + from ... import events +from ._template import EventMixinTemplate, Processor if TYPE_CHECKING: from interactions.api.events import RawGatewayEvent @@ -17,6 +21,7 @@ async def _raw_application_command_permissions_update(self, event: "RawGatewayEv perms = [ApplicationCommandPermission.from_dict(perm, self) for perm in event.data["permissions"]] guild_id = to_snowflake(event.data["guild_id"]) command_id = to_snowflake(event.data["id"]) + application_id = to_snowflake(event.data["application_id"]) if guild := self.get_guild(guild_id): if guild.permissions: @@ -27,5 +32,4 @@ async def _raw_application_command_permissions_update(self, event: "RawGatewayEv command_permissions = guild.command_permissions[command_id] command_permissions.update_permissions(*perms) - - self.dispatch(events.ApplicationCommandPermissionsUpdate(guild, perms)) + self.dispatch(events.ApplicationCommandPermissionsUpdate(command_id, guild_id, application_id, perms)) diff --git a/interactions/api/events/processors/role_events.py b/interactions/api/events/processors/role_events.py index 909ff192b..697e42c9a 100644 --- a/interactions/api/events/processors/role_events.py +++ b/interactions/api/events/processors/role_events.py @@ -17,8 +17,8 @@ async def _on_raw_guild_role_create(self, event: "RawGatewayEvent") -> None: g_id = int(event.data.get("guild_id")) r_id = int(event.data["role"]["id"]) - guild = self.cache.get_guild(g_id) - guild._role_ids.add(r_id) + if guild := self.cache.get_guild(g_id): + guild._role_ids.add(r_id) role = self.cache.place_role_data(g_id, [event.data.get("role")])[r_id] self.dispatch(events.RoleCreate(g_id, role)) @@ -39,13 +39,13 @@ async def _on_raw_guild_role_delete(self, event: "RawGatewayEvent") -> None: g_id = int(event.data.get("guild_id")) r_id = int(event.data.get("role_id")) - guild = self.cache.get_guild(g_id) role = self.cache.get_role(r_id) self.cache.delete_role(r_id) - role_members = (member for member in guild.members if member.has_role(r_id)) - for member in role_members: - member._role_ids.remove(r_id) + if guild := self.cache.get_guild(g_id): + role_members = (member for member in guild.members if member.has_role(r_id)) + for member in role_members: + member._role_ids.remove(r_id) self.dispatch(events.RoleDelete(g_id, r_id, role)) diff --git a/interactions/api/events/processors/voice_events.py b/interactions/api/events/processors/voice_events.py index e68501764..ea243a414 100644 --- a/interactions/api/events/processors/voice_events.py +++ b/interactions/api/events/processors/voice_events.py @@ -13,15 +13,19 @@ class VoiceEvents(EventMixinTemplate): @Processor.define() async def _on_raw_voice_state_update(self, event: "RawGatewayEvent") -> None: - before = copy.copy(self.cache.get_voice_state(event.data["user_id"])) or None - after = await self.cache.place_voice_state_data(event.data) - - self.dispatch(events.VoiceStateUpdate(before, after)) - - if before and before.user_id == self.user.id: - if vc := self.cache.get_bot_voice_state(event.data["guild_id"]): + if str(event.data["user_id"]) == str(self.user.id): + # User is the bot itself + before = copy.copy(self.cache.get_bot_voice_state(event.data["guild_id"])) or None + after = await self.cache.place_voice_state_data(event.data, update_cache=False) + if vc := before: # noinspection PyProtectedMember await vc._voice_state_update(before, after, event.data) + else: + # User is not the bot + before = copy.copy(self.cache.get_voice_state(event.data["user_id"])) or None + after = await self.cache.place_voice_state_data(event.data) + + self.dispatch(events.VoiceStateUpdate(before, after)) if before and after: if (before.mute != after.mute) or (before.self_mute != after.self_mute): diff --git a/interactions/api/gateway/gateway.py b/interactions/api/gateway/gateway.py index f03d9c2ac..3ed5210ad 100644 --- a/interactions/api/gateway/gateway.py +++ b/interactions/api/gateway/gateway.py @@ -1,5 +1,6 @@ """Outlines the interaction between interactions and Discord's Gateway API.""" import asyncio +import logging import sys import time import zlib @@ -171,31 +172,32 @@ async def run(self) -> None: async def dispatch_opcode(self, data, op: OPCODE) -> None: match op: case OPCODE.HEARTBEAT: - self.logger.debug("Received heartbeat request from gateway") + self.state.wrapped_logger(logging.DEBUG, "❤ Received heartbeat request from gateway") return await self.send_heartbeat() case OPCODE.HEARTBEAT_ACK: self._latency.append(time.perf_counter() - self._last_heartbeat) if self._last_heartbeat != 0 and self._latency[-1] >= 15: - self.logger.warning( - f"High Latency! shard ID {self.shard[0]} heartbeat took {self._latency[-1]:.1f}s to be acknowledged!" + self.state.wrapped_logger( + logging.WARNING, + f"❤ High Latency! shard ID {self.shard[0]} heartbeat took {self._latency[-1]:.1f}s to be acknowledged!", ) else: - self.logger.debug(f"❤ Heartbeat acknowledged after {self._latency[-1]:.5f} seconds") + self.state.wrapped_logger(logging.DEBUG, "❤ Received heartbeat acknowledgement from gateway") return self._acknowledged.set() case OPCODE.RECONNECT: - self.logger.debug("Gateway requested reconnect. Reconnecting...") + self.state.wrapped_logger(logging.DEBUG, "Gateway requested reconnect. Reconnecting...") return await self.reconnect(resume=True, url=self.ws_resume_url) case OPCODE.INVALIDATE_SESSION: - self.logger.warning("Gateway has invalidated session! Reconnecting...") + self.state.wrapped_logger(logging.WARNING, "Gateway invalidated session. Reconnecting...") return await self.reconnect() case _: - return self.logger.debug(f"Unhandled OPCODE: {op} = {OPCODE(op).name}") + return self.state.wrapped_logger(logging.DEBUG, f"Unhandled OPCODE: {op} = {OPCODE(op).name}") async def dispatch_event(self, data, seq, event) -> None: match event: @@ -207,12 +209,14 @@ async def dispatch_event(self, data, seq, event) -> None: self.ws_resume_url = ( f"{data['resume_gateway_url']}?encoding=json&v={__api_version__}&compress=zlib-stream" ) - self.logger.info(f"Shard {self.shard[0]} has connected to gateway!") - self.logger.debug(f"Session ID: {self.session_id} Trace: {self._trace}") + self.state.wrapped_logger(logging.INFO, "Gateway connection established") + self.state.wrapped_logger(logging.DEBUG, f"Session ID: {self.session_id} Trace: {self._trace}") return self.state.client.dispatch(events.WebsocketReady(data)) case "RESUMED": - self.logger.info(f"Successfully resumed connection! Session_ID: {self.session_id}") + self.state.wrapped_logger( + logging.INFO, f"Successfully resumed connection! Session_ID: {self.session_id}" + ) self.state.client.dispatch(events.Resume()) return None @@ -228,9 +232,11 @@ async def dispatch_event(self, data, seq, event) -> None: processor(events.RawGatewayEvent(data.copy(), override_name=event_name)) ) except Exception as ex: - self.logger.error(f"Failed to run event processor for {event_name}: {ex}") + self.state.wrapped_logger( + logging.ERROR, f"Failed to run event processor for {event_name}: {ex}" + ) else: - self.logger.debug(f"No processor for `{event_name}`") + self.state.wrapped_logger(logging.DEBUG, f"No processor for `{event_name}`") self.state.client.dispatch(events.RawGatewayEvent(data.copy(), override_name="raw_gateway_event")) self.state.client.dispatch(events.RawGatewayEvent(data.copy(), override_name=f"raw_{event.lower()}")) @@ -263,8 +269,8 @@ async def _identify(self) -> None: serialized = FastJson.dumps(payload) await self.ws.send_str(serialized) - self.logger.debug( - f"Shard ID {self.shard[0]} has identified itself to Gateway, requesting intents: {self.state.intents}!" + self.state.wrapped_logger( + logging.DEBUG, f"Identification payload sent to gateway, requesting intents: {self.state.intents}" ) async def reconnect(self, *, resume: bool = False, code: int = 1012, url: str | None = None) -> None: @@ -289,11 +295,11 @@ async def _resume_connection(self) -> None: serialized = FastJson.dumps(payload) await self.ws.send_str(serialized) - self.logger.debug(f"{self.shard[0]} is attempting to resume a connection") + self.state.wrapped_logger(logging.DEBUG, f"Resume payload sent to gateway, session ID: {self.session_id}") async def send_heartbeat(self) -> None: await self.send_json({"op": OPCODE.HEARTBEAT, "d": self.sequence}, bypass=True) - self.logger.debug(f"❤ Shard {self.shard[0]} is sending a Heartbeat") + self.state.wrapped_logger(logging.DEBUG, "❤ Gateway is sending a Heartbeat") async def change_presence(self, activity=None, status: Status = Status.ONLINE, since=None) -> None: """Update the bot's presence status.""" diff --git a/interactions/api/gateway/state.py b/interactions/api/gateway/state.py index 0193989f6..aa90917ae 100644 --- a/interactions/api/gateway/state.py +++ b/interactions/api/gateway/state.py @@ -1,4 +1,5 @@ import asyncio +import logging import traceback from datetime import datetime from logging import Logger @@ -72,7 +73,7 @@ async def start(self) -> None: """Connect to the Discord Gateway.""" self.gateway_url = await self.client.http.get_gateway() - self.logger.debug(f"Starting Shard ID {self.shard_id}") + self.wrapped_logger(logging.INFO, "Starting Shard") self.start_time = datetime.now() self._shard_task = asyncio.create_task(self._ws_connect()) @@ -84,7 +85,7 @@ async def start(self) -> None: async def stop(self) -> None: """Disconnect from the Discord Gateway.""" - self.logger.debug(f"Shutting down shard ID {self.shard_id}") + self.wrapped_logger(logging.INFO, "Stopping Shard") if self.gateway is not None: self.gateway.close() self.gateway = None @@ -102,7 +103,7 @@ def clear_ready(self) -> None: async def _ws_connect(self) -> None: """Connect to the Discord Gateway.""" - self.logger.info(f"Shard {self.shard_id} is attempting to connect to gateway...") + self.wrapped_logger(logging.INFO, "Shard is attempting to connect to gateway...") try: async with GatewayClient(self, (self.shard_id, self.client.total_shards)) as self.gateway: try: @@ -127,7 +128,18 @@ async def _ws_connect(self) -> None: except Exception as e: self.client.dispatch(events.Disconnect()) - self.logger.error("".join(traceback.format_exception(type(e), e, e.__traceback__))) + self.wrapped_logger("".join(traceback.format_exception(type(e), e, e.__traceback__))) + + def wrapped_logger(self, level: int, message: str, **kwargs) -> None: + """ + A logging wrapper that adds shard information to the message. + + Args: + level: The logging level + message: The message to log + **kwargs: Any additional keyword arguments that Logger.log accepts + """ + self.logger.log(level, f"Shard ID {self.shard_id} | {message}", **kwargs) async def change_presence( self, @@ -157,7 +169,9 @@ async def change_presence( if activity.type == ActivityType.STREAMING: if not activity.url: - self.logger.warning("Streaming activity cannot be set without a valid URL attribute") + self.wrapped_logger( + logging.WARNING, "Streaming activity cannot be set without a valid URL attribute" + ) elif activity.type not in [ ActivityType.GAME, ActivityType.STREAMING, @@ -165,7 +179,9 @@ async def change_presence( ActivityType.WATCHING, ActivityType.COMPETING, ]: - self.logger.warning(f"Activity type `{ActivityType(activity.type).name}` may not be enabled for bots") + self.wrapped_logger( + logging.WARNING, f"Activity type `{ActivityType(activity.type).name}` may not be enabled for bots" + ) if status: if not isinstance(status, Status): try: @@ -175,7 +191,7 @@ async def change_presence( elif self.client.status: status = self.client.status else: - self.logger.warning("Status must be set to a valid status type, defaulting to online") + self.wrapped_logger(logging.WARNING, "Status must be set to a valid status type, defaulting to online") status = Status.ONLINE self.client._status = status diff --git a/interactions/api/http/http_client.py b/interactions/api/http/http_client.py index c3d5f3eb0..f208f642f 100644 --- a/interactions/api/http/http_client.py +++ b/interactions/api/http/http_client.py @@ -218,7 +218,7 @@ def __init__( connector: BaseConnector | None = None, logger: Logger = MISSING, show_ratelimit_tracebacks: bool = False, - proxy: tuple[str, BasicAuth] | None = None, + proxy: tuple[str | None, BasicAuth | None] | None = None, ) -> None: self.connector: BaseConnector | None = connector self.__session: ClientSession | None = None @@ -233,7 +233,7 @@ def __init__( self.user_agent: str = ( f"DiscordBot ({__repo_url__} {__version__} Python/{__py_version__}) aiohttp/{aiohttp.__version__}" ) - self.proxy: tuple[str, BasicAuth] | None = proxy + self.proxy: tuple[str | None, BasicAuth | None] | None = proxy self.__proxy_validated: bool = False if logger is MISSING: diff --git a/interactions/client/client.py b/interactions/client/client.py index f6a530abd..9ce1d7d30 100644 --- a/interactions/client/client.py +++ b/interactions/client/client.py @@ -1,9 +1,11 @@ import asyncio import contextlib import functools +import glob import importlib.util import inspect import logging +import os import re import sys import time @@ -346,8 +348,9 @@ def __init__( if isinstance(proxy_auth, tuple): proxy_auth = BasicAuth(*proxy_auth) + proxy = (proxy_url, proxy_auth) if proxy_url or proxy_auth else None self.http: HTTPClient = HTTPClient( - logger=self.logger, show_ratelimit_tracebacks=show_ratelimit_tracebacks, proxy=(proxy_url, proxy_auth) + logger=self.logger, show_ratelimit_tracebacks=show_ratelimit_tracebacks, proxy=proxy ) """The HTTP client to use when interacting with discord endpoints""" @@ -2002,6 +2005,35 @@ def load_extension( module = importlib.import_module(module_name, package) self.__load_module(module, module_name, **load_kwargs) + def load_extensions( + self, + *packages: str, + recursive: bool = False, + ) -> None: + """ + Load multiple extensions at once. + + Removes the need of manually looping through the package + and loading the extensions. + + Args: + *packages: The package(s) where the extensions are located. + recursive: Whether to load extensions from the subdirectories within the package. + """ + if not packages: + raise ValueError("You must specify at least one package.") + + for package in packages: + # If recursive then include subdirectories ('**') + # otherwise just the package specified by the user. + pattern = os.path.join(package, "**" if recursive else "", "*.py") + + # Find all files matching the pattern, and convert slashes to dots. + extensions = [f.replace(os.path.sep, ".").replace(".py", "") for f in glob.glob(pattern, recursive=True)] + + for ext in extensions: + self.load_extension(ext) + def unload_extension( self, name: str, package: str | None = None, force: bool = False, **unload_kwargs: Any ) -> None: diff --git a/interactions/client/smart_cache.py b/interactions/client/smart_cache.py index 568079b80..575f69741 100644 --- a/interactions/client/smart_cache.py +++ b/interactions/client/smart_cache.py @@ -747,12 +747,15 @@ def get_voice_state(self, user_id: Optional["Snowflake_Type"]) -> Optional[Voice """ return self.voice_state_cache.get(to_optional_snowflake(user_id)) - async def place_voice_state_data(self, data: discord_typings.VoiceStateData) -> Optional[VoiceState]: + async def place_voice_state_data( + self, data: discord_typings.VoiceStateData, update_cache=True + ) -> Optional[VoiceState]: """ Take json data representing a VoiceState, process it, and cache it. Args: data: json representation of the VoiceState + update_cache: Bool for updating cache or not Returns: The processed VoiceState object @@ -768,7 +771,7 @@ async def place_voice_state_data(self, data: discord_typings.VoiceStateData) -> # check if the channel_id is None # if that is the case, the user disconnected, and we can delete them from the cache if not data["channel_id"]: - if user_id in self.voice_state_cache: + if update_cache and user_id in self.voice_state_cache: self.voice_state_cache.pop(user_id) voice_state = None @@ -780,7 +783,8 @@ async def place_voice_state_data(self, data: discord_typings.VoiceStateData) -> new_channel._voice_member_ids.append(user_id) voice_state = VoiceState.from_dict(data, self._client) - self.voice_state_cache[user_id] = voice_state + if update_cache: + self.voice_state_cache[user_id] = voice_state return voice_state diff --git a/interactions/models/__init__.py b/interactions/models/__init__.py index 25df73447..41ccfc7e0 100644 --- a/interactions/models/__init__.py +++ b/interactions/models/__init__.py @@ -167,7 +167,9 @@ to_optional_snowflake, to_snowflake, to_snowflake_list, + TYPE_ALL_ACTION, TYPE_ALL_CHANNEL, + TYPE_ALL_TRIGGER, TYPE_CHANNEL_MAPPING, TYPE_COMPONENT_MAPPING, TYPE_DM_CHANNEL, @@ -576,7 +578,9 @@ "to_optional_snowflake", "to_snowflake", "to_snowflake_list", + "TYPE_ALL_ACTION", "TYPE_ALL_CHANNEL", + "TYPE_ALL_TRIGGER", "TYPE_CHANNEL_MAPPING", "TYPE_COMPONENT_MAPPING", "TYPE_DM_CHANNEL", diff --git a/interactions/models/discord/__init__.py b/interactions/models/discord/__init__.py index e7c25618b..035932e62 100644 --- a/interactions/models/discord/__init__.py +++ b/interactions/models/discord/__init__.py @@ -2,7 +2,7 @@ from .app_perms import ApplicationCommandPermission from .application import Application from .asset import Asset -from .auto_mod import AutoModerationAction, AutoModRule +from .auto_mod import AutoModerationAction, AutoModRule, TYPE_ALL_ACTION, TYPE_ALL_TRIGGER from .channel import ( BaseChannel, ChannelHistory, @@ -337,7 +337,9 @@ "to_optional_snowflake", "to_snowflake", "to_snowflake_list", + "TYPE_ALL_ACTION", "TYPE_ALL_CHANNEL", + "TYPE_ALL_TRIGGER", "TYPE_CHANNEL_MAPPING", "TYPE_COMPONENT_MAPPING", "TYPE_DM_CHANNEL", diff --git a/interactions/models/discord/activity.py b/interactions/models/discord/activity.py index 477c15344..1c5f8b7db 100644 --- a/interactions/models/discord/activity.py +++ b/interactions/models/discord/activity.py @@ -78,7 +78,7 @@ class Activity(DictSerializationMixin): details: Optional[str] = attrs.field(repr=False, default=None) """What the player is currently doing""" state: Optional[str] = attrs.field(repr=False, default=None) - """The user's current party status""" + """The user's current party status, or text used for a custom status if type is set as CUSTOM""" emoji: Optional[PartialEmoji] = attrs.field(repr=False, default=None, converter=optional(PartialEmoji.from_dict)) """The emoji used for a custom status""" party: Optional[ActivityParty] = attrs.field(repr=False, default=None, converter=optional(ActivityParty.from_dict)) @@ -99,7 +99,9 @@ class Activity(DictSerializationMixin): """The custom buttons shown in the Rich Presence (max 2)""" @classmethod - def create(cls, name: str, type: ActivityType = ActivityType.GAME, url: Optional[str] = None) -> "Activity": + def create( + cls, name: str, type: ActivityType = ActivityType.GAME, url: Optional[str] = None, state: Optional[str] = None + ) -> "Activity": """ Creates an activity object for the bot. @@ -107,12 +109,13 @@ def create(cls, name: str, type: ActivityType = ActivityType.GAME, url: Optional name: The new activity's name type: Type of activity to create url: Stream link for the activity + state: Current party status, or text used for a custom status if type is set as CUSTOM Returns: The new activity object """ - return cls(name=name, type=type, url=url) + return cls(name=name, type=type, url=url, state=state) def to_dict(self) -> dict: - return dict_filter_none({"name": self.name, "type": self.type, "url": self.url}) + return dict_filter_none({"name": self.name, "type": self.type, "state": self.state, "url": self.url}) diff --git a/interactions/models/discord/auto_mod.py b/interactions/models/discord/auto_mod.py index c9be2d142..404791867 100644 --- a/interactions/models/discord/auto_mod.py +++ b/interactions/models/discord/auto_mod.py @@ -1,24 +1,32 @@ -from typing import Any, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Optional, Union import attrs -from interactions.client.const import get_logger, MISSING, Absent +from interactions.client.const import MISSING, Absent, get_logger from interactions.client.mixins.serialization import DictSerializationMixin from interactions.client.utils import list_converter, optional from interactions.client.utils.attr_utils import docs from interactions.models.discord.base import ClientObject, DiscordObject from interactions.models.discord.enums import ( - AutoModTriggerType, AutoModAction, AutoModEvent, AutoModLanuguageType, + AutoModTriggerType, ) -from interactions.models.discord.snowflake import to_snowflake_list, to_snowflake +from interactions.models.discord.snowflake import to_snowflake, to_snowflake_list if TYPE_CHECKING: - from interactions import Snowflake_Type, Guild, GuildText, Message, Client, Member, User + from interactions import ( + Client, + Guild, + GuildText, + Member, + Message, + Snowflake_Type, + User, + ) -__all__ = ("AutoModerationAction", "AutoModRule") +__all__ = ("AutoModerationAction", "AutoModRule", "TYPE_ALL_ACTION", "TYPE_ALL_TRIGGER") @attrs.define(eq=False, order=False, hash=False, kw_only=True) @@ -71,7 +79,7 @@ def _process_dict(cls, data: dict[str, Any]) -> dict[str, Any]: return data @classmethod - def from_dict_factory(cls, data: dict) -> "BaseAction": + def from_dict_factory(cls, data: dict) -> "TYPE_ALL_TRIGGER": trigger_class = TRIGGER_MAPPING.get(data.get("trigger_type")) meta = data.get("trigger_metadata", {}) if not trigger_class: @@ -103,10 +111,22 @@ class KeywordTrigger(BaseTrigger): repr=True, metadata=docs("The type of trigger"), ) - keyword_filter: str | list[str] = attrs.field( + keyword_filter: list[str] = attrs.field( factory=list, repr=True, - metadata=docs("What words will trigger this"), + metadata=docs("Substrings which will be searched for in content"), + converter=_keyword_converter, + ) + regex_patterns: list[str] = attrs.field( + factory=list, + repr=True, + metadata=docs("Regular expression patterns which will be matched against content"), + converter=_keyword_converter, + ) + allow_list: list[str] = attrs.field( + factory=list, + repr=True, + metadata=docs("Substrings which should not trigger the rule"), converter=_keyword_converter, ) @@ -137,37 +157,57 @@ class KeywordPresetTrigger(BaseTrigger): factory=list, converter=list_converter(AutoModLanuguageType), repr=True, - metadata=docs("The preset list of keywords that will trigger this"), + metadata=docs("The internally pre-defined wordsets which will be searched for in content"), ) @attrs.define(eq=False, order=False, hash=False, kw_only=True) class MentionSpamTrigger(BaseTrigger): - """A trigger that checks if content contains more mentions than allowed""" + """A trigger that checks if content contains more unique mentions than allowed""" mention_total_limit: int = attrs.field( default=3, repr=True, metadata=docs("The maximum number of mentions allowed") ) + mention_raid_protection_enabled: bool = attrs.field( + repr=True, metadata=docs("Whether to automatically detect mention raids") + ) @attrs.define(eq=False, order=False, hash=False, kw_only=True) class MemberProfileTrigger(BaseTrigger): + """A trigger that checks if member profile contains words from a user defined list of keywords""" + regex_patterns: list[str] = attrs.field( - factory=list, repr=True, metadata=docs("The regex patterns to check against") + factory=list, + repr=True, + metadata=docs("Regular expression patterns which will be matched against content"), + converter=_keyword_converter, ) - keyword_filter: str | list[str] = attrs.field( - factory=list, repr=True, metadata=docs("The keywords to check against") + keyword_filter: list[str] = attrs.field( + factory=list, + repr=True, + metadata=docs("Substrings which will be searched for in content"), + converter=_keyword_converter, ) - allow_list: list["Snowflake_Type"] = attrs.field( - factory=list, repr=True, metadata=docs("The roles exempt from this rule") + allow_list: list[str] = attrs.field( + factory=list, + repr=True, + metadata=docs("Substrings which should not trigger the rule"), + converter=_keyword_converter, ) +@attrs.define(eq=False, order=False, hash=False, kw_only=True) +class SpamTrigger(BaseTrigger): + """A trigger that checks if content represents generic spam""" + + @attrs.define(eq=False, order=False, hash=False, kw_only=True) class BlockMessage(BaseAction): - """blocks the content of a message according to the rule""" + """Blocks the content of a message according to the rule""" type: AutoModAction = attrs.field(repr=False, default=AutoModAction.BLOCK_MESSAGE, converter=AutoModAction) + custom_message: Optional[str] = attrs.field(repr=True, default=None) @attrs.define(eq=False, order=False, hash=False, kw_only=True) @@ -204,13 +244,13 @@ class AutoModRule(DiscordObject): enabled: bool = attrs.field(repr=False, default=False) """whether the rule is enabled""" - actions: list[BaseAction] = attrs.field(repr=False, factory=list) + actions: list["TYPE_ALL_ACTION"] = attrs.field(repr=False, factory=list) """the actions which will execute when the rule is triggered""" event_type: AutoModEvent = attrs.field( repr=False, ) """the rule event type""" - trigger: BaseTrigger = attrs.field( + trigger: "TYPE_ALL_TRIGGER" = attrs.field( repr=False, ) """The trigger for this rule""" @@ -262,10 +302,10 @@ async def modify( self, *, name: Absent[str] = MISSING, - trigger: Absent[BaseTrigger] = MISSING, + trigger: Absent["TYPE_ALL_TRIGGER"] = MISSING, trigger_type: Absent[AutoModTriggerType] = MISSING, trigger_metadata: Absent[dict] = MISSING, - actions: Absent[list[BaseAction]] = MISSING, + actions: Absent[list["TYPE_ALL_ACTION"]] = MISSING, exempt_channels: Absent[list["Snowflake_Type"]] = MISSING, exempt_roles: Absent[list["Snowflake_Type"]] = MISSING, event_type: Absent[AutoModEvent] = MISSING, @@ -318,7 +358,7 @@ class AutoModerationAction(ClientObject): repr=False, ) - action: BaseAction = attrs.field(default=MISSING, repr=True) + action: "TYPE_ALL_ACTION" = attrs.field(default=MISSING, repr=True) matched_keyword: str = attrs.field(repr=True) matched_content: Optional[str] = attrs.field(repr=False, default=None) @@ -369,7 +409,12 @@ def member(self) -> "Optional[Member]": TRIGGER_MAPPING = { AutoModTriggerType.KEYWORD: KeywordTrigger, AutoModTriggerType.HARMFUL_LINK: HarmfulLinkFilter, + AutoModTriggerType.SPAM: SpamTrigger, AutoModTriggerType.KEYWORD_PRESET: KeywordPresetTrigger, AutoModTriggerType.MENTION_SPAM: MentionSpamTrigger, AutoModTriggerType.MEMBER_PROFILE: MemberProfileTrigger, } + +TYPE_ALL_TRIGGER = Union[KeywordTrigger, SpamTrigger, KeywordPresetTrigger, MentionSpamTrigger, MemberProfileTrigger] + +TYPE_ALL_ACTION = Union[BlockMessage, AlertMessage, TimeoutUser, BlockMemberInteraction] diff --git a/interactions/models/discord/guild.py b/interactions/models/discord/guild.py index cc605bdc9..5551f2bf7 100644 --- a/interactions/models/discord/guild.py +++ b/interactions/models/discord/guild.py @@ -12,7 +12,11 @@ from interactions.client.const import MISSING, PREMIUM_GUILD_LIMITS, Absent from interactions.client.errors import EventLocationNotProvided, NotFound from interactions.client.mixins.serialization import DictSerializationMixin -from interactions.client.utils.attr_converters import optional, list_converter, timestamp_converter +from interactions.client.utils.attr_converters import ( + list_converter, + optional, + timestamp_converter, +) from interactions.client.utils.attr_utils import docs from interactions.client.utils.deserialise_app_cmds import deserialize_app_cmds from interactions.client.utils.serializer import no_export_meta, to_image_data @@ -33,6 +37,7 @@ DefaultNotificationLevel, ExplicitContentFilterLevel, ForumLayoutType, + ForumSortOrder, IntegrationExpireBehaviour, MFALevel, NSFWLevel, @@ -41,7 +46,6 @@ ScheduledEventType, SystemChannelFlags, VerificationLevel, - ForumSortOrder, ) from .snowflake import ( Snowflake_Type, @@ -1399,6 +1403,17 @@ async def fetch_custom_sticker(self, sticker_id: Snowflake_Type) -> Optional["mo return None return models.Sticker.from_dict(sticker_data, self._client) + async def fetch_all_webhooks(self) -> List["models.Webhook"]: + """ + Fetches all the webhooks for this guild. + + Returns: + A list of webhook objects. + + """ + webhooks_data = await self._client.http.get_guild_webhooks(self.id) + return models.Webhook.from_list(webhooks_data, self._client) + async def fetch_active_threads(self) -> "models.ThreadList": """ Fetches all active threads in the guild, including public and private threads. Threads are ordered by their id, in descending order. diff --git a/interactions/models/discord/message.py b/interactions/models/discord/message.py index 659671fbf..dccecf023 100644 --- a/interactions/models/discord/message.py +++ b/interactions/models/discord/message.py @@ -8,41 +8,47 @@ AsyncGenerator, Dict, List, + Mapping, Optional, Sequence, Union, - Mapping, ) import attrs import interactions.models as models from interactions.client.const import GUILD_WELCOME_MESSAGES, MISSING, Absent -from interactions.client.errors import ThreadOutsideOfGuild, NotFound +from interactions.client.errors import NotFound, ThreadOutsideOfGuild from interactions.client.mixins.serialization import DictSerializationMixin from interactions.client.utils.attr_converters import optional as optional_c from interactions.client.utils.attr_converters import timestamp_converter from interactions.client.utils.serializer import dict_filter_none from interactions.client.utils.text_utils import mentions from interactions.models.discord.channel import BaseChannel, GuildChannel +from interactions.models.discord.embed import process_embeds from interactions.models.discord.emoji import process_emoji_req_format from interactions.models.discord.file import UPLOADABLE_TYPE -from interactions.models.discord.embed import process_embeds + from .base import DiscordObject from .enums import ( + AutoArchiveDuration, ChannelType, InteractionType, MentionType, MessageActivityType, MessageFlags, MessageType, - AutoArchiveDuration, ) -from .snowflake import to_snowflake, Snowflake_Type, to_snowflake_list, to_optional_snowflake +from .snowflake import ( + Snowflake_Type, + to_optional_snowflake, + to_snowflake, + to_snowflake_list, +) if TYPE_CHECKING: - from interactions.client import Client from interactions import InteractionContext + from interactions.client import Client __all__ = ( "Attachment", @@ -365,10 +371,13 @@ class Message(BaseMessage): _referenced_message_id: Optional["Snowflake_Type"] = attrs.field(repr=False, default=None) @property - async def mention_users(self) -> AsyncGenerator["models.Member", None]: + async def mention_users(self) -> AsyncGenerator[Union["models.Member", "models.User"], None]: """A generator of users mentioned in this message""" for u_id in self._mention_ids: - yield await self._client.cache.fetch_member(self._guild_id, u_id) + if self._guild_id: + yield await self._client.cache.fetch_member(self._guild_id, u_id) + else: + yield await self._client.cache.fetch_user(u_id) @property async def mention_roles(self) -> AsyncGenerator["models.Role", None]: diff --git a/interactions/models/discord/user.pyi b/interactions/models/discord/user.pyi index ffa655b6f..9119afff3 100644 --- a/interactions/models/discord/user.pyi +++ b/interactions/models/discord/user.pyi @@ -52,6 +52,8 @@ class FakeBaseUserMixin(DiscordObject, _SendDMMixin): def display_name(self) -> str: ... @property def display_avatar(self) -> Asset: ... + @property + def avatar_url(self) -> str: ... async def fetch_dm(self, *, force: bool) -> DM: ... def get_dm(self) -> Optional["DM"]: ... @property diff --git a/interactions/models/internal/cooldowns.py b/interactions/models/internal/cooldowns.py index 1d1765f8f..e0fa31d48 100644 --- a/interactions/models/internal/cooldowns.py +++ b/interactions/models/internal/cooldowns.py @@ -47,15 +47,15 @@ async def get_key(self, context: "BaseContext") -> Any: if self is Buckets.USER: return context.author.id if self is Buckets.GUILD: - return context.guild_id if context.guild else context.author.id + return context.guild_id or context.author.id if self is Buckets.CHANNEL: return context.channel.id if self is Buckets.MEMBER: - return (context.guild_id, context.author.id) if context.guild else context.author.id + return (context.guild_id, context.author.id) if context.guild_id else context.author.id if self is Buckets.CATEGORY: return await context.channel.parent_id if context.channel.parent else context.channel.id if self is Buckets.ROLE: - return context.author.top_role.id if context.guild else context.channel.id + return context.author.top_role.id if context.guild_id else context.channel.id return context.author.id def __call__(self, context: "BaseContext") -> Any: