From bb34581fd1c5b47e0fad9ce39435acd01bfd7da6 Mon Sep 17 00:00:00 2001 From: kennhh <133614589+kennhh@users.noreply.github.com> Date: Fri, 14 Jul 2023 01:53:05 -0400 Subject: [PATCH 01/33] feat: add args, kwargs to task (#1478) * modified: interactions/models/internal/tasks/task.py * reset to set_last_call_time Signed-off-by: kennhh <133614589+kennhh@users.noreply.github.com> --------- Signed-off-by: kennhh <133614589+kennhh@users.noreply.github.com> --- interactions/models/internal/tasks/task.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/interactions/models/internal/tasks/task.py b/interactions/models/internal/tasks/task.py index 24bda8f2b..54c68551a 100644 --- a/interactions/models/internal/tasks/task.py +++ b/interactions/models/internal/tasks/task.py @@ -75,25 +75,25 @@ def on_error(self, error: Exception) -> None: self.on_error_sentry_hook(error) interactions.Client.default_error_handler("Task", error) - async def __call__(self) -> None: + async def __call__(self, *args, **kwargs) -> None: try: if inspect.iscoroutinefunction(self.callback): - val = await self.callback() + val = await self.callback(*args, **kwargs) else: - val = self.callback() + val = self.callback(*args, **kwargs) if isinstance(val, BaseTrigger): self.reschedule(val) except Exception as e: self.on_error(e) - def _fire(self, fire_time: datetime) -> None: + def _fire(self, fire_time: datetime, *args, **kwargs) -> None: """Called when the task is being fired.""" self.trigger.set_last_call_time(fire_time) - _ = asyncio.create_task(self()) + _ = asyncio.create_task(self(*args, **kwargs)) self.iteration += 1 - async def _task_loop(self) -> None: + async def _task_loop(self, *args, **kwargs) -> None: """The main task loop to fire the task at the specified time based on triggers configured.""" while not self._stop.is_set(): fire_time = self.trigger.next_fire() @@ -106,14 +106,14 @@ async def _task_loop(self) -> None: if future in done: return None - self._fire(fire_time) + self._fire(fire_time, *args, **kwargs) - def start(self) -> None: + def start(self, *args, **kwargs) -> None: """Start this task.""" try: self.trigger.reschedule() self._stop.clear() - self.task = asyncio.create_task(self._task_loop()) + self.task = asyncio.create_task(self._task_loop(*args, **kwargs)) except RuntimeError: get_logger().error( "Unable to start task without a running event loop! We recommend starting tasks within an `on_startup` event." @@ -125,10 +125,10 @@ def stop(self) -> None: if self.task: self.task.cancel() - def restart(self) -> None: + def restart(self, *args, **kwargs) -> None: """Restart this task.""" self.stop() - self.start() + self.start(*args, **kwargs) def reschedule(self, trigger: BaseTrigger) -> None: """ From 390d3fd70172544a84263bf1b6fd32f96942942d Mon Sep 17 00:00:00 2001 From: i0bs <41456914+i0bs@users.noreply.github.com> Date: Fri, 14 Jul 2023 02:12:02 -0400 Subject: [PATCH 02/33] feat: add id attr to app perms update event --- interactions/api/events/discord.py | 1 + 1 file changed, 1 insertion(+) diff --git a/interactions/api/events/discord.py b/interactions/api/events/discord.py index 51b2b17bd..688c00f48 100644 --- a/interactions/api/events/discord.py +++ b/interactions/api/events/discord.py @@ -142,6 +142,7 @@ class AutoModDeleted(AutoModCreated): @attrs.define(eq=False, order=False, hash=False, kw_only=False) class ApplicationCommandPermissionsUpdate(BaseEvent): + id: "Snowflake_Type" = attrs.field(repr=False, metadata=docs("The ID of the command permissions were updated for")) guild_id: "Snowflake_Type" = attrs.field( repr=False, metadata=docs("The guild the command permissions were updated in") ) From 019eeef27f0cd7c0c29c49183e57c003e068d43b Mon Sep 17 00:00:00 2001 From: i0bs <41456914+i0bs@users.noreply.github.com> Date: Fri, 14 Jul 2023 02:25:02 -0400 Subject: [PATCH 03/33] revert: commit signoff err --- interactions/api/events/discord.py | 1 - 1 file changed, 1 deletion(-) diff --git a/interactions/api/events/discord.py b/interactions/api/events/discord.py index 688c00f48..51b2b17bd 100644 --- a/interactions/api/events/discord.py +++ b/interactions/api/events/discord.py @@ -142,7 +142,6 @@ class AutoModDeleted(AutoModCreated): @attrs.define(eq=False, order=False, hash=False, kw_only=False) class ApplicationCommandPermissionsUpdate(BaseEvent): - id: "Snowflake_Type" = attrs.field(repr=False, metadata=docs("The ID of the command permissions were updated for")) guild_id: "Snowflake_Type" = attrs.field( repr=False, metadata=docs("The guild the command permissions were updated in") ) From c5c50451cea2fa60afa28f8c33dc753f88c8d0f1 Mon Sep 17 00:00:00 2001 From: Sophia <41456914+i0bs@users.noreply.github.com> Date: Fri, 14 Jul 2023 17:33:00 -0400 Subject: [PATCH 04/33] ci: show pre-commit diff on failure (#1484) --- .github/workflows/precommit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/precommit.yml b/.github/workflows/precommit.yml index b50d944cd..91006e8eb 100644 --- a/.github/workflows/precommit.yml +++ b/.github/workflows/precommit.yml @@ -17,4 +17,4 @@ jobs: pip install pre-commit - name: Run Pre-commit run: | - pre-commit run --all-files + pre-commit run --all-files --show-diff-on-failure From e828fb5c09d103e2198ada4a9bdf154bff412e7b Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Sat, 15 Jul 2023 04:32:06 -0400 Subject: [PATCH 05/33] feat: add alt methods for multi-arg params for prefixed cmds (#1471) * feat: add alt methods for multi-arg params for prefixed cmds * fix: use empty if the typehint is just tuple * ci: correct from checks. * fix: support ConsumeRest without typehint --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/src/Guides/26 Prefixed Commands.md | 54 +++++++++----- ...{KeywordParam.png => ConsumeRestParam.png} | Bin ...thQuotes.png => ConsumeRestWithQuotes.png} | Bin interactions/__init__.py | 2 + .../ext/hybrid_commands/hybrid_slash.py | 20 +++++- interactions/ext/prefixed_commands/command.py | 68 +++++++++++++----- interactions/models/__init__.py | 2 + interactions/models/internal/__init__.py | 2 + interactions/models/internal/converters.py | 14 +++- 9 files changed, 125 insertions(+), 37 deletions(-) rename docs/src/images/PrefixedCommands/{KeywordParam.png => ConsumeRestParam.png} (100%) rename docs/src/images/PrefixedCommands/{KeywordParamWithQuotes.png => ConsumeRestWithQuotes.png} (100%) diff --git a/docs/src/Guides/26 Prefixed Commands.md b/docs/src/Guides/26 Prefixed Commands.md index 11e62e1dc..e58d24b15 100644 --- a/docs/src/Guides/26 Prefixed Commands.md +++ b/docs/src/Guides/26 Prefixed Commands.md @@ -100,18 +100,27 @@ async def test(ctx: PrefixedContext, arg1, arg2): ![Two Parameters](../images/PrefixedCommands/TwoParams.png "The above running with the arguments: one two") -### Variable and Keyword-Only Arguments +### Variable and Consume Rest Arguments There may be times where you wish for an argument to be able to have multiple words without wrapping them in quotes. There are two ways of approaching this. #### Variable If you wish to get a list (or more specifically, a tuple) of words for one argument, or simply want an undetermined amount of arguments for a command, then you should use a *variable* argument: -```python -@prefixed_command() -async def test(ctx: PrefixedContext, *args): - await ctx.reply(f"{len(args)} arguments: {', '.join(args)}") -``` + +=== ":one: Tuple Argument" + ```python + @prefixed_command() + async def test(ctx: PrefixedContext, args: tuple[str, ...]): + await ctx.reply(f"{len(args)} arguments: {', '.join(args)}") + ``` + +=== ":two: Variable Positional Argument" + ```python + @prefixed_command() + async def test(ctx: PrefixedContext, *args): + await ctx.reply(f"{len(args)} arguments: {', '.join(args)}") + ``` The result looks something like this: @@ -119,26 +128,37 @@ The result looks something like this: Notice how the quoted words are still parsed as one argument in the tuple. -#### Keyword-Only +#### Consume Rest -If you simply wish to take in the rest of the user's input as an argument, you can use a keyword-only argument, like so: -```python -@prefixed_command() -async def test(ctx: PrefixedContext, *, arg): - await ctx.reply(arg) -``` +If you simply wish to take in the rest of the user's input as an argument, you can use a consume rest argument, like so: + +=== ":one: ConsumeRest Alias" + ```python + from interactions import ConsumeRest + + @prefixed_command() + async def test(ctx: PrefixedContext, arg: ConsumeRest[str]): + await ctx.reply(arg) + ``` + +=== ":two: Keyword-only Argument" + ```python + @prefixed_command() + async def test(ctx: PrefixedContext, *, arg): + await ctx.reply(arg) + ``` The result looks like this: -![Keyword-Only Parameter](../images/PrefixedCommands/KeywordParam.png "The above running with the arguments: hello world!") +![Consume Rest Parameter](../images/PrefixedCommands/ConsumeRestParam.png "The above running with the arguments: hello world!") ???+ note "Quotes" - If a user passes quotes into a keyword-only argument, then the resulting argument will have said quotes. + If a user passes quotes into consume rest argument, then the resulting argument will have said quotes. - ![Keyword-Only Quotes](../images/PrefixedCommands/KeywordParamWithQuotes.png "The above running with the arguments: "hello world!"") + ![Consume Rest Quotes](../images/PrefixedCommands/ConsumeRestWithQuotes.png "The above running with the arguments: "hello world!"") !!! warning "Parser ambiguities" - Due to parser ambiguities, you can *only* have either a single variable or keyword-only/consume rest argument. + Due to parser ambiguities, you can *only* have either a single variable or consume rest argument. ## Typehinting and Converters diff --git a/docs/src/images/PrefixedCommands/KeywordParam.png b/docs/src/images/PrefixedCommands/ConsumeRestParam.png similarity index 100% rename from docs/src/images/PrefixedCommands/KeywordParam.png rename to docs/src/images/PrefixedCommands/ConsumeRestParam.png diff --git a/docs/src/images/PrefixedCommands/KeywordParamWithQuotes.png b/docs/src/images/PrefixedCommands/ConsumeRestWithQuotes.png similarity index 100% rename from docs/src/images/PrefixedCommands/KeywordParamWithQuotes.png rename to docs/src/images/PrefixedCommands/ConsumeRestWithQuotes.png diff --git a/interactions/__init__.py b/interactions/__init__.py index 925377124..ac801b24c 100644 --- a/interactions/__init__.py +++ b/interactions/__init__.py @@ -103,6 +103,7 @@ ComponentCommand, ComponentContext, ComponentType, + ConsumeRest, context_menu, ContextMenu, ContextMenuContext, @@ -413,6 +414,7 @@ "ComponentCommand", "ComponentContext", "ComponentType", + "ConsumeRest", "const", "context_menu", "CONTEXT_MENU_NAME_LENGTH", diff --git a/interactions/ext/hybrid_commands/hybrid_slash.py b/interactions/ext/hybrid_commands/hybrid_slash.py index 927f057f3..6f998b9df 100644 --- a/interactions/ext/hybrid_commands/hybrid_slash.py +++ b/interactions/ext/hybrid_commands/hybrid_slash.py @@ -1,11 +1,12 @@ import asyncio import inspect -from typing import Any, Callable, List, Optional, Union, TYPE_CHECKING, Awaitable +from typing import Any, Callable, List, Optional, Union, TYPE_CHECKING, Awaitable, Annotated, get_origin, get_args import attrs from interactions import ( BaseContext, Converter, + ConsumeRest, NoArgumentConverter, Attachment, SlashCommandChoice, @@ -31,7 +32,7 @@ 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.converters import _LiteralConverter, CONSUME_REST_MARKER from interactions.models.internal.checks import guild_only if TYPE_CHECKING: @@ -355,12 +356,24 @@ def slash_to_prefixed(cmd: HybridSlashCommand) -> _HybridToPrefixedCommand: # n default = inspect.Parameter.empty kind = inspect.Parameter.POSITIONAL_ONLY if cmd._uses_arg else inspect.Parameter.POSITIONAL_OR_KEYWORD + consume_rest: bool = False + 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 + # here come the hacks - these allow ConsumeRest (the class) to be passed through + if get_origin(slash_param.type) == Annotated: + args = get_args(slash_param.type) + # ComsumeRest[str] or Annotated[ConsumeRest[str], Converter] support + # by all means, the second isn't allowed in prefixed commands, but we'll ignore that for converter support for slash cmds + if args[1] is CONSUME_REST_MARKER or ( + args[0] == Annotated and get_args(args[0])[1] is CONSUME_REST_MARKER + ): + consume_rest = True + if slash_param.converter: annotation = slash_param.converter if slash_param.default is not MISSING: @@ -387,6 +400,9 @@ def slash_to_prefixed(cmd: HybridSlashCommand) -> _HybridToPrefixedCommand: # n if not option.required and default == inspect.Parameter.empty: default = None + if consume_rest: + annotation = ConsumeRest[annotation] + actual_param = inspect.Parameter( name=name, kind=kind, diff --git a/interactions/ext/prefixed_commands/command.py b/interactions/ext/prefixed_commands/command.py index de878b31a..f8aca4fd2 100644 --- a/interactions/ext/prefixed_commands/command.py +++ b/interactions/ext/prefixed_commands/command.py @@ -18,7 +18,7 @@ import attrs from typing_extensions import Self -from interactions.client.const import MISSING +from interactions.client.const import MISSING, T from interactions.client.errors import BadArgument from interactions.client.utils.input_utils import _quotes from interactions.client.utils.misc_utils import get_object_name, maybe_coroutine @@ -27,6 +27,7 @@ _LiteralConverter, NoArgumentConverter, Greedy, + CONSUME_REST_MARKER, MODEL_TO_CONVERTER, ) from interactions.models.internal.protocols import Converter @@ -62,6 +63,7 @@ class PrefixedCommandParameter: "union", "variable", "consume_rest", + "consume_rest_class", "no_argument", ) @@ -499,13 +501,39 @@ def _parse_parameters(self) -> None: # noqa: C901 cmd_param = PrefixedCommandParameter.from_param(param) anno = param.annotation + # this is ugly, ik + if typing.get_origin(anno) == Annotated and typing.get_args(anno)[1] is CONSUME_REST_MARKER: + cmd_param.consume_rest = True + finished_params = True + anno = typing.get_args(anno)[0] + + if anno == T: + # someone forgot to typehint + anno = inspect._empty + + if typing.get_origin(anno) == Annotated: + anno = _get_from_anno_type(anno) + if typing.get_origin(anno) == Greedy: + if finished_params: + raise ValueError("Consume rest arguments cannot be Greedy.") + anno, default = _greedy_parse(anno, param) if default is not param.empty: cmd_param.default = default cmd_param.greedy = True + if typing.get_origin(anno) == tuple: + if cmd_param.optional: + # there's a lot of parser ambiguities here, so i'd rather not + raise ValueError("Variable arguments cannot have default values or be Optional.") + cmd_param.variable = True + finished_params = True + + # use empty if the typehint is just "tuple" + anno = typing.get_args(anno)[0] if typing.get_args(anno) else inspect._empty + if typing.get_origin(anno) in {Union, UnionType}: cmd_param.union = True for arg in typing.get_args(anno): @@ -524,22 +552,23 @@ def _parse_parameters(self) -> None: # noqa: C901 converter = _get_converter(anno, name) cmd_param.converters.append(converter) - match param.kind: - case param.KEYWORD_ONLY: - if cmd_param.greedy: - raise ValueError("Keyword-only arguments cannot be Greedy.") + if not finished_params: + match param.kind: + case param.KEYWORD_ONLY: + if cmd_param.greedy: + raise ValueError("Consume rest arguments cannot be Greedy.") - cmd_param.consume_rest = True - finished_params = True - case param.VAR_POSITIONAL: - if cmd_param.optional: - # there's a lot of parser ambiguities here, so i'd rather not - raise ValueError("Variable arguments cannot have default values or be Optional.") - if cmd_param.greedy: - raise ValueError("Variable arguments cannot be Greedy.") + cmd_param.consume_rest = True + finished_params = True + case param.VAR_POSITIONAL: + if cmd_param.optional: + # there's a lot of parser ambiguities here, so i'd rather not + raise ValueError("Variable arguments cannot have default values or be Optional.") + if cmd_param.greedy: + raise ValueError("Variable arguments cannot be Greedy.") - cmd_param.variable = True - finished_params = True + cmd_param.variable = True + finished_params = True self.parameters.append(cmd_param) @@ -698,7 +727,14 @@ async def call_callback(self, callback: Callable, ctx: "PrefixedContext") -> Non args_to_convert = args.get_rest_of_args() new_arg = [await _convert(param, ctx, arg) for arg in args_to_convert] new_arg = tuple(arg[0] for arg in new_arg) - new_args.extend(new_arg) + + if param.kind == inspect.Parameter.VAR_POSITIONAL: + new_args.extend(new_arg) + elif param.kind == inspect.Parameter.POSITIONAL_ONLY: + new_args.append(new_arg) + else: + kwargs[param.name] = new_arg + param_index += 1 break diff --git a/interactions/models/__init__.py b/interactions/models/__init__.py index e8a7c0058..4dfeb9f68 100644 --- a/interactions/models/__init__.py +++ b/interactions/models/__init__.py @@ -210,6 +210,7 @@ context_menu, user_context_menu, message_context_menu, + ConsumeRest, ContextMenu, ContextMenuContext, Converter, @@ -362,6 +363,7 @@ "ComponentCommand", "ComponentContext", "ComponentType", + "ConsumeRest", "context_menu", "ContextMenu", "ContextMenuContext", diff --git a/interactions/models/internal/__init__.py b/interactions/models/internal/__init__.py index 0570d3a11..ef6e311cb 100644 --- a/interactions/models/internal/__init__.py +++ b/interactions/models/internal/__init__.py @@ -58,6 +58,7 @@ from .converters import ( BaseChannelConverter, ChannelConverter, + ConsumeRest, CustomEmojiConverter, DMChannelConverter, DMConverter, @@ -124,6 +125,7 @@ "context_menu", "user_context_menu", "message_context_menu", + "ConsumeRest", "ContextMenu", "ContextMenuContext", "Converter", diff --git a/interactions/models/internal/converters.py b/interactions/models/internal/converters.py index 932f47d0e..7a638ff69 100644 --- a/interactions/models/internal/converters.py +++ b/interactions/models/internal/converters.py @@ -1,8 +1,8 @@ import re import typing -from typing import Any, Optional, List +from typing import Any, Optional, List, Annotated -from interactions.client.const import T, T_co +from interactions.client.const import T, T_co, Sentinel from interactions.client.errors import BadArgument from interactions.client.errors import Forbidden, HTTPException from interactions.models.discord.channel import ( @@ -66,6 +66,7 @@ "CustomEmojiConverter", "MessageConverter", "Greedy", + "ConsumeRest", "MODEL_TO_CONVERTER", ) @@ -572,6 +573,15 @@ class Greedy(List[T]): """A special marker class to mark an argument in a prefixed command to repeatedly convert until it fails to convert an argument.""" +class ConsumeRestMarker(Sentinel): + pass + + +CONSUME_REST_MARKER = ConsumeRestMarker() + +ConsumeRest = Annotated[T, CONSUME_REST_MARKER] +"""A special marker type alias to mark an argument in a prefixed command to consume the rest of the arguments.""" + MODEL_TO_CONVERTER: dict[type, type[Converter]] = { SnowflakeObject: SnowflakeConverter, BaseChannel: BaseChannelConverter, From d7bcfbae1a9e2feafd7e99f4cada1746336cb60e Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Sat, 15 Jul 2023 04:32:30 -0400 Subject: [PATCH 06/33] feat: cache channel data from interactions (#1479) --- interactions/models/internal/context.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/interactions/models/internal/context.py b/interactions/models/internal/context.py index 9163d39ee..93b6842c4 100644 --- a/interactions/models/internal/context.py +++ b/interactions/models/internal/context.py @@ -284,6 +284,9 @@ def from_dict(cls, client: "interactions.Client", payload: dict) -> Self: instance.resolved = Resolved.from_dict(client, payload["data"].get("resolved", {}), payload.get("guild_id")) instance.channel_id = Snowflake(payload["channel_id"]) + if channel := payload.get("channel"): + client.cache.place_channel_data(channel) + if member := payload.get("member"): instance.author_id = Snowflake(member["user"]["id"]) instance.guild_id = Snowflake(payload["guild_id"]) From cea4f784725beb3cba58d5b0b1f1abef93d9a51e Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Sat, 15 Jul 2023 04:33:04 -0400 Subject: [PATCH 07/33] fix: account for BaseChannel for channel mentions (#1480) --- interactions/models/discord/message.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/interactions/models/discord/message.py b/interactions/models/discord/message.py index bccc6a36b..659671fbf 100644 --- a/interactions/models/discord/message.py +++ b/interactions/models/discord/message.py @@ -24,7 +24,7 @@ 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 +from interactions.models.discord.channel import BaseChannel, GuildChannel 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 @@ -109,7 +109,7 @@ def _process_dict(cls, data: Dict[str, Any], _) -> Dict[str, Any]: @attrs.define(eq=False, order=False, hash=False, kw_only=True) class ChannelMention(DiscordObject): - guild_id: "Snowflake_Type" = attrs.field( + guild_id: "Snowflake_Type | None" = attrs.field( repr=False, ) """id of the guild containing the channel""" @@ -466,7 +466,7 @@ def _process_dict(cls, data: dict, client: "Client") -> dict: # noqa: C901 if channel_id not in found_ids and (channel := client.get_channel(channel_id)): channel_data = { "id": channel.id, - "guild_id": channel._guild_id, + "guild_id": channel._guild_id if isinstance(channel, GuildChannel) else None, "type": channel.type, "name": channel.name, } From 3bd51fc14ec9edb5bf40e4d22b8c1fc3ebca9e4f Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Sat, 15 Jul 2023 04:34:12 -0400 Subject: [PATCH 08/33] feat: add default to delete/edit init interaction msg (#1481) --- interactions/models/internal/context.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/interactions/models/internal/context.py b/interactions/models/internal/context.py index 93b6842c4..489e20866 100644 --- a/interactions/models/internal/context.py +++ b/interactions/models/internal/context.py @@ -547,18 +547,20 @@ async def send( respond = send - async def delete(self, message: "Snowflake_Type") -> None: + async def delete(self, message: "Snowflake_Type" = "@original") -> None: """ Delete a message sent in response to this interaction. Args: - message: The message to delete + message: The message to delete. Defaults to @original which represents the initial response message. """ - await self.client.http.delete_interaction_message(self.client.app.id, self.token, to_snowflake(message)) + await self.client.http.delete_interaction_message( + self.client.app.id, self.token, to_snowflake(message) if message != "@original" else message + ) async def edit( self, - message: "Snowflake_Type", + message: "Snowflake_Type" = "@original", *, content: typing.Optional[str] = None, embeds: typing.Optional[ @@ -594,7 +596,7 @@ async def edit( payload=message_payload, application_id=self.client.app.id, token=self.token, - message_id=to_snowflake(message), + message_id=to_snowflake(message) if message != "@original" else message, files=files, ) if message_data: From bddc2bd497796e59143245a8b27f3ed46203bb66 Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Sat, 15 Jul 2023 04:34:54 -0400 Subject: [PATCH 09/33] docs: remove outdated cookiecutter template (#1483) * docs: remove outdated cookiecutter template * docs: add boilerplate mention --- docs/src/Guides/01 Getting Started.md | 125 ++++++++++---------------- 1 file changed, 48 insertions(+), 77 deletions(-) diff --git a/docs/src/Guides/01 Getting Started.md b/docs/src/Guides/01 Getting Started.md index 1037f56bb..ca59cc573 100644 --- a/docs/src/Guides/01 Getting Started.md +++ b/docs/src/Guides/01 Getting Started.md @@ -9,102 +9,73 @@ Ready to get your Python on and create a Discord bot? This guide's got you cover - [x] [A bot account](02 Creating Your Bot.md) - [ ] An aversion to puns -## Installation Methods +## Installing and Setting up a Bot -There are two different ways to install this library and create your bot. +### Virtual Environments -=== "Using a Template" +We strongly recommend that you make use of Virtual Environments when working on any project. +This means that each project will have its own libraries of any version and does not affect anything else on your system. +Don't worry, this isn't setting up a full-fledged virtual machine, just small python environment. - We created a [cookiecutter template](https://github.com/Discord-Snake-Pit/Bot-Template) which you can use to set up your own bot faster. - With the template, your code will already have a well-defined structure which will make development easier for you. - - We recommend newer devs to make use of this template. - - ### Template Feature - - Basic, ready to go bot - - Implementation of best practises - - General extensibility - - Example command, context menu, component, and event - - Logging to both console and file - - Pip and poetry config - - Pre-commit config - - Dockerfile and pre-made docker-compose - - ### Template Installation - 1. Install cookiecutter - `pip install cookiecutter` - 2. Set up the template - `cookiecutter https://github.com/Discord-Snake-Pit/Bot-Template` - - And that's it! - - More information can be found [here](https://github.com/Discord-Snake-Pit/Bot-Template). - - -=== "Manual Installation" - - ### Virtual-Environments - - We strongly recommend that you make use of Virtual Environments when working on any project. - This means that each project will have its own libraries of any version and does not affect anything else on your system. - Don't worry, this isn't setting up a full-fledged virtual machine, just small python environment. - - === ":material-linux: Linux" - ```shell - cd "[your bots directory]" - python3 -m venv venv - source venv/bin/activate - ``` +=== ":material-linux: Linux" + ```shell + cd "[your bots directory]" + python3 -m venv venv + source venv/bin/activate + ``` - === ":material-microsoft-windows: Windows" - ```shell - cd "[your bots directory]" - py -3 -m venv venv - venv/Scripts/activate - ``` +=== ":material-microsoft-windows: Windows" + ```shell + cd "[your bots directory]" + py -3 -m venv venv + venv/Scripts/activate + ``` - It's that simple, now you're using a virtual environment. If you want to leave the environment just type `deactivate`. - If you want to learn more about the virtual environments, check out [this page](https://docs.python.org/3/tutorial/venv.html) +It's that simple, now you're using a virtual environment. If you want to leave the environment just type `deactivate`. +If you want to learn more about the virtual environments, check out [this page](https://docs.python.org/3/tutorial/venv.html) - ### Pip install +### Pip install - Now let's get the library installed. +Now let's get the library installed. - === ":material-linux: Linux" - ```shell - python3 -m pip install discord-py-interactions --upgrade - ``` +=== ":material-linux: Linux" + ```shell + python3 -m pip install discord-py-interactions --upgrade + ``` - === ":material-microsoft-windows: Windows" - ```shell - py -3 -m pip install discord-py-interactions --upgrade - ``` +=== ":material-microsoft-windows: Windows" + ```shell + py -3 -m pip install discord-py-interactions --upgrade + ``` - ### Basic bot +### Basic bot - Now let's get a basic bot going, for your code, you'll want something like this: +!!! note + This is a very basic bot. For a more detailed example/template bot that demonstrates many parts of interactions.py, see [the boilerplate repository.](https://github.com/interactions-py/boilerplate) - ```python - from interactions import Client, Intents, listen +Now let's get a basic bot going, for your code, you'll want something like this: - bot = Client(intents=Intents.DEFAULT) - # intents are what events we want to receive from discord, `DEFAULT` is usually fine +```python +from interactions import Client, Intents, listen - @listen() # this decorator tells snek that it needs to listen for the corresponding event, and run this coroutine - async def on_ready(): - # This event is called when the bot is ready to respond to commands - print("Ready") - print(f"This bot is owned by {bot.owner}") +bot = Client(intents=Intents.DEFAULT) +# intents are what events we want to receive from discord, `DEFAULT` is usually fine +@listen() # this decorator tells snek that it needs to listen for the corresponding event, and run this coroutine +async def on_ready(): + # This event is called when the bot is ready to respond to commands + print("Ready") + print(f"This bot is owned by {bot.owner}") - @listen() - async def on_message_create(event): - # This event is called when a message is sent in a channel the bot can see - print(f"message received: {event.message.content}") +@listen() +async def on_message_create(event): + # This event is called when a message is sent in a channel the bot can see + print(f"message received: {event.message.content}") - bot.start("Put your token here") - ``` ---- +bot.start("Put your token here") +``` Congratulations! You now have a basic understanding of this library. If you have any questions check out our other guides, or join the From b299a7f21ed04f49349ed80ed4fb76f924012de2 Mon Sep 17 00:00:00 2001 From: Sophia <41456914+i0bs@users.noreply.github.com> Date: Sat, 15 Jul 2023 04:35:33 -0400 Subject: [PATCH 10/33] feat: add id attr to app perms update event (#1485) --- interactions/api/events/discord.py | 1 + 1 file changed, 1 insertion(+) diff --git a/interactions/api/events/discord.py b/interactions/api/events/discord.py index 51b2b17bd..688c00f48 100644 --- a/interactions/api/events/discord.py +++ b/interactions/api/events/discord.py @@ -142,6 +142,7 @@ class AutoModDeleted(AutoModCreated): @attrs.define(eq=False, order=False, hash=False, kw_only=False) class ApplicationCommandPermissionsUpdate(BaseEvent): + id: "Snowflake_Type" = attrs.field(repr=False, metadata=docs("The ID of the command permissions were updated for")) guild_id: "Snowflake_Type" = attrs.field( repr=False, metadata=docs("The guild the command permissions were updated in") ) From 6a2d8e60a7b1780f92e7dd9c801a417e13092743 Mon Sep 17 00:00:00 2001 From: LordOfPolls Date: Sat, 15 Jul 2023 10:33:10 +0100 Subject: [PATCH 11/33] feat: add sort order for forums (#1488) * feat: add forum sort order * feat: cache layout and sort order for forums --- interactions/__init__.py | 2 ++ interactions/models/__init__.py | 2 ++ interactions/models/discord/__init__.py | 2 ++ interactions/models/discord/channel.py | 10 ++++++++++ interactions/models/discord/enums.py | 14 +++++++++++++- interactions/models/discord/guild.py | 4 ++++ 6 files changed, 33 insertions(+), 1 deletion(-) diff --git a/interactions/__init__.py b/interactions/__init__.py index ac801b24c..e85e5c0b6 100644 --- a/interactions/__init__.py +++ b/interactions/__init__.py @@ -339,6 +339,7 @@ ExponentialBackoffSystem, LeakyBucketSystem, TokenBucketSystem, + ForumSortOrder, ) from .api import events from . import ext @@ -460,6 +461,7 @@ "File", "FlatUIColors", "FlatUIColours", + "ForumSortOrder", "ForumLayoutType", "get_components_ids", "get_logger", diff --git a/interactions/models/__init__.py b/interactions/models/__init__.py index 4dfeb9f68..25df73447 100644 --- a/interactions/models/__init__.py +++ b/interactions/models/__init__.py @@ -187,6 +187,7 @@ WebhookMixin, WebhookTypes, WebSocketOPCode, + ForumSortOrder, ) from .internal import ( ActiveVoiceState, @@ -398,6 +399,7 @@ "File", "FlatUIColors", "FlatUIColours", + "ForumSortOrder", "ForumLayoutType", "get_components_ids", "global_autocomplete", diff --git a/interactions/models/discord/__init__.py b/interactions/models/discord/__init__.py index 2793aefc1..e7c25618b 100644 --- a/interactions/models/discord/__init__.py +++ b/interactions/models/discord/__init__.py @@ -114,6 +114,7 @@ VerificationLevel, VideoQualityMode, WebSocketOPCode, + ForumSortOrder, ) from .file import File, open_file, UPLOADABLE_TYPE from .guild import ( @@ -226,6 +227,7 @@ "File", "FlatUIColors", "FlatUIColours", + "ForumSortOrder", "ForumLayoutType", "get_components_ids", "Guild", diff --git a/interactions/models/discord/channel.py b/interactions/models/discord/channel.py index b13125ab0..be33f9d29 100644 --- a/interactions/models/discord/channel.py +++ b/interactions/models/discord/channel.py @@ -36,6 +36,8 @@ StagePrivacyLevel, MessageFlags, InviteTargetType, + ForumSortOrder, + ForumLayoutType, ) if TYPE_CHECKING: @@ -2394,6 +2396,14 @@ class GuildForum(GuildChannel): """The default emoji to react with for posts""" last_message_id: Optional[Snowflake_Type] = attrs.field(repr=False, default=None) # TODO: Implement "template" once the API supports them + default_sort_order: Optional[ForumSortOrder] = attrs.field( + repr=False, default=None, converter=ForumSortOrder.converter + ) + """the default sort order type used to order posts in GUILD_FORUM channels. Defaults to null, which indicates a preferred sort order hasn't been set by a channel admin""" + default_forum_layout: ForumLayoutType = attrs.field( + repr=False, default=ForumLayoutType.NOT_SET, converter=ForumLayoutType + ) + """The default forum layout view used to display posts in GUILD_FORUM channels. Defaults to 0, which indicates a layout view has not been set by a channel admin""" @classmethod def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any]: diff --git a/interactions/models/discord/enums.py b/interactions/models/discord/enums.py index d5b3ec054..c201f6d50 100644 --- a/interactions/models/discord/enums.py +++ b/interactions/models/discord/enums.py @@ -1,7 +1,7 @@ from enum import Enum, EnumMeta, IntEnum, IntFlag from functools import reduce from operator import or_ -from typing import Iterator, Tuple, TypeVar, Type +from typing import Iterator, Tuple, TypeVar, Type, Optional from interactions.client.const import get_logger @@ -19,6 +19,7 @@ "DefaultNotificationLevel", "ExplicitContentFilterLevel", "ForumLayoutType", + "ForumSortOrder", "IntegrationExpireBehaviour", "Intents", "InteractionPermissionTypes", @@ -1046,3 +1047,14 @@ class ForumLayoutType(CursedIntEnum): NOT_SET = 0 LIST = 1 GALLERY = 2 + + +class ForumSortOrder(CursedIntEnum): + """The order of a forum channel.""" + + LATEST_ACTIVITY = 0 + CREATION_DATE = 1 + + @classmethod + def converter(cls, value: Optional[int]) -> "ForumSortOrder": + return None if value is None else cls(value) diff --git a/interactions/models/discord/guild.py b/interactions/models/discord/guild.py index f166ba540..1f5dbf86b 100644 --- a/interactions/models/discord/guild.py +++ b/interactions/models/discord/guild.py @@ -41,6 +41,7 @@ ScheduledEventType, SystemChannelFlags, VerificationLevel, + ForumSortOrder, ) from .snowflake import ( Snowflake_Type, @@ -1023,6 +1024,7 @@ async def create_forum_channel( default_reaction_emoji: Absent[Union[dict, "models.PartialEmoji", "models.DefaultReaction", str]] = MISSING, available_tags: Absent["list[dict | models.ThreadTag] | dict | models.ThreadTag"] = MISSING, layout: ForumLayoutType = ForumLayoutType.NOT_SET, + sort_order: Absent[ForumSortOrder] = MISSING, reason: Absent[Optional[str]] = MISSING, ) -> "models.GuildForum": """ @@ -1039,6 +1041,7 @@ async def create_forum_channel( default_reaction_emoji: The default emoji to react with when creating a thread available_tags: The available tags for this forum channel layout: The layout of the forum channel + sort_order: The sort order of the forum channel reason: The reason for creating this channel Returns: @@ -1057,6 +1060,7 @@ async def create_forum_channel( default_reaction_emoji=models.process_default_reaction(default_reaction_emoji), available_tags=list_converter(models.process_thread_tag)(available_tags) if available_tags else MISSING, default_forum_layout=layout, + default_sort_order=sort_order, reason=reason, ) From c229ce1ba953c0495d03c1506779eb18720fe6a0 Mon Sep 17 00:00:00 2001 From: LordOfPolls Date: Sat, 15 Jul 2023 10:34:34 +0100 Subject: [PATCH 12/33] feat: add rate limit per user where needed (#1489) Signed-off-by: LordOfPolls --- interactions/models/discord/channel.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/interactions/models/discord/channel.py b/interactions/models/discord/channel.py index be33f9d29..f35e228c6 100644 --- a/interactions/models/discord/channel.py +++ b/interactions/models/discord/channel.py @@ -242,6 +242,8 @@ class MessageableMixin(SendMixin): repr=False, default=None, converter=optional_c(timestamp_converter) ) """When the last pinned message was pinned. This may be None when a message is not pinned.""" + rate_limit_per_user: int = attrs.field(repr=False, default=0) + """Amount of seconds a user has to wait before sending another message (0-21600)""" async def _send_http_request( self, message_payload: Union[dict, "FormData"], files: list["UPLOADABLE_TYPE"] | None = None @@ -1693,8 +1695,6 @@ async def create_thread_from_message( class GuildText(GuildChannel, MessageableMixin, InvitableMixin, ThreadableMixin, WebhookMixin): topic: Optional[str] = attrs.field(repr=False, default=None) """The channel topic (0-1024 characters)""" - rate_limit_per_user: int = attrs.field(repr=False, default=0) - """Amount of seconds a user has to wait before sending another message (0-21600)""" async def edit( self, @@ -2396,6 +2396,8 @@ class GuildForum(GuildChannel): """The default emoji to react with for posts""" last_message_id: Optional[Snowflake_Type] = attrs.field(repr=False, default=None) # TODO: Implement "template" once the API supports them + rate_limit_per_user: int = attrs.field(repr=False, default=0) + """Amount of seconds a user has to wait before sending another message (0-21600)""" default_sort_order: Optional[ForumSortOrder] = attrs.field( repr=False, default=None, converter=ForumSortOrder.converter ) From a38b56fc5388350b68ca67d509f7f32b5b4f5675 Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Sun, 16 Jul 2023 13:40:27 -0400 Subject: [PATCH 13/33] fix: use message channel for PrefixedContext (#1491) --- interactions/ext/prefixed_commands/context.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/interactions/ext/prefixed_commands/context.py b/interactions/ext/prefixed_commands/context.py index 5777e1b0f..ff6177491 100644 --- a/interactions/ext/prefixed_commands/context.py +++ b/interactions/ext/prefixed_commands/context.py @@ -4,6 +4,7 @@ from interactions.client.client import Client from interactions.client.mixins.send import SendMixin +from interactions.models.discord.channel import TYPE_MESSAGEABLE_CHANNEL from interactions.models.discord.embed import Embed from interactions.models.discord.file import UPLOADABLE_TYPE from interactions.models.discord.message import Message @@ -62,6 +63,11 @@ def message(self) -> Message: """The message that invoked this context.""" return self._message + @property + def channel(self) -> TYPE_MESSAGEABLE_CHANNEL: + """The channel this context was invoked in.""" + return self.message.channel + @property def invoke_target(self) -> str: """The name of the command to be invoked.""" From f3583b1ecb95b4b853c9fa26bb5ab9ec4116619b Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Sun, 16 Jul 2023 13:40:50 -0400 Subject: [PATCH 14/33] fix: bound app_permissions for HybridContext (#1492) --- interactions/ext/hybrid_commands/context.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/interactions/ext/hybrid_commands/context.py b/interactions/ext/hybrid_commands/context.py index 678cee838..b881d1fb0 100644 --- a/interactions/ext/hybrid_commands/context.py +++ b/interactions/ext/hybrid_commands/context.py @@ -118,6 +118,8 @@ def from_prefixed_context(cls, ctx: prefixed.PrefixedContext) -> Self: 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 + else: + app_permissions = Permissions(0) self = cls(ctx.client) self.guild_id = ctx.guild_id From baef57d47449a519e8080c7ffcb9b95a4e975a8a Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Sun, 16 Jul 2023 13:41:26 -0400 Subject: [PATCH 15/33] feat: add start_time(s) for AutoShardedClient (#1482) * feat: add start_time(s) for AutoShardedClient * feat: make start_times a dict * docs: make start_times docstring clearer Co-authored-by: Sophia <41456914+i0bs@users.noreply.github.com> Signed-off-by: Astrea <25420078+AstreaTSS@users.noreply.github.com> --------- Signed-off-by: Astrea <25420078+AstreaTSS@users.noreply.github.com> Co-authored-by: Sophia <41456914+i0bs@users.noreply.github.com> --- interactions/client/auto_shard_client.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/interactions/client/auto_shard_client.py b/interactions/client/auto_shard_client.py index f0cba0441..935ccaa1f 100644 --- a/interactions/client/auto_shard_client.py +++ b/interactions/client/auto_shard_client.py @@ -1,5 +1,6 @@ import asyncio import time +from datetime import datetime from collections import defaultdict from typing import TYPE_CHECKING, Optional @@ -68,6 +69,16 @@ def latencies(self) -> dict[int, float]: """ return {state.shard_id: state.latency for state in self._connection_states} + @property + def start_time(self) -> datetime: + """The start time of the first shard of the bot.""" + return next((state.start_time for state in self._connection_states), MISSING) # type: ignore + + @property + def start_times(self) -> dict[int, datetime]: + """The start times of all shards of the bot, keyed by each shard ID.""" + return {state.shard_id: state.start_time for state in self._connection_states} # type: ignore + async def stop(self) -> None: """Shutdown the bot.""" self.logger.debug("Stopping the bot.") From 579de0eec7283f4529e4261b18bd75da92f37e9a Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Tue, 18 Jul 2023 14:20:44 -0400 Subject: [PATCH 16/33] fix: correct listen typehint (#1495) --- interactions/models/internal/listener.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/models/internal/listener.py b/interactions/models/internal/listener.py index a4c09d6b6..4edc223ef 100644 --- a/interactions/models/internal/listener.py +++ b/interactions/models/internal/listener.py @@ -116,7 +116,7 @@ def lazy_parse_params(self): def listen( - event_name: Absent[str | BaseEvent] = MISSING, + event_name: Absent[str | type[BaseEvent]] = MISSING, *, delay_until_ready: bool = False, is_default_listener: bool = False, From fcb9dc603568a15ec7e4cd25b2a8d5acadb9bcfa Mon Sep 17 00:00:00 2001 From: Sophia <41456914+i0bs@users.noreply.github.com> Date: Wed, 19 Jul 2023 20:27:08 -0400 Subject: [PATCH 17/33] chore: move mutable class ref/attrs to ClassVar typing (#1497) --- interactions/client/const.py | 4 ++-- interactions/client/errors.py | 6 +++--- interactions/ext/debug_extension/__init__.py | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/interactions/client/const.py b/interactions/client/const.py index 1d0ced829..341b9a897 100644 --- a/interactions/client/const.py +++ b/interactions/client/const.py @@ -42,7 +42,7 @@ import sys from collections import defaultdict from importlib.metadata import version as _v, PackageNotFoundError -from typing import TypeVar, Union, Callable, Coroutine +from typing import TypeVar, Union, Callable, Coroutine, ClassVar __all__ = ( "__version__", @@ -131,7 +131,7 @@ def get_logger() -> logging.Logger: class Singleton(type): - _instances = {} + _instances: ClassVar[dict] = {} def __call__(self, *args, **kwargs) -> "Singleton": if self not in self._instances: diff --git a/interactions/client/errors.py b/interactions/client/errors.py index 871e11e88..09518728c 100644 --- a/interactions/client/errors.py +++ b/interactions/client/errors.py @@ -1,4 +1,4 @@ -from typing import Dict, Any, TYPE_CHECKING, Callable, Coroutine, List, Optional, SupportsInt, Union +from typing import Dict, Any, TYPE_CHECKING, Callable, Coroutine, ClassVar, List, Optional, SupportsInt, Union import aiohttp @@ -201,7 +201,7 @@ class WebSocketClosed(LibraryException): """The websocket was closed.""" code: int = 0 - codes: Dict[int, str] = { + codes: ClassVar[Dict[int, str]] = { 1000: "Normal Closure", 4000: "Unknown Error", 4001: "Unknown OpCode", @@ -228,7 +228,7 @@ class VoiceWebSocketClosed(LibraryException): """The voice websocket was closed.""" code: int = 0 - codes: Dict[int, str] = { + codes: ClassVar[Dict[int, str]] = { 1000: "Normal Closure", 4000: "Unknown Error", 4001: "Unknown OpCode", diff --git a/interactions/ext/debug_extension/__init__.py b/interactions/ext/debug_extension/__init__.py index dd4350fa8..b018448a8 100644 --- a/interactions/ext/debug_extension/__init__.py +++ b/interactions/ext/debug_extension/__init__.py @@ -1,6 +1,7 @@ import asyncio import platform import tracemalloc +from typing import ClassVar from interactions import ( Client, @@ -28,7 +29,7 @@ class Metadata(Extension.Metadata): description = "Debugging utilities for interactions.py" version = "1.0.0" url = "https://github.com/interactions-py/interactions.py" - requirements = ["interactions>=5.0.0"] + requirements: ClassVar[list] = ["interactions>=5.0.0"] def __init__(self, bot: Client) -> None: bot.logger.info("Debug Extension is mounting!") From 0b467bd2faaffd6597457c88092ef8e529853e3b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 20:27:58 -0400 Subject: [PATCH 18/33] ci: weekly check. (#1460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: weekly check. updates: - https://github.com/charliermarsh/ruff-pre-commit → https://github.com/astral-sh/ruff-pre-commit - [github.com/astral-sh/ruff-pre-commit: v0.0.272 → v0.0.278](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.272...v0.0.278) - [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0) * ci: correct from checks. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 ++--- .../api/http/http_requests/threads.py | 8 +++---- interactions/api/http/http_requests/users.py | 4 ++-- .../api/http/http_requests/webhooks.py | 8 ++++--- interactions/api/voice/recorder.py | 2 +- interactions/client/client.py | 2 +- .../ext/hybrid_commands/hybrid_slash.py | 8 +++---- interactions/ext/prefixed_commands/command.py | 2 +- interactions/ext/sentry.py | 2 +- interactions/models/discord/channel.py | 22 +++++++++---------- interactions/models/discord/components.py | 2 +- interactions/models/discord/guild.py | 4 ++-- interactions/models/discord/webhooks.py | 4 ++-- .../models/internal/annotations/slash.py | 12 +++++----- .../models/internal/application_commands.py | 10 ++++----- 15 files changed, 49 insertions(+), 47 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f148629a..d79d7e743 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,13 +29,13 @@ repos: name: TOML Structure - id: check-merge-conflict name: Merge Conflicts - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: 'v0.0.272' + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.0.278' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black name: Black Formatting diff --git a/interactions/api/http/http_requests/threads.py b/interactions/api/http/http_requests/threads.py index f2c9935f6..0f23a0ccd 100644 --- a/interactions/api/http/http_requests/threads.py +++ b/interactions/api/http/http_requests/threads.py @@ -81,7 +81,7 @@ async def list_thread_members(self, thread_id: "Snowflake_Type") -> List[discord async def list_public_archived_threads( self, channel_id: "Snowflake_Type", - limit: int = None, + limit: int | None = None, before: Optional["Snowflake_Type"] = None, ) -> discord_typings.ListThreadsData: """ @@ -108,7 +108,7 @@ async def list_public_archived_threads( async def list_private_archived_threads( self, channel_id: "Snowflake_Type", - limit: int = None, + limit: int | None = None, before: Optional["Snowflake_Type"] = None, ) -> discord_typings.ListThreadsData: """ @@ -135,7 +135,7 @@ async def list_private_archived_threads( async def list_joined_private_archived_threads( self, channel_id: "Snowflake_Type", - limit: int = None, + limit: int | None = None, before: Optional["Snowflake_Type"] = None, ) -> discord_typings.ListThreadsData: """ @@ -229,7 +229,7 @@ async def create_forum_thread( name: str, auto_archive_duration: int, message: dict | FormData, - applied_tags: List[str] = None, + applied_tags: List[str] | None = None, rate_limit_per_user: Absent[int] = MISSING, files: Absent["UPLOADABLE_TYPE"] = MISSING, reason: Absent[str] = MISSING, diff --git a/interactions/api/http/http_requests/users.py b/interactions/api/http/http_requests/users.py index f601f3da9..73519cf10 100644 --- a/interactions/api/http/http_requests/users.py +++ b/interactions/api/http/http_requests/users.py @@ -100,7 +100,7 @@ async def group_dm_add_recipient( channel_id: "Snowflake_Type", user_id: "Snowflake_Type", access_token: str, - nick: str = None, + nick: str | None = None, ) -> None: """ Adds a recipient to a Group DM using their access token. @@ -130,7 +130,7 @@ async def group_dm_remove_recipient(self, channel_id: "Snowflake_Type", user_id: Route("DELETE", "/channels/{channel_id}/recipients/{user_id}", channel_id=channel_id, user_id=user_id) ) - async def modify_current_user_nick(self, guild_id: "Snowflake_Type", nickname: str = None) -> None: + async def modify_current_user_nick(self, guild_id: "Snowflake_Type", nickname: str | None = None) -> None: """ Modifies the nickname of the current user in a guild. diff --git a/interactions/api/http/http_requests/webhooks.py b/interactions/api/http/http_requests/webhooks.py index cf1a97889..f5b372c1c 100644 --- a/interactions/api/http/http_requests/webhooks.py +++ b/interactions/api/http/http_requests/webhooks.py @@ -59,7 +59,9 @@ async def get_guild_webhooks(self, guild_id: "Snowflake_Type") -> List[discord_t """ return await self.request(Route("GET", "/guilds/{guild_id}/webhooks", guild_id=guild_id)) - async def get_webhook(self, webhook_id: "Snowflake_Type", webhook_token: str = None) -> discord_typings.WebhookData: + async def get_webhook( + self, webhook_id: "Snowflake_Type", webhook_token: str | None = None + ) -> discord_typings.WebhookData: """ Return the new webhook object for the given id. @@ -81,7 +83,7 @@ async def modify_webhook( name: str, avatar: Any, channel_id: "Snowflake_Type", - webhook_token: str = None, + webhook_token: str | None = None, ) -> discord_typings.WebhookData: """ Modify a webhook. @@ -101,7 +103,7 @@ async def modify_webhook( payload={"name": name, "avatar": avatar, "channel_id": channel_id}, ) - async def delete_webhook(self, webhook_id: "Snowflake_Type", webhook_token: str = None) -> None: + async def delete_webhook(self, webhook_id: "Snowflake_Type", webhook_token: str | None = None) -> None: """ Delete a webhook. diff --git a/interactions/api/voice/recorder.py b/interactions/api/voice/recorder.py index 3939e718a..6ae3c51ea 100644 --- a/interactions/api/voice/recorder.py +++ b/interactions/api/voice/recorder.py @@ -29,7 +29,7 @@ class Recorder(threading.Thread): - def __init__(self, v_state, loop, *, output_dir: str = None) -> None: + def __init__(self, v_state, loop, *, output_dir: str | None = None) -> None: super().__init__() self.daemon = True diff --git a/interactions/client/client.py b/interactions/client/client.py index 4a029deb8..ff1d5383f 100644 --- a/interactions/client/client.py +++ b/interactions/client/client.py @@ -1111,7 +1111,7 @@ async def wait_for_component( dict, ] ] = None, - check: Absent[Optional[Union[Callable[..., bool], Callable[..., Awaitable[bool]]]]] = None, + check: Absent[Optional[Union[Callable[..., bool], Callable[..., Awaitable[bool]]]]] | None = None, timeout: Optional[float] = None, ) -> "events.Component": """ diff --git a/interactions/ext/hybrid_commands/hybrid_slash.py b/interactions/ext/hybrid_commands/hybrid_slash.py index 6f998b9df..98485c625 100644 --- a/interactions/ext/hybrid_commands/hybrid_slash.py +++ b/interactions/ext/hybrid_commands/hybrid_slash.py @@ -239,7 +239,7 @@ async def __call__(self, context: SlashContext, *args, **kwargs) -> None: def group( self, - name: str = None, + name: str | None = None, description: str = "No Description Set", inherit_checks: bool = True, aliases: list[str] | None = None, @@ -263,7 +263,7 @@ def subcommand( group_name: LocalisedName | str = None, sub_cmd_description: Absent[LocalisedDesc | str] = MISSING, group_description: Absent[LocalisedDesc | str] = MISSING, - options: List[Union[SlashCommandOption, dict]] = None, + options: List[Union[SlashCommandOption, dict]] | None = None, nsfw: bool = False, inherit_checks: bool = True, aliases: list[str] | None = None, @@ -541,8 +541,8 @@ def hybrid_slash_subcommand( 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, + scopes: List["Snowflake_Type"] | None = None, + options: List[dict] | None = None, nsfw: bool = False, silence_autocomplete_errors: bool = False, ) -> Callable[[AsyncCallable], HybridSlashCommand]: diff --git a/interactions/ext/prefixed_commands/command.py b/interactions/ext/prefixed_commands/command.py index f8aca4fd2..73c0c7ea7 100644 --- a/interactions/ext/prefixed_commands/command.py +++ b/interactions/ext/prefixed_commands/command.py @@ -92,7 +92,7 @@ def __init__( self, name: str, default: Any = MISSING, - type: Type = None, + type: Type | None = None, kind: inspect._ParameterKind = inspect._ParameterKind.POSITIONAL_OR_KEYWORD, converters: Optional[list[Callable[["PrefixedContext", str], Any]]] = None, greedy: bool = False, diff --git a/interactions/ext/sentry.py b/interactions/ext/sentry.py index 6b334516e..821899927 100644 --- a/interactions/ext/sentry.py +++ b/interactions/ext/sentry.py @@ -89,7 +89,7 @@ def on_error_sentry_hook(self: Task, error: Exception) -> None: def setup( bot: Client, - token: str = None, + token: str | None = None, filter: Optional[Callable[[dict[str, Any], dict[str, Any]], Optional[dict[str, Any]]]] = None, **kwargs, ) -> None: diff --git a/interactions/models/discord/channel.py b/interactions/models/discord/channel.py index f35e228c6..2638d4a6b 100644 --- a/interactions/models/discord/channel.py +++ b/interactions/models/discord/channel.py @@ -391,7 +391,7 @@ async def delete_messages( else: await self._client.http.bulk_delete_messages(self.id, message_ids, reason) - async def delete_message(self, message: Union[Snowflake_Type, "models.Message"], reason: str = None) -> None: + async def delete_message(self, message: Union[Snowflake_Type, "models.Message"], reason: str | None = None) -> None: """ Delete a single message from a channel. @@ -565,7 +565,7 @@ async def create_thread( invitable: Absent[bool] = MISSING, rate_limit_per_user: Absent[int] = MISSING, auto_archive_duration: AutoArchiveDuration = AutoArchiveDuration.ONE_DAY, - reason: Absent[str] = None, + reason: Absent[str] | None = None, ) -> "TYPE_THREAD_CHANNEL": """ Creates a new thread in this channel. If a message is provided, it will be used as the initial message. @@ -605,7 +605,7 @@ async def create_thread( return self._client.cache.place_channel_data(thread_data) async def fetch_public_archived_threads( - self, limit: int = None, before: Optional["models.Timestamp"] = None + self, limit: int | None = None, before: Optional["models.Timestamp"] = None ) -> "models.ThreadList": """ Get a `ThreadList` of archived **public** threads available in this channel. @@ -625,7 +625,7 @@ async def fetch_public_archived_threads( return models.ThreadList.from_dict(threads_data, self._client) async def fetch_private_archived_threads( - self, limit: int = None, before: Optional["models.Timestamp"] = None + self, limit: int | None = None, before: Optional["models.Timestamp"] = None ) -> "models.ThreadList": """ Get a `ThreadList` of archived **private** threads available in this channel. @@ -645,7 +645,7 @@ async def fetch_private_archived_threads( return models.ThreadList.from_dict(threads_data, self._client) async def fetch_archived_threads( - self, limit: int = None, before: Optional["models.Timestamp"] = None + self, limit: int | None = None, before: Optional["models.Timestamp"] = None ) -> "models.ThreadList": """ Get a `ThreadList` of archived threads available in this channel. @@ -668,7 +668,7 @@ async def fetch_archived_threads( return models.ThreadList.from_dict(threads_data, self._client) async def fetch_joined_private_archived_threads( - self, limit: int = None, before: Optional["models.Timestamp"] = None + self, limit: int | None = None, before: Optional["models.Timestamp"] = None ) -> "models.ThreadList": """ Get a `ThreadList` of threads the bot is a participant of in this channel. @@ -1224,7 +1224,7 @@ async def set_permission( view_audit_log: bool | None = None, view_channel: bool | None = None, view_guild_insights: bool | None = None, - reason: str = None, + reason: str | None = None, ) -> None: """ Set the Permission Overwrites for a given target. @@ -1668,7 +1668,7 @@ async def create_thread_from_message( name: str, message: Snowflake_Type, auto_archive_duration: AutoArchiveDuration = AutoArchiveDuration.ONE_DAY, - reason: Absent[str] = None, + reason: Absent[str] | None = None, ) -> "GuildNewsThread": """ Creates a new news thread in this channel. @@ -1751,7 +1751,7 @@ async def create_public_thread( name: str, auto_archive_duration: AutoArchiveDuration = AutoArchiveDuration.ONE_DAY, rate_limit_per_user: Absent[int] = MISSING, - reason: Absent[str] = None, + reason: Absent[str] | None = None, ) -> "GuildPublicThread": """ Creates a new public thread in this channel. @@ -1780,7 +1780,7 @@ async def create_private_thread( invitable: Absent[bool] = MISSING, auto_archive_duration: AutoArchiveDuration = AutoArchiveDuration.ONE_DAY, rate_limit_per_user: Absent[int] = MISSING, - reason: Absent[str] = None, + reason: Absent[str] | None = None, ) -> "GuildPrivateThread": """ Creates a new private thread in this channel. @@ -1810,7 +1810,7 @@ async def create_thread_from_message( name: str, message: Snowflake_Type, auto_archive_duration: AutoArchiveDuration = AutoArchiveDuration.ONE_DAY, - reason: Absent[str] = None, + reason: Absent[str] | None = None, ) -> "GuildPublicThread": """ Creates a new public thread in this channel. diff --git a/interactions/models/discord/components.py b/interactions/models/discord/components.py index 0cc3accd2..632089b5e 100644 --- a/interactions/models/discord/components.py +++ b/interactions/models/discord/components.py @@ -212,7 +212,7 @@ def __init__( style: ButtonStyle | int, label: str | None = None, emoji: "PartialEmoji | None | str" = None, - custom_id: str = None, + custom_id: str | None = None, url: str | None = None, disabled: bool = False, ) -> None: diff --git a/interactions/models/discord/guild.py b/interactions/models/discord/guild.py index 1f5dbf86b..325f6655d 100644 --- a/interactions/models/discord/guild.py +++ b/interactions/models/discord/guild.py @@ -1221,7 +1221,7 @@ async def create_category( ) async def delete_channel( - self, channel: Union["models.TYPE_GUILD_CHANNEL", Snowflake_Type], reason: str = None + self, channel: Union["models.TYPE_GUILD_CHANNEL", Snowflake_Type], reason: str | None = None ) -> None: """ Delete the given channel, can handle either a snowflake or channel object. @@ -1875,7 +1875,7 @@ async def unban( """ await self._client.http.remove_guild_ban(self.id, to_snowflake(user), reason=reason) - async def fetch_widget_image(self, style: str = None) -> str: + async def fetch_widget_image(self, style: str | None = None) -> str: """ Fetch a guilds widget image. diff --git a/interactions/models/discord/webhooks.py b/interactions/models/discord/webhooks.py index 4fda39f99..4d88f8534 100644 --- a/interactions/models/discord/webhooks.py +++ b/interactions/models/discord/webhooks.py @@ -189,8 +189,8 @@ async def send( tts: bool = False, suppress_embeds: bool = False, flags: Optional[Union[int, "MessageFlags"]] = None, - username: str = None, - avatar_url: str = None, + username: str | None = None, + avatar_url: str | None = None, wait: bool = False, thread: "Snowflake_Type" = None, **kwargs, diff --git a/interactions/models/internal/annotations/slash.py b/interactions/models/internal/annotations/slash.py index 6d0825638..8e156737f 100644 --- a/interactions/models/internal/annotations/slash.py +++ b/interactions/models/internal/annotations/slash.py @@ -32,7 +32,7 @@ def slash_str_option( description: str, required: bool = False, autocomplete: bool = False, - choices: List[Union["SlashCommandChoice", dict]] = None, + choices: List[Union["SlashCommandChoice", dict]] | None = None, min_length: Optional[int] = None, max_length: Optional[int] = None, ) -> Type[str]: @@ -64,7 +64,7 @@ def slash_float_option( description: str, required: bool = False, autocomplete: bool = False, - choices: List[Union["SlashCommandChoice", dict]] = None, + choices: List[Union["SlashCommandChoice", dict]] | None = None, min_value: Optional[float] = None, max_value: Optional[float] = None, ) -> Type[float]: @@ -96,7 +96,7 @@ def slash_int_option( description: str, required: bool = False, autocomplete: bool = False, - choices: List[Union["SlashCommandChoice", dict]] = None, + choices: List[Union["SlashCommandChoice", dict]] | None = None, min_value: Optional[float] = None, max_value: Optional[float] = None, ) -> Type[int]: @@ -171,7 +171,7 @@ def slash_channel_option( description: str, required: bool = False, autocomplete: bool = False, - choices: List[Union["SlashCommandChoice", dict]] = None, + choices: List[Union["SlashCommandChoice", dict]] | None = None, channel_types: Optional[list[Union["ChannelType", int]]] = None, ) -> Type["BaseChannel"]: """ @@ -200,7 +200,7 @@ def slash_role_option( description: str, required: bool = False, autocomplete: bool = False, - choices: List[Union["SlashCommandChoice", dict]] = None, + choices: List[Union["SlashCommandChoice", dict]] | None = None, ) -> Type["Role"]: """ Annotates an argument as a role type slash command option. @@ -226,7 +226,7 @@ def slash_mentionable_option( description: str, required: bool = False, autocomplete: bool = False, - choices: List[Union["SlashCommandChoice", dict]] = None, + choices: List[Union["SlashCommandChoice", dict]] | None = None, ) -> Type[Union["Role", "BaseChannel", "User", "Member"]]: """ Annotates an argument as a mentionable type slash command option. diff --git a/interactions/models/internal/application_commands.py b/interactions/models/internal/application_commands.py index e89aa1ae0..763bbefff 100644 --- a/interactions/models/internal/application_commands.py +++ b/interactions/models/internal/application_commands.py @@ -717,7 +717,7 @@ def wrapper(call: Callable[..., Coroutine]) -> Callable[..., Coroutine]: return wrapper def group( - self, name: str = None, description: str = "No Description Set", inherit_checks: bool = True + self, name: str | None = None, description: str = "No Description Set", inherit_checks: bool = True ) -> "SlashCommand": return SlashCommand( name=self.name, @@ -736,7 +736,7 @@ def subcommand( group_name: LocalisedName | str = None, sub_cmd_description: Absent[LocalisedDesc | str] = MISSING, group_description: Absent[LocalisedDesc | str] = MISSING, - options: List[Union[SlashCommandOption, Dict]] = None, + options: List[Union[SlashCommandOption, Dict]] | None = None, nsfw: bool = False, inherit_checks: bool = True, ) -> Callable[..., "SlashCommand"]: @@ -961,8 +961,8 @@ def subcommand( 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, + scopes: List["Snowflake_Type"] | None = None, + options: List[dict] | None = None, nsfw: bool = False, ) -> Callable[[AsyncCallable], SlashCommand]: """ @@ -1190,7 +1190,7 @@ def slash_option( opt_type: Union[OptionType, int], required: bool = False, autocomplete: bool = False, - choices: List[Union[SlashCommandChoice, dict]] = None, + choices: List[Union[SlashCommandChoice, dict]] | None = None, channel_types: Optional[list[Union[ChannelType, int]]] = None, min_value: Optional[float] = None, max_value: Optional[float] = None, From 1ec7128af3a58d5a31b4c8aa774c2e78959a2dbe Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Thu, 20 Jul 2023 10:40:51 -0400 Subject: [PATCH 19/33] feat: add ability to use different arg name than option name (#1493) * feat: add ability to use different arg name than option name * feat: raise better error if arg name doesnt exist * fix: use ValueError instead of TypeError TypeError seems to be suppressed --- .../ext/hybrid_commands/hybrid_slash.py | 2 +- .../models/internal/annotations/slash.py | 36 ++++++++++++----- .../models/internal/application_commands.py | 39 ++++++++++++++++--- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/interactions/ext/hybrid_commands/hybrid_slash.py b/interactions/ext/hybrid_commands/hybrid_slash.py index 98485c625..1a6e3f1ab 100644 --- a/interactions/ext/hybrid_commands/hybrid_slash.py +++ b/interactions/ext/hybrid_commands/hybrid_slash.py @@ -351,7 +351,7 @@ def slash_to_prefixed(cmd: HybridSlashCommand) -> _HybridToPrefixedCommand: # n # there isn't much we can do here raise ValueError("Autocomplete is unsupported in hybrid commands.") - name = str(option.name) + name = option.argument_name or 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 diff --git a/interactions/models/internal/annotations/slash.py b/interactions/models/internal/annotations/slash.py index 8e156737f..ae5e5b5b8 100644 --- a/interactions/models/internal/annotations/slash.py +++ b/interactions/models/internal/annotations/slash.py @@ -35,6 +35,7 @@ def slash_str_option( choices: List[Union["SlashCommandChoice", dict]] | None = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + name: Optional[str] = None, ) -> Type[str]: """ Annotates an argument as a string type slash command option. @@ -46,10 +47,11 @@ def slash_str_option( choices: The choices allowed by this command min_length: The minimum length of text a user can input. max_length: The maximum length of text a user can input. + name: The name of the option. Defaults to the name of the argument """ return SlashCommandOption( - name="placeholder", + name=name, description=description, required=required, autocomplete=autocomplete, @@ -67,6 +69,7 @@ def slash_float_option( choices: List[Union["SlashCommandChoice", dict]] | None = None, min_value: Optional[float] = None, max_value: Optional[float] = None, + name: Optional[str] = None, ) -> Type[float]: """ Annotates an argument as a float type slash command option. @@ -78,10 +81,11 @@ def slash_float_option( choices: The choices allowed by this command min_value: The minimum number allowed max_value: The maximum number allowed + name: The name of the option. Defaults to the name of the argument """ return SlashCommandOption( - name="placeholder", + name=name, description=description, required=required, autocomplete=autocomplete, @@ -99,6 +103,7 @@ def slash_int_option( choices: List[Union["SlashCommandChoice", dict]] | None = None, min_value: Optional[float] = None, max_value: Optional[float] = None, + name: Optional[str] = None, ) -> Type[int]: """ Annotates an argument as a integer type slash command option. @@ -110,10 +115,11 @@ def slash_int_option( choices: The choices allowed by this command min_value: The minimum number allowed max_value: The maximum number allowed + name: The name of the option. Defaults to the name of the argument """ return SlashCommandOption( - name="placeholder", + name=name, description=description, required=required, autocomplete=autocomplete, @@ -127,6 +133,7 @@ def slash_int_option( def slash_bool_option( description: str, required: bool = False, + name: Optional[str] = None, ) -> Type[bool]: """ Annotates an argument as a boolean type slash command option. @@ -134,10 +141,11 @@ def slash_bool_option( Args: description: The description of your option required: Is this option required? + name: The name of the option. Defaults to the name of the argument """ return SlashCommandOption( - name="placeholder", + name=name, description=description, required=required, type=models.OptionType.BOOLEAN, @@ -148,6 +156,7 @@ def slash_user_option( description: str, required: bool = False, autocomplete: bool = False, + name: Optional[str] = None, ) -> Type[Union["User", "Member"]]: """ Annotates an argument as a user type slash command option. @@ -156,10 +165,11 @@ def slash_user_option( description: The description of your option required: Is this option required? autocomplete: Use autocomplete for this option + name: The name of the option. Defaults to the name of the argument """ return SlashCommandOption( - name="placeholder", + name=name, description=description, required=required, autocomplete=autocomplete, @@ -173,6 +183,7 @@ def slash_channel_option( autocomplete: bool = False, choices: List[Union["SlashCommandChoice", dict]] | None = None, channel_types: Optional[list[Union["ChannelType", int]]] = None, + name: Optional[str] = None, ) -> Type["BaseChannel"]: """ Annotates an argument as a channel type slash command option. @@ -183,10 +194,11 @@ def slash_channel_option( autocomplete: Use autocomplete for this option choices: The choices allowed by this command channel_types: The types of channel allowed by this option + name: The name of the option. Defaults to the name of the argument """ return SlashCommandOption( - name="placeholder", + name=name, description=description, required=required, autocomplete=autocomplete, @@ -201,6 +213,7 @@ def slash_role_option( required: bool = False, autocomplete: bool = False, choices: List[Union["SlashCommandChoice", dict]] | None = None, + name: Optional[str] = None, ) -> Type["Role"]: """ Annotates an argument as a role type slash command option. @@ -210,10 +223,11 @@ def slash_role_option( required: Is this option required? autocomplete: Use autocomplete for this option choices: The choices allowed by this command + name: The name of the option. Defaults to the name of the argument """ return SlashCommandOption( - name="placeholder", + name=name, description=description, required=required, autocomplete=autocomplete, @@ -227,6 +241,7 @@ def slash_mentionable_option( required: bool = False, autocomplete: bool = False, choices: List[Union["SlashCommandChoice", dict]] | None = None, + name: Optional[str] = None, ) -> Type[Union["Role", "BaseChannel", "User", "Member"]]: """ Annotates an argument as a mentionable type slash command option. @@ -236,10 +251,11 @@ def slash_mentionable_option( required: Is this option required? autocomplete: Use autocomplete for this option choices: The choices allowed by this command + name: The name of the option. Defaults to the name of the argument """ return SlashCommandOption( - name="placeholder", + name=name, description=description, required=required, autocomplete=autocomplete, @@ -251,6 +267,7 @@ def slash_mentionable_option( def slash_attachment_option( description: str, required: bool = False, + name: Optional[str] = None, ) -> Type["Attachment"]: """ Annotates an argument as an attachment type slash command option. @@ -258,10 +275,11 @@ def slash_attachment_option( Args: description: The description of your option required: Is this option required? + name: The name of the option. Defaults to the name of the argument """ return SlashCommandOption( - name="placeholder", + name=name, description=description, required=required, type=models.OptionType.ATTACHMENT, diff --git a/interactions/models/internal/application_commands.py b/interactions/models/internal/application_commands.py index 763bbefff..3e9c5f4f0 100644 --- a/interactions/models/internal/application_commands.py +++ b/interactions/models/internal/application_commands.py @@ -400,6 +400,7 @@ class SlashCommandOption(DictSerializationMixin): max_value: The maximum value permitted. The option needs to be an integer or float min_length: The minimum length of text a user can input. The option needs to be a string max_length: The maximum length of text a user can input. The option needs to be a string + argument_name: The name of the argument to be used in the function. If not given, assumed to be the same as the name of the option """ @@ -418,6 +419,7 @@ class SlashCommandOption(DictSerializationMixin): max_value: Optional[float] = attrs.field(repr=False, default=None) min_length: Optional[int] = attrs.field(repr=False, default=None) max_length: Optional[int] = attrs.field(repr=False, default=None) + argument_name: Optional[str] = attrs.field(repr=False, default=None) @type.validator def _type_validator(self, attribute: str, value: int) -> None: @@ -488,6 +490,7 @@ def _max_length_validator(self, attribute: str, value: Optional[int]) -> None: def as_dict(self) -> dict: data = attrs.asdict(self) + data.pop("argument_name", None) data["name"] = str(self.name) data["description"] = str(self.description) data["choices"] = [ @@ -506,6 +509,11 @@ class SlashCommandParameter: kind: inspect._ParameterKind = attrs.field() default: typing.Any = attrs.field(default=MISSING) converter: typing.Optional[typing.Callable] = attrs.field(default=None) + _option_name: typing.Optional[str] = attrs.field(default=None) + + @property + def option_name(self) -> str: + return self._option_name or self.name def _get_option_from_annotated(annotated: Annotated) -> SlashCommandOption | None: @@ -601,10 +609,14 @@ def _add_option_from_anno_method(self, name: str, option: SlashCommandOption) -> if not self.options: self.options = [] - option.name = name + if option.name is None: + option.name = name + else: + option.argument_name = name + self.options.append(option) - def _parse_parameters(self) -> None: + def _parse_parameters(self) -> None: # noqa: C901 """ Parses the parameters that this command has into a form i.py can use. @@ -665,6 +677,20 @@ def _parse_parameters(self) -> None: self.parameters[param.name] = our_param + if self.options: + for option in self.options: + maybe_argument_name = ( + option.argument_name if isinstance(option, SlashCommandOption) else option.get("argument_name") + ) + if maybe_argument_name: + name = option.name if isinstance(option, SlashCommandOption) else option["name"] + try: + self.parameters[maybe_argument_name]._option_name = str(name) + except KeyError: + raise ValueError( + f'Argument name "{maybe_argument_name}" for "{name}" does not match any parameter in {self.resolved_name}\'s function.' + ) from None + def to_dict(self) -> dict: data = super().to_dict() @@ -780,8 +806,8 @@ async def call_callback(self, callback: typing.Callable, ctx: "InteractionContex new_args = [] new_kwargs = {} - for name, param in self.parameters.items(): - value = kwargs_copy.pop(name, MISSING) + for param in self.parameters.values(): + value = kwargs_copy.pop(param.option_name, MISSING) if value is MISSING: continue @@ -791,7 +817,7 @@ async def call_callback(self, callback: typing.Callable, ctx: "InteractionContex if param.kind == inspect.Parameter.POSITIONAL_ONLY: new_args.append(value) else: - new_kwargs[name] = value + new_kwargs[param.name] = value # i do want to address one thing: what happens if you have both *args and **kwargs # in your argument? @@ -1196,6 +1222,7 @@ def slash_option( max_value: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + argument_name: Optional[str] = None, ) -> Callable[[SlashCommandT], SlashCommandT]: r""" A decorator to add an option to a slash command. @@ -1212,6 +1239,7 @@ def slash_option( max_value: The maximum value permitted. The option needs to be an integer or float min_length: The minimum length of text a user can input. The option needs to be a string max_length: The maximum length of text a user can input. The option needs to be a string + argument_name: The name of the argument to be used in the function. If not given, assumed to be the same as the name of the option """ def wrapper(func: SlashCommandT) -> SlashCommandT: @@ -1230,6 +1258,7 @@ def wrapper(func: SlashCommandT) -> SlashCommandT: max_value=max_value, min_length=min_length, max_length=max_length, + argument_name=argument_name, ) if not hasattr(func, "options"): func.options = [] From b33ab89c8c79f4447c4a5d5a89f06912e2c92cae Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Fri, 21 Jul 2023 00:04:10 -0400 Subject: [PATCH 20/33] fix: make sure exported auto_defer is deco, not module (#1496) --- interactions/models/internal/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/models/internal/__init__.py b/interactions/models/internal/__init__.py index ef6e311cb..258a1c255 100644 --- a/interactions/models/internal/__init__.py +++ b/interactions/models/internal/__init__.py @@ -11,6 +11,7 @@ ) from .callback import CallbackObject from .active_voice_state import ActiveVoiceState +from .auto_defer import AutoDefer # purposely out of order to make sure auto_defer comes out as the deco from .application_commands import ( application_commands_to_dict, auto_defer, @@ -41,7 +42,6 @@ subcommand, sync_needed, ) -from .auto_defer import AutoDefer from .checks import dm_only, guild_only, has_any_role, has_id, has_role, is_owner from .command import BaseCommand, check, cooldown, max_concurrency from .context import ( From 364aa691a745513e37d53261008631dc2d09ed02 Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Fri, 21 Jul 2023 23:46:48 -0400 Subject: [PATCH 21/33] fix/feat: adjust invite obj to respect event variants (#1500) * fix/feat: adjust invite obj to respect event variants * docs: update docs for event object * revert: keep MISSING for backwards compatibility * style: consistency Co-authored-by: Sophia <41456914+i0bs@users.noreply.github.com> Signed-off-by: Astrea <25420078+AstreaTSS@users.noreply.github.com> --------- Signed-off-by: Astrea <25420078+AstreaTSS@users.noreply.github.com> Co-authored-by: Sophia <41456914+i0bs@users.noreply.github.com> --- interactions/models/discord/invite.py | 49 +++++++++++++++++---------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/interactions/models/discord/invite.py b/interactions/models/discord/invite.py index b829fc152..b7a45fe94 100644 --- a/interactions/models/discord/invite.py +++ b/interactions/models/discord/invite.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from interactions.client import Client - from interactions.models import TYPE_GUILD_CHANNEL + from interactions.models import TYPE_GUILD_CHANNEL, Guild from interactions.models.discord.user import User from interactions.models.discord.snowflake import Snowflake_Type @@ -25,54 +25,60 @@ @attrs.define(eq=False, order=False, hash=False, kw_only=True) class Invite(ClientObject): code: str = attrs.field(repr=True) - """the invite code (unique ID)""" + """The invite code (unique ID)""" # metadata uses: int = attrs.field(default=0, repr=True) - """the guild this invite is for""" + """How many times this invite has been used""" max_uses: int = attrs.field(repr=False, default=0) - """max number of times this invite can be used""" + """Max number of times this invite can be used""" max_age: int = attrs.field(repr=False, default=0) - """duration (in seconds) after which the invite expires""" + """Duration (in seconds) after which the invite expires""" created_at: Timestamp = attrs.field(default=MISSING, converter=optional_c(timestamp_converter), repr=True) - """when this invite was created""" + """When this invite was created""" temporary: bool = attrs.field(default=False, repr=True) - """whether this invite only grants temporary membership""" + """Whether this invite only grants temporary membership""" # target data target_type: Optional[Union[InviteTargetType, int]] = attrs.field( default=None, converter=optional_c(InviteTargetType), repr=True ) - """the type of target for this voice channel invite""" + """The type of target for this voice channel invite""" approximate_presence_count: Optional[int] = attrs.field(repr=False, default=MISSING) - """approximate count of online members, returned from the `GET /invites/` endpoint when `with_counts` is `True`""" + """Approximate count of online members, returned when fetching invites with `with_counts` set as `True`""" approximate_member_count: Optional[int] = attrs.field(repr=False, default=MISSING) - """approximate count of total members, returned from the `GET /invites/` endpoint when `with_counts` is `True`""" + """Approximate count of total members, returned when fetching invites with `with_counts` set as `True`""" scheduled_event: Optional["Snowflake_Type"] = attrs.field( default=None, converter=optional_c(to_snowflake), repr=True ) - """guild scheduled event data, only included if `guild_scheduled_event_id` contains a valid guild scheduled event id""" + """Guild scheduled event data, only included if `guild_scheduled_event_id` contains a valid guild scheduled event id""" expires_at: Optional[Timestamp] = attrs.field(default=None, converter=optional_c(timestamp_converter), repr=True) - """the expiration date of this invite, returned from the `GET /invites/` endpoint when `with_expiration` is `True`""" + """The expiration date of this invite, returned when fetching invites with `with_expiration` set as `True`""" stage_instance: Optional[StageInstance] = attrs.field(repr=False, default=None) - """stage instance data if there is a public Stage instance in the Stage channel this invite is for (deprecated)""" + """Stage instance data if there is a public Stage instance in the Stage channel this invite is for (deprecated)""" target_application: Optional[dict] = attrs.field(repr=False, default=None) - """the embedded application to open for this voice channel embedded application invite""" + """The embedded application to open for this voice channel embedded application invite""" guild_preview: Optional[GuildPreview] = attrs.field(repr=False, default=MISSING) - """the guild this invite is for""" + """The guild this invite is for - not given in invite events""" # internal for props _channel_id: "Snowflake_Type" = attrs.field(converter=to_snowflake, repr=True) + _guild_id: Optional["Snowflake_Type"] = attrs.field(default=None, converter=optional_c(to_snowflake), repr=True) _inviter_id: Optional["Snowflake_Type"] = attrs.field(default=None, converter=optional_c(to_snowflake), repr=True) _target_user_id: Optional["Snowflake_Type"] = attrs.field( repr=False, default=None, converter=optional_c(to_snowflake) ) @property - def channel(self) -> "TYPE_GUILD_CHANNEL": - """The channel the invite is for.""" + def channel(self) -> Optional["TYPE_GUILD_CHANNEL"]: + """The cached channel the invite is for.""" return self._client.cache.get_channel(self._channel_id) + @property + def guild(self) -> Optional["Guild"]: + """The cached guild the invite is.""" + return self._client.cache.get_guild(self._guild_id) if self._guild_id else None + @property def inviter(self) -> Optional["User"]: """The user that created the invite or None.""" @@ -95,16 +101,23 @@ def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any] data["scheduled_event"] = data["target_event_id"] if channel := data.pop("channel", None): - # invite metadata does not contain enough info to create a channel object + client.cache.place_channel_data(channel) data["channel_id"] = channel["id"] if guild := data.pop("guild", None): data["guild_preview"] = GuildPreview.from_dict(guild, client) + data["guild_id"] = guild["id"] + elif guild_id := data.pop("guild_id", None): + data["guild_id"] = guild_id if inviter := data.pop("inviter", None): inviter = client.cache.place_user_data(inviter) data["inviter_id"] = inviter.id + if target_user := data.pop("target_user", None): + target_user = client.cache.place_user_data(target_user) + data["target_user_id"] = target_user.id + return data def __str__(self) -> str: From d1caaa75a3a5af9f95a6560c1bf14a725865b79e Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Sun, 23 Jul 2023 03:06:49 -0400 Subject: [PATCH 22/33] fix: address issues with tag usage for guild forums (#1499) * fix: address issues with tags for guild forums * fix: is this breaking? better be safe than sorry --- interactions/models/discord/channel.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/interactions/models/discord/channel.py b/interactions/models/discord/channel.py index 2638d4a6b..3cdaa4996 100644 --- a/interactions/models/discord/channel.py +++ b/interactions/models/discord/channel.py @@ -2420,7 +2420,7 @@ async def create_post( self, name: str, content: str | None, - applied_tags: Optional[List[Union["Snowflake_Type", "ThreadTag", str]]] = MISSING, + applied_tags: Absent[List[Union["Snowflake_Type", "ThreadTag", str]]] = MISSING, *, auto_archive_duration: AutoArchiveDuration = AutoArchiveDuration.ONE_DAY, rate_limit_per_user: Absent[int] = MISSING, @@ -2463,7 +2463,7 @@ async def create_post( Returns: A GuildForumPost object representing the created post. """ - if applied_tags != MISSING: + if applied_tags is not MISSING: processed = [] for tag in applied_tags: if isinstance(tag, ThreadTag): @@ -2568,12 +2568,13 @@ def get_tag(self, value: str | Snowflake_Type, *, case_insensitive: bool = False Returns: A ThreadTag object representing the tag. """ + value = str(value) def maybe_insensitive(string: str) -> str: return string.lower() if case_insensitive else string def predicate(tag: ThreadTag) -> Optional["ThreadTag"]: - if str(tag.id) == str(value): + if str(tag.id) == value: return tag if maybe_insensitive(tag.name) == maybe_insensitive(value): return tag From 159e0207d37f542ccf63e5e76036ce00006a1ad5 Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Sun, 23 Jul 2023 03:08:12 -0400 Subject: [PATCH 23/33] =?UTF-8?q?docs=F0=9F=92=A5:=20revamp=20extension=20?= =?UTF-8?q?guide=20(#1494)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs💥: revamp extension guide * docs: update extension example * docs: oops, forgot a word Co-authored-by: Max Signed-off-by: Astrea <25420078+AstreaTSS@users.noreply.github.com> * docs: add section for loading all extensions in folder * docs: turns out ipy can load multi exts in one file * docs: lots of wording adjustments --------- Signed-off-by: Astrea <25420078+AstreaTSS@users.noreply.github.com> Co-authored-by: Max Co-authored-by: LordOfPolls --- docs/src/Guides/20 Extensions.md | 583 ++++++++++++++---- .../src/Guides/21 Advanced Extension Usage.md | 85 --- docs/src/Guides/index.md | 6 - 3 files changed, 458 insertions(+), 216 deletions(-) delete mode 100644 docs/src/Guides/21 Advanced Extension Usage.md diff --git a/docs/src/Guides/20 Extensions.md b/docs/src/Guides/20 Extensions.md index 0bc725241..cbe2a175e 100644 --- a/docs/src/Guides/20 Extensions.md +++ b/docs/src/Guides/20 Extensions.md @@ -1,248 +1,581 @@ # Extensions -Damn, your code is getting pretty messy now, huh? Wouldn't it be nice if you could organise your commands and listeners into separate files? +## Introduction -Well let me introduce you to `Extensions`
-Extensions allow you to split your commands and listeners into separate files to allow you to better organise your project, -as well as that, they allow you to reload Extensions without having to shut down your bot. +Your code's getting pretty big and messy being in a single file, huh? Wouldn't it be nice if you could organise your commands and listeners into separate files? -Sounds pretty good right? Well, let's go over how you can use them: - -## Usage +Well let me introduce you to `Extensions`!
+Extensions allow you to split your commands and listeners into separate files to allow you to better organise your project. +They also come with the additional benefit of being able to reload parts of your bot without shutting down your bot. -Below is an example of a bot, one with extensions, one without. +For example, you can see the difference of a bot with and without extensions: -??? Hint "Example Usage:" +??? Hint "Examples:" === "Without Extensions" ```python - # File: `main.py` - import logging + from interactions import ActionRow, Button, ButtonStyle, Client, listen, slash_command + from interactions.api.events import Component, GuildJoin, MessageCreate, Startup - import interactions.const - from interactions.client import Client - from interactions.models.discord_objects.components import Button, ActionRow - from interactions.models.enums import ButtonStyle - from interactions.models.enums import Intents - from interactions.models.events import Component - from interactions.models.listener import listen - from interactions.ext import prefixed_commands - from interactions.ext.prefixed_commands import prefixed_command + bot = Client() - logging.basicConfig() - cls_log = logging.getLogger(interactions.const.logger_name) - cls_log.setLevel(logging.DEBUG) - bot = Client(intents=Intents.DEFAULT, sync_interactions=True, asyncio_debug=True) - prefixed_commands.setup(bot) - - - @listen() - async def on_ready(): - print("Ready") - print(f"This bot is owned by {bot.owner}") + @listen(Startup) + async def on_startup(): + print(f"Ready - this bot is owned by {bot.owner}") - @listen() - async def on_guild_create(event): - print(f"guild created : {event.guild.name}") + @listen(GuildJoin) + async def on_guild_join(event: GuildJoin): + print(f"Guild joined : {event.guild.name}") - @listen() - async def on_message_create(event): - print(f"message received: {event.message.content}") + @listen(MessageCreate) + async def on_message_create(event: MessageCreate): + print(f"message received: {event.message}") @listen() async def on_component(event: Component): ctx = event.ctx - await ctx.edit_origin("test") + await ctx.edit_origin(content="test") - @prefixed_command() + @slash_command() async def multiple_buttons(ctx): await ctx.send( "2 buttons in a row", - components=[Button(ButtonStyle.BLURPLE, "A blurple button"), Button(ButtonStyle.RED, "A red button")], + components=[ + Button(style=ButtonStyle.BLURPLE, label="A blurple button"), + Button(style=ButtonStyle.RED, label="A red button"), + ], ) - @prefixed_command() + @slash_command() async def action_rows(ctx): await ctx.send( "2 buttons in 2 rows, using nested lists", - components=[[Button(ButtonStyle.BLURPLE, "A blurple button")], [Button(ButtonStyle.RED, "A red button")]], + components=[ + [Button(style=ButtonStyle.BLURPLE, label="A blurple button")], + [Button(style=ButtonStyle.RED, label="A red button")], + ], ) - @prefixed_command() + @slash_command() async def action_rows_more(ctx): await ctx.send( "2 buttons in 2 rows, using explicit action_rows lists", components=[ - ActionRow(Button(ButtonStyle.BLURPLE, "A blurple button")), - ActionRow(Button(ButtonStyle.RED, "A red button")), + ActionRow(Button(style=ButtonStyle.BLURPLE, label="A blurple button")), + ActionRow(Button(style=ButtonStyle.RED, label="A red button")), ], ) - bot.start("Token") + bot.start("token") ``` === "With Extensions" ```python # File: `main.py` - import logging + from interactions import Client, listen + from interactions.api.events import Component, GuildJoin, MessageCreate, Startup - import interactions.const - from interactions.client import Client - from interactions.models.context import ComponentContext - from interactions.models.enums import Intents - from interactions.models.events import Component - from interactions.models.listener import listen - from interactions.ext import prefixed_commands + bot = Client() - logging.basicConfig() - cls_log = logging.getLogger(interactions.const.logger_name) - cls_log.setLevel(logging.DEBUG) + @listen(Startup) + async def on_startup(): + print(f"Ready - this bot is owned by {bot.owner}") - bot = Client(intents=Intents.DEFAULT, sync_interactions=True, asyncio_debug=True) - prefixed_commands.setup(bot) - - @listen() - async def on_ready(): - print("Ready") - print(f"This bot is owned by {bot.owner}") - - - @listen() - async def on_guild_create(event): - print(f"guild created : {event.guild.name}") + @listen(GuildJoin) + async def on_guild_join(event: GuildJoin): + print(f"Guild joined : {event.guild.name}") - @listen() - async def on_message_create(event): - print(f"message received: {event.message.content}") + @listen(MessageCreate) + async def on_message_create(event: MessageCreate): + print(f"message received: {event.message}") @listen() async def on_component(event: Component): ctx = event.ctx - await ctx.edit_origin("test") + await ctx.edit_origin(content="test") bot.load_extension("test_components") - bot.start("Token") - + bot.start("token") ``` ```python # File: `test_components.py` - from interactions.models.command import prefixed_command - from interactions.models.discord_objects.components import Button, ActionRow - from interactions.models.enums import ButtonStyle - from interactions.models.extension import Extension - from interactions.ext.prefixed_commands import prefixed_command + from interactions import ActionRow, Button, ButtonStyle, Extension, slash_command class ButtonExampleSkin(Extension): - @prefixed_command() - async def blurple_button(self, ctx): - await ctx.send("hello there", components=Button(ButtonStyle.BLURPLE, "A blurple button")) - - @prefixed_command() + @slash_command() async def multiple_buttons(self, ctx): await ctx.send( "2 buttons in a row", - components=[Button(ButtonStyle.BLURPLE, "A blurple button"), Button(ButtonStyle.RED, "A red button")], + components=[ + Button(style=ButtonStyle.BLURPLE, label="A blurple button"), + Button(style=ButtonStyle.RED, label="A red button"), + ], ) - @prefixed_command() + + @slash_command() async def action_rows(self, ctx): await ctx.send( "2 buttons in 2 rows, using nested lists", - components=[[Button(ButtonStyle.BLURPLE, "A blurple button")], [Button(ButtonStyle.RED, "A red button")]], + components=[ + [Button(style=ButtonStyle.BLURPLE, label="A blurple button")], + [Button(style=ButtonStyle.RED, label="A red button")], + ], ) - @prefixed_command() + + @slash_command() async def action_rows_more(self, ctx): await ctx.send( "2 buttons in 2 rows, using explicit action_rows lists", components=[ - ActionRow(Button(ButtonStyle.BLURPLE, "A blurple button")), - ActionRow(Button(ButtonStyle.RED, "A red button")), + ActionRow(Button(style=ButtonStyle.BLURPLE, label="A blurple button")), + ActionRow(Button(style=ButtonStyle.RED, label="A red button")), ], ) + ``` +Sounds pretty good right? Well, let's go over how you can use them: - def setup(bot): - ButtonExampleSkin(bot) - ``` +## Basic Usage -Extensions are effectively just another python file that contains a class that inherits from an object called `Extension`, -inside this extension, you can put whatever you would like. And upon loading, the contents are added to the bot. +### Setup + +Extensions are effectively just another Python file that contains a class that inherits from an object called `Extension`, +inside this extension. + +For example, this is a valid extension file: ```python from interactions import Extension +class MyExtension(Extension): + pass +``` -class SomeClass(Extension): - ... +??? note "Differences from Other Python Discord Libraries" + If you come from another Python Discord library, you might have seen that there's no `__init__` and `setup` function in this example. + They still do exist as functions you *can* use (as discussed later), but interactions.py will do the appropriate logic to handle extensions + without either of the two. + For example, the following does the exact same thing as the above extension file: -def setup(bot): - # This is called by interactions.py so it knows how to load the Extension - SomeClass(bot) + ```python + from interactions import Extension + + class MyExtension(Extension): + def __init__(self, bot): + self.bot = bot + + def setup(bot): + # yes, the bot does not need to do any special logic - you just need to pass it into the extension + MyExtension(bot) + ``` + +### Events and Commands + +You probably want extensions to do a little bit more than just exist though. Most likely, you want some events and commands +in here. Thankfully, they're relatively simple to do. Expanding on the example a bit, a slash command looks like this: + +```python +from interactions import Extension, slash_command, SlashContext + +class MyExtension(Extension): + @slash_command() + async def test(self, ctx: SlashContext): + await ctx.send("Hello world!") ``` -As you can see, there's one extra bit, a function called `setup`, this function acts as an entry point for interactions.py, -so it knows how to load the extension properly. -To load a extension, you simply add the following to your `main` script, just above `bot.start`: +As you can see, they're almost identical to how you declare slash commands in your main bot file, even using the same decorator. +The only difference is the `self` variable - this is the instance of the extension that the command is being called in, and is +standard for functions inside of classes. Events follow a similar principal. + +interactions.py will automatically add all commands and events to the bot when you load the extension (discussed later), +so you don't need to worry about that. + +#### Accessing the Bot + +When an extension is loaded, the library automatically sets the `bot` for you. With this in mind, you can access your client using `self.bot`. +Using `self.client` also works - they are just aliases to each other. ```python -... +class MyExtension(Extension): + @slash_command() + async def test(self, ctx: SlashContext): + await ctx.send(f"Hello, I'm {self.bot.user.mention}!") +``` -bot.load_extension("Filename_here") +This also allows you to share data between extensions and the main bot itself. `Client` allows storing data in unused attributes, +so you can do something like this: +```python +from interactions import Client + +# main.py +bot = Client(...) +bot.my_data = "Hello world" + +# extension.py +class MyExtension(Extension): + @slash_command() + async def test(self, ctx: SlashContext): + await ctx.send(f"My data: {self.bot.my_data}!") +``` + +### Loading the Extension + +Now that you've got your extension, you need to load it. + +Let's pretend the extension is in a file called `extension.py`, and it looks like the command example: + +```python +from interactions import Extension, slash_command, SlashContext + +class MyExtension(Extension): + @slash_command() + async def test(self, ctx: SlashContext): + await ctx.send("Hello world!") +``` + +Now, let's say you have a file called `main.py` in the same directory that actually has the bot in it: + +```python +bot = Client(...) bot.start("token") ``` -Now, for the cool bit of Extensions, reloading. Extensions allow you to edit your code, and reload it, without restarting the bot. -To do this, simply run `bot.reload_extension("Filename_here")` and your new code will be used. Bare in mind any tasks your extension -is doing will be abruptly stopped. +To load the extension, you just need to use `bot.load_extension("filename.in_import_style")` before `bot.start`. So, in this case, it would look like this: + +```python +bot = Client(...) +bot.load_extension("extension") +bot.start("token") +``` + +And that's it! Your extension is now loaded and ready to go. + +#### "Import Style" + +In the example above, the filename is passed to `load_extension` without the `.py` extension. This is because interactions.py actually does an +*import* when loading the extension, so whatever string you give it needs to be a valid Python import path. This means that if you have a file structure like this: + +``` +main.py +exts/ + extension.py +``` + +You would need to pass `exts.extension` to `load_extension`, as that's the import path to the extension file. +#### Reloading and Unloading Extensions -You can pass keyword-arguments to the `load_extension`, `unload_extension` and `reload_extension` extension management methods. -Any arguments you pass to the `setup` or `teardown` methods, will also be passed to the `Extension.drop` method. +You can also reload and unload extensions. To do this, you use `bot.reload_extension` and `bot.unload_extension` respectively. + +```python +bot.reload_extension("extension") +bot.unload_extension("extension") +``` -Here is a basic "Extension switching" example: +Reloading and unloading extensions allows you to edit your code without restarting the bot, and to remove extensions you no longer need. +For example, if you organize your extensions so that moderation commands are in one extension, you can reload that extension (and so only moderation-related commands) +as you edit them. + +### Initialization + +You may want to do some logic to do when loading a specific extension. For that, you can add the `__init__` method, which takes a `Client` instance, in your extension: ```python -from interactions import Extension +class MyExtension(Extension): + def __init__(self, bot): + # do some initialization here + pass +``` + +#### Asynchronous Initialization + +As usual, `__init__` is synchronous. This may pose problems if you're trying to do something asynchronous in it, so there are various ways of solving it. + +If you're okay with only doing the asynchronous logic as the bot is starting up (and never again), there are two methods: + +=== "`async_start`" + ```python + class MyExtension(Extension): + async def async_start(self): + # do some initialization here + pass + ``` + +=== "`Startup` Event" + ```python + from interactions.api.events import Startup + + class MyExtension(Extension): + @event(Startup) + async def startup(self): + # do some initialization here + pass + ``` + +If you want to do the asynchronous logic every time the extension is loaded, you'll need to use `asyncio.create_task`: + +```python +import asyncio + +class MyExtension(Extension): + def __init__(self, bot): + asyncio.create_task(self.async_init()) + + async def async_init(self): + # do some initialization here + pass +``` +!!! warning "Warning about `asyncio.create_task`" + `asyncio.create_task` only works *if there is an event loop.* For the sake of simplicity we won't discuss what that is too much, + but the loop is only created when `asyncio.run()` is called (as it is in `bot.start()`). This means that if you call `asyncio.create_task` + before `bot.start()`, it will not work. If you need to do asynchronous logic before the bot starts, you'll need to load the extension + in an asynchronous function and use `await bot.astart()` instead of `bot.start()`. -class SomeExtension(Extension): + For example, this format of loading extensions will allow you to use `asyncio.create_task`: + + ```python + bot = Client(...) + + async def main(): + # event loop made! + bot.load_extension("extension") + await bot.astart("token") + + asyncio.run(main()) + ``` + +### Cleanup + +You may have some logic to do while unloading a specific extension. For that, you can override the `drop` method in your extension: + +```python +class MyExtension(Extension): + def drop(self): + # do some cleanup here + super().drop() # important - this part actually does the unloading +``` + +The `drop` method is synchronous. If you need to do something asynchronous, you can create a task with `asyncio` to do it: + +???+ note "Note about `asyncio.create_task`" + Usually, there's always an event loop running when unloading an extension (even when the bot is shutting down), so you can use `asyncio.create_task` without any problems. + However, if you are unloading an extension before `asyncio.run()` has called, the warning from above applies. + +```python +import asyncio + +class MyExtension(Extension): + def drop(self): + asyncio.create_task(self.async_drop()) + super().drop() + + async def async_drop(self): + # do some cleanup here + pass +``` + +## Advanced Usage + +### Loading All Extensions In a Folder + +Sometimes, you may have a lot of extensions contained in one folder. Writing them all out is both time consuming and not very scalable, so you may want an easier way to load them. + +If your folder with all of your extensions is "flat" (only containing Python files for extensions and no subfolders), then your best bet is to use [`pkgutil.iter_modules`](https://docs.python.org/3/library/pkgutil.html#pkgutil.iter_modules) and a for loop: +```python +import pkgutil + +# replace "exts" with your folder name +extension_names = [m.name for m in pkgutil.iter_modules(["exts"], prefix="exts.")] +for extension in extension_names: + bot.load_extension(extension) +``` + +`iter_modules` finds all modules (which include Python extension files) in the directories provided. By default, this *just* returns the module/import name without the folder name, so we need to add the folder name back in through the `prefix` argument. +Note how the folder passed and the prefix are basically the same thing - the prefix just has a period at the the end. + +If your folder with all of your extensions is *not* flat (for example, if you have subfolders in the extension folder containing Python files for extensions), you'll likely want to use [`glob.glob`](https://docs.python.org/3/library/glob.html#glob.glob) instead: +```python +import glob + +# replace "exts" with your folder name +ext_filenames = glob.glob("exts/**/*.py") +extension_names = [filename.removesuffix(".py").replace("/", ".") for filename in ext_filenames] +for extension in extension_names: + bot.load_extension(extension) +``` + +Note that `glob.glob` returns the *filenames* of all files that match the pattern we provided. To turn it into a module/import name, we need to remove the ".py" suffix and replace the slashes with periods. +On Windows, you may need to replace the slashes with backslashes instead. + +???+ note "Note About Loading Extensions From a File" + While these are two possible ways, they are by no means the *only* ways of finding all extensions in the folder and loading them. Which method is best method depends on your use case and is purely subjective. + + +### The `setup`/`teardown` Function + +You may have noticed that the `Extension` in the extension file is simply just a class, with no way of loading it. interactions.py is smart enough to detect `Extension` subclasses +and use them when loading from a file, but if you want more customization when loading an extension, you'll need to use the `setup` function. + +The `setup` function should be *outside* of any `Extension` subclass, and takes in the bot instance, like so: + +```python +class MyExtension(Extension): + ... + +def setup(bot): + # insert logic here + MyExtension(bot) +``` + +Here, the `Extension` subclass is initialized inside the `setup` function, and does not need to do any special function to add the extension in beyond being created using the instance. + +A similar function can be used for cleanup, called `teardown`. It takes no arguments, and should be outside of any `Extension` subclass, like so: + +```python +class MyExtension(Extension): + ... + +def teardown(): + # insert logic here + pass +``` + +You usually do not need to worry about unloading the specific extensions themselves, as interactions.py will do that for you. + +### Passing Arguments to Extensions + +If you would like to pass more than just the bot while initializing an extension, you can pass keyword arguments to the `load_extension` method: + +```python +class MyExtension(Extension): def __init__(self, bot, some_arg: int = 0): ... +bot.load_extension("extension", some_arg=5) +``` + +If you're using a `setup` function, the argument will be passed to that function instead, so you'll need to pass it to the `Extension` subclass yourself: + +```python +class MyExtension(Extension): + ... + +def setup(bot, some_arg: int = 0): + MyExtension(bot, some_arg) +``` + +## Extension-Wide Checks + +Sometimes, it is useful to have a check run before running any command in an extension. Thankfully, all you need to do is use `add_ext_check`: + +```python +class MyExtension(Extension): + def __init__(self, bot: Client): + self.add_ext_check(self.a_check) + + async def a_check(ctx: SlashContext) -> bool: + return bool(ctx.author.name.startswith("a")) + + @slash_command(...) + async def my_command(...): + # only ran with people whose names start with an a + ... +``` + +### Global Checks + +You may want to have a check that runs on every command in a bot. If all of your commands are in extensions (a good idea), you can use +a custom subclass of `Extension` to do it: + +```python +# file 1 +class CustomExtension(Extension): + def __init__(self, client: Client): + self.client = client + self.add_ext_check(self.a_check) + + async def a_check(ctx: InteractionContext) -> bool: + return bool(ctx.author.name.startswith("a")) + +# file 2 +class MyExtension(CustomExtension): + @slash_command(...) + async def my_command(...): + ... +``` + +### Pre And Post Run Events + +Pre- and post-run events are similar to checks. They run before and after a command is invoked, respectively: + +```python +from interactions import BaseContext + +class MyExtension(Extension): + def __init__(self, bot: Client): + self.add_extension_prerun(self.pre_run) + self.add_extension_postrun(self.post_run) -class AnotherExtension(Extension): - def __init__(self, bot, another_arg: float = 0.0): + async def pre_run(ctx: BaseContext): + print(f"Command started at: {datetime.datetime.now()}") + + async def post_run(ctx: BaseContext): + print(f"Command done at: {datetime.datetime.now()}") + + @slash_command(...) + async def my_command(...): + # pre and post run will be ran before/after this command ... +``` +### Extension-Wide Error Handlers -def setup(bot, default_extension: bool, **kwargs): # We don't care about other arguments here. - if default_extension: - SomeExtension(bot, **kwargs) - else: - AnotherExtension(bot, **kwargs) +Sometimes, you may want to have a custom error handler for all commands in an extension. You can do this by using `set_extension_error`: +```python +class MyExtension(Extension): + def __init__(self, bot: Client): + self.set_extension_error(self.error_handler) -... + async def error_handler(self, error: Exception, ctx: BaseContext): + # handle the error here + ... +``` -bot.load_extension("Filename_here", default_extension=False, another_arg=3.14) -# OR -bot.load_extension("Filename_here", default_extension=True, some_arg=555) +??? note "Error Handling Priority" + Only one error handler will run. Similar to CSS, the most specific handler takes precedence. + This goes: command error handlers -> extension -> listeners. + +### Extension Auto Defer + +You may want to automatically defer all commands in that extension. You can do this by using `add_ext_auto_defer`: + +```python +class MyExtension(Extension): + def __init__(self, bot: Client): + self.add_ext_auto_defer(enabled=True, ephemeral=False, time_until_defer=0.5) ``` + +??? note "Auto Defer Handling Priority" + Similar to errors, only one auto defer will be run, and the most specific auto defer takes precendence. + This goes: command auto defer -> extension -> bot. diff --git a/docs/src/Guides/21 Advanced Extension Usage.md b/docs/src/Guides/21 Advanced Extension Usage.md deleted file mode 100644 index 043d15a86..000000000 --- a/docs/src/Guides/21 Advanced Extension Usage.md +++ /dev/null @@ -1,85 +0,0 @@ -# Advanced Extension Usage - -You have learned how to create interactions and how to keep your code clean with extensions. -The following examples show you how to elevate your extensions to the next level. - -## Check This Out - -It would be cool the check some condition before invoking a command, wouldn't it? -You are in luck, that is exactly what checks are for. - -Checks prohibit the interaction from running if they return `False`. - -You can add your own check to your extension. In this example, we only want a user whose name starts with "a" to run any command from this extension. -```python -class MyExtension(Extension): - def __init__(self, client: Client): - self.client = client - self.add_ext_check(self.a_check) - - async def a_check(ctx: InteractionContext) -> bool: - return bool(ctx.author.name.startswith("a")) - - @slash_command(...) - async def my_command(...): - ... - -def setup(client): - MyExtension(client) -``` - -## Pre- And Post-Run Events - -Pre- and Post-Run events are similar to checks. They run before and after an interaction is invoked, respectively. - -In this example, we are just printing some stats before and after the interaction. -```python -class MyExt(Extension): - def __init__(self, client: Client): - self.client = client - self.add_extension_prerun(self.pre_run) - self.add_extension_postrun(self.post_run) - - async def pre_run(ctx: InteractionContext): - print(f"Command started at: {datetime.datetime.now()}") - - async def post_run(ctx: InteractionContext): - print(f"Command done at: {datetime.datetime.now()}") - - @slash_command(...) - async def my_command(...): - ... - -def setup(client): - MyExtension(client) -``` - -## Global Checks - -Now you learned how to make checks for a extension right after we told you to use extensions to split your code into different files. -Ironic, if you want a check for any interaction in any extension. - -Lucky you, however, since you seem to have forgotten about python subclassing. -By subclassing your own custom extension, your can still split your code into as many files as you want without having to redefine your checks. - -### File 1 -```python -class CustomExtension(Extension): - def __init__(self, client: Client): - self.client = client - self.add_ext_check(self.a_check) - - async def a_check(ctx: InteractionContext) -> bool: - return bool(ctx.author.name.startswith("a")) -``` - -### File 2 -```python -class MyExtension(CustomExtension): - @slash_command(...) - async def my_command(...): - ... - -def setup(client): - MyExtension(client) -``` diff --git a/docs/src/Guides/index.md b/docs/src/Guides/index.md index 57dbed964..4daf2dd0c 100644 --- a/docs/src/Guides/index.md +++ b/docs/src/Guides/index.md @@ -62,12 +62,6 @@ These guides are meant to help you get started with the library and offer a poin Damn, your code is getting pretty messy now, huh? Wouldn't it be nice if you could organise your commands and listeners into separate files? -- [__:material-cog-transfer: Advanced Extensions__](21 Advanced Extension Usage.md) - - --- - - You have learned how to create interactions and how to keep your code clean with extensions. This guide show you how to elevate your extensions to the next level. - - [__:material-music: Voice Support__](23 Voice.md) --- From 5c2187567c274b1b67933a35f534f282572b5572 Mon Sep 17 00:00:00 2001 From: Damego Date: Thu, 27 Jul 2023 17:57:50 +0300 Subject: [PATCH 24/33] feat: add `guild` & `channel` properties to `ThreadMembersUpdate` (#1504) --- interactions/api/events/discord.py | 7 ++++++- interactions/api/events/processors/thread_events.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/interactions/api/events/discord.py b/interactions/api/events/discord.py index 688c00f48..bc0a7ac06 100644 --- a/interactions/api/events/discord.py +++ b/interactions/api/events/discord.py @@ -249,7 +249,7 @@ class ThreadMemberUpdate(ThreadCreate): @attrs.define(eq=False, order=False, hash=False, kw_only=False) -class ThreadMembersUpdate(BaseEvent): +class ThreadMembersUpdate(GuildEvent): """Dispatched when anyone is added or removed from a thread.""" id: "Snowflake_Type" = attrs.field( @@ -263,6 +263,11 @@ class ThreadMembersUpdate(BaseEvent): removed_member_ids: List["Snowflake_Type"] = attrs.field(repr=False, factory=list) """Users removed from the thread""" + @property + def channel(self) -> Optional["TYPE_THREAD_CHANNEL"]: + """The thread channel this event is dispatched from""" + return self.client.get_channel(self.id) + @attrs.define(eq=False, order=False, hash=False, kw_only=False) class GuildJoin(GuildEvent): diff --git a/interactions/api/events/processors/thread_events.py b/interactions/api/events/processors/thread_events.py index 2cf39a4e0..af794f0cb 100644 --- a/interactions/api/events/processors/thread_events.py +++ b/interactions/api/events/processors/thread_events.py @@ -43,6 +43,7 @@ async def _on_raw_thread_members_update(self, event: "RawGatewayEvent") -> None: g_id = event.data.get("guild_id") self.dispatch( events.ThreadMembersUpdate( + g_id, event.data.get("id"), event.data.get("member_count"), [await self.cache.fetch_member(g_id, m["user_id"]) for m in event.data.get("added_members", [])], From 39ba1e52cc404c1e1d052292cdb879470a94760d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 27 Jul 2023 13:30:56 -0400 Subject: [PATCH 25/33] ci: weekly check. (#1503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: weekly check. updates: - [github.com/astral-sh/ruff-pre-commit: v0.0.278 → v0.0.280](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.278...v0.0.280) * ci: correct from checks. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- interactions/models/discord/channel.py | 4 ++-- interactions/models/internal/application_commands.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d79d7e743..598822c5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: - id: check-merge-conflict name: Merge Conflicts - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.0.278' + rev: 'v0.0.280' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/interactions/models/discord/channel.py b/interactions/models/discord/channel.py index 3cdaa4996..d8a0097a0 100644 --- a/interactions/models/discord/channel.py +++ b/interactions/models/discord/channel.py @@ -2612,7 +2612,7 @@ async def create_tag(self, name: str, emoji: Union["models.PartialEmoji", dict, data = await self._client.http.create_tag(**payload) channel_data = self._client.cache.place_channel_data(data) - return [tag for tag in channel_data.available_tags if tag.name == name][0] + return next(tag for tag in channel_data.available_tags if tag.name == name) async def edit_tag( self, @@ -2640,7 +2640,7 @@ async def edit_tag( data = await self._client.http.edit_tag(self.id, tag_id, name, emoji_name=emoji.name) channel_data = self._client.cache.place_channel_data(data) - return [tag for tag in channel_data.available_tags if tag.name == name][0] + return next(tag for tag in channel_data.available_tags if tag.name == name) async def delete_tag(self, tag_id: "Snowflake_Type") -> None: """ diff --git a/interactions/models/internal/application_commands.py b/interactions/models/internal/application_commands.py index 3e9c5f4f0..e1381bb83 100644 --- a/interactions/models/internal/application_commands.py +++ b/interactions/models/internal/application_commands.py @@ -275,7 +275,7 @@ def mention(self, scope: Optional["Snowflake_Type"] = None) -> str: if scope: cmd_id = self.get_cmd_id(scope=scope) else: - cmd_id = list(self.cmd_id.values())[0] + cmd_id = next(iter(self.cmd_id.values())) return f"" From 3802f748c6fb901376c8a35c2c12d7bf03d2596c Mon Sep 17 00:00:00 2001 From: Donbur4156 Date: Sun, 30 Jul 2023 05:06:18 +0200 Subject: [PATCH 26/33] fix(http): incorrect path for delete permission endpoint (#1506) * fix delete_permissions api endpoint fix API Endpoint in delete_channel_permission adding "permissions/" in Endpoint * ci: correct from checks. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- interactions/api/http/http_requests/channels.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/interactions/api/http/http_requests/channels.py b/interactions/api/http/http_requests/channels.py index 2f13cac55..f7d50eb95 100644 --- a/interactions/api/http/http_requests/channels.py +++ b/interactions/api/http/http_requests/channels.py @@ -444,7 +444,12 @@ async def delete_channel_permission( """ await self.request( - Route("DELETE", "/channels/{channel_id}/{overwrite_id}", channel_id=channel_id, overwrite_id=overwrite_id), + Route( + "DELETE", + "/channels/{channel_id}/permissions/{overwrite_id}", + channel_id=channel_id, + overwrite_id=overwrite_id, + ), reason=reason, ) From 5308f08a2f792cc367228f7660c15ad930554ec7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Aug 2023 03:04:10 -0400 Subject: [PATCH 27/33] ci: weekly check. (#1511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.0.280 → v0.0.281](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.280...v0.0.281) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 598822c5b..0b9063831 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: - id: check-merge-conflict name: Merge Conflicts - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.0.280' + rev: 'v0.0.281' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 2184b71e0e5dbe46b18efceb895e58c7eed89df0 Mon Sep 17 00:00:00 2001 From: Damego Date: Fri, 4 Aug 2023 00:55:11 +0500 Subject: [PATCH 28/33] feat: Implement missing stuff for scheduled events (#1507) * feat: Implement gateway support for scheduled events * fix: add optional to user add/remove * refactor: pre-commit'ed * refactor: change attrs to props and add `attrs.field` * refactor: set repr to True * feat(client): add `get_scheduled_event` helper method * refactor: use cache helpers in methods * chore: replace typing --- interactions/api/events/__init__.py | 10 ++++ interactions/api/events/discord.py | 59 +++++++++++++++++++ .../api/events/processors/__init__.py | 2 + .../api/events/processors/scheduled_events.py | 50 ++++++++++++++++ interactions/client/client.py | 28 ++++++++- interactions/client/smart_cache.py | 42 +++++++++++++ interactions/models/discord/guild.py | 6 +- 7 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 interactions/api/events/processors/scheduled_events.py diff --git a/interactions/api/events/__init__.py b/interactions/api/events/__init__.py index 8bcffad24..f8d1789a2 100644 --- a/interactions/api/events/__init__.py +++ b/interactions/api/events/__init__.py @@ -18,6 +18,11 @@ GuildJoin, GuildLeft, GuildMembersChunk, + GuildScheduledEventCreate, + GuildScheduledEventUpdate, + GuildScheduledEventDelete, + GuildScheduledEventUserAdd, + GuildScheduledEventUserRemove, GuildStickersUpdate, GuildUnavailable, GuildUpdate, @@ -126,6 +131,11 @@ "GuildJoin", "GuildLeft", "GuildMembersChunk", + "GuildScheduledEventCreate", + "GuildScheduledEventUpdate", + "GuildScheduledEventDelete", + "GuildScheduledEventUserAdd", + "GuildScheduledEventUserRemove", "GuildStickersUpdate", "GuildUnavailable", "GuildUpdate", diff --git a/interactions/api/events/discord.py b/interactions/api/events/discord.py index bc0a7ac06..cc4f5192c 100644 --- a/interactions/api/events/discord.py +++ b/interactions/api/events/discord.py @@ -48,6 +48,11 @@ async def an_event_handler(event: ChannelCreate): "GuildJoin", "GuildLeft", "GuildMembersChunk", + "GuildScheduledEventCreate", + "GuildScheduledEventUpdate", + "GuildScheduledEventDelete", + "GuildScheduledEventUserAdd", + "GuildScheduledEventUserRemove", "GuildStickersUpdate", "GuildAvailable", "GuildUnavailable", @@ -109,6 +114,7 @@ async def an_event_handler(event: ChannelCreate): 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) @@ -756,3 +762,56 @@ class GuildAuditLogEntryCreate(GuildEvent): audit_log_entry: interactions.models.AuditLogEntry = attrs.field(repr=False) """The audit log entry object""" + + +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class GuildScheduledEventCreate(BaseEvent): + """Dispatched when scheduled event is created""" + + scheduled_event: "ScheduledEvent" = attrs.field(repr=True) + """The scheduled event object""" + + +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class GuildScheduledEventUpdate(BaseEvent): + """Dispatched when scheduled event is updated""" + + before: Absent["ScheduledEvent"] = attrs.field(repr=True) + """The scheduled event before this event was created""" + after: "ScheduledEvent" = attrs.field(repr=True) + """The scheduled event after this event was created""" + + +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class GuildScheduledEventDelete(GuildScheduledEventCreate): + """Dispatched when scheduled event is deleted""" + + +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class GuildScheduledEventUserAdd(GuildEvent): + """Dispatched when scheduled event is created""" + + scheduled_event_id: "Snowflake_Type" = attrs.field(repr=True) + """The ID of the scheduled event""" + user_id: "Snowflake_Type" = attrs.field(repr=True) + """The ID of the user that has been added/removed from scheduled event""" + + @property + def scheduled_event(self) -> Optional["ScheduledEvent"]: + """The scheduled event object if cached""" + return self.client.get_scheduled_event(self.scheduled_event_id) + + @property + def user(self) -> Optional["User"]: + """The user that has been added/removed from scheduled event if cached""" + return self.client.get_user(self.user_id) + + @property + def member(self) -> Optional["Member"]: + """The guild member that has been added/removed from scheduled event if cached""" + return self.client.get_member(self.guild_id, self.user.id) + + +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class GuildScheduledEventUserRemove(GuildScheduledEventUserAdd): + """Dispatched when scheduled event is removed""" diff --git a/interactions/api/events/processors/__init__.py b/interactions/api/events/processors/__init__.py index 6a9241125..750d145a4 100644 --- a/interactions/api/events/processors/__init__.py +++ b/interactions/api/events/processors/__init__.py @@ -5,6 +5,7 @@ from .message_events import MessageEvents from .reaction_events import ReactionEvents from .role_events import RoleEvents +from .scheduled_events import ScheduledEvents from .stage_events import StageEvents from .thread_events import ThreadEvents from .user_events import UserEvents @@ -20,6 +21,7 @@ "MessageEvents", "ReactionEvents", "RoleEvents", + "ScheduledEvents", "StageEvents", "ThreadEvents", "UserEvents", diff --git a/interactions/api/events/processors/scheduled_events.py b/interactions/api/events/processors/scheduled_events.py new file mode 100644 index 000000000..4993c1ca2 --- /dev/null +++ b/interactions/api/events/processors/scheduled_events.py @@ -0,0 +1,50 @@ +import copy +from typing import TYPE_CHECKING + +import interactions.api.events as events +from interactions.client.const import MISSING +from interactions.models import ScheduledEvent +from ._template import EventMixinTemplate, Processor + +if TYPE_CHECKING: + from interactions.api.events import RawGatewayEvent + +__all__ = ("ScheduledEvents",) + + +class ScheduledEvents(EventMixinTemplate): + @Processor.define() + async def _on_raw_guild_scheduled_event_create(self, event: "RawGatewayEvent") -> None: + scheduled_event = self.cache.place_scheduled_event_data(event.data) + + self.dispatch(events.GuildScheduledEventCreate(scheduled_event)) + + @Processor.define() + async def _on_raw_guild_scheduled_event_update(self, event: "RawGatewayEvent") -> None: + before = copy.copy(self.cache.get_scheduled_event(event.data.get("id"))) + after = self.cache.place_scheduled_event_data(event.data) + + self.dispatch(events.GuildScheduledEventUpdate(before or MISSING, after)) + + @Processor.define() + async def _on_raw_guild_scheduled_event_delete(self, event: "RawGatewayEvent") -> None: + # for some reason this event returns the deleted scheduled event data? + # so we create an object from it + scheduled_event = ScheduledEvent.from_dict(event.data, self) + self.cache.delete_scheduled_event(event.data.get("id")) + + self.dispatch(events.GuildScheduledEventDelete(scheduled_event)) + + @Processor.define() + async def _on_raw_guild_scheduled_event_user_add(self, event: "RawGatewayEvent") -> None: + scheduled_event = self.cache.get_scheduled_event(event.data.get("guild_scheduled_event_id")) + user = self.cache.get_user(event.data.get("user_id")) + + self.dispatch(events.GuildScheduledEventUserAdd(event.data.get("guild_id"), scheduled_event, user)) + + @Processor.define() + async def _on_raw_guild_scheduled_event_user_remove(self, event: "RawGatewayEvent") -> None: + scheduled_event = self.cache.get_scheduled_event(event.data.get("guild_scheduled_event_id")) + user = self.cache.get_user(event.data.get("user_id")) + + self.dispatch(events.GuildScheduledEventUserRemove(event.data.get("guild_id"), scheduled_event, user)) diff --git a/interactions/client/client.py b/interactions/client/client.py index ff1d5383f..f6a530abd 100644 --- a/interactions/client/client.py +++ b/interactions/client/client.py @@ -173,6 +173,12 @@ events.AutoModCreated: [Intents.AUTO_MODERATION_CONFIGURATION, Intents.AUTO_MOD], events.AutoModUpdated: [Intents.AUTO_MODERATION_CONFIGURATION, Intents.AUTO_MOD], events.AutoModDeleted: [Intents.AUTO_MODERATION_CONFIGURATION, Intents.AUTO_MOD], + # Intents.GUILD_SCHEDULED_EVENTS + events.GuildScheduledEventCreate: [Intents.GUILD_SCHEDULED_EVENTS], + events.GuildScheduledEventUpdate: [Intents.GUILD_SCHEDULED_EVENTS], + events.GuildScheduledEventDelete: [Intents.GUILD_SCHEDULED_EVENTS], + events.GuildScheduledEventUserAdd: [Intents.GUILD_SCHEDULED_EVENTS], + events.GuildScheduledEventUserRemove: [Intents.GUILD_SCHEDULED_EVENTS], # multiple intents events.ThreadMembersUpdate: [Intents.GUILDS, Intents.GUILD_MEMBERS], events.TypingStart: [ @@ -211,6 +217,7 @@ class Client( processors.MessageEvents, processors.ReactionEvents, processors.RoleEvents, + processors.ScheduledEvents, processors.StageEvents, processors.ThreadEvents, processors.UserEvents, @@ -2282,10 +2289,29 @@ async def fetch_scheduled_event( """ try: scheduled_event_data = await self.http.get_scheduled_event(guild_id, scheduled_event_id, with_user_count) - return ScheduledEvent.from_dict(scheduled_event_data, self) + return self.cache.place_scheduled_event_data(scheduled_event_data) except NotFound: return None + def get_scheduled_event( + self, + scheduled_event_id: "Snowflake_Type", + ) -> Optional["ScheduledEvent"]: + """ + Get a scheduled event by id. + + !!! note + This method is an alias for the cache which will return a cached object. + + Args: + scheduled_event_id: The ID of the scheduled event to get + + Returns: + The scheduled event if found, otherwise None + + """ + return self.cache.get_scheduled_event(scheduled_event_id) + async def fetch_custom_emoji( self, emoji_id: "Snowflake_Type", guild_id: "Snowflake_Type", *, force: bool = False ) -> Optional[CustomEmoji]: diff --git a/interactions/client/smart_cache.py b/interactions/client/smart_cache.py index 8c807d67a..568079b80 100644 --- a/interactions/client/smart_cache.py +++ b/interactions/client/smart_cache.py @@ -16,6 +16,7 @@ from interactions.models.discord.role import Role from interactions.models.discord.snowflake import to_snowflake, to_optional_snowflake from interactions.models.discord.user import Member, User +from interactions.models.discord.scheduled_event import ScheduledEvent from interactions.models.internal.active_voice_state import ActiveVoiceState __all__ = ("GlobalCache", "create_cache") @@ -70,6 +71,7 @@ class GlobalCache: member_cache: dict = attrs.field(repr=False, factory=dict) # key: (guild_id, user_id) channel_cache: dict = attrs.field(repr=False, factory=dict) # key: channel_id guild_cache: dict = attrs.field(repr=False, factory=dict) # key: guild_id + scheduled_events_cache: dict = attrs.field(repr=False, factory=dict) # key: guild_scheduled_event_id # Expiring discord objects cache message_cache: TTLCache = attrs.field(repr=False, factory=TTLCache) # key: (channel_id, message_id) @@ -903,3 +905,43 @@ def delete_emoji(self, emoji_id: "Snowflake_Type") -> None: self.emoji_cache.pop(to_snowflake(emoji_id), None) # endregion Emoji cache + + # region ScheduledEvents cache + + def get_scheduled_event(self, scheduled_event_id: "Snowflake_Type") -> Optional["ScheduledEvent"]: + """ + Get a scheduled event based on the scheduled event ID. + + Args: + scheduled_event_id: The ID of the scheduled event + + Returns: + The ScheduledEvent if found + """ + return self.scheduled_events_cache.get(to_snowflake(scheduled_event_id)) + + def place_scheduled_event_data(self, data: discord_typings.GuildScheduledEventData) -> "ScheduledEvent": + """ + Take json data representing a scheduled event, process it, and cache it. + + Args: + data: json representation of the scheduled event + + Returns: + The processed scheduled event + """ + scheduled_event = ScheduledEvent.from_dict(data, self._client) + self.scheduled_events_cache[scheduled_event.id] = scheduled_event + + return scheduled_event + + def delete_scheduled_event(self, scheduled_event_id: "Snowflake_Type") -> None: + """ + Delete a scheduled event from the cache. + + Args: + scheduled_event_id: The ID of the scheduled event + """ + self.scheduled_events_cache.pop(to_snowflake(scheduled_event_id), None) + + # endregion ScheduledEvents cache diff --git a/interactions/models/discord/guild.py b/interactions/models/discord/guild.py index 325f6655d..cc605bdc9 100644 --- a/interactions/models/discord/guild.py +++ b/interactions/models/discord/guild.py @@ -1253,7 +1253,7 @@ async def list_scheduled_events(self, with_user_count: bool = False) -> List["mo """ scheduled_events_data = await self._client.http.list_schedules_events(self.id, with_user_count) - return models.ScheduledEvent.from_list(scheduled_events_data, self._client) + return [self._client.cache.place_scheduled_event_data(data) for data in scheduled_events_data] async def fetch_scheduled_event( self, scheduled_event_id: Snowflake_Type, with_user_count: bool = False @@ -1275,7 +1275,7 @@ async def fetch_scheduled_event( ) except NotFound: return None - return models.ScheduledEvent.from_dict(scheduled_event_data, self._client) + return self._client.cache.place_scheduled_event_data(scheduled_event_data) async def create_scheduled_event( self, @@ -1339,7 +1339,7 @@ async def create_scheduled_event( } scheduled_event_data = await self._client.http.create_scheduled_event(self.id, payload, reason) - return models.ScheduledEvent.from_dict(scheduled_event_data, self._client) + return self._client.cache.place_scheduled_event_data(scheduled_event_data) async def create_custom_sticker( self, From 8da4f25cf22ad26f72a0f18fb6a5e7905c9215a2 Mon Sep 17 00:00:00 2001 From: Sophia <41456914+i0bs@users.noreply.github.com> Date: Mon, 7 Aug 2023 01:33:16 -0400 Subject: [PATCH 29/33] docs: clarify intents in example (#1516) --- docs/src/Guides/90 Example.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/src/Guides/90 Example.md b/docs/src/Guides/90 Example.md index fc57a14a0..70690371f 100644 --- a/docs/src/Guides/90 Example.md +++ b/docs/src/Guides/90 Example.md @@ -15,7 +15,12 @@ logging.basicConfig() cls_log = logging.getLogger("MyLogger") cls_log.setLevel(logging.DEBUG) -bot = Client(intents=Intents.DEFAULT, sync_interactions=True, asyncio_debug=True, logger=cls_log) +bot = Client( + intents=Intents.DEFAULT | Intents.MESSAGE_CONTENT, + sync_interactions=True, + asyncio_debug=True, + logger=cls_log +) prefixed_commands.setup(bot) @@ -30,6 +35,8 @@ async def on_guild_create(event): print(f"guild created : {event.guild.name}") +# Message content is a privileged intent. +# Ensure you have message content enabled in the Developer Portal for this to work. @listen() async def on_message_create(event): print(f"message received: {event.message.content}") From ebebb07de754262a97fc830ef5fe15fb381cd819 Mon Sep 17 00:00:00 2001 From: Sophia <41456914+i0bs@users.noreply.github.com> Date: Mon, 7 Aug 2023 17:51:04 -0400 Subject: [PATCH 30/33] feat: infer modal/component callback names from coroutine (#1519) * feat: infer callback decor from coroutine name * docs: add new logic notation * chore: add doc notation to component callback definition --- interactions/models/internal/application_commands.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/interactions/models/internal/application_commands.py b/interactions/models/internal/application_commands.py index e1381bb83..50b863bd5 100644 --- a/interactions/models/internal/application_commands.py +++ b/interactions/models/internal/application_commands.py @@ -1162,7 +1162,9 @@ def component_callback(*custom_id: str | re.Pattern) -> Callable[[AsyncCallable] Your callback will be given a single argument, `ComponentContext` Note: - This can optionally take a regex pattern, which will be used to match against the custom ID of the component + This can optionally take a regex pattern, which will be used to match against the custom ID of the component. + + If you do not supply a `custom_id`, the name of the coroutine will be used instead. Args: *custom_id: The custom ID of the component to wait for @@ -1170,6 +1172,8 @@ def component_callback(*custom_id: str | re.Pattern) -> Callable[[AsyncCallable] """ def wrapper(func: AsyncCallable) -> ComponentCommand: + custom_id = custom_id or [func.__name__] # noqa: F823 + if not asyncio.iscoroutinefunction(func): raise ValueError("Commands must be coroutines") @@ -1188,7 +1192,9 @@ def modal_callback(*custom_id: str | re.Pattern) -> Callable[[AsyncCallable], Mo Your callback will be given a single argument, `ModalContext` Note: - This can optionally take a regex pattern, which will be used to match against the custom ID of the modal + This can optionally take a regex pattern, which will be used to match against the custom ID of the modal. + + If you do not supply a `custom_id`, the name of the coroutine will be used instead. Args: @@ -1196,6 +1202,8 @@ def modal_callback(*custom_id: str | re.Pattern) -> Callable[[AsyncCallable], Mo """ def wrapper(func: AsyncCallable) -> ModalCommand: + custom_id = custom_id or [func.__name__] # noqa: F823 + if not asyncio.iscoroutinefunction(func): raise ValueError("Commands must be coroutines") From 421811de0994ec4f798de4e91244cbb0c1068532 Mon Sep 17 00:00:00 2001 From: i0bs <41456914+i0bs@users.noreply.github.com> Date: Tue, 8 Aug 2023 10:12:34 -0400 Subject: [PATCH 31/33] docs: clarify intents for extensions --- docs/src/Guides/20 Extensions.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/Guides/20 Extensions.md b/docs/src/Guides/20 Extensions.md index cbe2a175e..c782f72d7 100644 --- a/docs/src/Guides/20 Extensions.md +++ b/docs/src/Guides/20 Extensions.md @@ -13,10 +13,10 @@ For example, you can see the difference of a bot with and without extensions: ??? Hint "Examples:" === "Without Extensions" ```python - from interactions import ActionRow, Button, ButtonStyle, Client, listen, slash_command + from interactions import ActionRow, Button, ButtonStyle, Client, Intents, listen, slash_command from interactions.api.events import Component, GuildJoin, MessageCreate, Startup - bot = Client() + bot = Client(intents=Intents.DEFAULT | Intents.MESSAGE_CONTENT) @listen(Startup) @@ -79,10 +79,10 @@ For example, you can see the difference of a bot with and without extensions: === "With Extensions" ```python # File: `main.py` - from interactions import Client, listen + from interactions import Client, Intents, listen from interactions.api.events import Component, GuildJoin, MessageCreate, Startup - bot = Client() + bot = Client(intents=Intents.DEFAULT | Intents.MESSAGE_CONTENT) @listen(Startup) From 447f3aa0b1f0cd1ea7652ccbb23896a46e95e31c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 8 Aug 2023 10:17:21 -0400 Subject: [PATCH 32/33] ci: weekly check. (#1521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.0.281 → v0.0.282](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.281...v0.0.282) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b9063831..bae06de8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: - id: check-merge-conflict name: Merge Conflicts - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.0.281' + rev: 'v0.0.282' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From d0a90693cd6a7d1f377e426270ed5937bb3bb0d8 Mon Sep 17 00:00:00 2001 From: Sophia <41456914+i0bs@users.noreply.github.com> Date: Tue, 8 Aug 2023 11:26:08 -0400 Subject: [PATCH 33/33] chore: pyproject version bump Signed-off-by: Sophia <41456914+i0bs@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 97b644c68..89a0b7ed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "interactions.py" -version = "5.7.0" +version = "5.9.0" description = "Easy, simple, scalable and modular: a Python API wrapper for interactions." authors = [ "LordOfPolls ",