From 842053d86161799b6e85e449d240bab350772520 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Sat, 18 Dec 2021 22:34:33 -0500 Subject: [PATCH 001/105] fix: Get rid of decimal residue in Timestamp computation. --- interactions/api/models/misc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/interactions/api/models/misc.py b/interactions/api/models/misc.py index 0e863abc6..653dbcffe 100644 --- a/interactions/api/models/misc.py +++ b/interactions/api/models/misc.py @@ -5,6 +5,7 @@ # also, it should be serialiser* but idk, fl0w'd say something if I left it like that. /shrug import datetime import logging +from math import floor from typing import Union log = logging.getLogger("mixin") @@ -147,7 +148,7 @@ def epoch(self) -> float: :return: A float containing the seconds since Discord Epoch. """ - return ((int(self._snowflake) >> 22) + 1420070400000) / 1000 + return floor(((int(self._snowflake) >> 22) + 1420070400000) / 1000) @property def timestamp(self) -> datetime.datetime: From 3d4c37b0950108ca3268da4f529ba78c5c7168ad Mon Sep 17 00:00:00 2001 From: James Walston Date: Sun, 19 Dec 2021 13:37:11 -0500 Subject: [PATCH 002/105] docs: allow dark mode interpretation. --- docs/conf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index ea19dccd7..1a6f980b6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,6 +39,7 @@ "sphinx.ext.autosectionlabel", "hoverxref.extension", "karma_sphinx_theme", + "sphinx_rtd_dark_mode", ] # Stackoverflow said that this is gonna cure my LaTeX errors for ref handling. @@ -49,6 +50,9 @@ """ } +# user starts in dark mode +default_dark_mode = True + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] From 85f5b9b630e336b98391acc06047e35552c46cbc Mon Sep 17 00:00:00 2001 From: James Walston Date: Sun, 19 Dec 2021 13:40:59 -0500 Subject: [PATCH 003/105] docs: remove it. (no RTD reg theme ref) --- docs/conf.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 1a6f980b6..ea19dccd7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,7 +39,6 @@ "sphinx.ext.autosectionlabel", "hoverxref.extension", "karma_sphinx_theme", - "sphinx_rtd_dark_mode", ] # Stackoverflow said that this is gonna cure my LaTeX errors for ref handling. @@ -50,9 +49,6 @@ """ } -# user starts in dark mode -default_dark_mode = True - # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] From bd651ad8ae4ed7a2e35de20d37250269b1965595 Mon Sep 17 00:00:00 2001 From: Toricane <73972068+Toricane@users.noreply.github.com> Date: Sun, 19 Dec 2021 18:00:38 -0800 Subject: [PATCH 004/105] fix(context)!: Fixed ability to send/edit multiple action rows * test #1 * test #2 * test #3 * test #4 * test #5 * test #6 * test #7 * test #8 * test #9 * test #10 * test #11 * test #12 * almost final * final formatted --- interactions/context.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/interactions/context.py b/interactions/context.py index 5f342ec93..063d35208 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -187,7 +187,19 @@ async def send( _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions _components: list = [{"type": 1, "components": []}] - if isinstance(components, ActionRow): + if ( + isinstance(components, list) + and components + and (isinstance(action_row, ActionRow) for action_row in components) + ): + _components = [] + for action_row in components: + _action_row = {"type": 1, "components": []} + _action_row["components"].extend( + [component._json for component in action_row.components] + ) + _components.append(_action_row) + elif isinstance(components, ActionRow): _components[0]["components"] = [component._json for component in components.components] elif isinstance(components, Button): _components[0]["components"] = [] if components is None else [components._json] @@ -292,7 +304,19 @@ async def edit( _message_reference: dict = {} if message_reference is None else message_reference._json _components: list = [{"type": 1, "components": []}] - if isinstance(components, ActionRow): + if ( + isinstance(components, list) + and components + and (isinstance(action_row, ActionRow) for action_row in components) + ): + _components = [] + for action_row in components: + _action_row = {"type": 1, "components": []} + _action_row["components"].extend( + [component._json for component in action_row.components] + ) + _components.append(_action_row) + elif isinstance(components, ActionRow): _components[0]["components"] = [component._json for component in components.components] elif isinstance(components, Button): _components[0]["components"] = [] if components is None else [components._json] From 4b0614a6a7ad6feb59369e4c42dffa0c1457e0bd Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Mon, 20 Dec 2021 18:25:19 -0500 Subject: [PATCH 005/105] fix: Remove hardlock orjson version dependency for py 3.10, redo reason HTTP logic, suppress unnecessary print logic --- interactions/api/http.py | 14 +++++++++----- interactions/api/models/gw.py | 1 - requirements.txt | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index 9dbf8af63..aef970736 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -193,9 +193,9 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: kwargs["headers"] = {**self.headers, **kwargs.get("headers", {})} kwargs["headers"]["Content-Type"] = "application/json" - if kwargs.get("reason"): - kwargs["headers"]["X-Audit-Log-Reason"] = kwargs["reason"] - del kwargs["reason"] + reason = kwargs.pop("reason", None) + if reason: + kwargs["headers"]["X-Audit-Log-Reason"] = quote(reason, safe="/ ") async with self.session.request( route.method, route.__api__ + route.path, **kwargs @@ -1175,7 +1175,9 @@ async def remove_member_role( reason=reason, ) - async def modify_member(self, user_id: int, guild_id: int, payload: dict): + async def modify_member( + self, user_id: int, guild_id: int, payload: dict, reason: Optional[str] = None + ): """ Edits a member. This can nick them, change their roles, mute/deafen (and its contrary), and moving them across channels and/or disconnect them @@ -1183,7 +1185,8 @@ async def modify_member(self, user_id: int, guild_id: int, payload: dict): :param user_id: Member ID snowflake. :param guild_id: Guild ID snowflake. :param payload: Payload representing parameters (nick, roles, mute, deaf, channel_id) - :return: ? (modified voice state? not sure) + :param reason: The reason for this action. Defaults to None. + :return: Modified member object. """ return await self._req.request( @@ -1191,6 +1194,7 @@ async def modify_member(self, user_id: int, guild_id: int, payload: dict): "PATCH", "/guilds/{guild_id}/members/{user_id}", guild_id=guild_id, user_id=user_id ), json=payload, + reason=reason, ) # Channel endpoint. diff --git a/interactions/api/models/gw.py b/interactions/api/models/gw.py index f351677ce..fbddfdd92 100644 --- a/interactions/api/models/gw.py +++ b/interactions/api/models/gw.py @@ -272,7 +272,6 @@ class Presence(DictSerializerMixin): def __init__(self, **kwargs): super().__init__(**kwargs) - print(self._json) self.guild_id = Snowflake(self.guild_id) if self._json.get("guild_id") else None self.user = User(**self.user) if self._json.get("user") else None self.activities = ( diff --git a/requirements.txt b/requirements.txt index dcf0c6da8..9dd71bcc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ black==21.11b1 colorama==0.4.4 flake8==3.9.2 isort==5.9.3 -orjson==3.6.3 +orjson pre-commit==2.16.0 Sphinx==4.1.2 sphinx-hoverxref==1.0.0 From d8936a7a358439d9c8ee465c2c94807e1cd4a729 Mon Sep 17 00:00:00 2001 From: Toricane <73972068+Toricane@users.noreply.github.com> Date: Mon, 20 Dec 2021 16:30:15 -0800 Subject: [PATCH 006/105] chore(context): organize multiple action-row sending into ternary operations. * Optimize #393 into few lines * Formatted --- interactions/context.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/interactions/context.py b/interactions/context.py index 063d35208..5aef1c387 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -192,13 +192,10 @@ async def send( and components and (isinstance(action_row, ActionRow) for action_row in components) ): - _components = [] - for action_row in components: - _action_row = {"type": 1, "components": []} - _action_row["components"].extend( - [component._json for component in action_row.components] - ) - _components.append(_action_row) + _components = [ + {"type": 1, "components": [component._json for component in action_row.components]} + for action_row in components + ] elif isinstance(components, ActionRow): _components[0]["components"] = [component._json for component in components.components] elif isinstance(components, Button): @@ -309,13 +306,10 @@ async def edit( and components and (isinstance(action_row, ActionRow) for action_row in components) ): - _components = [] - for action_row in components: - _action_row = {"type": 1, "components": []} - _action_row["components"].extend( - [component._json for component in action_row.components] - ) - _components.append(_action_row) + _components = [ + {"type": 1, "components": [component._json for component in action_row.components]} + for action_row in components + ] elif isinstance(components, ActionRow): _components[0]["components"] = [component._json for component in components.components] elif isinstance(components, Button): From 9d5e511952fbb611924473f62e24878ca78e0b17 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Mon, 20 Dec 2021 19:47:50 -0500 Subject: [PATCH 007/105] fix(slots): Include undocumented slotted properties to suppress warnings on ALL intents. --- interactions/api/models/channel.py | 13 ++++++++++++- interactions/api/models/presence.py | 3 +++ interactions/context.py | 6 ++++++ interactions/context.pyi | 2 ++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 38b59488a..e245e4f33 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -67,7 +67,18 @@ class ThreadMember(DictSerializerMixin): :ivar int flags: The bitshift flags for the member in the thread. """ - __slots__ = ("_json", "id", "user_id", "join_timestamp", "flags") + __slots__ = ( + "_json", + "id", + "user_id", + "join_timestamp", + "flags", + # TODO: Document below attributes. + "user", + "team_id", + "membership_state", + "permissions", + ) def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/interactions/api/models/presence.py b/interactions/api/models/presence.py index 56271d6ce..d2586fbee 100644 --- a/interactions/api/models/presence.py +++ b/interactions/api/models/presence.py @@ -116,10 +116,13 @@ class PresenceActivity(DictSerializerMixin): "flags", "buttons", # TODO: document/investigate what these do. + "user", "users", "status", "client_status", "activities", + "sync_id", + "session_id", ) def __init__(self, **kwargs): diff --git a/interactions/context.py b/interactions/context.py index 5aef1c387..5f001035b 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -97,6 +97,9 @@ class CommandContext(Context): "channel_id", "responded", "deferred", + # + "locale", + "guild_locale", ) def __init__(self, **kwargs) -> None: @@ -508,6 +511,9 @@ class ComponentContext(CommandContext): "channel_id", "responded", "deferred", + # + "locale", + "guild_locale", ) def __init__(self, **kwargs) -> None: diff --git a/interactions/context.pyi b/interactions/context.pyi index aaa397a26..3efad08bd 100644 --- a/interactions/context.pyi +++ b/interactions/context.pyi @@ -35,6 +35,8 @@ class CommandContext(Context): channel_id: Snowflake responded: bool deferred: bool + locale: str + guild_locale: str def __init__(self, **kwargs) -> None: ... async def defer(self, ephemeral: Optional[bool] = None) -> None: ... async def send( From 02f84aa6e1031e8e3a2d8e24930a21ef6a3cd52b Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Mon, 20 Dec 2021 21:26:25 -0500 Subject: [PATCH 008/105] fix!(components): Fix component menu invocation, select option value parsing --- interactions/client.py | 6 ++++-- interactions/models/component.py | 16 ++++++++++++++-- interactions/models/misc.py | 9 +++------ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index 36ef37889..a694d7639 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -21,7 +21,7 @@ from .decor import component as _component from .enums import ApplicationCommandType from .models.command import ApplicationCommand, Option -from .models.component import Button, Component, Modal, SelectMenu +from .models.component import Button, Modal, SelectMenu basicConfig(level=Data.LOGGER) log: Logger = getLogger("client") @@ -399,7 +399,9 @@ async def button_response(ctx): def decorator(coro: Coroutine) -> Any: payload: str = ( - _component(component).custom_id if isinstance(component, Component) else component + _component(component).custom_id + if isinstance(component, (Button, SelectMenu)) + else component ) return self.event(coro, name=payload) diff --git a/interactions/models/component.py b/interactions/models/component.py index 1377426a1..81d2a7433 100644 --- a/interactions/models/component.py +++ b/interactions/models/component.py @@ -85,7 +85,12 @@ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.type = ComponentType.SELECT self.options = ( - [SelectOption(**option._json) for option in self.options] + [ + SelectOption(**option._json) + if not isinstance(option, dict) + else SelectOption(**option) + for option in self.options + ] if self._json.get("options") else None ) @@ -192,7 +197,14 @@ def __init__(self, **kwargs) -> None: self.type = ComponentType(self.type) self.style = ButtonStyle(self.style) if self._json.get("style") else None self.options = ( - [SelectMenu(**option) for option in self.options] if self._json.get("options") else None + [ + SelectOption(**option._json) + if isinstance(option, SelectOption) + else SelectOption(**option) + for option in self.options + ] + if self._json.get("options") + else None ) if self._json.get("components"): self._json["components"] = [component._json for component in self.components] diff --git a/interactions/models/misc.py b/interactions/models/misc.py index 1393cd1d3..28a5774e0 100644 --- a/interactions/models/misc.py +++ b/interactions/models/misc.py @@ -8,7 +8,6 @@ from ..api.models.user import User from ..enums import ApplicationCommandType, ComponentType, InteractionType from ..models.command import Option -from ..models.component import SelectOption class InteractionResolvedData(DictSerializerMixin): @@ -70,7 +69,7 @@ class InteractionData(DictSerializerMixin): :ivar Optional[Option, List[Option]] options?: The options of the interaction. :ivar Optional[str] custom_id?: The custom ID of the interaction. :ivar Optional[ComponentType] component_type?: The type of component from the interaction. - :ivar Optional[List[SelectOption]] values?: The values of the selected options in the interaction. + :ivar Optional[List[str]] values?: The values of the selected options in the interaction. :ivar Optional[str] target_id?: The targeted ID of the interaction. """ @@ -81,7 +80,7 @@ class InteractionData(DictSerializerMixin): options: Optional[List[Option]] custom_id: Optional[str] component_type: Optional[ComponentType] - values: Optional[List[SelectOption]] + values: Optional[List[str]] target_id: Optional[Snowflake] __slots__ = ( @@ -113,9 +112,7 @@ def __init__(self, **kwargs): self.options = ( [Option(**option) for option in self.options] if self._json.get("options") else None ) - self.values = ( - [SelectOption(**value) for value in self.values] if self._json.get("values") else None - ) + self.values = self.values if self._json.get("values") else None if self._json.get("component_type"): self.component_type = ComponentType(self.component_type) self._json.update({"component_type": self.component_type.value}) From 412242664bfc3f0814953819e3e4331273c65f57 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Tue, 21 Dec 2021 20:25:01 +0100 Subject: [PATCH 009/105] feat: add helper methods to models. * added delete to message started edit of message fixed but when editing components in componentcontext * undo change in context * Update interactions/api/models/message.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/message.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * remove components from editing * completed edit, began send * commiting in order to be able to mere changes. * re add gitignore * added new functions * change tts check in message.reply * Added new functions ban remove_ban kick * new functions: add_member_roles remove_member_roles * fixed wrong func name - unban() -> remove_ban() * Update interactions/api/models/channel.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/gateway.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/message.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/message.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/role.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/role.pyi Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/channel.pyi Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/channel.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/channel.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * changed httpclient in messge.pyi * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/message.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/message.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/member.pyi Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/member.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.pyi Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * added things * changed docstring * added more helper methods in guild.py * channel create and edit * added modify to channel * role delete and modify * added send (dm) for members * reformatted .pyi files * changed names of methods and implemented guild.member * corrected role functions * fixed embed sending in channel * made fixes, added slots. In this commit all helper methods are functioning! * whoops, forgot removing a print statement Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> --- interactions/api/gateway.py | 2 + interactions/api/http.py | 5 +- interactions/api/models/channel.py | 141 ++++++++++ interactions/api/models/channel.pyi | 32 ++- interactions/api/models/guild.py | 416 ++++++++++++++++++++++++++++ interactions/api/models/guild.pyi | 98 ++++++- interactions/api/models/gw.py | 13 +- interactions/api/models/gw.pyi | 4 + interactions/api/models/member.py | 156 +++++++++++ interactions/api/models/member.pyi | 37 ++- interactions/api/models/message.py | 146 ++++++++++ interactions/api/models/message.pyi | 30 ++ interactions/api/models/role.py | 65 +++++ interactions/api/models/role.pyi | 19 ++ interactions/client.py | 2 +- 15 files changed, 1156 insertions(+), 10 deletions(-) diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index c2c0ab49f..a1f6391a0 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -298,6 +298,8 @@ def check_sub_auto(option: dict) -> tuple: __import__(path), _name, ) + if "_create" in event.lower() or "_add" in event.lower(): + data["_client"] = self.http self.dispatch.dispatch(f"on_{name}", obj(**data)) # noqa except AttributeError as error: # noqa log.fatal(f"You're missing a data model for the event {name}: {error}") diff --git a/interactions/api/http.py b/interactions/api/http.py index aef970736..c4a51d80d 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -987,9 +987,8 @@ async def remove_guild_ban( """ return await self._req.request( - Route( - "DELETE", "/guilds/{guild_id}/bans/{user_id}", guild_id=guild_id, user_id=user_id - ), + Route("DELETE", f"/guilds/{guild_id}/bans/{user_id}"), + json={}, reason=reason, ) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index e245e4f33..be8e85b56 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -1,5 +1,6 @@ from datetime import datetime from enum import IntEnum +from typing import Optional from .misc import DictSerializerMixin, Snowflake @@ -155,6 +156,7 @@ class Channel(DictSerializerMixin): "member", "default_auto_archive_duration", "permissions", + "_client", ) def __init__(self, **kwargs): @@ -175,3 +177,142 @@ def __init__(self, **kwargs): if self._json.get("last_pin_timestamp") else None ) + + async def send( + self, + content: Optional[str] = None, + *, + tts: Optional[bool] = False, + # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. + embeds=None, + allowed_mentions=None, + components=None, + ): + """ + Sends a message in the channel + + :param content?: The contents of the message as a string or string-converted value. + :type content: Optional[str] + :param tts?: Whether the message utilizes the text-to-speech Discord programme or not. + :type tts: Optional[bool] + :param embeds?: An embed, or list of embeds for the message. + :type embeds: Optional[Union[Embed, List[Embed]]] + :param allowed_mentions?: The message interactions/mention limits that the message can refer to. + :type allowed_mentions: Optional[MessageInteraction] + :param components?: A component, or list of components for the message. + :type components: Optional[Union[Component, List[Component]]] + :return: The sent message as an object. + :rtype: Message + """ + from ...models.component import ActionRow, Button, SelectMenu + from .message import Message + + _content: str = "" if content is None else content + _tts: bool = False if tts is None else tts + # _file = None if file is None else file + # _attachments = [] if attachments else None + _embeds: list = [] + _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions + _components: list = [{"type": 1, "components": []}] + if embeds: + if isinstance(embeds, list): + _embeds = [embed._json for embed in embeds] + else: + _embeds = [embeds._json] + + if isinstance(components, ActionRow): + _components[0]["components"] = [component._json for component in components.components] + elif isinstance(components, Button): + _components[0]["components"] = [] if components is None else [components._json] + elif isinstance(components, SelectMenu): + components._json["options"] = [option._json for option in components.options] + _components[0]["components"] = [] if components is None else [components._json] + else: + _components = [] if components is None else [components] + + # TODO: post-v4: Add attachments into Message obj. + payload = Message( + content=_content, + tts=_tts, + # file=file, + # attachments=_attachments, + embeds=_embeds, + allowed_mentions=_allowed_mentions, + components=_components, + ) + + res = await self._client.create_message(channel_id=int(self.id), payload=payload._json) + message = Message(**res, _client=self._client) + return message + + async def delete(self) -> None: + """ + Deletes the channel. + """ + await self._client.delete_channel(channel_id=int(self.id)) + + async def modify( + self, + name: Optional[str] = None, + topic: Optional[str] = None, + bitrate: Optional[int] = None, + user_limit: Optional[int] = None, + rate_limit_per_user: Optional[int] = None, + position: Optional[int] = None, + # permission_overwrites, + parent_id: Optional[int] = None, + nsfw: Optional[bool] = False, + reason: Optional[str] = None, + ) -> "Channel": + """ + Edits the channel + :param name?: The name of the channel, defaults to the current value of the channel + :type name: str + :param topic?: The topic of that channel, defaults to the current value of the channel + :type topic: Optional[str] + :param bitrate?: (voice channel only) The bitrate (in bits) of the voice channel, defaults to the current value of the channel + :type bitrate Optional[int] + :param user_limit?: (voice channel only) Maximum amount of users in the channel, defaults to the current value of the channel + :type user_limit: Optional[int] + :param rate_limit_per_use?: Amount of seconds a user has to wait before sending another message (0-21600), defaults to the current value of the channel + :type rate_limit_per_user: Optional[int] + :param position?: Sorting position of the channel, defaults to the current value of the channel + :type position: Optional[int] + :param parent_id?: The id of the parent category for a channel, defaults to the current value of the channel + :type parent_id: Optional[int] + :param nsfw?: Whether the channel is nsfw or not, defaults to the current value of the channel + :type nsfw: Optional[bool] + :param reason: The reason for the edit + :type reason: Optional[str] + :return: The modified channel as new object + :rtype: Channel + """ + _name = self.name if not name else name + _topic = self.topic if not topic else topic + _bitrate = self.bitrate if not bitrate else bitrate + _user_limit = self.user_limit if not user_limit else user_limit + _rate_limit_per_user = ( + self.rate_limit_per_user if not rate_limit_per_user else rate_limit_per_user + ) + _position = self.position if not position else position + _parent_id = self.parent_id if not parent_id else parent_id + _nsfw = self.nsfw if not nsfw else nsfw + _type = self.type + + payload = Channel( + name=_name, + type=_type, + topic=_topic, + bitrate=_bitrate, + user_limit=_user_limit, + rate_limit_per_user=_rate_limit_per_user, + position=_position, + parent_id=_parent_id, + nsfw=_nsfw, + ) + res = await self._client.modify_channel( + channel_id=int(self.id), + reason=reason, + data=payload._json, + ) + return Channel(**res, _client=self._client) diff --git a/interactions/api/models/channel.pyi b/interactions/api/models/channel.pyi index af7fe21cf..cf7643561 100644 --- a/interactions/api/models/channel.pyi +++ b/interactions/api/models/channel.pyi @@ -1,9 +1,13 @@ from datetime import datetime from enum import IntEnum -from typing import List, Optional +from typing import List, Optional, Union +from .message import Message, Embed, MessageInteraction +from ...models.component import Component from .misc import DictSerializerMixin, Overwrite, Snowflake from .user import User +from ..http import HTTPClient + class ChannelType(IntEnum): ... @@ -26,6 +30,7 @@ class ThreadMember(DictSerializerMixin): class Channel(DictSerializerMixin): _json: dict + _client: HTTPClient id: Snowflake type: ChannelType guild_id: Optional[Snowflake] @@ -53,3 +58,28 @@ class Channel(DictSerializerMixin): default_auto_archive_duration: Optional[int] permissions: Optional[str] def __init__(self, **kwargs): ... + async def send( + self, + content: Optional[str] = None, + *, + tts: Optional[bool] = False, + # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. + embeds: Optional[Union[Embed, List[Embed]]] = None, + allowed_mentions: Optional[MessageInteraction] = None, + components: Optional[Union[Component, List[Component]]] = None, + ) -> Message: ... + + async def delete(self) -> None: ... + async def modify( + self, + name: Optional[str] = None, + topic: Optional[str] = None, + bitrate: Optional[int] = None, + user_limit: Optional[int] = None, + rate_limit_per_user: Optional[int] = None, + position: Optional[int] = None, + # permission_overwrites, + parent_id: Optional[int] = None, + nsfw: Optional[bool] = False, + reason: Optional[str] = None, + ) -> "Channel": ... diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 2b2b70b1a..13faf434d 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -1,8 +1,12 @@ from datetime import datetime +from typing import Optional, Union +from .channel import Channel, ChannelType +from .member import Member from .message import Emoji, Sticker from .misc import DictSerializerMixin, Snowflake from .presence import PresenceActivity +from .role import Role from .team import Application from .user import User @@ -141,6 +145,7 @@ class Guild(DictSerializerMixin): __slots__ = ( "_json", "id", + "_client", "name", "icon", "icon_hash", @@ -235,6 +240,417 @@ def __init__(self, **kwargs): if self._json.get("stickers") else None ) + self.members = ( + [Member(**member, _client=self._client) for member in self.members] + if self._json.get("members") + else None + ) + if not self.members and self._client: + members = self._client.cache.self_guilds.values[str(self.id)].members + if all(isinstance(member, Member) for member in members): + self.members = members + else: + self.members = [Member(**member, _client=self._client) for member in members] + + async def ban( + self, + member_id: int, + reason: Optional[str] = None, + delete_message_days: Optional[int] = 0, + ) -> None: + """ + Bans a member from the guild + :param member_id: The id of the member to ban + :type member_id: int + :param reason?: The reason of the ban + :type reason: Optional[str] + :param delete_message_days?: Number of days to delete messages, from 0 to 7. Defaults to 0 + :type delete_message_days: Optional[int] + """ + await self._client.create_guild_ban( + guild_id=int(self.id), + user_id=member_id, + reason=reason, + delete_message_days=delete_message_days, + ) + + async def remove_ban( + self, + user_id: int, + reason: Optional[str] = None, + ) -> None: + """ + Removes the ban of a user + :param user_id: The id of the user to remove the ban from + :type user_id: int + :param reason?: The reason for the removal of the ban + :type reason: Optional[str] + """ + await self._client.remove_guild_ban( + guild_id=int(self.id), + user_id=user_id, + reason=reason, + ) + + async def kick( + self, + member_id: int, + reason: Optional[str] = None, + ) -> None: + """ + Kicks a member from the guild + :param member_id: The id of the member to kick + :type member_id: int + :param reason?: The reason for the kick + :type reason: Optional[str] + """ + await self._client.create_guild_kick( + guild_id=int(self.id), + user_id=member_id, + reason=reason, + ) + + async def add_member_role( + self, + role: Union[Role, int], + member_id: int, + reason: Optional[str], + ) -> None: + """ + This method adds a role to a member + :param role: The role to add. Either ``Role`` object or role_id + :type role Union[Role, int] + :param member_id: The id of the member to add the roles to + :type member_id: int + :param reason?: The reason why the roles are added + :type reason: Optional[str] + """ + if isinstance(role, Role): + await self._client.add_member_role( + guild_id=int(self.id), + user_id=member_id, + role_id=int(role.id), + reason=reason, + ) + else: + await self._client.add_member_role( + guild_id=int(self.id), + user_id=member_id, + role_id=role, + reason=reason, + ) + + async def remove_member_role( + self, + role: Union[Role, int], + member_id: int, + reason: Optional[str], + ) -> None: + """ + This method removes a or multiple role(s) from a member + :param role: The role to remove. Either ``Role`` object or role_id + :type role: Union[Role, int] + :param member_id: The id of the member to remove the roles from + :type member_id: int + :param reason?: The reason why the roles are removed + :type reason: Optional[str] + """ + if isinstance(role, Role): + await self._client.remove_member_role( + guild_id=int(self.id), + user_id=member_id, + role_id=int(role.id), + reason=reason, + ) + else: + await self._client.remove_member_role( + guild_id=int(self.id), + user_id=member_id, + role_id=role, + reason=reason, + ) + + async def create_role( + self, + name: str, + # permissions, + color: Optional[int] = 0, + hoist: Optional[bool] = False, + # icon, + # unicode_emoji, + mentionable: Optional[bool] = False, + reason: Optional[str] = None, + ) -> Role: + """ + Creates a new role in the guild + :param name: The name of the role + :type name: str + :param color?: RGB color value as integer, default ``0`` + :type color: Optional[int] + :param hoist?: Whether the role should be displayed separately in the sidebar, default ``False`` + :type hoist: Optional[bool] + :param mentionable?: Whether the role should be mentionable, default ``False`` + :type mentionable: Optional[bool] + :param reason?: The reason why the role is created, default ``None`` + :type reason: Optional[str] + :return: The created Role + :rtype: Role + """ + + payload = Role( + name=name, + color=color, + hoist=hoist, + mentionable=mentionable, + ) + res = await self._client.create_guild_role( + guild_id=int(self.id), + reason=reason, + data=payload._json, + ) + role = Role(**res, _client=self._client) + return role + + async def get_member( + self, + member_id: int, + ) -> Member: + """ + Searches for the member with specified id in the guild and returns the member as member object + :param member_id: The id of the member to search for + :type member_id: int + """ + res = await self._client.get_member( + guild_id=int(self.id), + member_id=member_id, + ) + member = Member(**res, _client=self._client) + return member + + async def delete_channel( + self, + channel_id: int, + ) -> None: + """ + Deletes a channel from the guild + :param channel_id: The id of the channel to delete + :type channel_id: int + """ + await self._client.delete_channel( + channel_id=channel_id, + ) + + async def delete_role( + self, + role_id: int, + reason: Optional[str] = None, + ) -> None: + """ + Deletes a role from the guild + :param role_id: The id of the role to delete + :type role_id: int + :param reason?: The reason of the deletion + :type reason: Optional[str] + """ + + await self._client.delete_guild_role( + guild_id=int(self.id), + role_id=role_id, + reason=reason, + ) + + async def modify_role( + self, + role_id: int, + name: Optional[str] = None, + # permissions, + color: Optional[int] = None, + hoist: Optional[bool] = None, + # icon, + # unicode_emoji, + mentionable: Optional[bool] = None, + reason: Optional[str] = None, + ) -> Role: + """ + Edits a role in the guild + :param role_id: The id of the role to edit + :type role_id: int + :param name?: The name of the role, defaults to the current value of the role + :type name: Optional[str] + :param color?: RGB color value as integer, defaults to the current value of the role + :type color: Optional[int] + :param hoist?: Whether the role should be displayed separately in the sidebar, defaults to the current value of the role + :type hoist: Optional[bool] + :param mentionable?: Whether the role should be mentionable, defaults to the current value of the role + :type mentionable: Optional[bool] + :param reason?: The reason why the role is edited, default ``None`` + :type reason: Optional[str] + :return: The modified role object + :rtype: Role + """ + + roles = await self._client.get_all_roles(guild_id=int(self.id)) + for i in roles: + if int(i["id"]) == role_id: + role = Role(**i) + break + else: + pass + _name = role.name if not name else name + _color = role.color if not color else color + _hoist = role.hoist if not hoist else hoist + _mentionable = role.mentionable if mentionable is None else mentionable + + payload = Role(name=_name, color=_color, hoist=_hoist, mentionable=_mentionable) + + res = await self._client.modify_guild_role( + guild_id=int(self.id), + role_id=role_id, + data=payload._json, + reason=reason, + ) + return Role(**res, _client=self._client) + + async def create_channel( + self, + name: str, + type: ChannelType, + topic: Optional[str] = None, + bitrate: Optional[int] = None, + user_limit: Optional[int] = None, + rate_limit_per_user: Optional[int] = 0, + position: Optional[int] = None, + # permission_overwrites, + parent_id: Optional[int] = None, + nsfw: Optional[bool] = False, + reason: Optional[str] = None, + ) -> Channel: + """ + Creates a channel in the guild + :param name: The name of the channel + :type name: str + :param type: The type of the channel + :type type: ChannelType + :param topic?: The topic of that channel + :type topic: Optional[str] + :param bitrate?: (voice channel only) The bitrate (in bits) of the voice channel + :type bitrate Optional[int] + :param user_limit?: (voice channel only) Maximum amount of users in the channel + :type user_limit: Optional[int] + :param rate_limit_per_use?: Amount of seconds a user has to wait before sending another message (0-21600) + :type rate_limit_per_user: Optional[int] + :param position?: Sorting position of the channel + :type position: Optional[int] + :param parent_id?: The id of the parent category for a channel + :type parent_id: Optional[int] + :param nsfw?: Whether the channel is nsfw or not, default ``False`` + :type nsfw: Optional[bool] + :param reason: The reason for the creation + :type reason: Optional[str] + """ + + if ( + type == ChannelType.DM + or type == ChannelType.DM.value + or type == ChannelType.GROUP_DM + or type == ChannelType.GROUP_DM.value + ): + raise ValueError( + "ChannelType must not be a direct-message when creating Guild Channels!" + ) + + payload = Channel( + name=name, + type=type, + topic=topic, + bitrate=bitrate, + user_limit=user_limit, + rate_limit_per_user=rate_limit_per_user, + position=position, + parent_id=parent_id, + nsfw=nsfw, + ) + + res = await self._client.create_channel( + guild_id=int(self.id), + reason=reason, + payload=payload._json, + ) + + channel = Channel(**res, _client=self._client) + return channel + + async def modify_channel( + self, + channel_id: int, + name: Optional[str] = None, + topic: Optional[str] = None, + bitrate: Optional[int] = None, + user_limit: Optional[int] = None, + rate_limit_per_user: Optional[int] = None, + position: Optional[int] = None, + # permission_overwrites, + parent_id: Optional[int] = None, + nsfw: Optional[bool] = False, + reason: Optional[str] = None, + ) -> Channel: + """ + Edits a channel of the guild + :param channel_id: The id of the channel to modify + :type channel_id: int + :param name?: The name of the channel, defaults to the current value of the channel + :type name: str + :param topic?: The topic of that channel, defaults to the current value of the channel + :type topic: Optional[str] + :param bitrate?: (voice channel only) The bitrate (in bits) of the voice channel, defaults to the current value of the channel + :type bitrate Optional[int] + :param user_limit?: (voice channel only) Maximum amount of users in the channel, defaults to the current value of the channel + :type user_limit: Optional[int] + :param rate_limit_per_use?: Amount of seconds a user has to wait before sending another message (0-21600), defaults to the current value of the channel + :type rate_limit_per_user: Optional[int] + :param position?: Sorting position of the channel, defaults to the current value of the channel + :type position: Optional[int] + :param parent_id?: The id of the parent category for a channel, defaults to the current value of the channel + :type parent_id: Optional[int] + :param nsfw?: Whether the channel is nsfw or not, defaults to the current value of the channel + :type nsfw: Optional[bool] + :param reason: The reason for the edit + :type reason: Optional[str] + :return: The modified channel + :rtype: Channel + """ + ch = Channel(**await self._client.get_channel(channel_id=channel_id)) + + _name = ch.name if not name else name + _topic = ch.topic if not topic else topic + _bitrate = ch.bitrate if not bitrate else bitrate + _user_limit = ch.user_limit if not user_limit else user_limit + _rate_limit_per_user = ( + ch.rate_limit_per_user if not rate_limit_per_user else rate_limit_per_user + ) + _position = ch.position if not position else position + _parent_id = ch.parent_id if not parent_id else parent_id + _nsfw = ch.nsfw if not nsfw else nsfw + _type = ch.type + + payload = Channel( + name=_name, + type=_type, + topic=_topic, + bitrate=_bitrate, + user_limit=_user_limit, + rate_limit_per_user=_rate_limit_per_user, + position=_position, + parent_id=_parent_id, + nsfw=_nsfw, + ) + + res = await self._client.modify_channel( + channel_id=channel_id, + reason=reason, + data=payload._json, + ) + return Channel(**res, _client=self._client) class GuildPreview(DictSerializerMixin): diff --git a/interactions/api/models/guild.pyi b/interactions/api/models/guild.pyi index 5fa979dae..c7acfbdc6 100644 --- a/interactions/api/models/guild.pyi +++ b/interactions/api/models/guild.pyi @@ -1,14 +1,15 @@ from datetime import datetime from enum import Enum -from typing import Any, List, Optional +from typing import Any, List, Optional, Union -from .channel import Channel +from .channel import Channel, ChannelType from .member import Member from .message import Emoji, Sticker from .misc import DictSerializerMixin, Snowflake from .presence import PresenceUpdate from .role import Role from .user import User +from ..http import HTTPClient class WelcomeChannels(DictSerializerMixin): _json: dict @@ -36,6 +37,7 @@ class StageInstance(DictSerializerMixin): class Guild(DictSerializerMixin): _json: dict + _client: HTTPClient id: Snowflake name: str icon: Optional[str] @@ -96,6 +98,98 @@ class Guild(DictSerializerMixin): lazy: Any application_command_counts: Any def __init__(self, **kwargs): ... + async def ban( + self, + member_id: int, + reason: Optional[str] = None, + delete_message_days: Optional[int] = 0, + ) -> None: ... + async def remove_ban( + self, + user_id: int, + reason: Optional[str] = None, + ) -> None: ... + async def kick( + self, + member_id: int, + reason: Optional[str] = None, + ) -> None: ... + async def add_member_role( + self, + role: Union[Role, int], + member_id: int, + reason: Optional[str], + ) -> None: ... + async def remove_member_role( + self, + role: Union[Role, int], + member_id: int, + reason: Optional[str], + ) -> None: ... + async def create_role( + self, + name: str, + # permissions, + color: Optional[int] = 0, + hoist: Optional[bool] = False, + # icon, + # unicode_emoji, + mentionable: Optional[bool] = False, + reason: Optional[str] = None, + ) -> Role: ... + async def get_member( + self, + member_id: int, + ) -> Member: ... + async def delete_channel( + self, + channel_id: int, + ) -> None: ... + async def delete_role( + self, + role_id: int, + reason: Optional[str], + ) -> None: ... + async def modify_role( + self, + role_id: int, + name: Optional[str] = None, + # permissions, + color: Optional[int] = None, + hoist: Optional[bool] = None, + # icon, + # unicode_emoji, + mentionable: Optional[bool] = None, + reason: Optional[str] = None, + ) -> Role: ... + async def create_channel( + self, + name: str, + type: ChannelType, + topic: Optional[str] = None, + bitrate: Optional[int] = None, + user_limit: Optional[int] = None, + rate_limit_per_user: Optional[int] = 0, + position: Optional[int] = None, + # permission_overwrites, + parent_id: Optional[int] = None, + nsfw: Optional[bool] = False, + reason: Optional[str] = None + ) -> Channel: ... + async def modify_channel( + self, + channel_id: int, + name: Optional[str] = None, + topic: Optional[str] = None, + bitrate: Optional[int] = None, + user_limit: Optional[int] = None, + rate_limit_per_user: Optional[int] = None, + position: Optional[int] = None, + # permission_overwrites, + parent_id: Optional[int] = None, + nsfw: Optional[bool] = False, + reason: Optional[str] = None + ) -> Channel: ... class GuildPreview(DictSerializerMixin): _json: dict diff --git a/interactions/api/models/gw.py b/interactions/api/models/gw.py index fbddfdd92..f72c9e801 100644 --- a/interactions/api/models/gw.py +++ b/interactions/api/models/gw.py @@ -40,7 +40,7 @@ class GuildBan(DictSerializerMixin): :ivar User user: The user of the event. """ - __slots__ = ("_json", "guild_id", "user") + __slots__ = ("_json", "guild_id", "user", "_client") def __init__(self, **kwargs): super().__init__(**kwargs) @@ -105,6 +105,8 @@ class GuildMember(DictSerializerMixin): "avatar", "joined_at", "premium_since", + "is_pending", # TODO: investigate what this is. + "_client", "communication_disabled_until", # TODO: investigate what this is. "deaf", "mute", @@ -174,7 +176,14 @@ class GuildRole(DictSerializerMixin): :ivar Optional[Snowflake] role_id?: The role ID of the event. """ - __slots__ = ("_json", "guild_id", "role", "role_id") + __slots__ = ( + "_json", + "guild_id", + "role", + "role_id", + "_client", + "guild_hashes", # TODO: investigate what this is. + ) def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/interactions/api/models/gw.pyi b/interactions/api/models/gw.pyi index a717c0316..9dd6765b3 100644 --- a/interactions/api/models/gw.pyi +++ b/interactions/api/models/gw.pyi @@ -8,6 +8,7 @@ from .misc import ClientStatus, DictSerializerMixin, Snowflake from .presence import PresenceActivity from .role import Role from .user import User +from ..http import HTTPClient class ChannelPins(DictSerializerMixin): _json: dict @@ -20,6 +21,7 @@ class GuildBan(DictSerializerMixin): _json: dict guild_id: Snowflake user: User + _client: Optional[HTTPClient] def __init__(self, **kwargs): ... class GuildEmojis(DictSerializerMixin): @@ -45,6 +47,7 @@ class GuildMember(DictSerializerMixin): deaf: Optional[bool] mute: Optional[bool] pending: Optional[bool] + _client: Optional[HTTPClient] def __init__(self, **kwargs): ... class GuildMembers(DictSerializerMixin): @@ -65,6 +68,7 @@ class GuildRole(DictSerializerMixin): guild_id: Snowflake role: Role role_id: Optional[Snowflake] + _client: Optional[HTTPClient] def __init__(self, **kwargs): ... class GuildStickers(DictSerializerMixin): diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 07fdd6368..053729d7e 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -1,6 +1,8 @@ from datetime import datetime +from typing import Optional, Union from .misc import DictSerializerMixin +from .role import Role from .user import User @@ -41,6 +43,7 @@ class Member(DictSerializerMixin): "permissions", "communication_disabled_until", "hoisted_role", + "_client", ) def __init__(self, **kwargs): @@ -56,3 +59,156 @@ def __init__(self, **kwargs): if self._json.get("premium_since") else None ) + + async def ban( + self, + guild_id: int, + reason: Optional[str] = None, + delete_message_days: Optional[int] = 0, + ) -> None: + """ + Bans the member from a guild + :param guild_id: The id of the guild to ban the member from + :type guild_id: int + :param reason?: The reason of the ban + :type reason: Optional[str] + :param delete_message_days?: Number of days to delete messages, from 0 to 7. Defaults to 0 + :type delete_message_days: Optional[int] + """ + await self._client.create_guild_ban( + guild_id=guild_id, + user_id=int(self.user.id), + reason=reason, + delete_message_days=delete_message_days, + ) + + async def kick( + self, + guild_id: int, + reason: Optional[str] = None, + ) -> None: + """ + Kicks the member from a guild + :param guild_id: The id of the guild to kick the member from + :type guild_id: int + :param reason?: The reason for the kick + :type reason: Optional[str] + """ + await self._client.create_guild_kick( + guild_id=guild_id, + user_id=int(self.user.id), + reason=reason, + ) + + async def add_role( + self, + role: Union[Role, int], + guild_id: int, + reason: Optional[str], + ) -> None: + """ + This method adds a role to a member + :param role: The role to add. Either ``Role`` object or role_id + :type role: Union[Role, int] + :param guild_id: The id of the guild to add the roles to the member + :type guild_id: int + :param reason?: The reason why the roles are added + :type reason: Optional[str] + """ + if isinstance(role, Role): + await self._client.add_member_role( + guild_id=guild_id, + user_id=int(self.user.id), + role_id=int(role.id), + reason=reason, + ) + else: + await self._client.add_member_role( + guild_id=guild_id, + user_id=int(self.user.id), + role_id=role, + reason=reason, + ) + + async def remove_role( + self, + role: Union[Role, int], + guild_id: int, + reason: Optional[str], + ) -> None: + """ + This method removes a role from a member + :param role: The role to remove. Either ``Role`` object or role_id + :type role: Union[Role, int] + :param guild_id: The id of the guild to remove the roles of the member + :type guild_id: int + :param reason?: The reason why the roles are removed + :type reason: Optional[str] + """ + if isinstance(role, Role): + await self._client.remove_member_role( + guild_id=guild_id, + user_id=int(self.user.id), + role_id=int(role.id), + reason=reason, + ) + else: + await self._client.remove_member_role( + guild_id=guild_id, + user_id=int(self.user.id), + role_id=role, + reason=reason, + ) + + async def send( + self, + content: Optional[str] = None, + *, + tts: Optional[bool] = False, + # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. + embeds=None, + allowed_mentions=None, + ): + """ + Sends a DM to the member + + :param content?: The contents of the message as a string or string-converted value. + :type content: Optional[str] + :param tts?: Whether the message utilizes the text-to-speech Discord programme or not. + :type tts: Optional[bool] + :param embeds?: An embed, or list of embeds for the message. + :type embeds: Optional[Union[Embed, List[Embed]]] + :param allowed_mentions?: The message interactions/mention limits that the message can refer to. + :type allowed_mentions: Optional[MessageInteraction] + :return: The sent message as an object. + :rtype: Message + """ + from .channel import Channel + from .message import Message + + _content: str = "" if content is None else content + _tts: bool = False if tts is None else tts + # _file = None if file is None else file + # _attachments = [] if attachments else None + _embeds: list = ( + [] + if embeds is None + else ([embed._json for embed in embeds] if isinstance(embeds, list) else [embeds._json]) + ) + _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions + + # TODO: post-v4: Add attachments into Message obj. + payload = Message( + content=_content, + tts=_tts, + # file=file, + # attachments=_attachments, + embeds=_embeds, + allowed_mentions=_allowed_mentions, + ) + + channel = Channel(**await self._client.create_dm(recipient_id=int(self.user.id))) + res = await self._client.create_message(channel_id=int(channel.id), payload=payload._json) + + message = Message(**res, _client=self._client) + return message diff --git a/interactions/api/models/member.pyi b/interactions/api/models/member.pyi index b0f2c2f9f..d855d344d 100644 --- a/interactions/api/models/member.pyi +++ b/interactions/api/models/member.pyi @@ -1,13 +1,16 @@ from datetime import datetime -from typing import Any, List, Optional +from typing import Any, List, Optional, Union from .misc import DictSerializerMixin from .role import Role from .user import User +from ..http import HTTPClient +from .message import Message class Member(DictSerializerMixin): _json: dict + _client: HTTPClient user: Optional[User] nick: Optional[str] avatar: Optional[str] @@ -22,3 +25,35 @@ class Member(DictSerializerMixin): communication_disabled_until: Optional[str] hoisted_role: Any # TODO: post-v4: Investigate what this is for when documented by Discord. def __init__(self, **kwargs): ... + async def ban( + self, + guild_id: int, + reason: Optional[str] = None, + delete_message_days: Optional[int] = 0, + ) -> None: ... + async def kick( + self, + guild_id: int, + reason: Optional[str] = None, + ) -> None: ... + async def add_role( + self, + role: Union[Role, int], + guild_id: int, + reason: Optional[str], + ) -> None: ... + async def remove_role( + self, + role: Union[Role, int], + guild_id: int, + reason: Optional[str], + ) -> None: ... + async def send( + self, + content: Optional[str] = None, + *, + tts: Optional[bool] = False, + # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. + embeds=None, + allowed_mentions=None, + ) -> Message: ... diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index 4fa8d9c2f..4c60c4de4 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -1,5 +1,6 @@ from datetime import datetime from enum import IntEnum +from typing import List, Optional, Union from .channel import Channel, ChannelType from .member import Member @@ -226,6 +227,7 @@ class Message(DictSerializerMixin): "components", "sticker_items", "stickers", + "_client", ) def __init__(self, **kwargs): @@ -278,6 +280,150 @@ def __init__(self, **kwargs): ) self.thread = Channel(**self.thread) if self._json.get("thread") else None + async def get_channel(self) -> Channel: + """ + Gets the channel where the message was sent + :rtype: Channel + """ + res = await self._client.get_channel(channel_id=int(self.channel_id)) + return Channel(**res, _client=self._client) + + async def get_guild(self): + from .guild import Guild + + res = await self._client.get_guild(guild_id=int(self.guild_id)) + return Guild(**res, _client=self._client) + + async def delete(self, reason: Optional[str] = None) -> None: + """ + Deletes the message. + :param reason: Optional reason to show up in the audit log. Defaults to `None`. + :type reason: Optional[str] + """ + await self._client.delete_message( + message_id=int(self.id), channel_id=int(self.channel_id), reason=reason + ) + + async def edit( + self, + content: Optional[str] = None, + *, + tts: Optional[bool] = False, + # file: Optional[FileIO] = None, + embeds: Optional[Union["Embed", List["Embed"]]] = None, + allowed_mentions: Optional["MessageInteraction"] = None, + message_reference: Optional["MessageReference"] = None, + ) -> "Message": + """ + This method edits a message. Only available for messages sent by the bot. + + :param content?: The contents of the message as a string or string-converted value. + :type content: Optional[str] + :param tts?: Whether the message utilizes the text-to-speech Discord programme or not. + :type tts: Optional[bool] + :param embeds?: An embed, or list of embeds for the message. + :type embeds: Optional[Union[Embed, List[Embed]]] + :param allowed_mentions?: The message interactions/mention limits that the message can refer to. + :type allowed_mentions: Optional[MessageInteraction] + :return: The edited message as an object. + :rtype: Message + """ + + _content: str = "" if content is None else content + _tts: bool = True if bool(tts) else tts + # _file = None if file is None else file + _embeds: list = ( + [] + if embeds is None + else ([embed._json for embed in embeds] if isinstance(embeds, list) else [embeds._json]) + ) + _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions + _message_reference: dict = {} if message_reference is None else message_reference._json + + payload: Message = Message( + content=_content, + tts=_tts, + # file=file, + embeds=_embeds, + allowed_mentions=_allowed_mentions, + message_reference=_message_reference, + ) + + await self._client.edit_message( + channel_id=int(self.channel_id), message_id=int(self.id), payload=payload._json + ) + return payload + + async def reply( + self, + content: Optional[str] = None, + *, + tts: Optional[bool] = False, + # attachments: Optional[List[Any]] = None + embeds: Optional[Union["Embed", List["Embed"]]] = None, + allowed_mentions: Optional["MessageInteraction"] = None, + components=None, + ) -> "Message": + """ + Sends a new message replying to the old. + + :param content?: The contents of the message as a string or string-converted value. + :type content: Optional[str] + :param tts?: Whether the message utilizes the text-to-speech Discord programme or not. + :type tts: Optional[bool] + :param embeds?: An embed, or list of embeds for the message. + :type embeds: Optional[Union[Embed, List[Embed]]] + :param allowed_mentions?: The message interactions/mention limits that the message can refer to. + :type allowed_mentions: Optional[MessageInteraction] + :param components?: A component, or list of components for the message. + :type components: Optional[Union[Component, List[Component]]] + :return: The sent message as an object. + :rtype: Message + """ + + from ...models.component import ActionRow, Button, SelectMenu + + _content: str = "" if content is None else content + _tts: bool = True if bool(tts) else tts + # _file = None if file is None else file + # _attachments = [] if attachments else None + _embeds: list = ( + [] + if embeds is None + else ([embed._json for embed in embeds] if isinstance(embeds, list) else [embeds._json]) + ) + _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions + _components: list = [{"type": 1, "components": []}] + _message_reference = MessageReference(message_id=int(self.id))._json + + if isinstance(components, ActionRow): + _components[0]["components"] = [component._json for component in components.components] + elif isinstance(components, Button): + _components[0]["components"] = [] if components is None else [components._json] + elif isinstance(components, SelectMenu): + components._json["options"] = [option._json for option in components.options] + _components[0]["components"] = [] if components is None else [components._json] + else: + _components = [] if components is None else [components] + + # TODO: post-v4: Add attachments into Message obj. + payload = Message( + content=_content, + tts=_tts, + # file=file, + # attachments=_attachments, + embeds=_embeds, + message_reference=_message_reference, + allowed_mentions=_allowed_mentions, + components=_components, + ) + + res = await self._client.create_message( + channel_id=int(self.channel_id), payload=payload._json + ) + message = Message(**res, _client=self._client) + return message + class Emoji(DictSerializerMixin): """ diff --git a/interactions/api/models/message.pyi b/interactions/api/models/message.pyi index 43e7c3563..86d9e7da0 100644 --- a/interactions/api/models/message.pyi +++ b/interactions/api/models/message.pyi @@ -7,6 +7,10 @@ from .misc import DictSerializerMixin, Snowflake from .role import Role from .team import Application from .user import User +from ..http import HTTPClient +from ...models.component import ActionRow, Button, SelectMenu +from .guild import Guild + class MessageActivity(DictSerializerMixin): _json: dict @@ -51,6 +55,7 @@ class ChannelMention(DictSerializerMixin): def __init__(self, **kwargs): ... class Message(DictSerializerMixin): + _client: HTTPClient _json: dict id: Snowflake channel_id: Snowflake @@ -86,6 +91,31 @@ class Message(DictSerializerMixin): sticker_items: Optional[List["PartialSticker"]] stickers: Optional[List["Sticker"]] # deprecated def __init__(self, **kwargs): ... + async def delete(self, reason: Optional[str] = None) -> None: ... + async def edit( + self, + content: Optional[str] = None, + *, + tts: Optional[bool] = None, + # file: Optional[FileIO] = None, + embeds: Optional[Union["Embed", List["Embed"]]] = None, + allowed_mentions: Optional["MessageInteraction"] = None, + message_reference: Optional["MessageReference"] = None, + components: Optional[Union[ActionRow, Button, SelectMenu]] = None, + ) -> "Message": ... + + async def reply(self, + content: Optional[str] = None, + *, + tts: Optional[bool] = False, + # attachments: Optional[List[Any]] = None + embeds: Optional[Union["Embed", List["Embed"]]] = None, + allowed_mentions: Optional["MessageInteraction"] = None, + components=None, + ) -> "Message": ... + async def get_channel(self) -> Channel: ... + async def get_guild(self) -> Guild: ... + class Emoji(DictSerializerMixin): _json: dict diff --git a/interactions/api/models/role.py b/interactions/api/models/role.py index 6bc8f2a55..f06c659d5 100644 --- a/interactions/api/models/role.py +++ b/interactions/api/models/role.py @@ -1,3 +1,5 @@ +from typing import Optional + from .misc import DictSerializerMixin, Snowflake @@ -53,9 +55,72 @@ class Role(DictSerializerMixin): "mentionable", "tags", "permissions", + "_client", ) def __init__(self, **kwargs): super().__init__(**kwargs) self.id = Snowflake(self.id) if self._json.get("id") else None self.tags = RoleTags(**self.tags) if self._json.get("tags") else None + + async def delete( + self, + guild_id: int, + reason: Optional[str] = None, + ) -> None: + """ + Deletes the role from the guild + :param guild_id: The id of the guild to delete the role from + :type guild_id: int + :param reason: The reason for the deletion + :type reason: Optional[str] + """ + + await self._client.delete_guild_role( + guild_id=guild_id, role_id=int(self.id), reason=reason + ), + + async def modify( + self, + guild_id: int, + name: Optional[str] = None, + # permissions, + color: Optional[int] = None, + hoist: Optional[bool] = None, + # icon, + # unicode_emoji, + mentionable: Optional[bool] = None, + reason: Optional[str] = None, + ) -> "Role": + """ + Edits the role in a guild + :param guild_id: The id of the guild to edit the role on + :type guild_id: int + :param name?: The name of the role, defaults to the current value of the role + :type name: Optional[str] + :param color?: RGB color value as integer, defaults to the current value of the role + :type color: Optional[int] + :param hoist?: Whether the role should be displayed separately in the sidebar, defaults to the current value of the role + :type hoist: Optional[bool] + :param mentionable?: Whether the role should be mentionable, defaults to the current value of the role + :type mentionable: Optional[bool] + :param reason?: The reason why the role is edited, default ``None`` + :type reason: Optional[str] + :return: The modified role object + :rtype: Role + """ + + _name = self.name if not name else name + _color = self.color if not color else color + _hoist = self.hoist if not hoist else hoist + _mentionable = self.mentionable if mentionable is None else mentionable + + payload = Role(name=_name, color=_color, hoist=_hoist, mentionable=_mentionable) + + res = await self._client.modify_guild_role( + guild_id=guild_id, + role_id=int(self.id), + data=payload._json, + reason=reason, + ) + return Role(**res, _client=self._client) diff --git a/interactions/api/models/role.pyi b/interactions/api/models/role.pyi index 06f1835a3..6a47e3e98 100644 --- a/interactions/api/models/role.pyi +++ b/interactions/api/models/role.pyi @@ -1,6 +1,7 @@ from typing import Any, Optional from .misc import DictSerializerMixin, Snowflake +from ..http import HTTPClient class RoleTags(DictSerializerMixin): _json: dict @@ -11,6 +12,7 @@ class RoleTags(DictSerializerMixin): class Role(DictSerializerMixin): _json: dict + _client: HTTPClient id: Snowflake name: str color: int @@ -23,3 +25,20 @@ class Role(DictSerializerMixin): mentionable: bool tags: Optional[RoleTags] def __init__(self, **kwargs): ... + async def delete( + self, + guild_id: int, + reason: Optional[str] = None, + ) -> None: ... + async def modify( + self, + guild_id: int, + name: Optional[str] = None, + # permissions, + color: Optional[int] = None, + hoist: Optional[bool] = None, + # icon, + # unicode_emoji, + mentionable: Optional[bool] = None, + reason: Optional[str] = None, + ) -> "Role": ... diff --git a/interactions/client.py b/interactions/client.py index a694d7639..b6ca126cd 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -590,7 +590,7 @@ async def raw_guild_create(self, guild) -> dict: :return: The guild as a dictionary of raw data. :rtype: dict """ - self.http.cache.guilds.add(Build(id=str(guild.id), value=guild)) + self.http.cache.self_guilds.add(Build(id=str(guild.id), value=guild)) return guild._json From 4736f04556fe613af1e3dad8d8163cf540157109 Mon Sep 17 00:00:00 2001 From: James Walston Date: Thu, 23 Dec 2021 15:43:06 -0500 Subject: [PATCH 010/105] feat: allow JetBrains Space internal building. --- .gitignore | 3 ++- simple_bot.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c96bc0153..135283511 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.egg-info/ *.eggs/ *.pyc +.idea .cache/ _build/ build/ @@ -11,5 +12,5 @@ dist/ .venv .vscode __pycache__ - *.token +*.pypirc diff --git a/simple_bot.py b/simple_bot.py index c79ee8d36..e81e4a1d8 100644 --- a/simple_bot.py +++ b/simple_bot.py @@ -12,7 +12,7 @@ async def on_ready(): name="global-command", description="ever wanted a global command? well, here it is!", ) -async def basic_command(ctx): +async def basic_command(ctx: interactions.CommandContext): await ctx.send("Global commands are back in action, baby!") From fd2345e3bc10560792960b44203517211a54df76 Mon Sep 17 00:00:00 2001 From: James Walston Date: Thu, 23 Dec 2021 15:49:51 -0500 Subject: [PATCH 011/105] fix: `Thread` object being dispatched. --- interactions/api/models/channel.py | 11 +++++++++++ interactions/api/models/channel.pyi | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index be8e85b56..a753a0298 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -316,3 +316,14 @@ async def modify( data=payload._json, ) return Channel(**res, _client=self._client) + + +class Thread(Channel): + """An object representing a thread. + + .. note:: + This is a derivation of the base Channel, since a + thread can be its own event. + """ + + ... diff --git a/interactions/api/models/channel.pyi b/interactions/api/models/channel.pyi index cf7643561..e9abf620a 100644 --- a/interactions/api/models/channel.pyi +++ b/interactions/api/models/channel.pyi @@ -8,7 +8,6 @@ from .misc import DictSerializerMixin, Overwrite, Snowflake from .user import User from ..http import HTTPClient - class ChannelType(IntEnum): ... class ThreadMetadata(DictSerializerMixin): @@ -68,7 +67,6 @@ class Channel(DictSerializerMixin): allowed_mentions: Optional[MessageInteraction] = None, components: Optional[Union[Component, List[Component]]] = None, ) -> Message: ... - async def delete(self) -> None: ... async def modify( self, @@ -83,3 +81,5 @@ class Channel(DictSerializerMixin): nsfw: Optional[bool] = False, reason: Optional[str] = None, ) -> "Channel": ... + +class Thread(Channel): ... From bd65b64d0cf9d50f95894d16ad07c22a462bc335 Mon Sep 17 00:00:00 2001 From: James Walston Date: Thu, 23 Dec 2021 15:51:53 -0500 Subject: [PATCH 012/105] fix: `MessageReaction` object being dispatched. --- interactions/api/models/gw.py | 4 ++-- interactions/api/models/gw.pyi | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/interactions/api/models/gw.py b/interactions/api/models/gw.py index f72c9e801..ef1442e90 100644 --- a/interactions/api/models/gw.py +++ b/interactions/api/models/gw.py @@ -293,7 +293,7 @@ def __init__(self, **kwargs): ) -class Reaction(DictSerializerMixin): +class MessageReaction(DictSerializerMixin): """ A class object representing the gateway event ``MESSAGE_REACTION_ADD``. @@ -317,7 +317,7 @@ def __init__(self, **kwargs): self.emoji = Emoji(**self.emoji) if self._json.get("emoji") else None -class ReactionRemove(Reaction): +class ReactionRemove(MessageReaction): """ A class object representing the gateway events ``MESSAGE_REACTION_REMOVE``, ``MESSAGE_REACTION_REMOVE_ALL`` and ``MESSAGE_REACTION_REMOVE_EMOJI``. diff --git a/interactions/api/models/gw.pyi b/interactions/api/models/gw.pyi index 9dd6765b3..779ccaae4 100644 --- a/interactions/api/models/gw.pyi +++ b/interactions/api/models/gw.pyi @@ -57,9 +57,7 @@ class GuildMembers(DictSerializerMixin): chunk_index: int chunk_count: int not_found: Optional[list] - presences: Optional[ - List["Presence"] - ] + presences: Optional[List["Presence"]] nonce: Optional[str] def __init__(self, **kwargs): ... @@ -87,7 +85,7 @@ class Presence(DictSerializerMixin): activities: List[PresenceActivity] client_status: ClientStatus -class Reaction(DictSerializerMixin): +class MessageReaction(DictSerializerMixin): # There's no official data model for this, so this is pseudo for the most part here. _json: dict user_id: Optional[Snowflake] @@ -98,7 +96,7 @@ class Reaction(DictSerializerMixin): emoji: Optional[Emoji] def __init__(self, **kwargs): ... -class ReactionRemove(Reaction): +class ReactionRemove(MessageReaction): # typehinting already subclassed def __init__(self, **kwargs): ... From dc8163f380d555be7a13a3ff6357f2c5726b0072 Mon Sep 17 00:00:00 2001 From: James Walston Date: Thu, 23 Dec 2021 15:54:00 -0500 Subject: [PATCH 013/105] chore: cleanup `@component` confusion. --- interactions/client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index b6ca126cd..25da91517 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -371,7 +371,7 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: return decorator - def component(self, component: Union[Button, SelectMenu]) -> Callable[..., Any]: + def component(self, component: Union[str, Button, SelectMenu]) -> Callable[..., Any]: """ A decorator for listening to ``INTERACTION_CREATE`` dispatched gateway events involving components. @@ -380,6 +380,7 @@ def component(self, component: Union[Button, SelectMenu]) -> Callable[..., Any]: .. code-block:: python + # Method 1 @component(interactions.Button( style=interactions.ButtonStyle.PRIMARY, label="click me!", @@ -388,11 +389,16 @@ def component(self, component: Union[Button, SelectMenu]) -> Callable[..., Any]: async def button_response(ctx): ... + # Method 2 + @component("custom_id") + async def button_response(ctx): + ... + The context of the component callback decorator inherits the same as of the command decorator. :param component: The component you wish to callback for. - :type component: Union[Button, SelectMenu] + :type component: Union[str, Button, SelectMenu] :return: A callable response. :rtype: Callable[..., Any] """ From 3b2321a43526d9c1f987bfbfb20d979c297a5770 Mon Sep 17 00:00:00 2001 From: James Walston Date: Thu, 23 Dec 2021 16:00:06 -0500 Subject: [PATCH 014/105] fix: `Invite` missing attrs. --- interactions/api/models/guild.py | 17 ++++++++++++++++- interactions/api/models/guild.pyi | 10 ++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 13faf434d..1480dbebe 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -753,7 +753,22 @@ class Invite(DictSerializerMixin): :ivar datetime created_at: The time when this invite was created. """ - __slots__ = ("_json", "uses", "max_uses", "max_age", "temporary", "created_at") + __slots__ = ( + "_json", + "_client", + "uses", + "max_uses", + "max_age", + "temporary", + "created_at", + # TODO: Investigate their purposes and document. + "types", + "inviter", + "guild_id", + "expires_at", + "code", + "channel_id", + ) def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/interactions/api/models/guild.pyi b/interactions/api/models/guild.pyi index c7acfbdc6..d67b62985 100644 --- a/interactions/api/models/guild.pyi +++ b/interactions/api/models/guild.pyi @@ -174,7 +174,7 @@ class Guild(DictSerializerMixin): # permission_overwrites, parent_id: Optional[int] = None, nsfw: Optional[bool] = False, - reason: Optional[str] = None + reason: Optional[str] = None, ) -> Channel: ... async def modify_channel( self, @@ -188,7 +188,7 @@ class Guild(DictSerializerMixin): # permission_overwrites, parent_id: Optional[int] = None, nsfw: Optional[bool] = False, - reason: Optional[str] = None + reason: Optional[str] = None, ) -> Channel: ... class GuildPreview(DictSerializerMixin): @@ -206,6 +206,12 @@ class GuildPreview(DictSerializerMixin): class Invite(DictSerializerMixin): _json: dict + _client: HTTPClient + type: str + guild_id: Snowflake + expires_at: str + code: str + channel_id: Snowflake uses: int max_uses: int max_age: int From c6b0b91d026f05ab98d44f85aa2a58bcd2257238 Mon Sep 17 00:00:00 2001 From: James Walston Date: Thu, 23 Dec 2021 16:03:34 -0500 Subject: [PATCH 015/105] fix: `EmbedField` improperly generating in `Embed`. --- interactions/api/models/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index 4c60c4de4..ae8919c7a 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -729,6 +729,6 @@ def __init__(self, **kwargs): ) self.fields = ( [EmbedField(**field) for field in self.fields] - if isinstance(self._json.get("fields"), dict) + if isinstance(self._json.get("fields"), list) else self._json.get("fields") ) From 489fd6ab1a1f2c638e4d49e70bea82f65b02fbdc Mon Sep 17 00:00:00 2001 From: James Walston Date: Thu, 23 Dec 2021 16:07:27 -0500 Subject: [PATCH 016/105] fix: command/components not firing from same name. --- interactions/api/gateway.py | 4 ++-- interactions/client.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index a1f6391a0..a5abe237d 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -312,7 +312,7 @@ def check_sub_auto(option: dict) -> tuple: _args: list = [context] _kwargs: dict = dict() if data["type"] == InteractionType.APPLICATION_COMMAND: - _name = context.data.name + _name = f"command_{context.data.name}" if context.data._json.get("options"): if context.data.options: for option in context.data.options: @@ -322,7 +322,7 @@ def check_sub_auto(option: dict) -> tuple: ) ) elif data["type"] == InteractionType.MESSAGE_COMPONENT: - _name = context.data.custom_id + _name = f"component_{context.data.custom_id}" elif data["type"] == InteractionType.APPLICATION_COMMAND_AUTOCOMPLETE: _name = "autocomplete_" if context.data._json.get("options"): diff --git a/interactions/client.py b/interactions/client.py index 25da91517..330822330 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -281,7 +281,7 @@ def event(self, coro: Coroutine, name: Optional[str] = None) -> Callable[..., An :return: A callable response. :rtype: Callable[..., Any] """ - self.websocket.dispatch.register(coro, name=name) + self.websocket.dispatch.register(coro, name) return coro def command( @@ -367,7 +367,7 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: if self.automate_sync: [self.loop.run_until_complete(self.synchronize(command)) for command in commands] - return self.event(coro, name=name) + return self.event(coro, name=f"command_{name}") return decorator @@ -409,7 +409,7 @@ def decorator(coro: Coroutine) -> Any: if isinstance(component, (Button, SelectMenu)) else component ) - return self.event(coro, name=payload) + return self.event(coro, name=f"component_{payload}") return decorator From 0671209812137f13dff4111e57a03e00c1e155df Mon Sep 17 00:00:00 2001 From: James Walston Date: Thu, 23 Dec 2021 16:11:23 -0500 Subject: [PATCH 017/105] docs: Update `Guild`. --- interactions/api/models/guild.py | 3 +++ interactions/api/models/guild.pyi | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 1480dbebe..0257d88fa 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -123,6 +123,9 @@ class Guild(DictSerializerMixin): :ivar Optional[bool] large?: Whether the guild is considered "large." :ivar Optional[bool] unavailable?: Whether the guild is unavailable to access. :ivar Optional[int] member_count?: The amount of members in the guild. + :ivar Optional[List[Member]] members?: The members in the guild. + :ivar Optional[List[Channel]] channels?: The channels in the guild. + :ivar Optional[List[Thread]] threads?: All known threads in the guild. :ivar Optional[List[PresenceUpdate]] presences?: The list of presences in the guild. :ivar Optional[int] max_presences?: The maximum amount of presences allowed in the guild. :ivar Optional[int] max_members?: The maximum amount of members allowed in the guild. diff --git a/interactions/api/models/guild.pyi b/interactions/api/models/guild.pyi index d67b62985..63a61a70e 100644 --- a/interactions/api/models/guild.pyi +++ b/interactions/api/models/guild.pyi @@ -1,8 +1,7 @@ from datetime import datetime -from enum import Enum from typing import Any, List, Optional, Union -from .channel import Channel, ChannelType +from .channel import Channel, ChannelType, Thread from .member import Member from .message import Emoji, Sticker from .misc import DictSerializerMixin, Snowflake @@ -68,7 +67,7 @@ class Guild(DictSerializerMixin): member_count: Optional[int] members: Optional[List[Member]] channels: Optional[List[Channel]] - threads: Optional[List[Channel]] # threads, because of their metadata + threads: Optional[List[Thread]] # threads, because of their metadata presences: Optional[List[PresenceUpdate]] max_presences: Optional[int] max_members: Optional[int] From a377e7ef61c326f48aac7657d55daa159b6ccdf7 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Thu, 23 Dec 2021 16:36:27 -0500 Subject: [PATCH 018/105] docs: Redo simultaneous d.py and interactions client startup doc support. --- docs/faq.rst | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 4fcd49219..2c000bbed 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -62,11 +62,23 @@ What does that mean? Well, we'll show you: async def test(ctx): await ctx.send("Hello from discord-interactions!") - interactions.start() - dpy.run(token="...", bot=True) + loop = asyncio.get_event_loop() + + task2 = loop.create_task(dpy.start(token="...", bot=True)) + task1 = loop.create_task(interactions.ready()) + + gathered = asyncio.gather(task1, task2, loop=loop) + loop.run_until_complete(gathered) Both of these variables ``interactions`` and ``dpy`` will be able to run in the same established environment, and additionally -will both function properly as their respective libraries intend them to. What about the models, though? That's a simple answer: +will both function properly as their respective libraries intend them to. This implementation uses asyncio.gather to execute +both starts simultaneously as asyncio tasks, and runs them under one singular loop. + +Compared to traditional startup commands, ``interactions.ready()`` and ``dpy.start()`` is used instead of +the typical ``interactions.start()`` and ``dpy.run()`` methods because of synchronous/async functions. +``asyncio.gather()`` works with coroutines, hence the transition. + +What about the models, though? That's a simple answer: .. code-block:: python From cbaccc81048a11a30ce91eefee0ba54be8084fe2 Mon Sep 17 00:00:00 2001 From: James Walston <41456914+goverfl0w@users.noreply.github.com> Date: Sat, 1 Jan 2022 14:21:38 -0500 Subject: [PATCH 019/105] chore: update Copyright License --- LICENSE | 695 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 674 insertions(+), 21 deletions(-) diff --git a/LICENSE b/LICENSE index 30bb725f4..f288702d2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,674 @@ -MIT License - -Copyright (c) 2020-2021 eunwoo1104 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From d0d7e6b52545fa7392dc27aed5390408cd671e8f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 2 Jan 2022 08:22:38 -0500 Subject: [PATCH 020/105] [pre-commit.ci] pre-commit autoupdate (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0) Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> Co-authored-by: James Walston 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 514896ffe..26014a7ab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: requirements-txt-fixer name: Requirements From c5ab99b5e59086d294a04a8417eb09b9c9c4b8ce Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 2 Jan 2022 14:39:50 +0100 Subject: [PATCH 021/105] docs(components): fix typo in `label`. Co-authored-by: Thomas Petersson --- interactions/models/component.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/models/component.py b/interactions/models/component.py index 81d2a7433..675dc9b6b 100644 --- a/interactions/models/component.py +++ b/interactions/models/component.py @@ -114,7 +114,7 @@ class Button(DictSerializerMixin): :ivar ComponentType type: The type of button. Always defaults to ``2``. :ivar ButtonStyle style: The style of the button. :ivar str label: The label of the button. - :ivar Optional[Emoji] emoji?: The emoji used alongside the laebl of the button. + :ivar Optional[Emoji] emoji?: The emoji used alongside the label of the button. :ivar Optional[str] custom_id?: The customized "ID" of the button. :ivar Optional[str] url?: The URL route/path of the button. :ivar Optional[bool] disabled?: Whether the button is unable to be used. From ffc986502396f7ba1f83141ea6bafc15ea28b30b Mon Sep 17 00:00:00 2001 From: Toricane <73972068+Toricane@users.noreply.github.com> Date: Sun, 2 Jan 2022 11:37:30 -0800 Subject: [PATCH 022/105] fix: component sending/editing with context * fix: checks for component sending * more checks * fix select * fixed in send() as well * fix ActionRow sending & multiple * fix checking * fix List[Button | SelectMenu] sending * fix typo * implement list list components sending * testing * test * final commit pog * fix typehints for components= * implement interchangeable lists and ActionRows, and remove unused import * fix: url buttons not sending * fix: docstring and typehints * fix: docstring and typehints again --- interactions/context.py | 187 ++++++++++++++++++++++++++++++++++----- interactions/context.pyi | 8 +- 2 files changed, 170 insertions(+), 25 deletions(-) diff --git a/interactions/context.py b/interactions/context.py index 5f001035b..b4163eaa4 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -10,7 +10,7 @@ from .base import CustomFormatter, Data from .enums import InteractionCallbackType, InteractionType from .models.command import Choice -from .models.component import ActionRow, Button, Component, Modal, SelectMenu +from .models.component import ActionRow, Button, Modal, SelectMenu from .models.misc import InteractionData basicConfig(level=Data.LOGGER) @@ -156,7 +156,9 @@ async def send( # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds: Optional[Union[Embed, List[Embed]]] = None, allowed_mentions: Optional[MessageInteraction] = None, - components: Optional[Union[Component, List[Component]]] = None, + components: Optional[ + Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]] + ] = None, ephemeral: Optional[bool] = False, ) -> Message: """ @@ -172,7 +174,7 @@ async def send( :param allowed_mentions?: The message interactions/mention limits that the message can refer to. :type allowed_mentions: Optional[MessageInteraction] :param components?: A component, or list of components for the message. - :type components: Optional[Union[Component, List[Component]]] + :type components: Optional[Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]]] :param ephemeral?: Whether the response is hidden or not. :type ephemeral: Optional[bool] :return: The sent message as an object. @@ -188,26 +190,95 @@ async def send( else ([embed._json for embed in embeds] if isinstance(embeds, list) else [embeds._json]) ) _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions - _components: list = [{"type": 1, "components": []}] + _components: List[dict] = [{"type": 1, "components": []}] if ( isinstance(components, list) and components - and (isinstance(action_row, ActionRow) for action_row in components) + and all(isinstance(action_row, ActionRow) for action_row in components) ): _components = [ - {"type": 1, "components": [component._json for component in action_row.components]} + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in action_row.components + ], + } for action_row in components ] + elif ( + isinstance(components, list) + and components + and all(isinstance(component, (Button, SelectMenu)) for component in components) + ): + if isinstance(components[0], SelectMenu): + components[0]._json["options"] = [option._json for option in components[0].options] + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components + ], + } + ] + elif ( + isinstance(components, list) + and components + and all(isinstance(action_row, (list, ActionRow)) for action_row in components) + ): + _components = [] + for action_row in components: + for component in ( + action_row if isinstance(action_row, list) else action_row.components + ): + if isinstance(component, SelectMenu): + component._json["options"] = [option._json for option in component.options] + _components.append( + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in ( + action_row + if isinstance(action_row, list) + else action_row.components + ) + ], + } + ) elif isinstance(components, ActionRow): - _components[0]["components"] = [component._json for component in components.components] - elif isinstance(components, Button): - _components[0]["components"] = [] if components is None else [components._json] - elif isinstance(components, SelectMenu): - components._json["options"] = [option._json for option in components.options] - _components[0]["components"] = [] if components is None else [components._json] + _components[0]["components"] = [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components.components + ] + elif isinstance(components, (Button, SelectMenu)): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + elif components is None: + _components = None else: - _components = [] if components is None else [components] + _components = [] _ephemeral: int = (1 << 6) if ephemeral else 0 @@ -281,7 +352,9 @@ async def edit( embeds: Optional[Union[Embed, List[Embed]]] = None, allowed_mentions: Optional[MessageInteraction] = None, message_reference: Optional[MessageReference] = None, - components: Optional[Union[ActionRow, Button, SelectMenu]] = None, + components: Optional[ + Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]] + ] = None, ) -> Message: """ This allows the invocation state described in the "context" @@ -307,20 +380,88 @@ async def edit( if ( isinstance(components, list) and components - and (isinstance(action_row, ActionRow) for action_row in components) + and all(isinstance(action_row, ActionRow) for action_row in components) ): _components = [ - {"type": 1, "components": [component._json for component in action_row.components]} + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in action_row.components + ], + } for action_row in components ] + elif ( + isinstance(components, list) + and components + and all(isinstance(component, (Button, SelectMenu)) for component in components) + ): + if isinstance(components[0], SelectMenu): + components[0]._json["options"] = [option._json for option in components[0].options] + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components + ], + } + ] + elif ( + isinstance(components, list) + and components + and all(isinstance(action_row, (list, ActionRow)) for action_row in components) + ): + _components = [] + for action_row in components: + for component in ( + action_row if isinstance(action_row, list) else action_row.components + ): + if isinstance(component, SelectMenu): + component._json["options"] = [option._json for option in component.options] + _components.append( + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in ( + action_row + if isinstance(action_row, list) + else action_row.components + ) + ], + } + ) elif isinstance(components, ActionRow): - _components[0]["components"] = [component._json for component in components.components] - elif isinstance(components, Button): - _components[0]["components"] = [] if components is None else [components._json] - elif isinstance(components, SelectMenu): - components._json["options"] = [option._json for option in components.options] - _components[0]["components"] = [] if components is None else [components._json] - + _components[0]["components"] = [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components.components + ] + elif isinstance(components, (Button, SelectMenu)): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + elif components is None: + _components = None else: _components = [] diff --git a/interactions/context.pyi b/interactions/context.pyi index 3efad08bd..ab622c865 100644 --- a/interactions/context.pyi +++ b/interactions/context.pyi @@ -47,7 +47,9 @@ class CommandContext(Context): # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds: Optional[Union[Embed, List[Embed]]] = None, allowed_mentions: Optional[MessageInteraction] = None, - components: Optional[Union[Component, List[Component]]] = None, + components: Optional[ + Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]] + ] = None, ephemeral: Optional[bool] = False, ) -> Message: ... async def edit( @@ -58,7 +60,9 @@ class CommandContext(Context): # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds: Optional[Union[Embed, List[Embed]]] = None, allowed_mentions: Optional[MessageInteraction] = None, - components: Optional[Union[Component, List[Component]]] = None, + components: Optional[ + Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]] + ] = None, ) -> Message: ... async def delete(self) -> None: ... async def popup(self, modal: Modal): ... From 8f743b3853c3960417449ab23ab6a4124cfe9761 Mon Sep 17 00:00:00 2001 From: James Walston Date: Sun, 2 Jan 2022 16:36:09 -0500 Subject: [PATCH 023/105] feat!: Control `@autocomplete` by command/ID. --- interactions/api/gateway.py | 8 +++----- interactions/client.py | 13 +++++++++++-- interactions/context.py | 5 +---- simple_bot.py | 21 ++++++++++++++++++++- 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index a5abe237d..9411e573f 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -324,7 +324,7 @@ def check_sub_auto(option: dict) -> tuple: elif data["type"] == InteractionType.MESSAGE_COMPONENT: _name = f"component_{context.data.custom_id}" elif data["type"] == InteractionType.APPLICATION_COMMAND_AUTOCOMPLETE: - _name = "autocomplete_" + _name = f"autocomplete_{context.data.id}" if context.data._json.get("options"): if context.data.options: for option in context.data.options: @@ -332,7 +332,7 @@ def check_sub_auto(option: dict) -> tuple: option if isinstance(option, dict) else option._json ) if add_name: - _name += add_name + _name += f"_{add_name}" if add_args: _args.append(add_args) elif data["type"] == InteractionType.MODAL_SUBMIT: @@ -368,10 +368,8 @@ def contextualize(self, data: dict) -> object: elif data["type"] == InteractionType.MESSAGE_COMPONENT: _context = "ComponentContext" - context: object = getattr(__import__("interactions.context"), _context) - data["client"] = self.http - + context: object = getattr(__import__("interactions.context"), _context) return context(**data) async def send(self, data: Union[str, dict]) -> None: diff --git a/interactions/client.py b/interactions/client.py index 330822330..2d0a0a121 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -7,6 +7,8 @@ from logging import Logger, StreamHandler, basicConfig, getLogger from typing import Any, Callable, Coroutine, Dict, List, Optional, Union +from interactions.api.models.misc import Snowflake + from .api.cache import Cache from .api.cache import Item as Build from .api.error import InteractionException, JSONException @@ -413,7 +415,9 @@ def decorator(coro: Coroutine) -> Any: return decorator - def autocomplete(self, name: str) -> Callable[..., Any]: + def autocomplete( + self, name: str, command: Union[ApplicationCommand, int] + ) -> Callable[..., Any]: """ A decorator for listening to ``INTERACTION_CREATE`` dispatched gateway events involving autocompletion fields. @@ -428,12 +432,17 @@ async def autocomplete_choice_list(ctx): :param name: The name of the option to autocomplete. :type name: str + :param command: The command or commnd ID with the option. + :type command: Union[ApplicationCommand, int] :return: A callable response. :rtype: Callable[..., Any] """ + _command: Union[Snowflake, int] = ( + command.id if isinstance(command, ApplicationCommand) else command + ) def decorator(coro: Coroutine) -> Any: - return self.event(coro, name=f"autocomplete_{name}") + return self.event(coro, name=f"autocomplete_{_command}_{name}") return decorator diff --git a/interactions/context.py b/interactions/context.py index 5f001035b..b6f57ae31 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -315,10 +315,7 @@ async def edit( ] elif isinstance(components, ActionRow): _components[0]["components"] = [component._json for component in components.components] - elif isinstance(components, Button): - _components[0]["components"] = [] if components is None else [components._json] - elif isinstance(components, SelectMenu): - components._json["options"] = [option._json for option in components.options] + elif isinstance(components, (Button, SelectMenu)): _components[0]["components"] = [] if components is None else [components._json] else: diff --git a/simple_bot.py b/simple_bot.py index e81e4a1d8..4ba250947 100644 --- a/simple_bot.py +++ b/simple_bot.py @@ -13,7 +13,26 @@ async def on_ready(): description="ever wanted a global command? well, here it is!", ) async def basic_command(ctx: interactions.CommandContext): - await ctx.send("Global commands are back in action, baby!") + fancy_schmancy = interactions.SelectMenu( + custom_id="select_awesomeness", + placeholder="please select UWU :(", + options=[ + interactions.SelectOption(label="im pretty", value="prettiness"), + interactions.SelectOption(label="im quirky", value="teenager"), + interactions.SelectOption(label="im cool", value="hipster"), + ], + min_values=1, + max_values=1, + ) + await ctx.send("Global commands are back in action, baby!", components=fancy_schmancy) + + +@bot.component("select_awesomeness") +async def component_res(ctx: interactions.ComponentContext): + await ctx.edit( + "global pizza domination :pizza:.", + components=interactions.SelectMenu(custom_id="x", disabled=True), + ) # bot.load("simple_cog") From 4433f43907924c4a5969b0832a51ebf25b46ee8c Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Mon, 3 Jan 2022 13:18:16 -0500 Subject: [PATCH 024/105] fix!: Fix Option and Choice _json/dict parsing and document channel_id and guild_id. --- interactions/context.py | 4 ++++ interactions/models/command.py | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/interactions/context.py b/interactions/context.py index b4163eaa4..bb524f974 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -44,6 +44,8 @@ def __init__(self, **kwargs) -> None: self.member = Member(**self.member) if self._json.get("member") else None self.author = self.member self.user = User(**self.user) if self._json.get("user") else None + + # TODO: The below attributes are always None because they aren't by API return. self.channel = Channel(**self.channel) if self._json.get("channel") else None self.guild = Guild(**self.guild) if self._json.get("guild") else None @@ -73,6 +75,8 @@ class CommandContext(Context): :ivar Optional[List[Option]] options?: The options of the command in the interaction, if any. :ivar InteractionData data: The application command data. :ivar str token: The token of the interaction response. + :ivar Snowflake channel_id: The ID of the current channel. + :ivar Snowflake guild_id: The ID of the current guild. :ivar bool responded: Whether an original response was made or not. :ivar bool deferred: Whether the response was deferred or not. """ diff --git a/interactions/models/command.py b/interactions/models/command.py index 86d067db7..ac1625cd6 100644 --- a/interactions/models/command.py +++ b/interactions/models/command.py @@ -111,12 +111,16 @@ def __init__(self, **kwargs) -> None: if all(isinstance(option, dict) for option in self.options): self._json["options"] = [option for option in self.options] else: - self._json["options"] = [option._json for option in self.options] + self._json["options"] = [ + option if isinstance(option, dict) else option._json for option in self.options + ] if self._json.get("choices"): if isinstance(self._json.get("choices"), dict): self._json["choices"] = [choice for choice in self.choices] else: - self._json["choices"] = [choice._json for choice in self.choices] + self._json["choices"] = [ + choice if isinstance(choice, dict) else choice._json for choice in self.choices + ] class Permission(DictSerializerMixin): From ced3441ea5301a5b3b76d4e810c6f3191d18a8d0 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Mon, 3 Jan 2022 19:45:25 +0100 Subject: [PATCH 025/105] docs/feat: added new functions, fixed typos * fixed typos * added modify_member to guild * fixed bug * changed documentation * added modify to member * added add_to_thread * added add_member to channel * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * re-added add_member * Update interactions/api/models/channel.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * make requested changes * added client to context attributes * im dumb Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> --- README.rst | 2 +- interactions/api/error.py | 8 +-- interactions/api/http.py | 10 ++-- interactions/api/models/channel.py | 16 ++++++ interactions/api/models/channel.pyi | 4 ++ interactions/api/models/guild.py | 67 +++++++++++++++++++++++-- interactions/api/models/guild.pyi | 12 +++++ interactions/api/models/member.py | 76 ++++++++++++++++++++++++++++- interactions/api/models/member.pyi | 18 ++++++- interactions/api/models/message.py | 6 +-- interactions/api/models/misc.py | 6 +-- interactions/api/models/team.py | 2 +- interactions/client.py | 4 +- interactions/context.py | 10 ++-- interactions/decor.py | 2 +- interactions/enums.py | 2 +- interactions/models/command.py | 4 +- 17 files changed, 217 insertions(+), 32 deletions(-) diff --git a/README.rst b/README.rst index 7a540b0c3..c5e55a284 100644 --- a/README.rst +++ b/README.rst @@ -36,7 +36,7 @@ Ever since December 2019, this open-source project has become the culmination of - Looking for a compatible library that implements all interactions? - Itching to get your hands on slash commands, but in a simple manner? -Look no more! The goal of this library is to make all three of these questions go from possibilites to trivial matters. +Look no more! The goal of this library is to make all three of these questions go from possibilities to trivial matters. What can we do? *************** diff --git a/interactions/api/error.py b/interactions/api/error.py index 0e7248dc4..467e074fe 100644 --- a/interactions/api/error.py +++ b/interactions/api/error.py @@ -24,7 +24,7 @@ class InteractionException(Exception): and for extensive testing/review before integration. Likewise, this will show the concepts before use, and will be refined when time goes on. - :ivar interactions.api.error.ErrorFormatter _formatter: The built in formatter. + :ivar interactions.api.error.ErrorFormatter _formatter: The built-in formatter. :ivar dict _lookup: A dictionary containing the values from the built-in Enum. """ @@ -145,7 +145,7 @@ class GatewayException(InteractionException): """ This is a derivation of InteractionException in that this is used to represent Gateway closing OP codes. - :ivar ErrorFormatter _formatter: The built in formatter. + :ivar ErrorFormatter _formatter: The built-in formatter. :ivar dict _lookup: A dictionary containing the values from the built-in Enum. """ @@ -178,7 +178,7 @@ class HTTPException(InteractionException): """ This is a derivation of InteractionException in that this is used to represent HTTP Exceptions. - :ivar ErrorFormatter _formatter: The built in formatter. + :ivar ErrorFormatter _formatter: The built-in formatter. :ivar dict _lookup: A dictionary containing the values from the built-in Enum. """ @@ -204,7 +204,7 @@ class JSONException(InteractionException): """ This is a derivation of InteractionException in that this is used to represent JSON API Exceptions. - :ivar ErrorFormatter _formatter: The built in formatter. + :ivar ErrorFormatter _formatter: The built-in formatter. :ivar dict _lookup: A dictionary containing the values from the built-in Enum. """ diff --git a/interactions/api/http.py b/interactions/api/http.py index c4a51d80d..3f94d0f00 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -106,7 +106,7 @@ def __init__(self, lock: Lock) -> None: self.keep_open = True def click(self) -> None: - """Re-closes the lock after the instiantiation and invocation ends.""" + """Re-closes the lock after the instantiation and invocation ends.""" self.keep_open = False def __enter__(self) -> Any: @@ -609,7 +609,7 @@ async def get_guild_widget_settings(self, guild_id: int) -> dict: async def get_guild_widget_image(self, guild_id: int, style: Optional[str] = None) -> str: """ - Get a url representing a png image widget for the guild. + Get an url representing a png image widget for the guild. ..note:: See _ for list of styles. @@ -752,7 +752,7 @@ async def create_guild_from_guild_template( self, template_code: str, name: str, icon: Optional[str] = None ) -> Guild: """ - Create a a new guild based on a template. + Create a new guild based on a template. ..note:: This endpoint can only be used by bots in less than 10 guilds. @@ -1118,7 +1118,7 @@ async def get_list_of_members( async def search_guild_members(self, guild_id: int, query: str, limit: int = 1) -> List[Member]: """ - Search a guild for members who's username or nickname starts with provided string. + Search a guild for members whose username or nickname starts with provided string. :param guild_id: Guild ID snowflake. :param query: The string to search for @@ -2333,7 +2333,7 @@ async def delete_original_webhook_message(self, webhook_id: int, webhook_token: Route("DELETE", f"/webhooks/{webhook_id}/{webhook_token}/messages/@original") ) - # Emoji endpoints, a subset of guild but it should get it's own thing... + # Emoji endpoints, a subset of guild but it should get its own thing... async def get_all_emoji(self, guild_id: int) -> List[Emoji]: """ diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index a753a0298..0c6e6e4ec 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -317,6 +317,22 @@ async def modify( ) return Channel(**res, _client=self._client) + async def add_member( + self, + member_id: int, + ) -> None: + """ + This adds a member to the channel, if the channel is a thread + + :param member_id: The id of the member to add to the channel + :type member_id: int + """ + if not self.thread_metadata: + raise TypeError( + "The Channel you specified is not a thread!" + ) # TODO: Move to new error formatter. + await self._client.add_member_to_thread(thread_id=int(self.id), user_id=member_id) + class Thread(Channel): """An object representing a thread. diff --git a/interactions/api/models/channel.pyi b/interactions/api/models/channel.pyi index e9abf620a..e03d55e65 100644 --- a/interactions/api/models/channel.pyi +++ b/interactions/api/models/channel.pyi @@ -81,5 +81,9 @@ class Channel(DictSerializerMixin): nsfw: Optional[bool] = False, reason: Optional[str] = None, ) -> "Channel": ... + async def add_member( + self, + member_id: int, + ) -> None: ... class Thread(Channel): ... diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 0257d88fa..9d486084a 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional, Union +from typing import List, Optional, Union from .channel import Channel, ChannelType from .member import Member @@ -42,7 +42,7 @@ class WelcomeScreen(DictSerializerMixin): We assume it's for the welcome screen topic. - :ivar Optional[str] description?: The description of the welcome sceen. + :ivar Optional[str] description?: The description of the welcome screen. :ivar List[WelcomeChannels] welcome_channels: A list of welcome channels of the welcome screen. """ @@ -59,7 +59,7 @@ def __init__(self, **kwargs): class StageInstance(DictSerializerMixin): """ - A class object representing an instace of a stage channel in a guild. + A class object representing an instance of a stage channel in a guild. :ivar Snowflake id: The ID of the stage. :ivar Snowflake guild_id: The guild ID the stage is in. @@ -655,6 +655,65 @@ async def modify_channel( ) return Channel(**res, _client=self._client) + async def modify_member( + self, + member_id: int, + nick: Optional[str] = None, + roles: Optional[List[int]] = None, + mute: Optional[bool] = None, + deaf: Optional[bool] = None, + channel_id: Optional[int] = None, + communication_disabled_until: Optional[datetime.isoformat] = None, + reason: Optional[str] = None, + ) -> Member: + """ + Modifies a member of the guild. + + :param member_id: The id of the member to modify + :type member_id: int + :param nick?: The nickname of the member + :type nick: Optional[str] + :param roles?: A list of all role ids the member has + :type roles: Optional[List[int]] + :param mute?: whether the user is muted in voice channels + :type mute: Optional[bool] + :param deaf?: whether the user is deafened in voice channels + :type deaf: Optional[bool] + :param channel_id?: id of channel to move user to (if they are connected to voice) + :type channel_id: Optional[int] + :param communication_disabled_until?: when the user's timeout will expire and the user will be able to communicate in the guild again (up to 28 days in the future) + :type communication_disabled_until: Optional[datetime.isoformat] + :param reason?: The reason of the modifying + :type reason: Optional[str] + """ + + payload = {} + if nick: + payload["nick"] = nick + + if roles: + payload["roles"] = roles + + if channel_id: + payload["channel_id"] = channel_id + + if mute: + payload["mute"] = mute + + if deaf: + payload["deaf"] = deaf + + if communication_disabled_until: + payload["communication_disabled_until"] = communication_disabled_until + + res = await self._client.modify_member( + user_id=member_id, + guild_id=int(self.id), + payload=payload, + reason=reason, + ) + return Member(**res, _client=self._client) + class GuildPreview(DictSerializerMixin): """ @@ -851,7 +910,7 @@ class ScheduledEvents(DictSerializerMixin): :ivar Snowflake id: The ID of the scheduled event. :ivar Snowflake guild_id: The ID of the guild that this scheduled event belongs to. - :ivar Optional[Snowflake] channel_id?: The channel ID in wich the scheduled event belongs to, if any. + :ivar Optional[Snowflake] channel_id?: The channel ID in which the scheduled event belongs to, if any. :ivar Optional[Snowflake] creator_id?: The ID of the user that created the scheduled event. :ivar str name: The name of the scheduled event. :ivar str description: The description of the scheduled event. diff --git a/interactions/api/models/guild.pyi b/interactions/api/models/guild.pyi index 63a61a70e..f7fd5863e 100644 --- a/interactions/api/models/guild.pyi +++ b/interactions/api/models/guild.pyi @@ -190,6 +190,18 @@ class Guild(DictSerializerMixin): reason: Optional[str] = None, ) -> Channel: ... + async def modify_member( + self, + member_id: int, + nick: Optional[str] = None, + roles: Optional[List[int]] = None, + mute: Optional[bool] = None, + deaf: Optional[bool] = None, + channel_id: Optional[int] = None, + communication_disabled_until: Optional[datetime.isoformat] = None, + reason: Optional[str] = None, + ) -> Member: ... + class GuildPreview(DictSerializerMixin): _json: dict id: int diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 053729d7e..be43f9fd1 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional, Union +from typing import List, Optional, Union from .misc import DictSerializerMixin from .role import Role @@ -212,3 +212,77 @@ async def send( message = Message(**res, _client=self._client) return message + + async def modify( + self, + guild_id: int, + nick: Optional[str] = None, + roles: Optional[List[int]] = None, + mute: Optional[bool] = None, + deaf: Optional[bool] = None, + channel_id: Optional[int] = None, + communication_disabled_until: Optional[datetime.isoformat] = None, + reason: Optional[str] = None, + ) -> "Member": + """ + Modifies the member of a guild. + + :param guild_id: The id of the guild to modify the member on + :type guild_id: int + :param nick?: The nickname of the member + :type nick: Optional[str] + :param roles?: A list of all role ids the member has + :type roles: Optional[List[int]] + :param mute?: whether the user is muted in voice channels + :type mute: Optional[bool] + :param deaf?: whether the user is deafened in voice channels + :type deaf: Optional[bool] + :param channel_id?: id of channel to move user to (if they are connected to voice) + :type channel_id: Optional[int] + :param communication_disabled_until?: when the user's timeout will expire and the user will be able to communicate in the guild again (up to 28 days in the future) + :type communication_disabled_until: Optional[datetime.isoformat] + :param reason?: The reason of the modifying + :type reason: Optional[str] + """ + + payload = {} + if nick: + payload["nick"] = nick + + if roles: + payload["roles"] = roles + + if channel_id: + payload["channel_id"] = channel_id + + if mute: + payload["mute"] = mute + + if deaf: + payload["deaf"] = deaf + + if communication_disabled_until: + payload["communication_disabled_until"] = communication_disabled_until + + res = await self._client.modify_member( + user_id=int(self.user.id), + guild_id=guild_id, + payload=payload, + reason=reason, + ) + return Member(**res, _client=self._client) + + async def add_to_thread( + self, + thread_id: int, + ) -> None: + """ + Adds the member to a thread. + + :param thread_id: The id of the thread to add the member to + :type thread_id: int + """ + await self._client.add_member_to_thread( + user_id=int(self.user.id), + thread_id=thread_id, + ) diff --git a/interactions/api/models/member.pyi b/interactions/api/models/member.pyi index d855d344d..185c6498e 100644 --- a/interactions/api/models/member.pyi +++ b/interactions/api/models/member.pyi @@ -22,7 +22,7 @@ class Member(DictSerializerMixin): is_pending: Optional[bool] pending: Optional[bool] permissions: Optional[str] - communication_disabled_until: Optional[str] + communication_disabled_until: Optional[datetime.isoformat] hoisted_role: Any # TODO: post-v4: Investigate what this is for when documented by Discord. def __init__(self, **kwargs): ... async def ban( @@ -57,3 +57,19 @@ class Member(DictSerializerMixin): embeds=None, allowed_mentions=None, ) -> Message: ... + + async def modify( + self, + guild_id: int, + nick: Optional[str] = None, + roles: Optional[List[int]] = None, + mute: Optional[bool] = None, + deaf: Optional[bool] = None, + channel_id: Optional[int] = None, + communication_disabled_until: Optional[datetime.isoformat] = None, + reason: Optional[str] = None, + ) -> "Member": ... + async def add_to_thread( + self, + thread_id: int, + ) -> None: ... diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index ae8919c7a..cac6a39ec 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -171,7 +171,7 @@ class Message(DictSerializerMixin): :ivar Optional[datetime] edited_timestamp?: Timestamp denoting when the message was edited, if any. :ivar bool tts: Status dictating if this was a TTS message or not. :ivar bool mention_everyone: Status dictating of this message mentions everyone - :ivar Optional[List[Union[Member, User]]] mentions?: Array of user objects with an addictional partial member field. + :ivar Optional[List[Union[Member, User]]] mentions?: Array of user objects with an additional partial member field. :ivar Optional[List[str]] mention_roles?: Array of roles mentioned in this message :ivar Optional[List[ChannelMention]] mention_channels?: Channels mentioned in this message, if any. :ivar List[Attachment] attachments: An array of attachments @@ -187,9 +187,9 @@ class Message(DictSerializerMixin): :ivar Optional[Any] allowed_mentions: The allowed mentions of roles attached in the message. :ivar int flags: Message flags :ivar Optional[MessageInteraction] interaction?: Message interaction object, if the message is sent by an interaction. - :ivar Optional[Channel] thread:? The thread that started from this message, if any, with a thread member object embedded. + :ivar Optional[Channel] thread?: The thread that started from this message, if any, with a thread member object embedded. :ivar Optional[Union[Component, List[Component]]] components?: Components associated with this message, if any. - :ivar Optional[List[PartialSticker"]] sticker_items?: An array of message sticker item objects, if sent with them. + :ivar Optional[List[PartialSticker]] sticker_items?: An array of message sticker item objects, if sent with them. :ivar Optional[List[Sticker]] stickers?: Array of sticker objects sent with the message if any. Deprecated. """ diff --git a/interactions/api/models/misc.py b/interactions/api/models/misc.py index 653dbcffe..6d5ec8dd9 100644 --- a/interactions/api/models/misc.py +++ b/interactions/api/models/misc.py @@ -144,7 +144,7 @@ def process_id(self) -> int: @property def epoch(self) -> float: """ - This is the "Timestamp" field of the snowflake. + This is the Timestamp field of the snowflake. :return: A float containing the seconds since Discord Epoch. """ @@ -153,7 +153,7 @@ def epoch(self) -> float: @property def timestamp(self) -> datetime.datetime: """ - The Datetime object variation of the the "Timestamp" field of the snowflake. + The Datetime object variation of the Timestamp field of the snowflake. :return: The converted Datetime object from the Epoch. This respects UTC. """ @@ -203,7 +203,7 @@ class Format: def stylize(cls, format: str, **kwargs) -> str: r""" This takes a format style from the object and - converts it into a useable string for ease. + converts it into a usable string for ease. :param format: The format string to use. :type format: str diff --git a/interactions/api/models/team.py b/interactions/api/models/team.py index c0209d937..34c3fa526 100644 --- a/interactions/api/models/team.py +++ b/interactions/api/models/team.py @@ -53,7 +53,7 @@ def __init__(self, **kwargs): class Application(DictSerializerMixin): """ - A class object representing an appliation. + A class object representing an application. .. note:: ``type`` and ``hook`` are currently undocumented in the API. diff --git a/interactions/client.py b/interactions/client.py index 2d0a0a121..4a01a273a 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -160,7 +160,7 @@ async def synchronize(self, payload: Optional[ApplicationCommand] = None) -> Non """ Synchronizes the command specified by checking through the currently registered application commands on the API and - modifying if there is a detected chagne in structure. + modifying if there is a detected change in structure. .. warning:: This internal call does not need to be manually triggered, @@ -453,7 +453,7 @@ def modal(self, modal: Modal) -> Callable[..., Any]: .. error:: This feature is currently under experimental/**beta access** - to those whitelisted for tetsing. Currently using this will + to those whitelisted for testing. Currently using this will present you with an error with the modal not working. The structure for a modal callback: diff --git a/interactions/context.py b/interactions/context.py index bb524f974..d1a327e62 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -40,8 +40,12 @@ class Context(DictSerializerMixin): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - self.message = Message(**self.message) if self._json.get("message") else None - self.member = Member(**self.member) if self._json.get("member") else None + self.message = ( + Message(**self.message, _client=self.client) if self._json.get("message") else None + ) + self.member = ( + Member(**self.member, _client=self.client) if self._json.get("member") else None + ) self.author = self.member self.user = User(**self.user) if self._json.get("user") else None @@ -670,7 +674,7 @@ async def defer( self, ephemeral: Optional[bool] = False, edit_origin: Optional[bool] = False ) -> None: """ - This "defers" an component response, allowing up + This "defers" a component response, allowing up to a 15-minute delay between invocation and responding. :param ephemeral?: Whether the deferred state is hidden or not. diff --git a/interactions/decor.py b/interactions/decor.py index 56ddd8f8b..00408c1bb 100644 --- a/interactions/decor.py +++ b/interactions/decor.py @@ -21,7 +21,7 @@ def command( A wrapper designed to interpret the client-facing API for how a command is to be created and used. - :return: A list of command paylods. + :return: A list of command payloads. :rtype: List[ApplicationCommand] """ _type: int = 0 diff --git a/interactions/enums.py b/interactions/enums.py index b05d597df..3ce910e71 100644 --- a/interactions/enums.py +++ b/interactions/enums.py @@ -99,7 +99,7 @@ class PermissionType(IntEnum): class ComponentType(IntEnum): """ - An numerable object representing the types of a component. + An enumerable object representing the types of a component. :ivar ACTION_ROW: 1 :ivar BUTTON: 2 diff --git a/interactions/models/command.py b/interactions/models/command.py index ac1625cd6..1fbe8a4d2 100644 --- a/interactions/models/command.py +++ b/interactions/models/command.py @@ -50,7 +50,7 @@ class Option(DictSerializerMixin): interactions.Option( type=interactions.OptionType.STRING, name="option_name", - description="i'm a meaningless option in your life. (depressed noisese)", + description="i'm a meaningless option in your life. (depressed noises)", required=True, choices=[interactions.Choice(...)], # optional ) @@ -63,7 +63,7 @@ class Option(DictSerializerMixin): :ivar Optional[str] value?: The value that's currently typed out, if autocompleting. :ivar Optional[List[Choice]] choices?: The list of choices to select from. :ivar Optional[List[Option]] options?: The list of subcommand options included. - :ivar Optional[List[ChannelType] channel_types?: Restrictive shown channel types, if given. + :ivar Optional[List[ChannelType]] channel_types?: Restrictive shown channel types, if given. :ivar Optional[int] min_value?: The minimum value supported by the option. :ivar Optional[int] max_value?: The maximum value supported by the option. :ivar Optional[bool] autocomplete?: A status denoting whether this option is an autocomplete option. From f6fcda81681e102c4e61d3bcd27868dc046444d0 Mon Sep 17 00:00:00 2001 From: James Walston Date: Tue, 4 Jan 2022 14:46:47 -0500 Subject: [PATCH 026/105] chore: refactor with Sourcery AI. --- interactions/api/gateway.py | 27 ++++++------ interactions/api/models/channel.py | 3 +- interactions/api/models/guild.py | 23 ++++------ interactions/api/models/member.py | 3 +- interactions/api/models/message.py | 3 +- interactions/api/models/misc.py | 2 +- interactions/client.py | 11 +++-- interactions/context.py | 69 +++++++++++++++--------------- interactions/decor.py | 5 +-- interactions/models/command.py | 4 +- 10 files changed, 68 insertions(+), 82 deletions(-) diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index 9411e573f..5ff5d64c4 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -205,13 +205,11 @@ async def handle_connection( await self.heartbeat() self.keep_alive.start() - if op == OpCodeType.HEARTBEAT: - if self.keep_alive: - await self.heartbeat() + if op == OpCodeType.HEARTBEAT and self.keep_alive: + await self.heartbeat() - if op == OpCodeType.HEARTBEAT_ACK: - if self.keep_alive: - log.debug("HEARTBEAT_ACK") + if op == OpCodeType.HEARTBEAT_ACK and self.keep_alive: + log.debug("HEARTBEAT_ACK") if op in (OpCodeType.INVALIDATE_SESSION, OpCodeType.RECONNECT): log.debug("INVALID_SESSION/RECONNECT") @@ -226,15 +224,14 @@ async def handle_connection( self.session_id = None self.sequence = None self.closed = True + elif event == "READY": + self.session_id = data["session_id"] + self.sequence = stream["s"] + self.dispatch.dispatch("on_ready") + log.debug(f"READY (SES_ID: {self.session_id}, SEQ_ID: {self.sequence})") else: - if event == "READY": - self.session_id = data["session_id"] - self.sequence = stream["s"] - self.dispatch.dispatch("on_ready") - log.debug(f"READY (SES_ID: {self.session_id}, SEQ_ID: {self.sequence})") - else: - log.debug(f"{event}: {dumps(data, indent=4, sort_keys=True)}") - self.handle_dispatch(event, data) + log.debug(f"{event}: {dumps(data, indent=4, sort_keys=True)}") + self.handle_dispatch(event, data) def handle_dispatch(self, event: str, data: dict) -> None: """ @@ -246,7 +243,7 @@ def handle_dispatch(self, event: str, data: dict) -> None: """ def check_sub_command(option: dict) -> dict: - kwargs = dict() + kwargs: dict = {} if option["type"] == OptionType.SUB_COMMAND_GROUP: kwargs["sub_command_group"] = option["name"] if option.get("options"): diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index a753a0298..333552f58 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -242,8 +242,7 @@ async def send( ) res = await self._client.create_message(channel_id=int(self.id), payload=payload._json) - message = Message(**res, _client=self._client) - return message + return Message(**res, _client=self._client) async def delete(self) -> None: """ diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 0257d88fa..ebe9473a0 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -411,8 +411,7 @@ async def create_role( reason=reason, data=payload._json, ) - role = Role(**res, _client=self._client) - return role + return Role(**res, _client=self._client) async def get_member( self, @@ -427,8 +426,7 @@ async def get_member( guild_id=int(self.id), member_id=member_id, ) - member = Member(**res, _client=self._client) - return member + return Member(**res, _client=self._client) async def delete_channel( self, @@ -497,8 +495,6 @@ async def modify_role( if int(i["id"]) == role_id: role = Role(**i) break - else: - pass _name = role.name if not name else name _color = role.color if not color else color _hoist = role.hoist if not hoist else hoist @@ -552,12 +548,12 @@ async def create_channel( :type reason: Optional[str] """ - if ( - type == ChannelType.DM - or type == ChannelType.DM.value - or type == ChannelType.GROUP_DM - or type == ChannelType.GROUP_DM.value - ): + if type in [ + ChannelType.DM, + ChannelType.DM.value, + ChannelType.GROUP_DM, + ChannelType.GROUP_DM.value, + ]: raise ValueError( "ChannelType must not be a direct-message when creating Guild Channels!" ) @@ -580,8 +576,7 @@ async def create_channel( payload=payload._json, ) - channel = Channel(**res, _client=self._client) - return channel + return Channel(**res, _client=self._client) async def modify_channel( self, diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 053729d7e..53ec3a6ba 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -210,5 +210,4 @@ async def send( channel = Channel(**await self._client.create_dm(recipient_id=int(self.user.id))) res = await self._client.create_message(channel_id=int(channel.id), payload=payload._json) - message = Message(**res, _client=self._client) - return message + return Message(**res, _client=self._client) diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index ae8919c7a..d502a1186 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -421,8 +421,7 @@ async def reply( res = await self._client.create_message( channel_id=int(self.channel_id), payload=payload._json ) - message = Message(**res, _client=self._client) - return message + return Message(**res, _client=self._client) class Emoji(DictSerializerMixin): diff --git a/interactions/api/models/misc.py b/interactions/api/models/misc.py index 653dbcffe..cb19ec616 100644 --- a/interactions/api/models/misc.py +++ b/interactions/api/models/misc.py @@ -215,5 +215,5 @@ def stylize(cls, format: str, **kwargs) -> str: new: str = f"" # noqa: F541 for kwarg in kwargs: if format == kwarg: - new = new % format + new %= format return new diff --git a/interactions/client.py b/interactions/client.py index 2d0a0a121..dfe7f083c 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -350,12 +350,11 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: raise InteractionException( 11, message="Your command needs at least one argument to return context." ) - if options: - if (len(coro.__code__.co_varnames) + 1) < len(options): - raise InteractionException( - 11, - message="You must have the same amount of arguments as the options of the command.", - ) + if options and (len(coro.__code__.co_varnames) + 1) < len(options): + raise InteractionException( + 11, + message="You must have the same amount of arguments as the options of the command.", + ) commands: List[ApplicationCommand] = command( type=type, diff --git a/interactions/context.py b/interactions/context.py index b4163eaa4..8f05a9ea5 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -486,38 +486,37 @@ async def func(): token=self.token, application_id=str(self.application_id), ) + elif ( + self.callback == InteractionCallbackType.DEFERRED_UPDATE_MESSAGE + and self.type == InteractionType.MESSAGE_COMPONENT + ): + res = await self.client.edit_interaction_response( + data=payload._json, + token=self.token, + application_id=str(self.application_id), + ) + self.responded = True + self.message = Message(**res) + elif hasattr(self.message, "id") and self.message.id is not None: + res = await self.client.edit_message( + int(self.channel_id), int(self.message.id), payload=payload._json + ) + self.message = Message(**res) else: - if ( - self.callback == InteractionCallbackType.DEFERRED_UPDATE_MESSAGE - and self.type == InteractionType.MESSAGE_COMPONENT - ): - res = await self.client.edit_interaction_response( - data=payload._json, - token=self.token, - application_id=str(self.application_id), - ) - self.responded = True - self.message = Message(**res) - elif hasattr(self.message, "id") and self.message.id is not None: - res = await self.client.edit_message( - int(self.channel_id), int(self.message.id), payload=payload._json - ) - self.message = Message(**res) + res = await self.client.edit_interaction_response( + token=self.token, + application_id=str(self.id), + data={"type": self.callback.value, "data": payload._json}, + message_id=self.message.id if self.message else "@original", + ) + if res["flags"] == 64: + log.warning("You can't edit hidden messages.") + self.message = payload else: - res = await self.client.edit_interaction_response( - token=self.token, - application_id=str(self.id), - data={"type": self.callback.value, "data": payload._json}, - message_id=self.message.id if self.message else "@original", + await self.client.edit_message( + int(self.channel_id), res["id"], payload=payload._json ) - if res["flags"] == 64: - log.warning("You can't edit hidden messages.") - self.message = payload - else: - await self.client.edit_message( - int(self.channel_id), res["id"], payload=payload._json - ) - self.message = Message(**res) + self.message = Message(**res) else: self.callback = ( InteractionCallbackType.UPDATE_MESSAGE @@ -577,12 +576,11 @@ async def func(): _choices: list = [] if all(isinstance(choice, Choice) for choice in choices): _choices = [choice._json for choice in choices] - # elif all(isinstance(choice, Dict[str, Any]) for choice in choices): elif all( isinstance(choice, dict) and all(isinstance(x, str) for x in choice) for choice in choices ): - _choices = [choice for choice in choices] + _choices = list(choices) elif isinstance(choices, Choice): _choices = [choices._json] else: @@ -677,10 +675,11 @@ async def defer( self.deferred = True _ephemeral: int = (1 << 6) if bool(ephemeral) else 0 # ephemeral doesn't change callback typings. just data json - if self.type == InteractionType.MESSAGE_COMPONENT and edit_origin: - self.callback = InteractionCallbackType.DEFERRED_UPDATE_MESSAGE - elif self.type == InteractionType.MESSAGE_COMPONENT and not edit_origin: - self.callback = InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE + if self.type == InteractionType.MESSAGE_COMPONENT: + if edit_origin: + self.callback = InteractionCallbackType.DEFERRED_UPDATE_MESSAGE + else: + self.callback = InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE await self.client.create_interaction_response( token=self.token, diff --git a/interactions/decor.py b/interactions/decor.py index 56ddd8f8b..95fc10267 100644 --- a/interactions/decor.py +++ b/interactions/decor.py @@ -40,7 +40,7 @@ def command( isinstance(option, dict) and all(isinstance(value, str) for value in option) for option in options ): - _options = [option for option in options] + _options = list(options) elif isinstance(options, Option): _options = [options._json] else: @@ -110,5 +110,4 @@ def component(component: Union[Button, SelectMenu]) -> Component: :return: A component. :rtype: Component """ - payload: Component = Component(**component._json) - return payload + return Component(**component._json) diff --git a/interactions/models/command.py b/interactions/models/command.py index 86d067db7..a628bc3fa 100644 --- a/interactions/models/command.py +++ b/interactions/models/command.py @@ -109,12 +109,12 @@ def __init__(self, **kwargs) -> None: self._json.update({"type": self.type.value}) if self._json.get("options"): if all(isinstance(option, dict) for option in self.options): - self._json["options"] = [option for option in self.options] + self._json["options"] = list(self.options) else: self._json["options"] = [option._json for option in self.options] if self._json.get("choices"): if isinstance(self._json.get("choices"), dict): - self._json["choices"] = [choice for choice in self.choices] + self._json["choices"] = list(self.choices) else: self._json["choices"] = [choice._json for choice in self.choices] From 64452a7a47fb90eb054d6071a65b9fd1a73cf463 Mon Sep 17 00:00:00 2001 From: Toricane <73972068+Toricane@users.noreply.github.com> Date: Tue, 4 Jan 2022 13:08:35 -0800 Subject: [PATCH 027/105] feat: implement @message_command and @user_command --- interactions/client.py | 111 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/interactions/client.py b/interactions/client.py index cf6da9eb5..7d7be4145 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -372,6 +372,117 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: return decorator + def message_command( + self, + *, + name: Optional[str] = None, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, + default_permission: Optional[bool] = None, + ) -> Callable[..., Any]: + """ + A decorator for registering a message context menu to the Discord API, + as well as being able to listen for ``INTERACTION_CREATE`` dispatched + gateway events. + The structure of a user context menu: + .. code-block:: python + @message_command(name="Context menu name") + async def context_menu_name(ctx): + ... + The ``scope`` kwarg field may also be used to designate the command in question + applicable to a guild or set of guilds. + :param name: The name of the application command. This *is* required but kept optional to follow kwarg rules. + :type name: Optional[str] + :param scope?: The "scope"/applicable guilds the application command applies to. Defaults to ``None``. + :type scope: Optional[Union[int, Guild, List[int], List[Guild]]] + :param default_permission?: The default permission of accessibility for the application command. Defaults to ``True``. + :type default_permission: Optional[bool] + :return: A callable response. + :rtype: Callable[..., Any] + """ + + def decorator(coro: Coroutine) -> Callable[..., Any]: + if not name: + raise InteractionException(11, message="Your command must have a name.") + + if not len(coro.__code__.co_varnames): + raise InteractionException( + 11, + message="Your command needs at least one argument to return context.", + ) + + commands: List[ApplicationCommand] = command( + type=ApplicationCommandType.MESSAGE, + name=name, + description=None, + scope=scope, + options=None, + default_permission=default_permission, + ) + + if self.automate_sync: + [self.loop.run_until_complete(self.synchronize(command)) for command in commands] + + return self.event(coro, name=f"command_{name}") + + return decorator + + def user_command( + self, + *, + name: Optional[str] = None, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, + default_permission: Optional[bool] = None, + ) -> Callable[..., Any]: + """ + A decorator for registering a user context menu to the Discord API, + as well as being able to listen for ``INTERACTION_CREATE`` dispatched + gateway events. + The structure of a user context menu: + .. code-block:: python + @user_command(name="Context menu name") + async def context_menu_name(ctx): + ... + The ``scope`` kwarg field may also be used to designate the command in question + applicable to a guild or set of guilds. + :param name: The name of the application command. This *is* required but kept optional to follow kwarg rules. + :type name: Optional[str] + :param scope?: The "scope"/applicable guilds the application command applies to. Defaults to ``None``. + :type scope: Optional[Union[int, Guild, List[int], List[Guild]]] + :param default_permission?: The default permission of accessibility for the application command. Defaults to ``True``. + :type default_permission: Optional[bool] + :return: A callable response. + :rtype: Callable[..., Any] + """ + + def decorator(coro: Coroutine) -> Callable[..., Any]: + if not name: + raise InteractionException(11, message="Your command must have a name.") + + if not len(coro.__code__.co_varnames): + raise InteractionException( + 11, + message="Your command needs at least one argument to return context.", + ) + + commands: List[ApplicationCommand] = command( + type=ApplicationCommandType.USER, + name=name, + description=None, + scope=scope, + options=None, + default_permission=default_permission, + ) + + if self.automate_sync: + [ + self.loop.run_until_complete(self.synchronize(command)) + for command in commands + ] + + return self.event(coro, name=f"command_{name}") + + return decorator + def component(self, component: Union[str, Button, SelectMenu]) -> Callable[..., Any]: """ A decorator for listening to ``INTERACTION_CREATE`` dispatched gateway From 8dd8795b8a99fc43b5ebc8f1ebc60f15630f647f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Jan 2022 21:54:19 +0000 Subject: [PATCH 028/105] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- interactions/client.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index 7d7be4145..59d606ce1 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -425,7 +425,7 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: return self.event(coro, name=f"command_{name}") return decorator - + def user_command( self, *, @@ -474,10 +474,7 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: ) if self.automate_sync: - [ - self.loop.run_until_complete(self.synchronize(command)) - for command in commands - ] + [self.loop.run_until_complete(self.synchronize(command)) for command in commands] return self.event(coro, name=f"command_{name}") From 67887f45f79cb3998e0e194ce03d097e7feb1a6a Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Wed, 5 Jan 2022 12:22:27 +0100 Subject: [PATCH 029/105] feat: add new helper methods * added new functions * added return value to modify_guild * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * Update interactions/api/models/guild.py Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * changes Co-authored-by: James Walston <41456914+goverfl0w@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- interactions/api/http.py | 18 +- interactions/api/models/guild.py | 326 +++++++++++++++++++++++++++++- interactions/api/models/guild.pyi | 67 +++++- 3 files changed, 403 insertions(+), 8 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index 3f94d0f00..a875caf15 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -557,19 +557,20 @@ async def get_guild_preview(self, guild_id: int) -> GuildPreview: async def modify_guild( self, guild_id: int, payload: dict, reason: Optional[str] = None - ) -> None: + ) -> dict: """ Modifies a guild's attributes. - ..note:: - This only sends the payload. You will have to check it when a higher-level function calls this. - :param guild_id: Guild ID snowflake. :param payload: The parameters to change. :param reason: Reason to send to the audit log, if given. + :return: The modified guild object as a dictionary + :rtype: dict """ - await self._req.request(Route("PATCH", f"/guilds/{guild_id}"), json=payload, reason=reason) + return await self._req.request( + Route("PATCH", f"/guilds/{guild_id}"), json=payload, reason=reason + ) async def leave_guild(self, guild_id: int) -> None: """ @@ -2410,13 +2411,16 @@ async def create_scheduled_event(self, guild_id: Snowflake, data: dict) -> dict: "name", "privacy_level", "scheduled_start_time", + "scheduled_end_time", + "entity_metadata", "description", "entity_type", ) payload = {k: v for k, v in data.items() if k in valid_keys} return await self._req.request( - Route("POST", "guilds/{guild_id}/scheduled-events/", guild_id=guild_id), json=payload + Route("POST", "guilds/{guild_id}/scheduled-events/", guild_id=int(guild_id)), + json=payload, ) async def get_scheduled_event( @@ -2476,6 +2480,8 @@ async def modify_scheduled_event( "name", "privacy_level", "scheduled_start_time", + "scheduled_end_time", + "entity_metadata", "description", "entity_type", ) diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index dc8526f2a..e1f1bb2f0 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -1,4 +1,5 @@ from datetime import datetime +from enum import IntEnum from typing import List, Optional, Union from .channel import Channel, ChannelType @@ -11,6 +12,48 @@ from .user import User +class VerificationLevel(IntEnum): + """An enumerable object representing the verification level of a guild.""" + + NONE = 0 + LOW = 1 + MEDIUM = 2 + HIGH = 3 + VERY_HIGH = 4 + + +class ExplicitContentFilterLevel(IntEnum): + """An enumerable object representing the explicit content filter level of a guild.""" + + DISABLED = 0 + MEMBERS_WITHOUT_ROLES = 1 + ALL_MEMBERS = 2 + + +class DefaultMessageNotificationLevel(IntEnum): + """An enumerable object representing the default message notification level of a guild.""" + + ALL_MESSAGES = 0 + ONLY_MENTIONS = 1 + + +class EntityType(IntEnum): + """An enumerable object representing the type of event.""" + + STAGE_INSTANCE = 1 + VOICE = 2 + EXTERNAL = 3 + + +class EventStatus(IntEnum): + """An enumerable object representing the status of an event.""" + + SCHEDULED = 1 + ACTIVE = 2 + COMPLETED = 3 + CANCELED = 4 + + class WelcomeChannels(DictSerializerMixin): """ A class object representing a welcome channel on the welcome screen. @@ -555,7 +598,7 @@ async def create_channel( ChannelType.GROUP_DM.value, ]: raise ValueError( - "ChannelType must not be a direct-message when creating Guild Channels!" + "ChannelType must not be a direct-message when creating Guild Channels!" # TODO: move to custom error formatter ) payload = Channel( @@ -709,6 +752,285 @@ async def modify_member( ) return Member(**res, _client=self._client) + async def get_preview(self) -> "GuildPreview": + """Get the guild's preview.""" + return GuildPreview(**await self._client.get_guild_preview(guild_id=int(self.id))) + + async def leave(self) -> None: + """Removes the bot from the guild.""" + await self._client.leave_guild(guild_id=int(self.id)) + + async def modify( + self, + name: Optional[str] = None, + verification_level: Optional[VerificationLevel] = None, + default_message_notifications: Optional[DefaultMessageNotificationLevel] = None, + explicit_content_filter: Optional[ExplicitContentFilterLevel] = None, + afk_channel_id: Optional[int] = None, + afk_timeout: Optional[int] = None, + # icon, TODO: implement images + owner_id: Optional[int] = None, + # splash, TODO: implement images + # discovery_splash, TODO: implement images + # banner, TODO: implement images + system_channel_id: Optional[int] = None, + suppress_join_notifications: Optional[bool] = None, + suppress_premium_subscriptions: Optional[bool] = None, + suppress_guild_reminder_notifications: Optional[bool] = None, + suppress_join_notification_replies: Optional[bool] = None, + rules_channel_id: Optional[int] = None, + public_updates_channel_id: Optional[int] = None, + preferred_locale: Optional[str] = None, + description: Optional[str] = None, + premium_progress_bar_enabled: Optional[bool] = None, + reason: Optional[str] = None, + ) -> "Guild": + """ + Modifies the current guild. + + :param name?: The new name of the guild + :type name: Optional[str] + :param verification_level?: The verification level of the guild + :type verification_level: Optional[VerificationLevel] + :param default_message_notifications?: The default message notification level for members + :type default_message_notifications: Optional[DefaultMessageNotificationLevel] + :param explicit_content_filter?: The explicit content filter level for media content + :type explicit_content_filter: Optional[ExplicitContentFilterLevel] + :param afk_channel_id?: The id for the afk voice channel + :type afk_channel_id: Optional[int] + :param afk_timeout?: Afk timeout in seconds + :type afk_timeout: Optional[int] + :param owner_id?: The id of the user to transfer the guild ownership to. You must be the owner to perform this + :type owner_id: Optional[int] + :param system_channel_id?: The id of the channel where guild notices such as welcome messages and boost events are posted + :type system_channel_id: Optional[int] + :param suppress_join_notifications?: Whether to suppress member join notifications in the system channel or not + :type suppress_join_notifications: Optional[bool] + :param suppress_premium_subscriptions?: Whether to suppress server boost notifications in the system channel or not + :type suppress_premium_subscriptions: Optional[bool] + :param suppress_guild_reminder_notifications?: Whether to suppress server setup tips in the system channel or not + :type suppress_guild_reminder_notifications: Optional[bool] + :param suppress_join_notification_replies?: Whether to hide member join sticker reply buttons in the system channel or not + :type suppress_join_notification_replies: Optional[bool] + :param rules_channel_id?: The id of the channel where guilds display rules and/or guidelines + :type rules_channel_id: Optional[int] + :param public_updates_channel_id?: The id of the channel where admins and moderators of community guilds receive notices from Discord + :type public_updates_channel_id: Optional[int] + :param preferred_locale?: The preferred locale of a community guild used in server discovery and notices from Discord; defaults to "en-US" + :type preferred_locale: Optional[str] + :param description?: The description for the guild, if the guild is discoverable + :type description: Optional[str] + :param premium_progress_bar_enabled?: Whether the guild's boost progress bar is enabled + :type premium_progress_bar_enabled: Optional[bool] + :param reason?: The reason for the modifying + :type reason: Optional[str] + :return: The modified guild + :rtype: Guild + """ + + if ( + suppress_join_notifications is None + and suppress_premium_subscriptions is None + and suppress_guild_reminder_notifications is None + and suppress_join_notification_replies is None + ): + system_channel_flags = None + else: + _suppress_join_notifications = (1 << 0) if suppress_join_notifications else 0 + _suppress_premium_subscriptions = (1 << 1) if suppress_premium_subscriptions else 0 + _suppress_guild_reminder_notifications = ( + (1 << 2) if suppress_guild_reminder_notifications else 0 + ) + _suppress_join_notification_replies = ( + (1 << 3) if suppress_join_notification_replies else 0 + ) + system_channel_flags = ( + _suppress_join_notifications + | _suppress_premium_subscriptions + | _suppress_guild_reminder_notifications + | _suppress_join_notification_replies + ) + + payload = {} + + if name: + payload["name"] = name + if verification_level: + payload["verification_level"] = verification_level.value + if default_message_notifications: + payload["default_message_notifications"] = default_message_notifications.value + if explicit_content_filter: + payload["explicit_content_filter"] = explicit_content_filter.value + if afk_channel_id: + payload["afk_channel_id"] = afk_channel_id + if afk_timeout: + payload["afk_timeout"] = afk_timeout + if owner_id: + payload["owner_id"] = owner_id + if system_channel_id: + payload["system_channel_id"] = system_channel_id + if system_channel_flags: + payload["system_channel_flags"] = system_channel_flags + if rules_channel_id: + payload["rules_channel_id"] = rules_channel_id + if public_updates_channel_id: + payload["public_updates_channel_id"] = rules_channel_id + if preferred_locale: + payload["preferred_locale"] = preferred_locale + if description: + payload["description"] = description + if premium_progress_bar_enabled: + payload["premium_progress_bar_enabled"] = premium_progress_bar_enabled + + res = await self._client.modify_guild( + guild_id=int(self.id), + payload=payload, + reason=reason, + ) + return Guild(**res, _client=self._client) + + async def create_scheduled_event( + self, + name: str, + entity_type: EntityType, + scheduled_start_time: datetime.isoformat, + scheduled_end_time: Optional[datetime.isoformat] = None, + entity_metadata: Optional["EventMetadata"] = None, + channel_id: Optional[int] = None, + description: Optional[str] = None, + # privacy_level, TODO: implement when more levels available + ) -> "ScheduledEvents": + """ + creates a scheduled event for the guild. + + :param name: The name of the event + :type name: str + :param entity_type: The entity type of the scheduled event + :type entity_type: EntityType + :param scheduled_start_time: The time to schedule the scheduled event + :type scheduled_start_time: datetime.isoformat + :param scheduled_end_time?: The time when the scheduled event is scheduled to end + :type scheduled_end_time: Optional[datetime.isoformat] + :param entity_metadata?: The entity metadata of the scheduled event + :type entity_metadata: Optional[EventMetadata] + :param channel_id?: The channel id of the scheduled event. + :type channel_id: Optional[int] + :param description?: The description of the scheduled event + :type description: Optional[str] + :return: The created event + :rtype: ScheduledEvents + """ + + if entity_type != EntityType.EXTERNAL and not channel_id: + raise ValueError( + "channel_id is required when entity_type is not external!" + ) # TODO: replace with custom error formatter + if entity_type == EntityType.EXTERNAL and not entity_metadata: + raise ValueError( + "entity_metadata is required for external events!" + ) # TODO: replace with custom error formatter + + payload = {} + + payload["name"] = name + payload["entity_type"] = entity_type.value + payload["scheduled_start_time"] = scheduled_start_time + payload["privacy_level"] = 2 + if scheduled_end_time: + payload["scheduled_end_time"] = scheduled_end_time + if entity_metadata: + payload["entity_metadata"] = entity_metadata + if channel_id: + payload["channel_id"] = channel_id + if description: + payload["description"] = description + + res = await self._client.create_scheduled_event( + guild_id=self.id, + data=payload, + ) + return ScheduledEvents(**res) + + async def modify_scheduled_event( + self, + event_id: int, + name: Optional[str] = None, + entity_type: Optional[EntityType] = None, + scheduled_start_time: Optional[datetime.isoformat] = None, + scheduled_end_time: Optional[datetime.isoformat] = None, + entity_metadata: Optional["EventMetadata"] = None, + channel_id: Optional[int] = None, + description: Optional[str] = None, + # privacy_level, TODO: implement when more levels available + ) -> "ScheduledEvents": + """ + Edits a scheduled event of the guild. + + :param event_id: The id of the event to edit + :type event_id: int + :param name: The name of the event + :type name: Optional[str] + :param entity_type: The entity type of the scheduled event + :type entity_type: Optional[EntityType] + :param scheduled_start_time: The time to schedule the scheduled event + :type scheduled_start_time: Optional[datetime.isoformat] + :param scheduled_end_time?: The time when the scheduled event is scheduled to end + :type scheduled_end_time: Optional[datetime.isoformat] + :param entity_metadata?: The entity metadata of the scheduled event + :type entity_metadata: Optional[EventMetadata] + :param channel_id?: The channel id of the scheduled event. + :type channel_id: Optional[int] + :param description?: The description of the scheduled event + :type description: Optional[str] + :return: The modified event + :rtype: ScheduledEvents + """ + + if entity_type == EntityType.EXTERNAL and not entity_metadata: + raise ValueError( + "entity_metadata is required for external events!" + ) # TODO: replace with custom error formatter + if entity_type == EntityType.EXTERNAL and not scheduled_end_time: + raise ValueError( + "External events require an end time!" + ) # TODO: replace with custom error formatter + + payload = {} + if name: + payload["name"] = name + if channel_id: + payload["channel_id"] = channel_id + if scheduled_start_time: + payload["scheduled_start_time"] = scheduled_start_time + if entity_type: + payload["entity_type"] = entity_type.value + payload["channel_id"] = None + if scheduled_end_time: + payload["scheduled_end_time"] = scheduled_end_time + if entity_metadata: + payload["entity_metadata"] = entity_metadata + if description: + payload["description"] = description + + res = await self._client.modify_scheduled_event( + guild_id=self.id, + guild_scheduled_event_id=Snowflake(event_id), + data=payload, + ) + return ScheduledEvents(**res) + + async def delete_scheduled_event(self, event_id: int) -> None: + """ + Deletes a scheduled event of the guild + + :param event_id: The id of the event to delete + :type event_id: int + """ + await self._client.delete_scheduled_event( + guild_id=self.id, + guild_scheduled_event_id=Snowflake(event_id), + ) + class GuildPreview(DictSerializerMixin): """ @@ -917,6 +1239,7 @@ class ScheduledEvents(DictSerializerMixin): :ivar Optional[EventMetadata] entity_metadata?: Additional metadata associated with the scheduled event. :ivar Optional[User] creator?: The user that created the scheduled event. :ivar Optional[int] user_count?: The number of users subscribed to the scheduled event. + :ivar int status: The status of the scheduled event """ __slots__ = ( @@ -935,6 +1258,7 @@ class ScheduledEvents(DictSerializerMixin): "entity_metadata", "creator", "user_count", + "status", ) def __init__(self, **kwargs): diff --git a/interactions/api/models/guild.pyi b/interactions/api/models/guild.pyi index f7fd5863e..d20270ecb 100644 --- a/interactions/api/models/guild.pyi +++ b/interactions/api/models/guild.pyi @@ -1,5 +1,6 @@ from datetime import datetime from typing import Any, List, Optional, Union +from enum import IntEnum from .channel import Channel, ChannelType, Thread from .member import Member @@ -10,6 +11,16 @@ from .role import Role from .user import User from ..http import HTTPClient +class VerificationLevel(IntEnum): ... + +class ExplicitContentFilterLevel(IntEnum): ... + +class DefaultMessageNotificationLevel(IntEnum): ... + +class EntityType(IntEnum): ... + +class EventStatus(IntEnum): ... + class WelcomeChannels(DictSerializerMixin): _json: dict channel_id: int @@ -189,7 +200,6 @@ class Guild(DictSerializerMixin): nsfw: Optional[bool] = False, reason: Optional[str] = None, ) -> Channel: ... - async def modify_member( self, member_id: int, @@ -201,6 +211,60 @@ class Guild(DictSerializerMixin): communication_disabled_until: Optional[datetime.isoformat] = None, reason: Optional[str] = None, ) -> Member: ... + async def get_preview(self) -> GuildPreview: ... + async def leave(self) -> None: ... + async def modify( + self, + name: Optional[str] = None, + verification_level: Optional[VerificationLevel] = None, + default_message_notifications: Optional[DefaultMessageNotificationLevel] = None, + explicit_content_filter: Optional[ExplicitContentFilterLevel] = None, + afk_channel_id: Optional[int] = None, + afk_timeout: Optional[int] = None, + # icon, TODO: implement images + owner_id: Optional[int] = None, + # splash, TODO: implement images + # discovery_splash, TODO: implement images + # banner, TODO: implement images + system_channel_id: Optional[int] = None, + suppress_join_notifications: Optional[bool] = None, + suppress_premium_subscriptions: Optional[bool] = None, + suppress_guild_reminder_notifications: Optional[bool] = None, + suppress_join_notification_replies: Optional[bool] = None, + rules_channel_id: Optional[int] = None, + public_updates_channel_id: Optional[int] = None, + preferred_locale: Optional[str] = None, + description: Optional[str] = None, + premium_progress_bar_enabled: Optional[bool] = None, + reason: Optional[str] = None, + ) -> "Guild": ... + async def create_scheduled_event( + self, + name: str, + entity_type: EntityType, + scheduled_start_time: datetime.isoformat, + scheduled_end_time: Optional[datetime.isoformat] = None, + entity_metadata: Optional["EventMetadata"] = None, + channel_id: Optional[int] = None, + description: Optional[str] = None, + # privacy_level, TODO: implement when more levels available + ) -> "ScheduledEvents": ... + async def modify_scheduled_event( + self, + event_id: int, + name: Optional[str] = None, + entity_type: Optional[EntityType] = None, + scheduled_start_time: Optional[datetime.isoformat] = None, + scheduled_end_time: Optional[datetime.isoformat] = None, + entity_metadata: Optional["EventMetadata"] = None, + channel_id: Optional[int] = None, + description: Optional[str] = None, + # privacy_level, TODO: implement when more levels available + ) -> "ScheduledEvents": ... + async def delete_scheduled_event( + self, + event_id: int + ) -> None: ... class GuildPreview(DictSerializerMixin): _json: dict @@ -266,4 +330,5 @@ class ScheduledEvents(DictSerializerMixin): entity_metadata: Optional[EventMetadata] creator: Optional[User] user_count: Optional[int] + status: int def __init__(self, **kwargs): ... From bc3f4fbb71edde18789e3e7670c461ef9b15faaa Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 5 Jan 2022 07:21:39 -0500 Subject: [PATCH 030/105] fix(message)?: Partial fix for `EmbedField`. --- .pre-commit-config.yaml | 2 +- interactions/api/models/member.pyi | 1 - interactions/api/models/message.py | 19 ++++++++++++++++--- interactions/context.py | 2 +- interactions/models/component.py | 12 ++++-------- simple_bot.py | 24 +++++++++++++++++++++++- 6 files changed, 45 insertions(+), 15 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26014a7ab..d9eda9a86 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: name: flake8 Formatting language: python types: [file, python] - args: [--max-line-length=100, --ignore=E203 E501 E402 W503 W504] + args: [--max-line-length=100, --ignore=E203 E301 E302 E501 E402 E704 W503 W504] - repo: https://github.com/pycqa/isort rev: 5.10.1 hooks: diff --git a/interactions/api/models/member.pyi b/interactions/api/models/member.pyi index 185c6498e..263cf8420 100644 --- a/interactions/api/models/member.pyi +++ b/interactions/api/models/member.pyi @@ -57,7 +57,6 @@ class Member(DictSerializerMixin): embeds=None, allowed_mentions=None, ) -> Message: ... - async def modify( self, guild_id: int, diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index b57336397..a3d3f043c 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -727,7 +727,20 @@ def __init__(self, **kwargs): else self._json.get("author") ) self.fields = ( - [EmbedField(**field) for field in self.fields] - if isinstance(self._json.get("fields"), list) - else self._json.get("fields") + [ + EmbedField(**field) if isinstance(field, dict) else field + for field in self._json.get("fields")[0] + ] + if self._json.get("fields") + else None ) + + # TODO: Complete partial fix. + # The issue seems to be that this itself is not updating + # JSON result correctly. After numerous attempts I seem to + # have the attribute to do it, but _json won't budge at all. + # a genexpr is a poor way to go about this, but I know later + # on we'll be refactoring this anyhow. What the fuck is breaking + # it? + if self.fields: + self._json.update({"fields": [field._json for field in self.fields[0]]}) diff --git a/interactions/context.py b/interactions/context.py index c4e0bca44..7f200d962 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -165,7 +165,7 @@ async def send( embeds: Optional[Union[Embed, List[Embed]]] = None, allowed_mentions: Optional[MessageInteraction] = None, components: Optional[ - Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]] + Union[ActionRow, Button, SelectMenu, List[ActionRow], List[Button], List[SelectMenu]] ] = None, ephemeral: Optional[bool] = False, ) -> Message: diff --git a/interactions/models/component.py b/interactions/models/component.py index 675dc9b6b..4b0fcf16f 100644 --- a/interactions/models/component.py +++ b/interactions/models/component.py @@ -85,14 +85,10 @@ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.type = ComponentType.SELECT self.options = ( - [ - SelectOption(**option._json) - if not isinstance(option, dict) - else SelectOption(**option) - for option in self.options - ] - if self._json.get("options") - else None + SelectOption(**option) + if not isinstance(option, SelectOption) + else self._json.get("options") + for option in self.options ) self._json.update({"type": self.type.value}) diff --git a/simple_bot.py b/simple_bot.py index 4ba250947..ad77282f4 100644 --- a/simple_bot.py +++ b/simple_bot.py @@ -1,6 +1,6 @@ import interactions -bot = interactions.Client(token=open("bot.token").read(), log_level=-1) +bot = interactions.Client(token=open("bot.token").read(), disable_sync=True, log_level=10) @bot.event @@ -8,6 +8,28 @@ async def on_ready(): print("bot is now online.") +@bot.command(name="guild-command", description="haha guild go brrr", scope=852402668294766612) +async def guild_command(ctx: interactions.CommandContext): + embed = interactions.Embed( + title="Embed title", + fields=[ + interactions.EmbedField( + name="field name", + value="values!", + ), + interactions.EmbedField( + name="field name", + value="values!", + ), + interactions.EmbedField( + name="field name", + value="values!", + ), + ], + ) + await ctx.send("aloha senor.", embeds=embed) + + @bot.command( name="global-command", description="ever wanted a global command? well, here it is!", From c0cc9833251d7395aae5a889d0029e57877170a4 Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 5 Jan 2022 07:26:42 -0500 Subject: [PATCH 031/105] fix(message): allow `EmbedAuthor` usage. --- interactions/api/models/message.py | 3 +++ simple_bot.py | 18 ++++-------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index a3d3f043c..16485b067 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -744,3 +744,6 @@ def __init__(self, **kwargs): # it? if self.fields: self._json.update({"fields": [field._json for field in self.fields[0]]}) + + if self.author: + self._json.update({"author": self.author._json}) diff --git a/simple_bot.py b/simple_bot.py index ad77282f4..8b1209dd6 100644 --- a/simple_bot.py +++ b/simple_bot.py @@ -12,20 +12,10 @@ async def on_ready(): async def guild_command(ctx: interactions.CommandContext): embed = interactions.Embed( title="Embed title", - fields=[ - interactions.EmbedField( - name="field name", - value="values!", - ), - interactions.EmbedField( - name="field name", - value="values!", - ), - interactions.EmbedField( - name="field name", - value="values!", - ), - ], + author=interactions.EmbedAuthor( + name="author name", + url="https://cdn.discordapp.com/avatars/242351388137488384/85f546d0b24092658b47f0778506cf35.webp?size=512", + ), ) await ctx.send("aloha senor.", embeds=embed) From 44a299e06d08f99ae86094d0287b8e68d007fc4c Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Wed, 5 Jan 2022 14:00:49 +0100 Subject: [PATCH 032/105] added missing paremeters to docstrings --- interactions/api/models/guild.py | 11 +++++++++++ interactions/api/models/member.py | 2 ++ 2 files changed, 13 insertions(+) diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index e1f1bb2f0..36f44daca 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -464,6 +464,8 @@ async def get_member( Searches for the member with specified id in the guild and returns the member as member object :param member_id: The id of the member to search for :type member_id: int + :return: The member searched for + :rtype: Member """ res = await self._client.get_member( guild_id=int(self.id), @@ -589,6 +591,8 @@ async def create_channel( :type nsfw: Optional[bool] :param reason: The reason for the creation :type reason: Optional[str] + :return: The created channel + :rtype: Channel """ if type in [ @@ -723,6 +727,8 @@ async def modify_member( :type communication_disabled_until: Optional[datetime.isoformat] :param reason?: The reason of the modifying :type reason: Optional[str] + :return: The modified member + :rtype: Member """ payload = {} @@ -961,6 +967,7 @@ async def modify_scheduled_event( entity_metadata: Optional["EventMetadata"] = None, channel_id: Optional[int] = None, description: Optional[str] = None, + status: Optional[EventStatus] = None, # privacy_level, TODO: implement when more levels available ) -> "ScheduledEvents": """ @@ -982,6 +989,8 @@ async def modify_scheduled_event( :type channel_id: Optional[int] :param description?: The description of the scheduled event :type description: Optional[str] + :param status?: The status of the scheduled event + :type status: Optional[EventStatus] :return: The modified event :rtype: ScheduledEvents """ @@ -1011,6 +1020,8 @@ async def modify_scheduled_event( payload["entity_metadata"] = entity_metadata if description: payload["description"] = description + if status: + payload["status"] = status res = await self._client.modify_scheduled_event( guild_id=self.id, diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 2d27e8346..305117832 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -242,6 +242,8 @@ async def modify( :type communication_disabled_until: Optional[datetime.isoformat] :param reason?: The reason of the modifying :type reason: Optional[str] + :return: The modified member object + :rtype: Member """ payload = {} From b508bae0f81d8cba75bb568358edab8e4df4f763 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Wed, 5 Jan 2022 14:01:14 +0100 Subject: [PATCH 033/105] added missing paremeters to docstrings --- interactions/api/models/message.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index 16485b067..fc325de9d 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -289,6 +289,10 @@ async def get_channel(self) -> Channel: return Channel(**res, _client=self._client) async def get_guild(self): + """ + Gets the guild where the message was sent + :rtype: Guild + """ from .guild import Guild res = await self._client.get_guild(guild_id=int(self.guild_id)) From 169b5ecdb8d83f571594312745764c0d89b74765 Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 5 Jan 2022 08:05:32 -0500 Subject: [PATCH 034/105] fix: more `Extension` cleanup --- interactions/api/models/message.py | 2 +- interactions/client.py | 80 ++++++++-------- interactions/context.py | 149 +++++++++++++++-------------- simple_cog.py | 16 ++-- 4 files changed, 124 insertions(+), 123 deletions(-) diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index 16485b067..db6aa9459 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -729,7 +729,7 @@ def __init__(self, **kwargs): self.fields = ( [ EmbedField(**field) if isinstance(field, dict) else field - for field in self._json.get("fields")[0] + for field in self._json["fields"][0] ] if self._json.get("fields") else None diff --git a/interactions/client.py b/interactions/client.py index cf6da9eb5..39f3a8e9e 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -1,12 +1,12 @@ import sys from asyncio import get_event_loop - -# from functools import partial +from functools import partial from importlib import import_module from importlib.util import resolve_name from logging import Logger, StreamHandler, basicConfig, getLogger from typing import Any, Callable, Coroutine, Dict, List, Optional, Union +from interactions.api.dispatch import Listener from interactions.api.models.misc import Snowflake from .api.cache import Cache @@ -610,41 +610,41 @@ async def raw_guild_create(self, guild) -> dict: # TODO: Implement the rest of cog behaviour when possible. -# class Extension: -# """ -# A class that allows you to represent "extensions" of your code, or -# essentially cogs that can be ran independent of the root file in -# an object-oriented structure. - -# The structure of an extension: - -# .. code-block:: python - -# class CoolCode(interactions.Extension): -# def __init__(self, client): -# self.client = client - -# @command( -# type=interactions.ApplicationCommandType.USER, -# name="User command in cog", -# ) -# async def cog_user_cmd(self, ctx): -# ... - -# def setup(bot): -# CoolCode(bot) -# """ - -# client: Client -# commands: Optional[List[ApplicationCommand]] -# listeners: Optional[List[Listener]] - -# def __new__(cls, bot: Client) -> None: -# cls.client = bot -# cls.commands = [] - -# for _, content in cls.__dict__.items(): -# content = content if isinstance(content.callback, partial) else None -# if isinstance(content, ApplicationCommand): -# cls.commands.append(content) -# bot.command(**content) +class Extension: + """ + A class that allows you to represent "extensions" of your code, or + essentially cogs that can be ran independent of the root file in + an object-oriented structure. + + The structure of an extension: + + .. code-block:: python + + class CoolCode(interactions.Extension): + def __init__(self, client): + self.client = client + + @command( + type=interactions.ApplicationCommandType.USER, + name="User command in cog", + ) + async def cog_user_cmd(self, ctx): + ... + + def setup(bot): + CoolCode(bot) + """ + + client: Client + commands: Optional[List[ApplicationCommand]] + listeners: Optional[List[Listener]] + + def __new__(cls, bot: Client) -> None: + cls.client = bot + cls.commands = [] + + for _, content in cls.__dict__.items(): + content = content if isinstance(content.callback, partial) else None + if isinstance(content, ApplicationCommand): + cls.commands.append(content) + bot.command(**content) diff --git a/interactions/context.py b/interactions/context.py index 7f200d962..cb9cd63ce 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -200,58 +200,12 @@ async def send( _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions _components: List[dict] = [{"type": 1, "components": []}] - if ( - isinstance(components, list) - and components - and all(isinstance(action_row, ActionRow) for action_row in components) - ): - _components = [ - { - "type": 1, - "components": [ - ( - component._json - if component._json.get("custom_id") or component._json.get("url") - else [] - ) - for component in action_row.components - ], - } - for action_row in components - ] - elif ( - isinstance(components, list) - and components - and all(isinstance(component, (Button, SelectMenu)) for component in components) - ): - if isinstance(components[0], SelectMenu): - components[0]._json["options"] = [option._json for option in components[0].options] - _components = [ - { - "type": 1, - "components": [ - ( - component._json - if component._json.get("custom_id") or component._json.get("url") - else [] - ) - for component in components - ], - } - ] - elif ( - isinstance(components, list) - and components - and all(isinstance(action_row, (list, ActionRow)) for action_row in components) - ): - _components = [] - for action_row in components: - for component in ( - action_row if isinstance(action_row, list) else action_row.components - ): - if isinstance(component, SelectMenu): - component._json["options"] = [option._json for option in component.options] - _components.append( + # TODO: Break this obfuscation pattern down to a "builder" method. + if components: + if isinstance(components, list) and all( + isinstance(action_row, ActionRow) for action_row in components + ): + _components = [ { "type": 1, "components": [ @@ -260,33 +214,80 @@ async def send( if component._json.get("custom_id") or component._json.get("url") else [] ) - for component in ( - action_row - if isinstance(action_row, list) - else action_row.components + for component in action_row.components + ], + } + for action_row in components + ] + elif isinstance(components, list) and all( + isinstance(component, (Button, SelectMenu)) for component in components + ): + if isinstance(components[0], SelectMenu): + components[0]._json["options"] = [ + option._json for option in components[0].options + ] + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] ) + for component in components ], } - ) - elif isinstance(components, ActionRow): - _components[0]["components"] = [ - ( - component._json - if component._json.get("custom_id") or component._json.get("url") + ] + elif isinstance(components, list) and all( + isinstance(action_row, (list, ActionRow)) for action_row in components + ): + _components = [] + for action_row in components: + for component in ( + action_row if isinstance(action_row, list) else action_row.components + ): + if isinstance(component, SelectMenu): + component._json["options"] = [ + option._json for option in component.options + ] + _components.append( + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") + or component._json.get("url") + else [] + ) + for component in ( + action_row + if isinstance(action_row, list) + else action_row.components + ) + ], + } + ) + elif isinstance(components, ActionRow): + _components[0]["components"] = [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components.components + ] + elif isinstance(components, (Button, SelectMenu)): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") else [] ) - for component in components.components - ] - elif isinstance(components, (Button, SelectMenu)): - _components[0]["components"] = ( - [components._json] - if components._json.get("custom_id") or components._json.get("url") - else [] - ) - elif components is None: - _components = None - else: - _components = [] + elif components is None: + _components = None + else: + _components = [] _ephemeral: int = (1 << 6) if ephemeral else 0 diff --git a/simple_cog.py b/simple_cog.py index 068157204..4a2b7864c 100644 --- a/simple_cog.py +++ b/simple_cog.py @@ -3,15 +3,15 @@ class SimpleCog(interactions.Extension): def __init__(self, client) -> None: - super().__init__(client) + self.client = client - # @command( - # name="cog-command", - # description="wanna be in a cog? :)) ok.", - # scope=852402668294766612, - # ) - # async def cog_command(self, ctx): - # await ctx.send("we're a cog in the machine!") + @interactions.command( + name="cog-command", + description="wanna be in a cog? :)) ok.", + scope=852402668294766612, + ) + async def cog_command(self, ctx): + await ctx.send("we're a cog in the machine!") def startup(client): From 3e6719fb2ed063f450987752501e04b3cf026725 Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 5 Jan 2022 08:28:12 -0500 Subject: [PATCH 035/105] feat: more cool `Extension` stuff again. --- interactions/client.py | 14 +++++++++----- simple_bot.py | 8 +++++--- simple_cog.py | 12 ++++++------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index 39f3a8e9e..19dcbb9d5 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -1,6 +1,5 @@ import sys from asyncio import get_event_loop -from functools import partial from importlib import import_module from importlib.util import resolve_name from logging import Logger, StreamHandler, basicConfig, getLogger @@ -642,9 +641,14 @@ def setup(bot): def __new__(cls, bot: Client) -> None: cls.client = bot cls.commands = [] + cls.listeners = [] for _, content in cls.__dict__.items(): - content = content if isinstance(content.callback, partial) else None - if isinstance(content, ApplicationCommand): - cls.commands.append(content) - bot.command(**content) + if not content.startswith("__") or content.startswith("_"): + if "on_" in content: + cls.listeners.append(content) + else: + cls.commands.append(content) + + for _command in cls.commands: + cls.client.command(**_command) diff --git a/simple_bot.py b/simple_bot.py index 8b1209dd6..1a9a483bd 100644 --- a/simple_bot.py +++ b/simple_bot.py @@ -1,6 +1,6 @@ import interactions -bot = interactions.Client(token=open("bot.token").read(), disable_sync=True, log_level=10) +bot = interactions.Client(token=open("bot.token").read(), disable_sync=True) @bot.event @@ -14,7 +14,9 @@ async def guild_command(ctx: interactions.CommandContext): title="Embed title", author=interactions.EmbedAuthor( name="author name", - url="https://cdn.discordapp.com/avatars/242351388137488384/85f546d0b24092658b47f0778506cf35.webp?size=512", + url=interactions.EmbedImageStruct( + url="https://cdn.discordapp.com/avatars/242351388137488384/85f546d0b24092658b47f0778506cf35.webp?size=512" + ), ), ) await ctx.send("aloha senor.", embeds=embed) @@ -47,5 +49,5 @@ async def component_res(ctx: interactions.ComponentContext): ) -# bot.load("simple_cog") +bot.load("simple_cog") bot.start() diff --git a/simple_cog.py b/simple_cog.py index 4a2b7864c..6f40ba342 100644 --- a/simple_cog.py +++ b/simple_cog.py @@ -5,14 +5,14 @@ class SimpleCog(interactions.Extension): def __init__(self, client) -> None: self.client = client - @interactions.command( - name="cog-command", - description="wanna be in a cog? :)) ok.", - scope=852402668294766612, - ) + # @interactions.command( + # name="cog-command", + # description="wanna be in a cog? :)) ok.", + # scope=852402668294766612, + # ) async def cog_command(self, ctx): await ctx.send("we're a cog in the machine!") -def startup(client): +def setup(client): SimpleCog(client) From 0ca591a25f63d3be04cd6d93b901b8aa824fe1c8 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Wed, 5 Jan 2022 14:31:36 +0100 Subject: [PATCH 036/105] more functions --- interactions/api/models/channel.py | 44 +++++++++++++++++++++++++++++ interactions/api/models/channel.pyi | 12 ++++++++ interactions/api/models/message.py | 20 +++++++++++++ interactions/api/models/message.pyi | 3 ++ 4 files changed, 79 insertions(+) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 7381d9cd8..497da09bd 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -332,6 +332,50 @@ async def add_member( ) # TODO: Move to new error formatter. await self._client.add_member_to_thread(thread_id=int(self.id), user_id=member_id) + async def pin_message( + self, + message_id: int, + ) -> None: + """ + Pins a message to the channel + + :param message_id: The id of the message to pin + :type message_id: int + """ + + await self._client.pin_message(channel_id=int(self.id), message_id=message_id) + + async def unpin_message( + self, + message_id: int, + ) -> None: + """ + UNpins a message from the channel + + :param message_id: The id of the message to unpin + :type message_id: int + """ + + await self._client.unpin_message(channel_id=int(self.id), message_id=message_id) + + async def publish_message( + self, + message_id: int, + ): + """Publishes (API calls it crossposts) a message in the channel to any that is followed by. + + :param message_id: The id of the message to publish + :type message_id: int + :return: message object + :rtype: Message + """ + from .message import Message + + res = await self._client.publish_message( + channel_id=int(self.id), message_id=int(message_id) + ) + return Message(**res, _client=self._client) + class Thread(Channel): """An object representing a thread. diff --git a/interactions/api/models/channel.pyi b/interactions/api/models/channel.pyi index e03d55e65..1a2abacbf 100644 --- a/interactions/api/models/channel.pyi +++ b/interactions/api/models/channel.pyi @@ -85,5 +85,17 @@ class Channel(DictSerializerMixin): self, member_id: int, ) -> None: ... + async def pin_message( + self, + message_id: int, + ) -> None: ... + async def unpin_message( + self, + message_id: int, + ) -> None: ... + async def publish_message( + self, + message_id: int, + ) -> Message: ... class Thread(Channel): ... diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index fc325de9d..4510aa219 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -427,6 +427,26 @@ async def reply( ) return Message(**res, _client=self._client) + async def pin(self) -> None: + """Pins the message to its channel""" + + await self._client.pin_message(channel_id=int(self.channel_id), message_id=int(self.id)) + + async def unpin(self) -> None: + """Unpins the message from its channel""" + await self._client.unpin_message(channel_id=int(self.channel_id), message_id=int(self.id)) + + async def publish(self) -> "Message": + """Publishes (API calls it crossposts) the message in its channel to any that is followed by. + + :return: message object + :rtype: Message + """ + res = await self._client.publish_message( + channel_id=int(self.channel_id), message_id=int(self.id) + ) + return Message(**res, _client=self._client) + class Emoji(DictSerializerMixin): """ diff --git a/interactions/api/models/message.pyi b/interactions/api/models/message.pyi index 86d9e7da0..d2e0e8255 100644 --- a/interactions/api/models/message.pyi +++ b/interactions/api/models/message.pyi @@ -115,6 +115,9 @@ class Message(DictSerializerMixin): ) -> "Message": ... async def get_channel(self) -> Channel: ... async def get_guild(self) -> Guild: ... + async def pin(self) -> None: ... + async def unpin(self) -> None: ... + async def publish(self) -> "Message": ... class Emoji(DictSerializerMixin): From 592eeb79bb0b02db5114d9e1a59d6c49846e950f Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 5 Jan 2022 08:39:23 -0500 Subject: [PATCH 037/105] feat: move `HTTPException` to automatically trigger when an HTTP request fails. --- interactions/api/http.py | 3 +++ interactions/client.py | 33 ++++++++------------------------- 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index a875caf15..4a1417fa8 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -203,6 +203,7 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: log.debug(f"{route.method}: {route.__api__ + route.path}: {kwargs}") data = await response.json(content_type=None) log.debug(f"RETURN {response.status}: {dumps(data, indent=4, sort_keys=True)}") + if "X-Ratelimit-Remaining" in response.headers.keys(): remaining = response.headers["X-Ratelimit-Remaining"] @@ -216,6 +217,8 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: self.lock.set() if response.status in (300, 401, 403, 404): raise HTTPException(response.status) + if data.get("code"): + raise HTTPException(data["code"]) elif response.status == 429: retry_after = data["retry_after"] diff --git a/interactions/client.py b/interactions/client.py index 19dcbb9d5..1ec343f43 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -10,7 +10,7 @@ from .api.cache import Cache from .api.cache import Item as Build -from .api.error import InteractionException, JSONException +from .api.error import InteractionException from .api.gateway import WebSocket from .api.http import HTTPClient from .api.models.guild import Guild @@ -188,14 +188,10 @@ async def create(data: ApplicationCommand) -> None: f"Command {data.name} was not found in the API, creating and adding to the cache." ) - request = await self.http.create_application_command( + await self.http.create_application_command( application_id=self.me.id, data=data._json, guild_id=data.guild_id ) - - if request.get("code"): - raise JSONException(request["code"]) - else: - self.http.cache.interactions.add(Build(id=data.name, value=data)) + self.http.cache.interactions.add(Build(id=data.name, value=data)) if commands: log.debug("Commands were found, checking for sync.") @@ -229,7 +225,7 @@ async def create(data: ApplicationCommand) -> None: f"Command {result.name} found unsynced, editing in the API and updating the cache." ) payload._json["name"] = payload_name - request = await self.http.edit_application_command( + await self.http.edit_application_command( application_id=self.me.id, data=payload._json, command_id=result.id, @@ -238,9 +234,6 @@ async def create(data: ApplicationCommand) -> None: self.http.cache.interactions.add( Build(id=payload.name, value=payload) ) - - if request.get("code"): - raise JSONException(request["code"]) break else: await create(payload) @@ -260,16 +253,12 @@ async def create(data: ApplicationCommand) -> None: log.debug( f"Command {command['name']} was found in the API but never cached, deleting from the API and cache." ) - request = await self.http.delete_application_command( + await self.http.delete_application_command( application_id=self.me.id, command_id=command["id"], guild_id=command.get("guild_id"), ) - if request: - if request.get("code"): - raise JSONException(request["code"]) - def event(self, coro: Coroutine, name: Optional[str] = None) -> Callable[..., Any]: """ A decorator for listening to dispatched events from the @@ -525,15 +514,9 @@ def remove(self, name: str, package: Optional[str] = None) -> None: if module not in self.extensions: log.error(f"Extension {name} has not been loaded before. Skipping.") - try: - teardown = getattr(module, "teardown") - teardown() - except AttributeError: - pass - else: - log.debug(f"Removed extension {name}.") - del sys.modules[_name] - del self.extensions[_name] + log.debug(f"Removed extension {name}.") + del sys.modules[_name] + del self.extensions[_name] def reload(self, name: str, package: Optional[str] = None) -> None: """ From c8c9102684b8e529561e5b1eb06c02d19338775b Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Wed, 5 Jan 2022 14:57:59 +0100 Subject: [PATCH 038/105] even more functions --- interactions/api/models/guild.py | 59 +++++++++++++++++++++++++++++++ interactions/api/models/guild.pyi | 9 +++++ interactions/api/models/role.py | 27 +++++++++++++- interactions/api/models/role.pyi | 8 ++++- 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 36f44daca..951f10264 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -1042,6 +1042,65 @@ async def delete_scheduled_event(self, event_id: int) -> None: guild_scheduled_event_id=Snowflake(event_id), ) + async def get_all_channels(self) -> List[Channel]: + """ + Gets all channels of the guild as list + + :rtype: List[Channel] + """ + res = self._client.get_all_channels(int(self.id)) + channels = [Channel(**channel, _client=self._client) for channel in res] + return channels + + async def get_all_roles(self) -> List[Role]: + """ + Gets all roles of the guild as list + + :rtype: List[Role] + """ + res = self._client.get_all_roles(int(self.id)) + roles = [Role(**role, _client=self._client) for role in res] + return roles + + async def modify_role_position( + self, + role_id: Union[Role, int], + position: int, + reason: Optional[str] = None, + ) -> List[Role]: + """ + Modifies the position of a role in the guild + + :param role_id: The id of the role to modify the position of + :type role_id: Union[Role, int] + :param position: The new position of the role + :type position: int + :param reason?: The reason for the modifying + :type reason: Optional[str] + :return: List of guild roles with updated hierarchy + :rtype: List[Role] + """ + + _role_id = role_id.id if isinstance(role_id, Role) else role_id + res = await self._client.modify_guild_role_position( + guild_id=int(self.id), position=position, role_id=_role_id, reason=reason + ) + roles = [Role(**role, _client=self._client) for role in res] + return roles + + async def get_bans(self) -> List[dict]: + """ + Gets a list of banned users + + :return: List of banned users with reasons + :rtype: List[dict] + """ + + res = await self._client.get_guild_bans(int(self.id)) + for ban in res: + ban["user"] = User(**ban["user"]) + return res + class GuildPreview(DictSerializerMixin): """ diff --git a/interactions/api/models/guild.pyi b/interactions/api/models/guild.pyi index d20270ecb..76fe1c1bb 100644 --- a/interactions/api/models/guild.pyi +++ b/interactions/api/models/guild.pyi @@ -265,6 +265,15 @@ class Guild(DictSerializerMixin): self, event_id: int ) -> None: ... + async def get_all_channels(self) -> List[Channel]: ... + async def get_all_roles(self) -> List[Role]: ... + async def modify_role_position( + self, + role_id: Union[Role, int], + position: int, + reason: Optional[str] = None, + ) -> List[Role]: ... + async def get_bans(self) -> List[dict]: ... class GuildPreview(DictSerializerMixin): _json: dict diff --git a/interactions/api/models/role.py b/interactions/api/models/role.py index f06c659d5..587072a2e 100644 --- a/interactions/api/models/role.py +++ b/interactions/api/models/role.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Optional from .misc import DictSerializerMixin, Snowflake @@ -124,3 +124,28 @@ async def modify( reason=reason, ) return Role(**res, _client=self._client) + + async def modify_position( + self, + guild_id: int, + position: int, + reason: Optional[str] = None, + ) -> List["Role"]: + """ + Modifies the position of a role in the guild + + :param guild_id: The id of the guild to modify the role position on + :type guild_id: int + :param position: The new position of the role + :type position: int + :param reason?: The reason for the modifying + :type reason: Optional[str] + :return: List of guild roles with updated hierarchy + :rtype: List[Role] + """ + + res = await self._client.modify_guild_role_position( + guild_id=guild_id, position=position, role_id=int(self.id), reason=reason + ) + roles = [Role(**role, _client=self._client) for role in res] + return roles diff --git a/interactions/api/models/role.pyi b/interactions/api/models/role.pyi index 6a47e3e98..7365ace2b 100644 --- a/interactions/api/models/role.pyi +++ b/interactions/api/models/role.pyi @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Optional, List from .misc import DictSerializerMixin, Snowflake from ..http import HTTPClient @@ -42,3 +42,9 @@ class Role(DictSerializerMixin): mentionable: Optional[bool] = None, reason: Optional[str] = None, ) -> "Role": ... + async def modify_position( + self, + guild_id: int, + position: int, + reason: Optional[str] = None, + ) -> List["Role"]: ... From d5b06171c76be87f8019726981127def00f5848f Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Wed, 5 Jan 2022 15:15:00 +0100 Subject: [PATCH 039/105] funcs --- interactions/api/models/channel.py | 11 +++++++++++ interactions/api/models/channel.pyi | 1 + 2 files changed, 12 insertions(+) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 497da09bd..31967dacc 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -376,6 +376,17 @@ async def publish_message( ) return Message(**res, _client=self._client) + async def get_pinned_messages(self): + """ + Get all pinned messages from the channel. + :return: A list of pinned message objects. + """ + from .message import Message + + res = await self._client.get_pinned_messages(int(self.id)) + messages = [Message(**message, _client=self._client) for message in res] + return messages + class Thread(Channel): """An object representing a thread. diff --git a/interactions/api/models/channel.pyi b/interactions/api/models/channel.pyi index 1a2abacbf..73b08c150 100644 --- a/interactions/api/models/channel.pyi +++ b/interactions/api/models/channel.pyi @@ -97,5 +97,6 @@ class Channel(DictSerializerMixin): self, message_id: int, ) -> Message: ... + async def get_pinned_messages(self) -> List[Message]: ... class Thread(Channel): ... From fe334da717a3e2442d619d9bec346b020e63d18e Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Wed, 5 Jan 2022 15:20:17 +0100 Subject: [PATCH 040/105] typo --- interactions/api/models/channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 31967dacc..ad21471db 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -350,7 +350,7 @@ async def unpin_message( message_id: int, ) -> None: """ - UNpins a message from the channel + Upins a message from the channel :param message_id: The id of the message to unpin :type message_id: int From 6fef14c58d5ed40bff6648746bc02ff3fd0de87f Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Wed, 5 Jan 2022 15:20:29 +0100 Subject: [PATCH 041/105] typo --- interactions/api/models/channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index ad21471db..5317ba521 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -350,7 +350,7 @@ async def unpin_message( message_id: int, ) -> None: """ - Upins a message from the channel + Unpins a message from the channel :param message_id: The id of the message to unpin :type message_id: int From 16331faba057170be4756b91f8d14536fdad7196 Mon Sep 17 00:00:00 2001 From: Toricane <73972068+Toricane@users.noreply.github.com> Date: Wed, 5 Jan 2022 10:53:16 -0800 Subject: [PATCH 042/105] refactor: remove unnecessary =None calls --- interactions/client.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index 7d7be4145..70a0c29e5 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -413,9 +413,7 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: commands: List[ApplicationCommand] = command( type=ApplicationCommandType.MESSAGE, name=name, - description=None, scope=scope, - options=None, default_permission=default_permission, ) @@ -425,7 +423,7 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: return self.event(coro, name=f"command_{name}") return decorator - + def user_command( self, *, @@ -467,17 +465,12 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: commands: List[ApplicationCommand] = command( type=ApplicationCommandType.USER, name=name, - description=None, scope=scope, - options=None, default_permission=default_permission, ) if self.automate_sync: - [ - self.loop.run_until_complete(self.synchronize(command)) - for command in commands - ] + [self.loop.run_until_complete(self.synchronize(command)) for command in commands] return self.event(coro, name=f"command_{name}") From 882c273846631fd0ea92dc298250a64e2e397c99 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Wed, 5 Jan 2022 20:01:02 -0500 Subject: [PATCH 043/105] fix!: Fix context sending without components, HTTP error exception if proper data is received, add missing reason= --- interactions/api/http.py | 6 ++++-- interactions/context.py | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index 4a1417fa8..9a741eb2f 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -217,8 +217,9 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: self.lock.set() if response.status in (300, 401, 403, 404): raise HTTPException(response.status) - if data.get("code"): - raise HTTPException(data["code"]) + if isinstance(data, dict): + if data.get("code"): + raise HTTPException(data["code"]) elif response.status == 429: retry_after = data["retry_after"] @@ -1392,6 +1393,7 @@ async def edit_channel_permission( return await self._req.request( Route("PUT", f"/channels/{channel_id}/permissions/{overwrite_id}"), json={"allow": allow, "deny": deny, "type": perm_type}, + reason=reason, ) async def delete_channel_permission( diff --git a/interactions/context.py b/interactions/context.py index cb9cd63ce..b62f56408 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -284,10 +284,8 @@ async def send( if components._json.get("custom_id") or components._json.get("url") else [] ) - elif components is None: - _components = None - else: - _components = [] + else: + _components = [] _ephemeral: int = (1 << 6) if ephemeral else 0 From 67b80ad43c8560dff3980842455451ac0a93d6d6 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Thu, 6 Jan 2022 13:56:08 -0500 Subject: [PATCH 044/105] fix!: Fix context menu resolved data parsing, add default member avatar to User on Member object. --- interactions/api/models/member.py | 2 ++ interactions/models/misc.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 2d27e8346..cd7178018 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -59,6 +59,8 @@ def __init__(self, **kwargs): if self._json.get("premium_since") else None ) + if not self.avatar and self.user: + self.avatar = self.user.avatar async def ban( self, diff --git a/interactions/models/misc.py b/interactions/models/misc.py index 28a5774e0..128e557d4 100644 --- a/interactions/models/misc.py +++ b/interactions/models/misc.py @@ -36,26 +36,36 @@ def __init__(self, **kwargs): self.users.update({user: User(**self.users[user])}) for user in self._json.get("users") ] + else: + self.users = {} if self._json.get("members"): [ self.members.update({member: Member(**self.members[member])}) for member in self._json.get("members") ] + else: + self.members = {} if self._json.get("roles"): [ self.roles.update({role: Role(**self.roles[role])}) for role in self._json.get("roles") ] + else: + self.roles = {} if self._json.get("channels"): [ self.channels.update({channel: Channel(**self.channels[channel])}) for channel in self._json.get("channels") ] + else: + self.channels = {} if self._json.get("messages"): [ self.messages.update({message: Message(**self.messages[message])}) for message in self._json.get("messages") ] + else: + self.messages = {} class InteractionData(DictSerializerMixin): From f57c955d8d23f9c03d4574523a6766126ddeaee3 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Fri, 7 Jan 2022 13:17:20 -0500 Subject: [PATCH 045/105] feat: Introduce embedded activity model, remove id in PresenceActivity model. --- interactions/api/models/gw.py | 30 ++++++++++++++++++++++++++++ interactions/api/models/gw.pyi | 8 ++++++++ interactions/api/models/presence.py | 2 -- interactions/api/models/presence.pyi | 1 - 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/interactions/api/models/gw.py b/interactions/api/models/gw.py index ef1442e90..4aafb4142 100644 --- a/interactions/api/models/gw.py +++ b/interactions/api/models/gw.py @@ -32,6 +32,36 @@ def __init__(self, **kwargs): ) +class EmbeddedActivity(DictSerializerMixin): + """ + A class object representing the event ``EMBEDDED_ACTIVITY_UPDATE``. + + ..note:: This is entirely undocumented by the API. + + :ivar List[Snowflake] users: The list of users in the event. + :ivar Snowflake guild_id: The guild ID of the event. + :ivar PresenceActivity embedded_activity: The embedded presence activity of the associated event. + :ivar Snowflake channel_id: The channel_id ID of the event. + """ + + __slots__ = ("_json", "users", "guild_id", "embedded_activity", "channel_id") + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.users = ( + [Snowflake(user) for user in self._json.get("users")] + if self._json.get("users") + else None + ) + self.guild_id = Snowflake(self.guild_id) if self._json.get("guild_id") else None + self.embedded_activity = ( + PresenceActivity(**self.embedded_activity) + if self._json.get("embedded_activity") + else None + ) + self.channel_id = Snowflake(self.channel_id) if self._json.get("channel_id") else None + + class GuildBan(DictSerializerMixin): """ A class object representing the gateway event ``GUILD_BAN_ADD``. diff --git a/interactions/api/models/gw.pyi b/interactions/api/models/gw.pyi index 779ccaae4..ed3e62cfd 100644 --- a/interactions/api/models/gw.pyi +++ b/interactions/api/models/gw.pyi @@ -17,6 +17,14 @@ class ChannelPins(DictSerializerMixin): last_pin_timestamp: Optional[datetime] def __init__(self, **kwargs): ... +class EmbeddedActivity(DictSerializerMixin): + _json: dict + users: List[Snowflake] + guild_id: Snowflake + embedded_activity: PresenceActivity + channel_id: Snowflake + def __init__(self, **kwargs): ... + class GuildBan(DictSerializerMixin): _json: dict guild_id: Snowflake diff --git a/interactions/api/models/presence.py b/interactions/api/models/presence.py index d2586fbee..7ace046a9 100644 --- a/interactions/api/models/presence.py +++ b/interactions/api/models/presence.py @@ -81,7 +81,6 @@ class PresenceActivity(DictSerializerMixin): :ivar str name: The activity name :ivar str type: The activity type - :ivar str id: The activity ID. :ivar Optional[str] url?: stream url (if type is 1) :ivar Snowflake created_at: Unix timestamp of when the activity was created to the User's session :ivar Optional[PresenceTimestamp] timestamps?: Unix timestamps for start and/or end of the game @@ -101,7 +100,6 @@ class PresenceActivity(DictSerializerMixin): "_json", "name", "type", - "id", "url", "created_at", "timestamps", diff --git a/interactions/api/models/presence.pyi b/interactions/api/models/presence.pyi index f90b71cb9..c60d1b699 100644 --- a/interactions/api/models/presence.pyi +++ b/interactions/api/models/presence.pyi @@ -40,7 +40,6 @@ class PresenceActivity(DictSerializerMixin): _json: dict name: str type: int - id: str url: Optional[str] created_at: Snowflake timestamps: Optional[PresenceTimestamp] From 53d7a9fd1f98126dfe4f8b25518e5ee5392f6a94 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Fri, 7 Jan 2022 13:26:04 -0500 Subject: [PATCH 046/105] docs: Fix typos in the EmbeddedActivity model. --- interactions/api/models/gw.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interactions/api/models/gw.py b/interactions/api/models/gw.py index 4aafb4142..94827c0c7 100644 --- a/interactions/api/models/gw.py +++ b/interactions/api/models/gw.py @@ -38,10 +38,10 @@ class EmbeddedActivity(DictSerializerMixin): ..note:: This is entirely undocumented by the API. - :ivar List[Snowflake] users: The list of users in the event. + :ivar List[Snowflake] users: The list of users of the event. :ivar Snowflake guild_id: The guild ID of the event. :ivar PresenceActivity embedded_activity: The embedded presence activity of the associated event. - :ivar Snowflake channel_id: The channel_id ID of the event. + :ivar Snowflake channel_id: The channel ID of the event. """ __slots__ = ("_json", "users", "guild_id", "embedded_activity", "channel_id") From 2aafb9d861485bd78c4e4f711fb73b06d9fa5823 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Fri, 7 Jan 2022 13:53:35 -0500 Subject: [PATCH 047/105] feat: Implement GuildJoinRequest event, finish typehinting Integration model. --- interactions/api/models/gw.py | 20 +++++++++++++++++++- interactions/api/models/gw.pyi | 30 ++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/interactions/api/models/gw.py b/interactions/api/models/gw.py index 94827c0c7..3e58be70d 100644 --- a/interactions/api/models/gw.py +++ b/interactions/api/models/gw.py @@ -36,7 +36,8 @@ class EmbeddedActivity(DictSerializerMixin): """ A class object representing the event ``EMBEDDED_ACTIVITY_UPDATE``. - ..note:: This is entirely undocumented by the API. + .. note:: + This is entirely undocumented by the API. :ivar List[Snowflake] users: The list of users of the event. :ivar Snowflake guild_id: The guild ID of the event. @@ -110,6 +111,23 @@ def __init__(self, **kwargs): self.guild_id = Snowflake(self.guild_id) if self._json.get("guild_id") else None +class GuildJoinRequest(DictSerializerMixin): + """ + A class object representing the gateway events ``GUILD_JOIN_REQUEST_CREATE``, ``GUILD_JOIN_REQUEST_UPDATE``, and ``GUILD_JOIN_REQUEST_DELETE`` + + .. note:: + This is entirely undocumented by the API. + + :ivar Snowflake user_id: The user ID of the event. + :ivar Snowflake guild_id: The guild ID of the event. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.user_id = Snowflake(self.user_id) if self._json.get("user_id") else None + self.guild_id = Snowflake(self.guild_id) if self._json.get("guild_id") else None + + class GuildMember(DictSerializerMixin): """ A class object representing the gateway events ``GUILD_MEMBER_ADD``, ``GUILD_MEMBER_UPDATE`` and ``GUILD_MEMBER_REMOVE``. diff --git a/interactions/api/models/gw.pyi b/interactions/api/models/gw.pyi index ed3e62cfd..2d393f953 100644 --- a/interactions/api/models/gw.pyi +++ b/interactions/api/models/gw.pyi @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Any from .channel import Channel, ThreadMember from .member import Member @@ -8,6 +8,7 @@ from .misc import ClientStatus, DictSerializerMixin, Snowflake from .presence import PresenceActivity from .role import Role from .user import User +from .team import Application from ..http import HTTPClient class ChannelPins(DictSerializerMixin): @@ -43,6 +44,12 @@ class GuildIntegrations(DictSerializerMixin): guild_id: Snowflake def __init__(self, **kwargs): ... +class GuildJoinRequest(DictSerializerMixin): + _json: dict + user_id: Snowflake + guild_id: Snowflake + def __init__(self, **kwargs): ... + class GuildMember(DictSerializerMixin): _json: dict guild_id: Snowflake @@ -83,7 +90,26 @@ class GuildStickers(DictSerializerMixin): stickers: List[Sticker] def __init__(self, **kwargs): ... -class Integration(DictSerializerMixin): ... +class Integration(DictSerializerMixin): + _json: dict + id: Snowflake + name: str + type: str + enabled: bool + syncing: bool + role_id: Snowflake + enable_emoticons: bool + expire_behavior: int + expire_grace_period: int + user: User + account: Any + synced_at: datetime + subscriber_count: int + revoked: bool + application: Application + guild_id: Snowflake + + def __init__(self, **kwargs): ... class Presence(DictSerializerMixin): _json: dict From 99f374144e4b63ce088660d05b2aebf20ab664f6 Mon Sep 17 00:00:00 2001 From: Toricane <73972068+Toricane@users.noreply.github.com> Date: Fri, 7 Jan 2022 19:22:55 -0800 Subject: [PATCH 048/105] docs: correctly format docstring --- interactions/client.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/interactions/client.py b/interactions/client.py index 70a0c29e5..b01d9dc93 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -383,13 +383,18 @@ def message_command( A decorator for registering a message context menu to the Discord API, as well as being able to listen for ``INTERACTION_CREATE`` dispatched gateway events. + The structure of a user context menu: + .. code-block:: python + @message_command(name="Context menu name") async def context_menu_name(ctx): ... + The ``scope`` kwarg field may also be used to designate the command in question applicable to a guild or set of guilds. + :param name: The name of the application command. This *is* required but kept optional to follow kwarg rules. :type name: Optional[str] :param scope?: The "scope"/applicable guilds the application command applies to. Defaults to ``None``. @@ -435,13 +440,18 @@ def user_command( A decorator for registering a user context menu to the Discord API, as well as being able to listen for ``INTERACTION_CREATE`` dispatched gateway events. + The structure of a user context menu: + .. code-block:: python + @user_command(name="Context menu name") async def context_menu_name(ctx): ... + The ``scope`` kwarg field may also be used to designate the command in question applicable to a guild or set of guilds. + :param name: The name of the application command. This *is* required but kept optional to follow kwarg rules. :type name: Optional[str] :param scope?: The "scope"/applicable guilds the application command applies to. Defaults to ``None``. From 0779821d1936829e25178d50a42f8984e5ba42e4 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Fri, 7 Jan 2022 22:49:55 -0500 Subject: [PATCH 049/105] fix: Implement cache safeguard on some requests that require ID checking. This is in response to #403. --- interactions/api/http.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index 9a741eb2f..8fb947b02 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -432,7 +432,8 @@ async def create_message(self, payload: dict, channel_id: int) -> dict: request = await self._req.request( Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id), json=payload ) - self.cache.messages.add(Item(id=request["id"], value=Message(**request))) + if request.get("id"): + self.cache.messages.add(Item(id=request["id"], value=Message(**request))) return request @@ -536,7 +537,8 @@ async def get_self_guilds(self) -> list: request = await self._req.request(Route("GET", "/users/@me/guilds")) for guild in request: - self.cache.self_guilds.add(Item(id=guild["id"], value=Guild(**guild))) + if guild.get("id"): + self.cache.self_guilds.add(Item(id=guild["id"], value=Guild(**guild))) return request @@ -863,7 +865,8 @@ async def get_all_channels(self, guild_id: int) -> List[dict]: ) for channel in request: - self.cache.channels.add(Item(id=channel["id"], value=Channel(**channel))) + if channel.get("id"): + self.cache.channels.add(Item(id=channel["id"], value=Channel(**channel))) return request @@ -878,7 +881,8 @@ async def get_all_roles(self, guild_id: int) -> List[dict]: ) for role in request: - self.cache.roles.add(Item(id=role["id"], value=Role(**role))) + if role.get("id"): + self.cache.roles.add(Item(id=role["id"], value=Role(**role))) return request @@ -895,7 +899,8 @@ async def create_guild_role( request = await self._req.request( Route("POST", f"/guilds/{guild_id}/roles"), json=data, reason=reason ) - self.cache.roles.add(Item(id=request["id"], value=Role(**request))) + if request.get("id"): + self.cache.roles.add(Item(id=request["id"], value=Role(**request))) return request @@ -1269,7 +1274,8 @@ async def get_channel_messages( ) for message in request: - self.cache.messages.add(Item(id=message["id"], value=Message(**message))) + if message.get("id"): + self.cache.messages.add(Item(id=message["id"], value=Message(**message))) return request @@ -1290,7 +1296,8 @@ async def create_channel( request = await self._req.request( Route("POST", f"/guilds/{guild_id}/channels"), json=payload, reason=reason ) - self.cache.channels.add(Item(id=request["id"], value=Channel(**request))) + if request.get("id"): + self.cache.channels.add(Item(id=request["id"], value=Channel(**request))) return request @@ -1651,7 +1658,8 @@ async def create_thread( json=payload, reason=reason, ) - self.cache.channels.add(Item(id=request["id"], value=request)) + if request.get("id"): + self.cache.channels.add(Item(id=request["id"], value=request)) return request payload["type"] = thread_type @@ -1659,7 +1667,8 @@ async def create_thread( request = await self._req.request( Route("POST", f"/channels/{channel_id}/threads"), json=payload, reason=reason ) - self.cache.channels.add(Item(id=request["id"], value=request)) + if request.get("id"): + self.cache.channels.add(Item(id=request["id"], value=request)) return request From 052cf5ce578d0481b49b2824cec58da58b91f402 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Sat, 8 Jan 2022 16:05:55 +0100 Subject: [PATCH 050/105] Update interactions/api/models/channel.py Co-authored-by: fl0w <41456914+goverfl0w@users.noreply.github.com> --- interactions/api/models/channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 5317ba521..62adb0e02 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -366,7 +366,7 @@ async def publish_message( :param message_id: The id of the message to publish :type message_id: int - :return: message object + :return: The message published :rtype: Message """ from .message import Message From 2aef47349b328985d593db0ef55f49a110f9f7b4 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Sat, 8 Jan 2022 16:06:01 +0100 Subject: [PATCH 051/105] Update interactions/api/models/channel.py Co-authored-by: fl0w <41456914+goverfl0w@users.noreply.github.com> --- interactions/api/models/channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 62adb0e02..336345c30 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -376,7 +376,7 @@ async def publish_message( ) return Message(**res, _client=self._client) - async def get_pinned_messages(self): + async def get_pinned_messages(self) -> List[Message]: """ Get all pinned messages from the channel. :return: A list of pinned message objects. From 7056012bce7747e83d89b64041b6788babf90a85 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Sat, 8 Jan 2022 16:06:07 +0100 Subject: [PATCH 052/105] Update interactions/api/models/channel.py Co-authored-by: fl0w <41456914+goverfl0w@users.noreply.github.com> --- interactions/api/models/channel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 336345c30..f9d712533 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -380,6 +380,7 @@ async def get_pinned_messages(self) -> List[Message]: """ Get all pinned messages from the channel. :return: A list of pinned message objects. + :rtype: List[Message] """ from .message import Message From 5ca6b9c08a3a78ea38bd67e14528bbd8f3f0dd33 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Sat, 8 Jan 2022 16:06:11 +0100 Subject: [PATCH 053/105] Update interactions/api/models/guild.py Co-authored-by: fl0w <41456914+goverfl0w@users.noreply.github.com> --- interactions/api/models/guild.py | 1 + 1 file changed, 1 insertion(+) diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 951f10264..721e00f0d 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -1056,6 +1056,7 @@ async def get_all_roles(self) -> List[Role]: """ Gets all roles of the guild as list + :return: The roles of the guild. :rtype: List[Role] """ res = self._client.get_all_roles(int(self.id)) From d00e498631651105e4eb21dabe7549388dcca8e2 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Sat, 8 Jan 2022 16:06:16 +0100 Subject: [PATCH 054/105] Update interactions/api/models/guild.py Co-authored-by: fl0w <41456914+goverfl0w@users.noreply.github.com> --- interactions/api/models/guild.py | 1 + 1 file changed, 1 insertion(+) diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 721e00f0d..00d0bc66c 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -1046,6 +1046,7 @@ async def get_all_channels(self) -> List[Channel]: """ Gets all channels of the guild as list + :return: The channels of the guild. :rtype: List[Channel] """ res = self._client.get_all_channels(int(self.id)) From 2e859c63367abbd0f7a942efccfd6e375673ee07 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Sat, 8 Jan 2022 16:07:51 +0100 Subject: [PATCH 055/105] Update interactions/api/models/guild.py Co-authored-by: fl0w <41456914+goverfl0w@users.noreply.github.com> --- interactions/api/models/guild.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 00d0bc66c..27472303b 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -1095,7 +1095,7 @@ async def get_bans(self) -> List[dict]: Gets a list of banned users :return: List of banned users with reasons - :rtype: List[dict] + :rtype: List[User] """ res = await self._client.get_guild_bans(int(self.id)) From eaea91f3abd7cb36db9cc91fecf8019d8d769c51 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Sat, 8 Jan 2022 16:08:45 +0100 Subject: [PATCH 056/105] Update interactions/api/models/message.py Co-authored-by: fl0w <41456914+goverfl0w@users.noreply.github.com> --- interactions/api/models/message.py | 1 + 1 file changed, 1 insertion(+) diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index 64a5569f4..1e586b457 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -434,6 +434,7 @@ async def pin(self) -> None: async def unpin(self) -> None: """Unpins the message from its channel""" + await self._client.unpin_message(channel_id=int(self.channel_id), message_id=int(self.id)) async def publish(self) -> "Message": From a0bfdc72cecc2d35514ccf8b1423526efd999fa6 Mon Sep 17 00:00:00 2001 From: Toricane <73972068+Toricane@users.noreply.github.com> Date: Sat, 8 Jan 2022 14:16:03 -0800 Subject: [PATCH 057/105] docs: fix typo in docstring --- interactions/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/client.py b/interactions/client.py index 288acc5ca..4bef677b8 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -372,7 +372,7 @@ def message_command( as well as being able to listen for ``INTERACTION_CREATE`` dispatched gateway events. - The structure of a user context menu: + The structure of a message context menu: .. code-block:: python From f0e71b58107dedb66783b9fda1b90db73bb9023f Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Sat, 8 Jan 2022 22:30:47 -0500 Subject: [PATCH 058/105] fix: Fix embed field parsing and adjust documentation. --- interactions/api/models/message.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index db6aa9459..dd37b6b5d 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -669,7 +669,7 @@ class Embed(DictSerializerMixin): :ivar Optional[EmbedImageStruct] video?: Video information :ivar Optional[EmbedProvider] provider?: Provider information :ivar Optional[EmbedAuthor] author?: Author information - :ivar Optional[EmbedField] fields?: A list of fields denoting field information + :ivar Optional[List[EmbedField]] fields?: A list of fields denoting field information """ __slots__ = ( @@ -729,7 +729,7 @@ def __init__(self, **kwargs): self.fields = ( [ EmbedField(**field) if isinstance(field, dict) else field - for field in self._json["fields"][0] + for field in self._json["fields"] ] if self._json.get("fields") else None @@ -743,7 +743,7 @@ def __init__(self, **kwargs): # on we'll be refactoring this anyhow. What the fuck is breaking # it? if self.fields: - self._json.update({"fields": [field._json for field in self.fields[0]]}) + self._json.update({"fields": [field._json for field in self.fields]}) if self.author: self._json.update({"author": self.author._json}) From d186ed3578efbd975503c7f826a3b0eb98fb469e Mon Sep 17 00:00:00 2001 From: Catalyst4 <84055084+Catalyst4222@users.noreply.github.com> Date: Sun, 9 Jan 2022 11:04:20 -0500 Subject: [PATCH 059/105] refactor: change intents.py to flags.py --- interactions/__init__.py | 2 +- interactions/api/gateway.py | 2 +- interactions/api/gateway.pyi | 2 +- interactions/api/models/__init__.py | 2 +- interactions/api/models/{intents.py => flags.py} | 0 interactions/client.py | 2 +- interactions/client.pyi | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename interactions/api/models/{intents.py => flags.py} (100%) diff --git a/interactions/__init__.py b/interactions/__init__.py index 3a89e940a..22c031362 100644 --- a/interactions/__init__.py +++ b/interactions/__init__.py @@ -14,7 +14,7 @@ from .api.models.channel import * # noqa: F401 F403 from .api.models.guild import * # noqa: F401 F403 from .api.models.gw import * # noqa: F401 F403 -from .api.models.intents import * # noqa: F401 F403 +from .api.models.flags import * # noqa: F401 F403 from .api.models.member import * # noqa: F401 F403 from .api.models.message import * # noqa: F401 F403 from .api.models.misc import * # noqa: F401 F403 diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index c2c0ab49f..83cd11383 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -17,7 +17,7 @@ from .enums import OpCodeType from .error import GatewayException from .http import HTTPClient -from .models.intents import Intents +from .models.flags import Intents basicConfig(level=Data.LOGGER) log: Logger = getLogger("gateway") diff --git a/interactions/api/gateway.pyi b/interactions/api/gateway.pyi index 5c93feeb1..d8cb194f7 100644 --- a/interactions/api/gateway.pyi +++ b/interactions/api/gateway.pyi @@ -5,7 +5,7 @@ from typing import Any, List, Optional, Union from .dispatch import Listener from .http import HTTPClient from .models.gw import Presence -from .models.intents import Intents +from .models.flags import Intents class Heartbeat(Thread): ws: Any diff --git a/interactions/api/models/__init__.py b/interactions/api/models/__init__.py index 4e4a670b1..f60f50c57 100644 --- a/interactions/api/models/__init__.py +++ b/interactions/api/models/__init__.py @@ -8,7 +8,7 @@ from .channel import * # noqa: F401 F403 from .guild import * # noqa: F401 F403 from .gw import * # noqa: F401 F403 -from .intents import * # noqa: F401 F403 +from .flags import * # noqa: F401 F403 from .member import * # noqa: F401 F403 from .message import * # noqa: F401 F403 from .misc import * # noqa: F401 F403 diff --git a/interactions/api/models/intents.py b/interactions/api/models/flags.py similarity index 100% rename from interactions/api/models/intents.py rename to interactions/api/models/flags.py diff --git a/interactions/client.py b/interactions/client.py index 36ef37889..30870a30b 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -14,7 +14,7 @@ from .api.http import HTTPClient from .api.models.guild import Guild from .api.models.gw import Presence -from .api.models.intents import Intents +from .api.models.flags import Intents from .api.models.team import Application from .base import CustomFormatter, Data from .decor import command diff --git a/interactions/client.pyi b/interactions/client.pyi index 1e8114d60..61ef9afaf 100644 --- a/interactions/client.pyi +++ b/interactions/client.pyi @@ -7,7 +7,7 @@ from .api.cache import Cache from .api.gateway import WebSocket from .api.http import HTTPClient from .api.models.guild import Guild -from .api.models.intents import Intents +from .api.models.flags import Intents from .api.models.team import Application from .enums import ApplicationCommandType from .models.command import ApplicationCommand, Option From b8e405e73c1e2487ffa47e7063b462cee5c6520e Mon Sep 17 00:00:00 2001 From: Catalyst4 <84055084+Catalyst4222@users.noreply.github.com> Date: Sun, 9 Jan 2022 11:41:06 -0500 Subject: [PATCH 060/105] feat: create a Permissions IntFlag for use in Member.permissions --- interactions/api/models/flags.py | 45 ++++++++++++++++++++++++++++++ interactions/api/models/member.py | 8 +++++- interactions/api/models/member.pyi | 3 +- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/interactions/api/models/flags.py b/interactions/api/models/flags.py index 3e5eb2e73..7a0c3c0ef 100644 --- a/interactions/api/models/flags.py +++ b/interactions/api/models/flags.py @@ -39,3 +39,48 @@ class Intents(IntFlag): | GUILD_SCHEDULED_EVENTS ) ALL = DEFAULT | PRIVILEGED + + +class Permissions(IntFlag): + """An integer flag bitshift object representing the different member permissions given by Discord.""" + CREATE_INSTANT_INVITE = 1 << 0 + KICK_MEMBERS = 1 << 1 + BAN_MEMBERS = 1 << 2 + ADMINISTRATOR = 1 << 3 + MANAGE_CHANNELS = 1 << 4 + MANAGE_GUILD = 1 << 5 + ADD_REACTIONS = 1 << 6 + VIEW_AUDIT_LOG = 1 << 7 + PRIORITY_SPEAKER = 1 << 8 + STREAM = 1 << 9 + VIEW_CHANNEL = 1 << 10 + SEND_MESSAGES = 1 << 11 + SEND_TTS_MESSAGES = 1 << 12 + MANAGE_MESSAGES = 1 << 13 + EMBED_LINKS = 1 << 14 + ATTACH_FILES = 1 << 15 + READ_MESSAGE_HISTORY = 1 << 16 + MENTION_EVERYONE = 1 << 17 + USE_EXTERNAL_EMOJIS = 1 << 18 + VIEW_GUILD_INSIGHTS = 1 << 19 + CONNECT = 1 << 20 + SPEAK = 1 << 21 + MUTE_MEMBERS = 1 << 22 + DEAFEN_MEMBERS = 1 << 23 + MOVE_MEMBERS = 1 << 24 + USE_VAD = 1 << 25 + CHANGE_NICKNAME = 1 << 26 + MANAGE_NICKNAMES = 1 << 27 + MANAGE_ROLES = 1 << 28 + MANAGE_WEBHOOKS = 1 << 29 + MANAGE_EMOJIS_AND_STICKERS = 1 << 30 + USE_APPLICATION_COMMANDS = 1 << 31 + REQUEST_TO_SPEAK = 1 << 32 + MANAGE_EVENTS = 1 << 33 + MANAGE_THREADS = 1 << 34 + CREATE_PUBLIC_THREADS = 1 << 35 + CREATE_PRIVATE_THREADS = 1 << 36 + USE_EXTERNAL_STICKERS = 1 << 37 + SEND_MESSAGES_IN_THREADS = 1 << 38 + START_EMBEDDED_ACTIVITIES = 1 << 39 + MODERATE_MEMBERS = 1 << 40 diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 07fdd6368..5a99936ce 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -2,6 +2,7 @@ from .misc import DictSerializerMixin from .user import User +from .flags import Permissions class Member(DictSerializerMixin): @@ -22,7 +23,7 @@ class Member(DictSerializerMixin): :ivar bool deaf: Whether the member is deafened. :ivar bool mute: Whether the member is muted. :ivar Optional[bool] pending?: Whether the member is pending to pass membership screening. - :ivar Optional[str] permissions?: Whether the member has permissions. + :ivar Optional[Permissions] permissions?: Whether the member has permissions. :ivar Optional[str] communication_disabled_until?: How long until they're unmuted, if any. """ @@ -56,3 +57,8 @@ def __init__(self, **kwargs): if self._json.get("premium_since") else None ) + self.permissions = ( + Permissions(int(self._json.get("permissions"))) + if self._json.get("permissions") + else None + ) diff --git a/interactions/api/models/member.pyi b/interactions/api/models/member.pyi index b0f2c2f9f..728fb9ebd 100644 --- a/interactions/api/models/member.pyi +++ b/interactions/api/models/member.pyi @@ -4,6 +4,7 @@ from typing import Any, List, Optional from .misc import DictSerializerMixin from .role import Role from .user import User +from .flags import Permissions class Member(DictSerializerMixin): @@ -18,7 +19,7 @@ class Member(DictSerializerMixin): mute: bool is_pending: Optional[bool] pending: Optional[bool] - permissions: Optional[str] + permissions: Optional[Permissions] communication_disabled_until: Optional[str] hoisted_role: Any # TODO: post-v4: Investigate what this is for when documented by Discord. def __init__(self, **kwargs): ... From 8ffad01ae8a772ff283a68cedb1a6a47a227844a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 9 Jan 2022 17:53:37 +0000 Subject: [PATCH 061/105] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- interactions/__init__.py | 2 +- interactions/api/models/__init__.py | 2 +- interactions/api/models/flags.py | 1 + interactions/api/models/member.py | 2 +- interactions/client.py | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/interactions/__init__.py b/interactions/__init__.py index 22c031362..809eed726 100644 --- a/interactions/__init__.py +++ b/interactions/__init__.py @@ -12,9 +12,9 @@ Co-authored by DeltaXW. """ from .api.models.channel import * # noqa: F401 F403 +from .api.models.flags import * # noqa: F401 F403 from .api.models.guild import * # noqa: F401 F403 from .api.models.gw import * # noqa: F401 F403 -from .api.models.flags import * # noqa: F401 F403 from .api.models.member import * # noqa: F401 F403 from .api.models.message import * # noqa: F401 F403 from .api.models.misc import * # noqa: F401 F403 diff --git a/interactions/api/models/__init__.py b/interactions/api/models/__init__.py index f60f50c57..872948aee 100644 --- a/interactions/api/models/__init__.py +++ b/interactions/api/models/__init__.py @@ -6,9 +6,9 @@ models for dispatched Gateway events. """ from .channel import * # noqa: F401 F403 +from .flags import * # noqa: F401 F403 from .guild import * # noqa: F401 F403 from .gw import * # noqa: F401 F403 -from .flags import * # noqa: F401 F403 from .member import * # noqa: F401 F403 from .message import * # noqa: F401 F403 from .misc import * # noqa: F401 F403 diff --git a/interactions/api/models/flags.py b/interactions/api/models/flags.py index 7a0c3c0ef..d7ea08b0b 100644 --- a/interactions/api/models/flags.py +++ b/interactions/api/models/flags.py @@ -43,6 +43,7 @@ class Intents(IntFlag): class Permissions(IntFlag): """An integer flag bitshift object representing the different member permissions given by Discord.""" + CREATE_INSTANT_INVITE = 1 << 0 KICK_MEMBERS = 1 << 1 BAN_MEMBERS = 1 << 2 diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 5a99936ce..e5a456ba9 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -1,8 +1,8 @@ from datetime import datetime +from .flags import Permissions from .misc import DictSerializerMixin from .user import User -from .flags import Permissions class Member(DictSerializerMixin): diff --git a/interactions/client.py b/interactions/client.py index 30870a30b..a4698fb40 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -12,9 +12,9 @@ from .api.error import InteractionException, JSONException from .api.gateway import WebSocket from .api.http import HTTPClient +from .api.models.flags import Intents from .api.models.guild import Guild from .api.models.gw import Presence -from .api.models.flags import Intents from .api.models.team import Application from .base import CustomFormatter, Data from .decor import command From d5803e1f8d3c940391ef5d9326160e64a93cc0a0 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Sun, 9 Jan 2022 15:50:41 -0500 Subject: [PATCH 062/105] fix: Fix embed footer parsing, add Discord Status enum. --- interactions/api/models/message.py | 3 +++ interactions/enums.py | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index dd37b6b5d..a34cf66cb 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -747,3 +747,6 @@ def __init__(self, **kwargs): if self.author: self._json.update({"author": self.author._json}) + + if self.footer: + self._json.update({"footer": self.footer._json}) diff --git a/interactions/enums.py b/interactions/enums.py index 3ce910e71..1c5ce22ce 100644 --- a/interactions/enums.py +++ b/interactions/enums.py @@ -1,4 +1,4 @@ -from enum import IntEnum +from enum import Enum, IntEnum class ApplicationCommandType(IntEnum): @@ -141,3 +141,16 @@ class TextStyleType(IntEnum): SHORT = 1 PARAGRAPH = 2 + + +# TODO: Move this to flags.py after # 420 +class StatusType(str, Enum): + """ + A string enum representing Discord status icons that a user may have. + """ + + ONLINE = "online" + DND = "dnd" + IDLE = "idle" + INVISIBLE = "invisible" + OFFLINE = "offline" From 6ec822451349748cbbc8e2b7855a219b406679a5 Mon Sep 17 00:00:00 2001 From: LordOfPolls Date: Mon, 10 Jan 2022 06:12:53 +0000 Subject: [PATCH 063/105] fix: Stop configuring logging --- interactions/api/dispatch.py | 4 ---- interactions/api/gateway.py | 9 ++------- interactions/api/http.py | 9 ++------- interactions/client.py | 9 ++------- interactions/context.py | 4 ---- 5 files changed, 6 insertions(+), 29 deletions(-) diff --git a/interactions/api/dispatch.py b/interactions/api/dispatch.py index 95e6e4cf3..973addda1 100644 --- a/interactions/api/dispatch.py +++ b/interactions/api/dispatch.py @@ -6,10 +6,6 @@ basicConfig(level=Data.LOGGER) log: Logger = getLogger("dispatch") -stream: StreamHandler = StreamHandler() -stream.setLevel(Data.LOGGER) -stream.setFormatter(CustomFormatter()) -log.addHandler(stream) class Listener: diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index c2c0ab49f..da63de9d5 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -1,7 +1,7 @@ import sys from asyncio import get_event_loop, run_coroutine_threadsafe from json import dumps -from logging import Logger, StreamHandler, basicConfig, getLogger +from logging import Logger, basicConfig, getLogger from random import random from threading import Event, Thread from typing import Any, List, Optional, Union @@ -11,20 +11,15 @@ from interactions.api.models.gw import Presence from interactions.enums import InteractionType, OptionType - -from ..base import CustomFormatter, Data from .dispatch import Listener from .enums import OpCodeType from .error import GatewayException from .http import HTTPClient from .models.intents import Intents +from ..base import Data basicConfig(level=Data.LOGGER) log: Logger = getLogger("gateway") -stream: StreamHandler = StreamHandler() -stream.setLevel(Data.LOGGER) -stream.setFormatter(CustomFormatter()) -log.addHandler(stream) __all__ = ("Heartbeat", "WebSocket") diff --git a/interactions/api/http.py b/interactions/api/http.py index 9dbf8af63..95b233cd8 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -1,6 +1,6 @@ from asyncio import AbstractEventLoop, Event, Lock, get_event_loop, sleep from json import dumps -from logging import Logger, StreamHandler, basicConfig, getLogger +from logging import Logger, basicConfig, getLogger from sys import version_info from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union from urllib.parse import quote @@ -9,7 +9,6 @@ from aiohttp import __version__ as http_version import interactions.api.cache - from ..api.cache import Cache, Item from ..api.error import HTTPException from ..api.models import ( @@ -28,14 +27,10 @@ User, WelcomeScreen, ) -from ..base import CustomFormatter, Data, __version__ +from ..base import Data, __version__ basicConfig(level=Data.LOGGER) log: Logger = getLogger("http") -stream: StreamHandler = StreamHandler() -stream.setLevel(Data.LOGGER) -stream.setFormatter(CustomFormatter()) -log.addHandler(stream) __all__ = ("Route", "Padlock", "Request", "HTTPClient") session: ClientSession = ClientSession() diff --git a/interactions/client.py b/interactions/client.py index 36ef37889..201881a09 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -1,10 +1,9 @@ import sys from asyncio import get_event_loop - # from functools import partial from importlib import import_module from importlib.util import resolve_name -from logging import Logger, StreamHandler, basicConfig, getLogger +from logging import Logger, basicConfig, getLogger from typing import Any, Callable, Coroutine, Dict, List, Optional, Union from .api.cache import Cache @@ -16,7 +15,7 @@ from .api.models.gw import Presence from .api.models.intents import Intents from .api.models.team import Application -from .base import CustomFormatter, Data +from .base import Data from .decor import command from .decor import component as _component from .enums import ApplicationCommandType @@ -25,10 +24,6 @@ basicConfig(level=Data.LOGGER) log: Logger = getLogger("client") -stream: StreamHandler = StreamHandler() -stream.setLevel(Data.LOGGER) -stream.setFormatter(CustomFormatter()) -log.addHandler(stream) _token: str = "" # noqa _cache: Optional[Cache] = None diff --git a/interactions/context.py b/interactions/context.py index 5f342ec93..6f8f0a54a 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -15,10 +15,6 @@ basicConfig(level=Data.LOGGER) log: Logger = getLogger("context") -stream: StreamHandler = StreamHandler() -stream.setLevel(Data.LOGGER) -stream.setFormatter(CustomFormatter()) -log.addHandler(stream) class Context(DictSerializerMixin): From be2fdcbf782e6d64879254f01d25dcb207b11524 Mon Sep 17 00:00:00 2001 From: LordOfPolls Date: Mon, 10 Jan 2022 07:36:38 +0000 Subject: [PATCH 064/105] fix: Remove additional logging references --- interactions/api/dispatch.py | 5 +---- interactions/api/gateway.py | 5 ++--- interactions/api/http.py | 6 +++--- interactions/client.py | 11 ++--------- interactions/context.py | 4 +--- 5 files changed, 9 insertions(+), 22 deletions(-) diff --git a/interactions/api/dispatch.py b/interactions/api/dispatch.py index 973addda1..bc4aa31ee 100644 --- a/interactions/api/dispatch.py +++ b/interactions/api/dispatch.py @@ -1,10 +1,7 @@ from asyncio import get_event_loop -from logging import Logger, StreamHandler, basicConfig, getLogger +from logging import Logger, getLogger from typing import Coroutine, Optional -from ..base import CustomFormatter, Data - -basicConfig(level=Data.LOGGER) log: Logger = getLogger("dispatch") diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index da63de9d5..e21fc5915 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -1,7 +1,7 @@ import sys from asyncio import get_event_loop, run_coroutine_threadsafe from json import dumps -from logging import Logger, basicConfig, getLogger +from logging import Logger, getLogger from random import random from threading import Event, Thread from typing import Any, List, Optional, Union @@ -11,14 +11,13 @@ from interactions.api.models.gw import Presence from interactions.enums import InteractionType, OptionType + from .dispatch import Listener from .enums import OpCodeType from .error import GatewayException from .http import HTTPClient from .models.intents import Intents -from ..base import Data -basicConfig(level=Data.LOGGER) log: Logger = getLogger("gateway") __all__ = ("Heartbeat", "WebSocket") diff --git a/interactions/api/http.py b/interactions/api/http.py index 95b233cd8..2565798e8 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -1,6 +1,6 @@ from asyncio import AbstractEventLoop, Event, Lock, get_event_loop, sleep from json import dumps -from logging import Logger, basicConfig, getLogger +from logging import Logger, getLogger from sys import version_info from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union from urllib.parse import quote @@ -9,6 +9,7 @@ from aiohttp import __version__ as http_version import interactions.api.cache + from ..api.cache import Cache, Item from ..api.error import HTTPException from ..api.models import ( @@ -27,9 +28,8 @@ User, WelcomeScreen, ) -from ..base import Data, __version__ +from ..base import __version__ -basicConfig(level=Data.LOGGER) log: Logger = getLogger("http") __all__ = ("Route", "Padlock", "Request", "HTTPClient") diff --git a/interactions/client.py b/interactions/client.py index 201881a09..2a7d1483f 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -1,9 +1,10 @@ import sys from asyncio import get_event_loop + # from functools import partial from importlib import import_module from importlib.util import resolve_name -from logging import Logger, basicConfig, getLogger +from logging import Logger, getLogger from typing import Any, Callable, Coroutine, Dict, List, Optional, Union from .api.cache import Cache @@ -15,14 +16,12 @@ from .api.models.gw import Presence from .api.models.intents import Intents from .api.models.team import Application -from .base import Data from .decor import command from .decor import component as _component from .enums import ApplicationCommandType from .models.command import ApplicationCommand, Option from .models.component import Button, Component, Modal, SelectMenu -basicConfig(level=Data.LOGGER) log: Logger = getLogger("client") _token: str = "" # noqa _cache: Optional[Cache] = None @@ -45,7 +44,6 @@ def __init__( token: str, intents: Optional[Union[Intents, List[Intents]]] = Intents.DEFAULT, disable_sync: Optional[bool] = False, - log_level: Optional[int] = Data.LOGGER, shard: Optional[List[int]] = None, presence: Optional[Presence] = None, ) -> None: @@ -57,7 +55,6 @@ def __init__( :param disable_sync?: Whether you want to disable automate synchronization or not. :type disable_sync: Optional[bool] :param log_level?: The logging level to set for the terminal. Defaults to what is set internally. - :type log_level: Optional[int] :param presence?: The presence of the application when connecting. :type presence: Optional[Presence] """ @@ -87,10 +84,6 @@ def __init__( else: self.automate_sync = True - log_names: list = ["client", "context", "dispatch", "gateway", "http", "mixin"] - for logger in log_names: - getLogger(logger).setLevel(log_level) - if not self.me: data = self.loop.run_until_complete(self.http.get_current_bot_information()) self.me = Application(**data) diff --git a/interactions/context.py b/interactions/context.py index 6f8f0a54a..2a0f1b0ae 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -1,4 +1,4 @@ -from logging import Logger, StreamHandler, basicConfig, getLogger +from logging import Logger, getLogger from typing import List, Optional, Union from .api.models.channel import Channel @@ -7,13 +7,11 @@ from .api.models.message import Embed, Message, MessageInteraction, MessageReference from .api.models.misc import DictSerializerMixin, Snowflake from .api.models.user import User -from .base import CustomFormatter, Data from .enums import InteractionCallbackType, InteractionType from .models.command import Choice from .models.component import ActionRow, Button, Component, Modal, SelectMenu from .models.misc import InteractionData -basicConfig(level=Data.LOGGER) log: Logger = getLogger("context") From 5318ae077aa019728eab902035286b1d16da0376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20W=C3=BCrtz?= Date: Tue, 11 Jan 2022 11:23:26 +0100 Subject: [PATCH 065/105] docs: Fix example in autocomplete documentation. --- interactions/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/client.py b/interactions/client.py index 1ec343f43..18cecc29e 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -414,7 +414,7 @@ def autocomplete( .. code-block:: python @autocomplete("option_name") - async def autocomplete_choice_list(ctx): + async def autocomplete_choice_list(ctx, user_input: str = ""): await ctx.populate([...]) :param name: The name of the option to autocomplete. From 89fa332a39bbdd98c0a5693d1ed0f990c4805569 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Tue, 11 Jan 2022 12:03:10 -0500 Subject: [PATCH 066/105] docs: Fix message docs, add banner attribute to channel --- interactions/api/models/channel.py | 2 ++ interactions/api/models/message.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 7381d9cd8..912d99caf 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -157,6 +157,8 @@ class Channel(DictSerializerMixin): "default_auto_archive_duration", "permissions", "_client", + # TODO: Document banner when Discord officially documents them. + "banner", ) def __init__(self, **kwargs): diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index a34cf66cb..33291f94d 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -163,7 +163,7 @@ class Message(DictSerializerMixin): :ivar Snowflake id: ID of the message. :ivar Snowflake channel_id: ID of the channel the message was sent in - :ivar Optional[Snowflake] guild_id:? ID of the guild the message was sent in, if it exists. + :ivar Optional[Snowflake] guild_id?: ID of the guild the message was sent in, if it exists. :ivar User author: The author of the message. :ivar Optional[Member] member?: The member object associated with the author, if any. :ivar str content: Message contents. From 106344e7280f94cd7c781b8c6429c70089ea64c7 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Wed, 12 Jan 2022 16:47:44 -0500 Subject: [PATCH 067/105] fix: Fix command cache by caching more attribute values. --- interactions/client.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index b7fb200dd..ef7905b25 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -188,10 +188,17 @@ async def create(data: ApplicationCommand) -> None: f"Command {data.name} was not found in the API, creating and adding to the cache." ) - await self.http.create_application_command( - application_id=self.me.id, data=data._json, guild_id=data.guild_id + _created_command = ApplicationCommand( + **( + await self.http.create_application_command( + application_id=self.me.id, data=data._json, guild_id=data.guild_id + ) + ) + ) + + self.http.cache.interactions.add( + Build(id=_created_command.name, value=_created_command) ) - self.http.cache.interactions.add(Build(id=data.name, value=data)) if commands: log.debug("Commands were found, checking for sync.") From ba282122724c33682509ab28f97efd77f201d2cb Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Wed, 12 Jan 2022 23:53:27 -0500 Subject: [PATCH 068/105] fix: Update cache for updated commands based on API result. --- interactions/client.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index e65996f6c..9094a791f 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -220,14 +220,16 @@ async def create(data: ApplicationCommand) -> None: f"Command {result.name} found unsynced, editing in the API and updating the cache." ) payload._json["name"] = payload_name - await self.http.edit_application_command( - application_id=self.me.id, - data=payload._json, - command_id=result.id, - guild_id=result._json.get("guild_id"), + _updated = ApplicationCommand( + **await self.http.edit_application_command( + application_id=self.me.id, + data=payload._json, + command_id=result.id, + guild_id=result._json.get("guild_id"), + ) ) self.http.cache.interactions.add( - Build(id=payload.name, value=payload) + Build(id=_updated.name, value=_updated) ) break else: From ae0c226273e4018479cedb2a714da356e0471602 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Thu, 13 Jan 2022 18:19:43 +0100 Subject: [PATCH 069/105] fix!: Allow component sending in `Member.send()` --- interactions/api/models/channel.py | 106 ++++++++++++++++++++++++---- interactions/api/models/channel.pyi | 4 +- interactions/api/models/guild.py | 2 +- interactions/api/models/member.py | 93 ++++++++++++++++++++++++ interactions/api/models/member.pyi | 3 + interactions/context.py | 2 +- 6 files changed, 193 insertions(+), 17 deletions(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index de49f86d6..d962a2c79 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -1,7 +1,8 @@ from datetime import datetime from enum import IntEnum -from typing import Optional +from typing import List, Optional, Union +from ...models.component import ActionRow, Button, SelectMenu from .misc import DictSerializerMixin, Snowflake @@ -188,7 +189,9 @@ async def send( # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds=None, allowed_mentions=None, - components=None, + components: Optional[ + Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]] + ] = None, ): """ Sends a message in the channel @@ -202,7 +205,7 @@ async def send( :param allowed_mentions?: The message interactions/mention limits that the message can refer to. :type allowed_mentions: Optional[MessageInteraction] :param components?: A component, or list of components for the message. - :type components: Optional[Union[Component, List[Component]]] + :type components: Optional[Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]]] :return: The sent message as an object. :rtype: Message """ @@ -215,22 +218,99 @@ async def send( # _attachments = [] if attachments else None _embeds: list = [] _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions - _components: list = [{"type": 1, "components": []}] + _components: List[dict] = [{"type": 1, "components": []}] if embeds: if isinstance(embeds, list): _embeds = [embed._json for embed in embeds] else: _embeds = [embeds._json] - if isinstance(components, ActionRow): - _components[0]["components"] = [component._json for component in components.components] - elif isinstance(components, Button): - _components[0]["components"] = [] if components is None else [components._json] - elif isinstance(components, SelectMenu): - components._json["options"] = [option._json for option in components.options] - _components[0]["components"] = [] if components is None else [components._json] + # TODO: Break this obfuscation pattern down to a "builder" method. + if components: + if isinstance(components, list) and all( + isinstance(action_row, ActionRow) for action_row in components + ): + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in action_row.components + ], + } + for action_row in components + ] + elif isinstance(components, list) and all( + isinstance(component, (Button, SelectMenu)) for component in components + ): + if isinstance(components[0], SelectMenu): + components[0]._json["options"] = [ + option._json for option in components[0].options + ] + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components + ], + } + ] + elif isinstance(components, list) and all( + isinstance(action_row, (list, ActionRow)) for action_row in components + ): + _components = [] + for action_row in components: + for component in ( + action_row if isinstance(action_row, list) else action_row.components + ): + if isinstance(component, SelectMenu): + component._json["options"] = [ + option._json for option in component.options + ] + _components.append( + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") + or component._json.get("url") + else [] + ) + for component in ( + action_row + if isinstance(action_row, list) + else action_row.components + ) + ], + } + ) + elif isinstance(components, ActionRow): + _components[0]["components"] = [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components.components + ] + elif isinstance(components, (Button, SelectMenu)): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) else: - _components = [] if components is None else [components] + _components = [] # TODO: post-v4: Add attachments into Message obj. payload = Message( @@ -378,7 +458,7 @@ async def publish_message( ) return Message(**res, _client=self._client) - async def get_pinned_messages(self) -> List[Message]: + async def get_pinned_messages(self): """ Get all pinned messages from the channel. :return: A list of pinned message objects. diff --git a/interactions/api/models/channel.pyi b/interactions/api/models/channel.pyi index 73b08c150..7b10095ff 100644 --- a/interactions/api/models/channel.pyi +++ b/interactions/api/models/channel.pyi @@ -3,7 +3,7 @@ from enum import IntEnum from typing import List, Optional, Union from .message import Message, Embed, MessageInteraction -from ...models.component import Component +from ...models.component import ActionRow, Button, SelectMenu from .misc import DictSerializerMixin, Overwrite, Snowflake from .user import User from ..http import HTTPClient @@ -65,7 +65,7 @@ class Channel(DictSerializerMixin): # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds: Optional[Union[Embed, List[Embed]]] = None, allowed_mentions: Optional[MessageInteraction] = None, - components: Optional[Union[Component, List[Component]]] = None, + components: Optional[Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]]] = None, ) -> Message: ... async def delete(self) -> None: ... async def modify( diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 27472303b..00d0bc66c 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -1095,7 +1095,7 @@ async def get_bans(self) -> List[dict]: Gets a list of banned users :return: List of banned users with reasons - :rtype: List[User] + :rtype: List[dict] """ res = await self._client.get_guild_bans(int(self.id)) diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index fdd0726d8..7b090595d 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import List, Optional, Union +from ...models.component import ActionRow, Button, SelectMenu from .flags import Permissions from .misc import DictSerializerMixin from .role import Role @@ -174,6 +175,7 @@ async def send( self, content: Optional[str] = None, *, + components: Optional[Union[ActionRow, Button, SelectMenu]] = None, tts: Optional[bool] = False, # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds=None, @@ -184,6 +186,8 @@ async def send( :param content?: The contents of the message as a string or string-converted value. :type content: Optional[str] + :param components?: A component, or list of components for the message. + :type components: Optional[Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]]] :param tts?: Whether the message utilizes the text-to-speech Discord programme or not. :type tts: Optional[bool] :param embeds?: An embed, or list of embeds for the message. @@ -206,6 +210,94 @@ async def send( else ([embed._json for embed in embeds] if isinstance(embeds, list) else [embeds._json]) ) _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions + _components: List[dict] = [{"type": 1, "components": []}] + + # TODO: Break this obfuscation pattern down to a "builder" method. + if components: + if isinstance(components, list) and all( + isinstance(action_row, ActionRow) for action_row in components + ): + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in action_row.components + ], + } + for action_row in components + ] + elif isinstance(components, list) and all( + isinstance(component, (Button, SelectMenu)) for component in components + ): + if isinstance(components[0], SelectMenu): + components[0]._json["options"] = [ + option._json for option in components[0].options + ] + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components + ], + } + ] + elif isinstance(components, list) and all( + isinstance(action_row, (list, ActionRow)) for action_row in components + ): + _components = [] + for action_row in components: + for component in ( + action_row if isinstance(action_row, list) else action_row.components + ): + if isinstance(component, SelectMenu): + component._json["options"] = [ + option._json for option in component.options + ] + _components.append( + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") + or component._json.get("url") + else [] + ) + for component in ( + action_row + if isinstance(action_row, list) + else action_row.components + ) + ], + } + ) + elif isinstance(components, ActionRow): + _components[0]["components"] = [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components.components + ] + elif isinstance(components, (Button, SelectMenu)): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + else: + _components = [] # TODO: post-v4: Add attachments into Message obj. payload = Message( @@ -214,6 +306,7 @@ async def send( # file=file, # attachments=_attachments, embeds=_embeds, + components=_components, allowed_mentions=_allowed_mentions, ) diff --git a/interactions/api/models/member.pyi b/interactions/api/models/member.pyi index 9f20ac8d9..e5537bcc5 100644 --- a/interactions/api/models/member.pyi +++ b/interactions/api/models/member.pyi @@ -7,6 +7,8 @@ from .user import User from .flags import Permissions from ..http import HTTPClient from .message import Message +from ...models.component import ActionRow, Button, SelectMenu + class Member(DictSerializerMixin): @@ -53,6 +55,7 @@ class Member(DictSerializerMixin): self, content: Optional[str] = None, *, + components: Optional[Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]]] = None, tts: Optional[bool] = False, # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds=None, diff --git a/interactions/context.py b/interactions/context.py index 67dda0605..c8ad98dff 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -159,7 +159,7 @@ async def send( embeds: Optional[Union[Embed, List[Embed]]] = None, allowed_mentions: Optional[MessageInteraction] = None, components: Optional[ - Union[ActionRow, Button, SelectMenu, List[ActionRow], List[Button], List[SelectMenu]] + Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]] ] = None, ephemeral: Optional[bool] = False, ) -> Message: From f5b8893c6aecc38f3e71b08391777c47ccc893ff Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Thu, 13 Jan 2022 18:47:22 +0100 Subject: [PATCH 070/105] fix! - Circular imports --- interactions/api/models/channel.py | 7 ++----- interactions/api/models/member.py | 4 ++-- interactions/api/models/presence.py | 1 + 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index d962a2c79..4153d36c0 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -1,8 +1,7 @@ from datetime import datetime from enum import IntEnum -from typing import List, Optional, Union +from typing import List, Optional -from ...models.component import ActionRow, Button, SelectMenu from .misc import DictSerializerMixin, Snowflake @@ -189,9 +188,7 @@ async def send( # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds=None, allowed_mentions=None, - components: Optional[ - Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]] - ] = None, + components=None, ): """ Sends a message in the channel diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 7b090595d..313e339a3 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -1,7 +1,6 @@ from datetime import datetime from typing import List, Optional, Union -from ...models.component import ActionRow, Button, SelectMenu from .flags import Permissions from .misc import DictSerializerMixin from .role import Role @@ -175,7 +174,7 @@ async def send( self, content: Optional[str] = None, *, - components: Optional[Union[ActionRow, Button, SelectMenu]] = None, + components=None, tts: Optional[bool] = False, # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds=None, @@ -197,6 +196,7 @@ async def send( :return: The sent message as an object. :rtype: Message """ + from ...models.component import ActionRow, Button, SelectMenu from .channel import Channel from .message import Message diff --git a/interactions/api/models/presence.py b/interactions/api/models/presence.py index 7ace046a9..ae16627ea 100644 --- a/interactions/api/models/presence.py +++ b/interactions/api/models/presence.py @@ -121,6 +121,7 @@ class PresenceActivity(DictSerializerMixin): "activities", "sync_id", "session_id", + "id", ) def __init__(self, **kwargs): From bfbaccb47247f39c6234a3a81908ba7ca044263e Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Thu, 13 Jan 2022 12:59:32 -0500 Subject: [PATCH 071/105] fix: Add undocumented attributes, fix headers, fix efficiency of command syncing. --- interactions/api/models/channel.py | 1 + interactions/client.py | 29 ++++++++++++++++++++++++++--- interactions/client.pyi | 15 +++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 912d99caf..7137db878 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -159,6 +159,7 @@ class Channel(DictSerializerMixin): "_client", # TODO: Document banner when Discord officially documents them. "banner", + "guild_hashes", ) def __init__(self, **kwargs): diff --git a/interactions/client.py b/interactions/client.py index 9094a791f..0fd3c2833 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -143,6 +143,30 @@ def register_events() -> None: log.debug("Client is now ready.") await self.login(self.token) + async def compare_sync(self, payload: dict, result: dict) -> bool: + """ + This compares the payloads between the Client and the API such that + it can mitigate synchronisation API calls. + """ + + # This needs to be redone after discord updates their docs. + + _res = True + + for attrs in ["type", "guild_id", "name", "description", "options", "default_permission"]: + if attrs == "options" and payload["type"] != 1: + continue + + if attrs == "guild_id": + if str(payload.get(attrs, None)) == result.get(attrs, None): + continue + + if payload.get(attrs, None) != result.get(attrs, (None if attrs != "options" else [])): + _res = False + return _res + + return _res + async def synchronize(self, payload: Optional[ApplicationCommand] = None) -> None: """ Synchronizes the command specified by checking through the @@ -212,10 +236,9 @@ async def create(data: ApplicationCommand) -> None: if payload.name == result.name: payload_name: str = payload.name - del result._json["name"] - del payload._json["name"] + _cmp = await self.compare_sync(payload._json, result._json) - if result._json != payload._json: + if not _cmp: log.debug( f"Command {result.name} found unsynced, editing in the API and updating the cache." ) diff --git a/interactions/client.pyi b/interactions/client.pyi index 61ef9afaf..9e5681ac7 100644 --- a/interactions/client.pyi +++ b/interactions/client.pyi @@ -39,6 +39,7 @@ class Client: async def login(self, token: str) -> None: ... def start(self) -> None: ... async def ready(self) -> None: ... + async def compare_sync(self, payload: dict, result: dict) -> bool: ... async def synchronize(self, payload: Optional[ApplicationCommand] = None) -> None: ... def event(self, coro: Coroutine, name: Optional[str] = None) -> Callable[..., Any]: ... def command( @@ -51,6 +52,20 @@ class Client: options: Optional[List[Option]] = None, default_permission: Optional[bool] = None, ) -> Callable[..., Any]: ... + def message_command( + self, + *, + name: Optional[str] = None, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, + default_permission: Optional[bool] = None, + ) -> Callable[..., Any]: ... + def user_command( + self, + *, + name: Optional[str] = None, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, + default_permission: Optional[bool] = None, + ) -> Callable[..., Any]: ... def component(self, component: Union[Button, SelectMenu]) -> Callable[..., Any]: ... def autocomplete(self, name: str) -> Callable[..., Any]: ... def modal(self, modal: Modal) -> Callable[..., Any]: ... From 592e4a84bfb6e83860858aab8511c3e514dd43f8 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Thu, 13 Jan 2022 19:28:14 +0100 Subject: [PATCH 072/105] fix! - broken component logic --- interactions/api/models/channel.py | 22 +++++++++++++++++----- interactions/api/models/member.py | 22 +++++++++++++++++----- interactions/context.py | 22 +++++++++++++++++----- interactions/models/component.py | 2 +- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 4153d36c0..d5d158793 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -244,10 +244,12 @@ async def send( elif isinstance(components, list) and all( isinstance(component, (Button, SelectMenu)) for component in components ): - if isinstance(components[0], SelectMenu): - components[0]._json["options"] = [ - option._json for option in components[0].options - ] + for component in components: + if isinstance(component, SelectMenu): + component._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in component._json["options"] + ] _components = [ { "type": 1, @@ -300,7 +302,17 @@ async def send( ) for component in components.components ] - elif isinstance(components, (Button, SelectMenu)): + elif isinstance(components, Button): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + elif isinstance(components, SelectMenu): + components._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in components._json["options"] + ] _components[0]["components"] = ( [components._json] if components._json.get("custom_id") or components._json.get("url") diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 313e339a3..840183409 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -234,10 +234,12 @@ async def send( elif isinstance(components, list) and all( isinstance(component, (Button, SelectMenu)) for component in components ): - if isinstance(components[0], SelectMenu): - components[0]._json["options"] = [ - option._json for option in components[0].options - ] + for component in components: + if isinstance(component, SelectMenu): + component._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in component._json["options"] + ] _components = [ { "type": 1, @@ -290,7 +292,17 @@ async def send( ) for component in components.components ] - elif isinstance(components, (Button, SelectMenu)): + elif isinstance(components, Button): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + elif isinstance(components, SelectMenu): + components._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in components._json["options"] + ] _components[0]["components"] = ( [components._json] if components._json.get("custom_id") or components._json.get("url") diff --git a/interactions/context.py b/interactions/context.py index c8ad98dff..ed9e537e6 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -216,10 +216,12 @@ async def send( elif isinstance(components, list) and all( isinstance(component, (Button, SelectMenu)) for component in components ): - if isinstance(components[0], SelectMenu): - components[0]._json["options"] = [ - option._json for option in components[0].options - ] + for component in components: + if isinstance(component, SelectMenu): + component._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in component._json["options"] + ] _components = [ { "type": 1, @@ -272,7 +274,17 @@ async def send( ) for component in components.components ] - elif isinstance(components, (Button, SelectMenu)): + elif isinstance(components, Button): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + elif isinstance(components, SelectMenu): + components._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in components._json["options"] + ] _components[0]["components"] = ( [components._json] if components._json.get("custom_id") or components._json.get("url") diff --git a/interactions/models/component.py b/interactions/models/component.py index 4b0fcf16f..296cc67fa 100644 --- a/interactions/models/component.py +++ b/interactions/models/component.py @@ -335,7 +335,7 @@ def __init__(self, **kwargs) -> None: self.type = ComponentType.ACTION_ROW for component in self.components: if isinstance(component, SelectMenu): - component._json["options"] = [option._json for option in component.options] + component._json["options"] = [option._json for option in component._json["options"]] self.components = ( [Component(**component._json) for component in self.components] if self._json.get("components") From e855a875c6483411c6131807c6544259091ef40e Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Thu, 13 Jan 2022 18:47:22 +0100 Subject: [PATCH 073/105] fix!: Reduce circular imports --- interactions/api/models/channel.py | 7 ++----- interactions/api/models/member.py | 4 ++-- interactions/api/models/presence.py | 1 + 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index d962a2c79..4153d36c0 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -1,8 +1,7 @@ from datetime import datetime from enum import IntEnum -from typing import List, Optional, Union +from typing import List, Optional -from ...models.component import ActionRow, Button, SelectMenu from .misc import DictSerializerMixin, Snowflake @@ -189,9 +188,7 @@ async def send( # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds=None, allowed_mentions=None, - components: Optional[ - Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]] - ] = None, + components=None, ): """ Sends a message in the channel diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 7b090595d..313e339a3 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -1,7 +1,6 @@ from datetime import datetime from typing import List, Optional, Union -from ...models.component import ActionRow, Button, SelectMenu from .flags import Permissions from .misc import DictSerializerMixin from .role import Role @@ -175,7 +174,7 @@ async def send( self, content: Optional[str] = None, *, - components: Optional[Union[ActionRow, Button, SelectMenu]] = None, + components=None, tts: Optional[bool] = False, # attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type. embeds=None, @@ -197,6 +196,7 @@ async def send( :return: The sent message as an object. :rtype: Message """ + from ...models.component import ActionRow, Button, SelectMenu from .channel import Channel from .message import Message diff --git a/interactions/api/models/presence.py b/interactions/api/models/presence.py index 7ace046a9..ae16627ea 100644 --- a/interactions/api/models/presence.py +++ b/interactions/api/models/presence.py @@ -121,6 +121,7 @@ class PresenceActivity(DictSerializerMixin): "activities", "sync_id", "session_id", + "id", ) def __init__(self, **kwargs): From 1cf2929928d06dedb6307de390f90b1a23f51d0a Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Thu, 13 Jan 2022 19:28:14 +0100 Subject: [PATCH 074/105] fix!: broken component logic --- interactions/api/models/channel.py | 22 +++++++++++++++++----- interactions/api/models/member.py | 22 +++++++++++++++++----- interactions/context.py | 22 +++++++++++++++++----- interactions/models/component.py | 2 +- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 4153d36c0..d5d158793 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -244,10 +244,12 @@ async def send( elif isinstance(components, list) and all( isinstance(component, (Button, SelectMenu)) for component in components ): - if isinstance(components[0], SelectMenu): - components[0]._json["options"] = [ - option._json for option in components[0].options - ] + for component in components: + if isinstance(component, SelectMenu): + component._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in component._json["options"] + ] _components = [ { "type": 1, @@ -300,7 +302,17 @@ async def send( ) for component in components.components ] - elif isinstance(components, (Button, SelectMenu)): + elif isinstance(components, Button): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + elif isinstance(components, SelectMenu): + components._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in components._json["options"] + ] _components[0]["components"] = ( [components._json] if components._json.get("custom_id") or components._json.get("url") diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 313e339a3..840183409 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -234,10 +234,12 @@ async def send( elif isinstance(components, list) and all( isinstance(component, (Button, SelectMenu)) for component in components ): - if isinstance(components[0], SelectMenu): - components[0]._json["options"] = [ - option._json for option in components[0].options - ] + for component in components: + if isinstance(component, SelectMenu): + component._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in component._json["options"] + ] _components = [ { "type": 1, @@ -290,7 +292,17 @@ async def send( ) for component in components.components ] - elif isinstance(components, (Button, SelectMenu)): + elif isinstance(components, Button): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + elif isinstance(components, SelectMenu): + components._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in components._json["options"] + ] _components[0]["components"] = ( [components._json] if components._json.get("custom_id") or components._json.get("url") diff --git a/interactions/context.py b/interactions/context.py index c8ad98dff..ed9e537e6 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -216,10 +216,12 @@ async def send( elif isinstance(components, list) and all( isinstance(component, (Button, SelectMenu)) for component in components ): - if isinstance(components[0], SelectMenu): - components[0]._json["options"] = [ - option._json for option in components[0].options - ] + for component in components: + if isinstance(component, SelectMenu): + component._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in component._json["options"] + ] _components = [ { "type": 1, @@ -272,7 +274,17 @@ async def send( ) for component in components.components ] - elif isinstance(components, (Button, SelectMenu)): + elif isinstance(components, Button): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + elif isinstance(components, SelectMenu): + components._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in components._json["options"] + ] _components[0]["components"] = ( [components._json] if components._json.get("custom_id") or components._json.get("url") diff --git a/interactions/models/component.py b/interactions/models/component.py index 4b0fcf16f..296cc67fa 100644 --- a/interactions/models/component.py +++ b/interactions/models/component.py @@ -335,7 +335,7 @@ def __init__(self, **kwargs) -> None: self.type = ComponentType.ACTION_ROW for component in self.components: if isinstance(component, SelectMenu): - component._json["options"] = [option._json for option in component.options] + component._json["options"] = [option._json for option in component._json["options"]] self.components = ( [Component(**component._json) for component in self.components] if self._json.get("components") From 4f50a0645a8ed18318f3449b08b93e8472deb89b Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Thu, 13 Jan 2022 12:59:32 -0500 Subject: [PATCH 075/105] fix: Add undocumented attributes, fix headers, fix efficiency of command syncing. --- interactions/api/models/channel.py | 1 + interactions/client.py | 29 ++++++++++++++++++++++++++--- interactions/client.pyi | 15 +++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index d5d158793..2bfb93c8f 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -159,6 +159,7 @@ class Channel(DictSerializerMixin): "_client", # TODO: Document banner when Discord officially documents them. "banner", + "guild_hashes", ) def __init__(self, **kwargs): diff --git a/interactions/client.py b/interactions/client.py index 9094a791f..0fd3c2833 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -143,6 +143,30 @@ def register_events() -> None: log.debug("Client is now ready.") await self.login(self.token) + async def compare_sync(self, payload: dict, result: dict) -> bool: + """ + This compares the payloads between the Client and the API such that + it can mitigate synchronisation API calls. + """ + + # This needs to be redone after discord updates their docs. + + _res = True + + for attrs in ["type", "guild_id", "name", "description", "options", "default_permission"]: + if attrs == "options" and payload["type"] != 1: + continue + + if attrs == "guild_id": + if str(payload.get(attrs, None)) == result.get(attrs, None): + continue + + if payload.get(attrs, None) != result.get(attrs, (None if attrs != "options" else [])): + _res = False + return _res + + return _res + async def synchronize(self, payload: Optional[ApplicationCommand] = None) -> None: """ Synchronizes the command specified by checking through the @@ -212,10 +236,9 @@ async def create(data: ApplicationCommand) -> None: if payload.name == result.name: payload_name: str = payload.name - del result._json["name"] - del payload._json["name"] + _cmp = await self.compare_sync(payload._json, result._json) - if result._json != payload._json: + if not _cmp: log.debug( f"Command {result.name} found unsynced, editing in the API and updating the cache." ) diff --git a/interactions/client.pyi b/interactions/client.pyi index 61ef9afaf..9e5681ac7 100644 --- a/interactions/client.pyi +++ b/interactions/client.pyi @@ -39,6 +39,7 @@ class Client: async def login(self, token: str) -> None: ... def start(self) -> None: ... async def ready(self) -> None: ... + async def compare_sync(self, payload: dict, result: dict) -> bool: ... async def synchronize(self, payload: Optional[ApplicationCommand] = None) -> None: ... def event(self, coro: Coroutine, name: Optional[str] = None) -> Callable[..., Any]: ... def command( @@ -51,6 +52,20 @@ class Client: options: Optional[List[Option]] = None, default_permission: Optional[bool] = None, ) -> Callable[..., Any]: ... + def message_command( + self, + *, + name: Optional[str] = None, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, + default_permission: Optional[bool] = None, + ) -> Callable[..., Any]: ... + def user_command( + self, + *, + name: Optional[str] = None, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, + default_permission: Optional[bool] = None, + ) -> Callable[..., Any]: ... def component(self, component: Union[Button, SelectMenu]) -> Callable[..., Any]: ... def autocomplete(self, name: str) -> Callable[..., Any]: ... def modal(self, modal: Modal) -> Callable[..., Any]: ... From 743338fc5b5bcd191e8ba940b3610bf9eee839a1 Mon Sep 17 00:00:00 2001 From: Jeff Carter Date: Sat, 15 Jan 2022 14:20:15 -0600 Subject: [PATCH 076/105] Fix for corrupted command in cache --- interactions/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/client.py b/interactions/client.py index 0fd3c2833..b5190aeee 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -265,7 +265,7 @@ async def create(data: ApplicationCommand) -> None: await create(payload) cached_commands: List[dict] = [command for command in self.http.cache.interactions.view] - cached_command_names = [command["name"] for command in cached_commands] + cached_command_names = [command.get("name") for command in cached_commands if command.get("name")] if cached_commands: for command in commands: From 513971749e970fb22a7658c8a3b4e5ff1101cec4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 15 Jan 2022 20:22:48 +0000 Subject: [PATCH 077/105] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- interactions/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/interactions/client.py b/interactions/client.py index b5190aeee..ec4fcf7ec 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -265,7 +265,9 @@ async def create(data: ApplicationCommand) -> None: await create(payload) cached_commands: List[dict] = [command for command in self.http.cache.interactions.view] - cached_command_names = [command.get("name") for command in cached_commands if command.get("name")] + cached_command_names = [ + command.get("name") for command in cached_commands if command.get("name") + ] if cached_commands: for command in commands: From d1d32be45e00b816a6646c992b45a710f884cd48 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Sat, 15 Jan 2022 18:49:41 -0500 Subject: [PATCH 078/105] fix: Fix edit global command endpoint. --- interactions/api/http.py | 4 ++-- interactions/client.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index bc2de5f26..ead7ad0e4 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -1942,7 +1942,7 @@ async def edit_application_command( application_id, command_id = int(application_id), int(command_id) r = ( Route( - "POST", + "PATCH", "/applications/{application_id}/commands/{command_id}", application_id=application_id, command_id=command_id, @@ -1950,7 +1950,7 @@ async def edit_application_command( if guild_id in (None, "None") else Route( "PATCH", - "/applications/{application_id}/guilds/" "{guild_id}/commands/{command_id}", + "/applications/{application_id}/guilds/{guild_id}/commands/{command_id}", application_id=application_id, command_id=command_id, guild_id=guild_id, diff --git a/interactions/client.py b/interactions/client.py index ec4fcf7ec..043f9f0c8 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -269,6 +269,9 @@ async def create(data: ApplicationCommand) -> None: command.get("name") for command in cached_commands if command.get("name") ] + print(cached_commands) + print(cached_command_names) + if cached_commands: for command in commands: if command["name"] not in cached_command_names: From cdba706accf0cccb497dbec0bf7fb43873cb78e9 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Sun, 16 Jan 2022 19:39:33 +0100 Subject: [PATCH 079/105] docs: - replace interactions.Client variable in the dpy example because it was named as the library import what could lead to confusion because you'd have to import every model yourself. --- docs/faq.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 2c000bbed..c1891f346 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -48,14 +48,14 @@ What does that mean? Well, we'll show you: import interactions from discord.ext import commands - interactions = interactions.Client(token="...") + client = interactions.Client(token="...") dpy = commands.Bot(prefix="/") @dpy.command() async def hello(ctx): await ctx.send("Hello from discord.py!") - @interactions.command( + @client.command( name="test", description="this is just a testing command." ) @@ -65,7 +65,7 @@ What does that mean? Well, we'll show you: loop = asyncio.get_event_loop() task2 = loop.create_task(dpy.start(token="...", bot=True)) - task1 = loop.create_task(interactions.ready()) + task1 = loop.create_task(client.ready()) gathered = asyncio.gather(task1, task2, loop=loop) loop.run_until_complete(gathered) @@ -89,7 +89,7 @@ What about the models, though? That's a simple answer: async def borrowing(ctx, member: Member): await ctx.send(f"Member ID: {member.id}") - @interactions.command(...) + @client.command(...) async def second_borrowing(ctx, member: discord.Member): await ctx.send(f"Member ID: {member.id}") From bb778ce01a0535e79f28b05cd4c209fd8f486369 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Sun, 16 Jan 2022 19:56:27 +0100 Subject: [PATCH 080/105] docs: --- docs/faq.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index c1891f346..abf7f65c5 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -83,10 +83,10 @@ What about the models, though? That's a simple answer: .. code-block:: python import discord - from interactions.api.models.member import Member + import interactions @dpy.command() - async def borrowing(ctx, member: Member): + async def borrowing(ctx, member: interactions.Member): await ctx.send(f"Member ID: {member.id}") @client.command(...) From 49122eaf7b0f3e770806f80a8c4d3f845d06dd83 Mon Sep 17 00:00:00 2001 From: SnazzyFox <12706268+snazzyfox@users.noreply.github.com> Date: Tue, 18 Jan 2022 23:53:17 -0800 Subject: [PATCH 081/105] fix: change print to log.debug --- interactions/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index 043f9f0c8..fbfbffcec 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -269,8 +269,8 @@ async def create(data: ApplicationCommand) -> None: command.get("name") for command in cached_commands if command.get("name") ] - print(cached_commands) - print(cached_command_names) + log.debug(f"Cached commands: {cached_commands}") + log.debug(f"Cached command names: {cached_command_names}") if cached_commands: for command in commands: From cfadec07853ea276f2ddbdc0c277438594542af9 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Wed, 19 Jan 2022 11:01:01 -0500 Subject: [PATCH 082/105] docs: Add image support for scheduled events. --- interactions/api/models/guild.py | 2 ++ interactions/api/models/guild.pyi | 1 + 2 files changed, 3 insertions(+) diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 00d0bc66c..f61bbd6a3 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -1312,6 +1312,7 @@ class ScheduledEvents(DictSerializerMixin): :ivar Optional[User] creator?: The user that created the scheduled event. :ivar Optional[int] user_count?: The number of users subscribed to the scheduled event. :ivar int status: The status of the scheduled event + :ivar Optional[str] image: The hash containing the image of an event, if applicable. """ __slots__ = ( @@ -1331,6 +1332,7 @@ class ScheduledEvents(DictSerializerMixin): "creator", "user_count", "status", + "image", ) def __init__(self, **kwargs): diff --git a/interactions/api/models/guild.pyi b/interactions/api/models/guild.pyi index 76fe1c1bb..1ae419a6c 100644 --- a/interactions/api/models/guild.pyi +++ b/interactions/api/models/guild.pyi @@ -340,4 +340,5 @@ class ScheduledEvents(DictSerializerMixin): creator: Optional[User] user_count: Optional[int] status: int + image: Optional[str] def __init__(self, **kwargs): ... From 02dfe05c7ecfbb2359f82c48127aefe72efd972d Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 19 Jan 2022 21:08:23 -0500 Subject: [PATCH 083/105] refactor(client, decor): Rewrite mapping conditions; synchronization process. --- interactions/client.py | 455 +++++++++++++++++++--------------------- interactions/client.pyi | 38 ++-- interactions/decor.py | 22 +- simple_bot.py | 54 ++--- 4 files changed, 255 insertions(+), 314 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index fbfbffcec..41a407861 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -5,17 +5,15 @@ from logging import Logger, getLogger from typing import Any, Callable, Coroutine, Dict, List, Optional, Union -from interactions.api.dispatch import Listener -from interactions.api.models.misc import Snowflake - from .api.cache import Cache from .api.cache import Item as Build +from .api.dispatch import Listener from .api.error import InteractionException from .api.gateway import WebSocket from .api.http import HTTPClient from .api.models.flags import Intents from .api.models.guild import Guild -from .api.models.gw import Presence +from .api.models.misc import Snowflake from .api.models.team import Application from .decor import command from .decor import component as _component @@ -32,79 +30,210 @@ class Client: """ A class representing the client connection to Discord's gateway and API via. WebSocket and HTTP. - :ivar AbstractEventLoop loop: The main overall asynchronous coroutine loop in effect. - :ivar Listener listener: An instance of :class:`interactions.api.dispatch.Listener`. - :ivar Optional[Union[Intents, List[Intents]]] intents: The application's intents as :class:`interactions.api.models.Intents`. - :ivar HTTPClient http: An instance of :class:`interactions.api.http.Request`. - :ivar WebSocket websocket: An instance of :class:`interactions.api.gateway.WebSocket`. - :ivar str token: The application token. + :ivar AbstractEventLoop _loop: The asynchronous event loop of the client. + :ivar HTTPClient _http: The user-facing HTTP connection to the Web API, as its own separate client. + :ivar WebSocket _websocket: An object-orientation of a websocket server connection to the Gateway. + :ivar Optional[Intents] _intents: The Gateway intents of the application. Defaults to ``Intents.DEFAULT``. + :ivar Optional[List[Tuple[int]]] _shard: The list of bucketed shards for the application's connection. + :ivar Optional[Presence] _presence: The RPC-like presence shown on an application once connected. + :ivar str _token: The token of the application used for authentication when connecting. + :ivar Optional[Dict[str, ModuleType]] _extensions: The "extensions" or cog equivalence registered to the main client. + :ivar Application me: The application representation of the client. """ def __init__( self, token: str, - intents: Optional[Union[Intents, List[Intents]]] = Intents.DEFAULT, - disable_sync: Optional[bool] = False, - shard: Optional[List[int]] = None, - presence: Optional[Presence] = None, + **kwargs, ) -> None: - """ + r""" + Establishes a client connection to the Web API and Gateway. + :param token: The token of the application for authentication and connection. :type token: str - :param intents?: The intents you wish to pass through the client. Defaults to :meth:`interactions.api.models.intents.Intents.DEFAULT` or ``513``. - :type intents: Optional[Union[Intents, List[Intents]]] - :param disable_sync?: Whether you want to disable automate synchronization or not. - :type disable_sync: Optional[bool] - :param log_level?: The logging level to set for the terminal. Defaults to what is set internally. - :param presence?: The presence of the application when connecting. - :type presence: Optional[Presence] - """ - if isinstance(intents, list): - for intent in intents: - self.intents |= intent - else: - self.intents = intents - - self.loop = get_event_loop() - self.http = HTTPClient(token) - self.websocket = WebSocket(intents=self.intents) + :param \**kwargs: Multiple key-word arguments able to be passed through. + :type \**kwargs: dict + """ + + # Arguments + # ~~~~~~~~~ + # token : str + # The token of the application for authentication and connection. + # intents? : Optional[Intents] + # Allows specific control of permissions the application has when connected. + # In order to use multiple intents, the | operator is recommended. + # Defaults to ``Intents.DEFAULT``. + # shards? : Optional[List[Tuple[int]]] + # Dictates and controls the shards that the application connects under. + # presence? : Optional[Presence] + # Sets an RPC-like presence on the application when connected to the Gateway. + # disable_sync? : Optional[bool] + # Controls whether synchronization in the user-facing API should be automatic or not. + + self._loop = get_event_loop() + self._http = HTTPClient(token=token) + self._intents = kwargs.get("intents", Intents.DEFAULT) + self._websocket = WebSocket(intents=self._intents) + self._shard = kwargs.get("shards", []) + self._presence = kwargs.get("presence") + self._token = token + self._extensions = {} self.me = None - self.token = token - self.http.token = token - self.shard = shard - self.presence = presence - self.extensions = {} - _token = token # noqa: F841 - _cache = self.http.cache # noqa: F841 - - if disable_sync: - self.automate_sync = False + _token = self._token # noqa: F841 + _cache = self._http.cache # noqa: F841 + + if kwargs.get("disable_sync"): + self._automate_sync = False log.warning( "Automatic synchronization has been disabled. Interactions may need to be manually synchronized." ) else: - self.automate_sync = True + self._automate_sync = True + + data = self._loop.run_until_complete(self._http.get_current_bot_information()) + self.me = Application(**data) + + def start(self) -> None: + """Starts the client session.""" + self._loop.run_until_complete(self._ready()) + + def __register_events(self) -> None: + """Registers all raw gateway events to the known events.""" + self._websocket.dispatch.register(self.__raw_socket_create) + self._websocket.dispatch.register(self.__raw_channel_create, "on_channel_create") + self._websocket.dispatch.register(self.__raw_message_create, "on_message_create") + self._websocket.dispatch.register(self.__raw_message_create, "on_message_update") + self._websocket.dispatch.register(self.__raw_guild_create, "on_guild_create") + + async def __compare_sync(self, data: dict, pool: List[dict]) -> bool: + """ + Compares an application command during the synchronization process. + + :param data: The application command to compare. + :type data: dict + :param pool: The "pool" or list of commands to compare from. + :type pool: List[dict] + :return: Whether the command has changed or not. + :rtype: bool + """ + attrs: List[str] = ["type", "name", "description", "options", "guild_id"] + log.info(f"Current attributes to compare: {', '.join(attrs)}.") + clean: bool = True + + for command in pool: + if command["name"] == data["name"]: + for attr in attrs: + if hasattr(data, attr) and command.get(attr) == data.get(attr): + continue + else: + clean = False - if not self.me: - data = self.loop.run_until_complete(self.http.get_current_bot_information()) - self.me = Application(**data) + return clean - async def login(self, token: str) -> None: + async def __create_sync(self, data: dict) -> None: """ - Makes a login with the Discord API. + Creates an application command during the synchronization process. - :param token: The application token needed for authorization. - :type token: str - :return: None + :param data: The application command to create. + :type data: dict """ - while not self.websocket.closed: - await self.websocket.connect(token, self.shard, self.presence) + log.info(f"Creating command {data['name']}.") - def start(self) -> None: - """Starts the client session.""" - self.loop.run_until_complete(self.ready()) + command: ApplicationCommand = ApplicationCommand( + **( + await self._http.create_application_command( + application_id=self.me.id, data=data, guild_id=data.get("guild_id") + ) + ) + ) + self._http.cache.interactions.add(Build(id=command.name, value=command)) + + async def __bulk_update_sync(self, data: List[dict], delete: Optional[bool] = False) -> None: + """ + Bulk updates a list of application commands during the synchronization process. + + The theory behind this is that instead of sending individual ``PATCH`` + requests to the Web API, we collect the commands needed and do a bulk + overwrite instead. This is to mitigate the amount of calls, and hopefully, + chances of hitting rate limits during the readying state. + + :param data: The application commands to update. + :type data: List[dict] + :param delete?: Whether these commands are being deleted or not. + :type delete: Optional[bool] + """ + guild_commands: dict = {} + global_commands: List[dict] = [] + + for command in data: + if command.get("guild_id"): + if guild_commands.get(command["guild_id"]): + guild_commands[command["guild_id"]].append(command) + else: + guild_commands[command["guild_id"]] = [command] + else: + global_commands.append(command) + + self._http.cache.interactions.add( + Build(id=command["name"], value=ApplicationCommand(**command)) + ) + + for guild, commands in guild_commands.items(): + log.info( + f"Guild commands {', '.join(command['name'] for command in commands)} under ID {guild} have been {'deleted' if delete else 'synced'}." + ) + await self._http.overwrite_application_command( + application_id=self.me.id, + data=[] if delete else commands, + guild_id=guild["guild_id"], + ) + + if global_commands: + log.info( + f"Global commands {', '.join(command['name'] for command in global_commands)} have been {'deleted' if delete else 'synced'}." + ) + await self._http.overwrite_application_command( + application_id=self.me.id, data=[] if delete else global_commands + ) - async def ready(self) -> None: + async def _synchronize(self, payload: Optional[dict] = None) -> None: + """ + Synchronizes a command from the client-facing API to the Web API. + + :ivar payload?: The application command to synchronize. Defaults to ``None`` where a global synchronization process begins. + :type payload: Optional[dict] + """ + cache: Optional[List[dict]] = self._http.cache.interactions.view + + if cache: + log.info("A command cache was detected, using for synchronization instead.") + commands: List[dict] = cache + else: + log.info("No command cache was found present, retrieving from Web API instead.") + commands: Optional[Union[dict, List[dict]]] = await self._http.get_application_command( + application_id=self.me.id, guild_id=payload.get("guild_id") if payload else None + ) + + names: List[str] = [command["name"] for command in commands] if commands else [] + to_sync: list = [] + to_delete: list = [] + + if payload: + log.info(f"Checking command {payload['name']}.") + if payload["name"] in names: + if not await self.__compare_sync(payload, commands): + to_sync.append(payload) + else: + await self.__create_sync(payload) + else: + for command in commands: + if command not in cache: + to_delete.append(command) + + await self.__bulk_update_sync(to_sync) + await self.__bulk_update_sync(to_delete, delete=True) + + async def _ready(self) -> None: """ Prepares the client with an internal "ready" check to ensure that all conditions have been met in a chronological order: @@ -125,178 +254,36 @@ async def ready(self) -> None: """ ready: bool = False - def register_events() -> None: - self.websocket.dispatch.register(self.raw_socket_create) - self.websocket.dispatch.register(self.raw_channel_create, "on_channel_create") - self.websocket.dispatch.register(self.raw_message_create, "on_message_create") - self.websocket.dispatch.register(self.raw_message_create, "on_message_update") - self.websocket.dispatch.register(self.raw_guild_create, "on_guild_create") - try: - register_events() - await self.synchronize() + self.__register_events() + if self._automate_sync: + await self._synchronize() ready = True except Exception as error: log.critical(f"Could not prepare the client: {error}") finally: if ready: log.debug("Client is now ready.") - await self.login(self.token) - - async def compare_sync(self, payload: dict, result: dict) -> bool: - """ - This compares the payloads between the Client and the API such that - it can mitigate synchronisation API calls. - """ - - # This needs to be redone after discord updates their docs. - - _res = True - - for attrs in ["type", "guild_id", "name", "description", "options", "default_permission"]: - if attrs == "options" and payload["type"] != 1: - continue - - if attrs == "guild_id": - if str(payload.get(attrs, None)) == result.get(attrs, None): - continue + await self._login() - if payload.get(attrs, None) != result.get(attrs, (None if attrs != "options" else [])): - _res = False - return _res - - return _res - - async def synchronize(self, payload: Optional[ApplicationCommand] = None) -> None: - """ - Synchronizes the command specified by checking through the - currently registered application commands on the API and - modifying if there is a detected change in structure. - - .. warning:: - This internal call does not need to be manually triggered, - as it will automatically be done for you. Additionally, - this will not delete unused commands for you. - - :param payload?: The payload/object of the command. - :type payload: Optional[ApplicationCommand] - """ - _guild = None - if payload: - _guild = str(payload.guild_id) - - commands: List[dict] = await self.http.get_application_command( - application_id=self.me.id, guild_id=_guild - ) - command_names: List[str] = [command["name"] for command in commands] - - async def create(data: ApplicationCommand) -> None: - """ - Creates a new application command in the API if one does not exist for it. - :param data: The data of the command to create. - :type data: ApplicationCommand - """ - log.debug( - f"Command {data.name} was not found in the API, creating and adding to the cache." - ) - - _created_command = ApplicationCommand( - **( - await self.http.create_application_command( - application_id=self.me.id, data=data._json, guild_id=data.guild_id - ) - ) - ) - - self.http.cache.interactions.add( - Build(id=_created_command.name, value=_created_command) - ) - - if commands: - log.debug("Commands were found, checking for sync.") - for command in commands: - result: ApplicationCommand = ApplicationCommand( - application_id=command.get("application_id"), - id=command.get("id"), - type=command.get("type"), - guild_id=str(command["guild_id"]) if command.get("guild_id") else None, - name=command.get("name"), - description=command.get("description", ""), - default_permission=command.get("default_permission", False), - default_member_permissions=command.get("default_member_permissions", None), - version=command.get("version"), - name_localizations=command.get("name_localizations"), - description_localizations=command.get("description_localizations"), - ) - - if payload: - if payload.name in command_names: - log.debug(f"Checking command {payload.name} for syncing.") - - if payload.name == result.name: - payload_name: str = payload.name - - _cmp = await self.compare_sync(payload._json, result._json) - - if not _cmp: - log.debug( - f"Command {result.name} found unsynced, editing in the API and updating the cache." - ) - payload._json["name"] = payload_name - _updated = ApplicationCommand( - **await self.http.edit_application_command( - application_id=self.me.id, - data=payload._json, - command_id=result.id, - guild_id=result._json.get("guild_id"), - ) - ) - self.http.cache.interactions.add( - Build(id=_updated.name, value=_updated) - ) - break - else: - await create(payload) - else: - log.debug(f"Adding command {result.name} to cache.") - self.http.cache.interactions.add(Build(id=result.name, value=result)) - else: - if payload: - await create(payload) - - cached_commands: List[dict] = [command for command in self.http.cache.interactions.view] - cached_command_names = [ - command.get("name") for command in cached_commands if command.get("name") - ] - - log.debug(f"Cached commands: {cached_commands}") - log.debug(f"Cached command names: {cached_command_names}") - - if cached_commands: - for command in commands: - if command["name"] not in cached_command_names: - log.debug( - f"Command {command['name']} was found in the API but never cached, deleting from the API and cache." - ) - await self.http.delete_application_command( - application_id=self.me.id, - command_id=command["id"], - guild_id=command.get("guild_id"), - ) + async def _login(self) -> None: + """Makes a login with the Discord API.""" + while not self._websocket.closed: + await self._websocket.connect(self._token, self._shard, self._presence) def event(self, coro: Coroutine, name: Optional[str] = None) -> Callable[..., Any]: """ - A decorator for listening to dispatched events from the - gateway. + A decorator for listening to events dispatched from the + Gateway. :param coro: The coroutine of the event. :type coro: Coroutine - :param name?: The name of the event. + :param name(?): The name of the event. :type name: Optional[str] :return: A callable response. :rtype: Callable[..., Any] """ - self.websocket.dispatch.register(coro, name) + self._websocket.dispatch.register(coro, name) return coro def command( @@ -378,8 +365,8 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: default_permission=default_permission, ) - if self.automate_sync: - [self.loop.run_until_complete(self.synchronize(command)) for command in commands] + if self._automate_sync: + [self._loop.run_until_complete(self._synchronize(command)) for command in commands] return self.event(coro, name=f"command_{name}") @@ -388,7 +375,7 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: def message_command( self, *, - name: Optional[str] = None, + name: str, scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, default_permission: Optional[bool] = None, ) -> Callable[..., Any]: @@ -408,7 +395,7 @@ async def context_menu_name(ctx): The ``scope`` kwarg field may also be used to designate the command in question applicable to a guild or set of guilds. - :param name: The name of the application command. This *is* required but kept optional to follow kwarg rules. + :param name: The name of the application command. :type name: Optional[str] :param scope?: The "scope"/applicable guilds the application command applies to. Defaults to ``None``. :type scope: Optional[Union[int, Guild, List[int], List[Guild]]] @@ -435,8 +422,8 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: default_permission=default_permission, ) - if self.automate_sync: - [self.loop.run_until_complete(self.synchronize(command)) for command in commands] + if self._automate_sync: + [self._loop.run_until_complete(self._synchronize(command)) for command in commands] return self.event(coro, name=f"command_{name}") @@ -445,7 +432,7 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: def user_command( self, *, - name: Optional[str] = None, + name: str, scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, default_permission: Optional[bool] = None, ) -> Callable[..., Any]: @@ -465,7 +452,7 @@ async def context_menu_name(ctx): The ``scope`` kwarg field may also be used to designate the command in question applicable to a guild or set of guilds. - :param name: The name of the application command. This *is* required but kept optional to follow kwarg rules. + :param name: The name of the application command. :type name: Optional[str] :param scope?: The "scope"/applicable guilds the application command applies to. Defaults to ``None``. :type scope: Optional[Union[int, Guild, List[int], List[Guild]]] @@ -492,8 +479,8 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: default_permission=default_permission, ) - if self.automate_sync: - [self.loop.run_until_complete(self.synchronize(command)) for command in commands] + if self._automate_sync: + [self._loop.run_until_complete(self._synchronize(command)) for command in commands] return self.event(coro, name=f"command_{name}") @@ -623,7 +610,7 @@ def load(self, name: str, package: Optional[str] = None) -> None: """ _name: str = resolve_name(name, package) - if _name in self.extensions: + if _name in self._extensions: log.error(f"Extension {name} has already been loaded. Skipping.") module = import_module(name, package) @@ -636,7 +623,7 @@ def load(self, name: str, package: Optional[str] = None) -> None: log.error(f"Could not load {name}: {error}. Skipping.") else: log.debug(f"Loaded extension {name}.") - self.extensions[_name] = module + self._extensions[_name] = module def remove(self, name: str, package: Optional[str] = None) -> None: """ @@ -648,14 +635,14 @@ def remove(self, name: str, package: Optional[str] = None) -> None: :type package: Optional[str] """ _name: str = resolve_name(name, package) - module = self.extensions.get(_name) + module = self._extensions.get(_name) - if module not in self.extensions: + if module not in self._extensions: log.error(f"Extension {name} has not been loaded before. Skipping.") log.debug(f"Removed extension {name}.") del sys.modules[_name] - del self.extensions[_name] + del self._extensions[_name] def reload(self, name: str, package: Optional[str] = None) -> None: """ @@ -667,7 +654,7 @@ def reload(self, name: str, package: Optional[str] = None) -> None: :type package: Optional[str] """ _name: str = resolve_name(name, package) - module = self.extensions.get(_name) + module = self._extensions.get(_name) if module is None: log.warning(f"Extension {name} could not be reloaded because it was never loaded.") @@ -676,7 +663,7 @@ def reload(self, name: str, package: Optional[str] = None) -> None: self.remove(name, package) self.load(name, package) - async def raw_socket_create(self, data: Dict[Any, Any]) -> Dict[Any, Any]: + async def __raw_socket_create(self, data: Dict[Any, Any]) -> Dict[Any, Any]: """ This is an internal function that takes any gateway socket event and then returns the data purely based off of what it does in @@ -690,7 +677,7 @@ async def raw_socket_create(self, data: Dict[Any, Any]) -> Dict[Any, Any]: return data - async def raw_channel_create(self, channel) -> dict: + async def __raw_channel_create(self, channel) -> dict: """ This is an internal function that caches the channel creates when dispatched. @@ -699,11 +686,11 @@ async def raw_channel_create(self, channel) -> dict: :return: The channel as a dictionary of raw data. :rtype: dict """ - self.http.cache.channels.add(Build(id=channel.id, value=channel)) + self._http.cache.channels.add(Build(id=channel.id, value=channel)) return channel._json - async def raw_message_create(self, message) -> dict: + async def __raw_message_create(self, message) -> dict: """ This is an internal function that caches the message creates when dispatched. @@ -712,11 +699,11 @@ async def raw_message_create(self, message) -> dict: :return: The message as a dictionary of raw data. :rtype: dict """ - self.http.cache.messages.add(Build(id=message.id, value=message)) + self._http.cache.messages.add(Build(id=message.id, value=message)) return message._json - async def raw_guild_create(self, guild) -> dict: + async def __raw_guild_create(self, guild) -> dict: """ This is an internal function that caches the guild creates on ready. @@ -725,7 +712,7 @@ async def raw_guild_create(self, guild) -> dict: :return: The guild as a dictionary of raw data. :rtype: dict """ - self.http.cache.self_guilds.add(Build(id=str(guild.id), value=guild)) + self._http.cache.self_guilds.add(Build(id=str(guild.id), value=guild)) return guild._json diff --git a/interactions/client.pyi b/interactions/client.pyi index 9e5681ac7..5e313ba6a 100644 --- a/interactions/client.pyi +++ b/interactions/client.pyi @@ -1,5 +1,6 @@ from asyncio import AbstractEventLoop -from typing import Any, Callable, Coroutine, Dict, List, Optional, Union +from types import ModuleType +from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple, Union from interactions.api.models.gw import Presence @@ -17,30 +18,29 @@ _token: str = "" # noqa _cache: Optional[Cache] = None class Client: - loop: AbstractEventLoop - intents: Optional[Union[Intents, List[Intents]]] - http: HTTPClient - websocket: WebSocket + _loop: AbstractEventLoop + _http: HTTPClient + _websocket: WebSocket + _intents: Intents + _shard: Optional[List[int]] + _presence: Optional[Presence] + _token: str + _automate_sync: bool + _extensions: Optional[Dict[str, ModuleType]] me: Optional[Application] - token: str - automate_sync: Optional[bool] - shard: Optional[List[int]] - presence: Optional[Presence] - extensions: Optional[Any] def __init__( self, token: str, - intents: Optional[Union[Intents, List[Intents]]] = Intents.DEFAULT, - disable_sync: Optional[bool] = None, - log_level: Optional[int] = None, - shard: Optional[List[int]] = None, - presence: Optional[Presence] = None, + **kwargs, ) -> None: ... - async def login(self, token: str) -> None: ... def start(self) -> None: ... - async def ready(self) -> None: ... - async def compare_sync(self, payload: dict, result: dict) -> bool: ... - async def synchronize(self, payload: Optional[ApplicationCommand] = None) -> None: ... + def __register_events(self) -> None: ... + async def __compare_sync(self, data: dict) -> None: ... + async def __create_sync(self, data: dict) -> None: ... + async def __bulk_update_sync(self, data: List[dict], delete: Optional[bool] = False) -> None: ... + async def _synchronize(self, payload: Optional[dict] = None) -> None: ... + async def _ready(self) -> None: ... + async def _login(self) -> None: ... def event(self, coro: Coroutine, name: Optional[str] = None) -> Callable[..., Any]: ... def command( self, diff --git a/interactions/decor.py b/interactions/decor.py index 45be85ab9..43520c3db 100644 --- a/interactions/decor.py +++ b/interactions/decor.py @@ -47,24 +47,6 @@ def command( _options = [options] _default_permission: bool = True if default_permission is None else default_permission - - # TODO: Implement permission building and syncing. - # _permissions: list = [] - - # if permissions: - # if all(isinstance(permission, Permission) for permission in permissions): - # _permissions = [permission._json for permission in permissions] - # elif all( - # isinstance(permission, dict) - # and all(isinstance(value, str) for value in permission) - # for permission in permissions - # ): - # _permissions = [permission for permission in permissions] - # elif isinstance(permissions, Permission): - # _permissions = [permissions._json] - # else: - # _permissions = [permissions] - _scope: list = [] payloads: list = [] @@ -88,7 +70,7 @@ def command( options=_options, default_permission=_default_permission, ) - payloads.append(payload) + payloads.append(payload._json) else: payload: ApplicationCommand = ApplicationCommand( type=_type, @@ -97,7 +79,7 @@ def command( options=_options, default_permission=_default_permission, ) - payloads.append(payload) + payloads.append(payload._json) return payloads diff --git a/simple_bot.py b/simple_bot.py index 1a9a483bd..ef3e5add1 100644 --- a/simple_bot.py +++ b/simple_bot.py @@ -1,6 +1,11 @@ +import logging + import interactions -bot = interactions.Client(token=open("bot.token").read(), disable_sync=True) +logging.basicConfig(level=logging.DEBUG) + + +bot = interactions.Client(token=open("bot.token").read()) @bot.event @@ -8,46 +13,13 @@ async def on_ready(): print("bot is now online.") -@bot.command(name="guild-command", description="haha guild go brrr", scope=852402668294766612) -async def guild_command(ctx: interactions.CommandContext): - embed = interactions.Embed( - title="Embed title", - author=interactions.EmbedAuthor( - name="author name", - url=interactions.EmbedImageStruct( - url="https://cdn.discordapp.com/avatars/242351388137488384/85f546d0b24092658b47f0778506cf35.webp?size=512" - ), - ), - ) - await ctx.send("aloha senor.", embeds=embed) - - @bot.command( - name="global-command", - description="ever wanted a global command? well, here it is!", + type=interactions.ApplicationCommandType.MESSAGE, + name="simple testing command", + scope=852402668294766612, ) -async def basic_command(ctx: interactions.CommandContext): - fancy_schmancy = interactions.SelectMenu( - custom_id="select_awesomeness", - placeholder="please select UWU :(", - options=[ - interactions.SelectOption(label="im pretty", value="prettiness"), - interactions.SelectOption(label="im quirky", value="teenager"), - interactions.SelectOption(label="im cool", value="hipster"), - ], - min_values=1, - max_values=1, - ) - await ctx.send("Global commands are back in action, baby!", components=fancy_schmancy) - - -@bot.component("select_awesomeness") -async def component_res(ctx: interactions.ComponentContext): - await ctx.edit( - "global pizza domination :pizza:.", - components=interactions.SelectMenu(custom_id="x", disabled=True), - ) - - -bot.load("simple_cog") +async def simple_testing_command(ctx): + await ctx.send("Hello world!") + + bot.start() From 2aef53b69d93d27cfcc8233f3eb45f48aa11dbc0 Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 19 Jan 2022 21:12:47 -0500 Subject: [PATCH 084/105] fix: incorrect `guild_id` detection on bulk overwrite. --- interactions/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/client.py b/interactions/client.py index 41a407861..16bb98a54 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -185,7 +185,7 @@ async def __bulk_update_sync(self, data: List[dict], delete: Optional[bool] = Fa await self._http.overwrite_application_command( application_id=self.me.id, data=[] if delete else commands, - guild_id=guild["guild_id"], + guild_id=guild, ) if global_commands: From ec5f5a177d1d0808697648a64134fa30fcbbd5b0 Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 19 Jan 2022 21:15:29 -0500 Subject: [PATCH 085/105] chore(client): update interfacing of attrs. --- interactions/client.py | 2 +- interactions/client.pyi | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index 16bb98a54..d302b3b11 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -33,7 +33,7 @@ class Client: :ivar AbstractEventLoop _loop: The asynchronous event loop of the client. :ivar HTTPClient _http: The user-facing HTTP connection to the Web API, as its own separate client. :ivar WebSocket _websocket: An object-orientation of a websocket server connection to the Gateway. - :ivar Optional[Intents] _intents: The Gateway intents of the application. Defaults to ``Intents.DEFAULT``. + :ivar Intents _intents: The Gateway intents of the application. Defaults to ``Intents.DEFAULT``. :ivar Optional[List[Tuple[int]]] _shard: The list of bucketed shards for the application's connection. :ivar Optional[Presence] _presence: The RPC-like presence shown on an application once connected. :ivar str _token: The token of the application used for authentication when connecting. diff --git a/interactions/client.pyi b/interactions/client.pyi index 5e313ba6a..d2f91c125 100644 --- a/interactions/client.pyi +++ b/interactions/client.pyi @@ -22,7 +22,7 @@ class Client: _http: HTTPClient _websocket: WebSocket _intents: Intents - _shard: Optional[List[int]] + _shard: Optional[List[Tuple[int]]] _presence: Optional[Presence] _token: str _automate_sync: bool @@ -37,7 +37,9 @@ class Client: def __register_events(self) -> None: ... async def __compare_sync(self, data: dict) -> None: ... async def __create_sync(self, data: dict) -> None: ... - async def __bulk_update_sync(self, data: List[dict], delete: Optional[bool] = False) -> None: ... + async def __bulk_update_sync( + self, data: List[dict], delete: Optional[bool] = False + ) -> None: ... async def _synchronize(self, payload: Optional[dict] = None) -> None: ... async def _ready(self) -> None: ... async def _login(self) -> None: ... From 103a15d2cd1ee3b0775139fa7ff9d35cb0600ebd Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 19 Jan 2022 21:30:23 -0500 Subject: [PATCH 086/105] feat: add sentinal value `MISSING`. --- interactions/client.py | 53 ++++++++++++++++++------------------- interactions/client.pyi | 53 +++++++++++++++++++------------------ interactions/decor.py | 19 +++++++------ interactions/models/misc.py | 6 +++++ 4 files changed, 68 insertions(+), 63 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index d302b3b11..bb3bf5bee 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -3,7 +3,7 @@ from importlib import import_module from importlib.util import resolve_name from logging import Logger, getLogger -from typing import Any, Callable, Coroutine, Dict, List, Optional, Union +from typing import Any, Callable, Coroutine, Dict, List, NoReturn, Optional, Union from .api.cache import Cache from .api.cache import Item as Build @@ -20,6 +20,7 @@ from .enums import ApplicationCommandType from .models.command import ApplicationCommand, Option from .models.component import Button, Modal, SelectMenu +from .models.misc import MISSING log: Logger = getLogger("client") _token: str = "" # noqa @@ -45,7 +46,7 @@ def __init__( self, token: str, **kwargs, - ) -> None: + ) -> NoReturn: r""" Establishes a client connection to the Web API and Gateway. @@ -93,11 +94,11 @@ def __init__( data = self._loop.run_until_complete(self._http.get_current_bot_information()) self.me = Application(**data) - def start(self) -> None: + def start(self) -> NoReturn: """Starts the client session.""" self._loop.run_until_complete(self._ready()) - def __register_events(self) -> None: + def __register_events(self) -> NoReturn: """Registers all raw gateway events to the known events.""" self._websocket.dispatch.register(self.__raw_socket_create) self._websocket.dispatch.register(self.__raw_channel_create, "on_channel_create") @@ -130,7 +131,7 @@ async def __compare_sync(self, data: dict, pool: List[dict]) -> bool: return clean - async def __create_sync(self, data: dict) -> None: + async def __create_sync(self, data: dict) -> NoReturn: """ Creates an application command during the synchronization process. @@ -148,7 +149,9 @@ async def __create_sync(self, data: dict) -> None: ) self._http.cache.interactions.add(Build(id=command.name, value=command)) - async def __bulk_update_sync(self, data: List[dict], delete: Optional[bool] = False) -> None: + async def __bulk_update_sync( + self, data: List[dict], delete: Optional[bool] = False + ) -> NoReturn: """ Bulk updates a list of application commands during the synchronization process. @@ -196,7 +199,7 @@ async def __bulk_update_sync(self, data: List[dict], delete: Optional[bool] = Fa application_id=self.me.id, data=[] if delete else global_commands ) - async def _synchronize(self, payload: Optional[dict] = None) -> None: + async def _synchronize(self, payload: Optional[dict] = None) -> NoReturn: """ Synchronizes a command from the client-facing API to the Web API. @@ -233,7 +236,7 @@ async def _synchronize(self, payload: Optional[dict] = None) -> None: await self.__bulk_update_sync(to_sync) await self.__bulk_update_sync(to_delete, delete=True) - async def _ready(self) -> None: + async def _ready(self) -> NoReturn: """ Prepares the client with an internal "ready" check to ensure that all conditions have been met in a chronological order: @@ -266,12 +269,12 @@ async def _ready(self) -> None: log.debug("Client is now ready.") await self._login() - async def _login(self) -> None: + async def _login(self) -> NoReturn: """Makes a login with the Discord API.""" while not self._websocket.closed: await self._websocket.connect(self._token, self._shard, self._presence) - def event(self, coro: Coroutine, name: Optional[str] = None) -> Callable[..., Any]: + def event(self, coro: Coroutine, name: Optional[str] = MISSING) -> Callable[..., Any]: """ A decorator for listening to events dispatched from the Gateway. @@ -290,11 +293,13 @@ def command( self, *, type: Optional[Union[int, ApplicationCommandType]] = ApplicationCommandType.CHAT_INPUT, - name: Optional[str] = None, - description: Optional[str] = None, - scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, - options: Optional[Union[Dict[str, Any], List[Dict[str, Any]], Option, List[Option]]] = None, - default_permission: Optional[bool] = None, + name: Optional[str] = MISSING, + description: Optional[str] = MISSING, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = MISSING, + options: Optional[ + Union[Dict[str, Any], List[Dict[str, Any]], Option, List[Option]] + ] = MISSING, + default_permission: Optional[bool] = MISSING, ) -> Callable[..., Any]: """ A decorator for registering an application command to the Discord API, @@ -338,10 +343,10 @@ async def message_command(ctx): """ def decorator(coro: Coroutine) -> Callable[..., Any]: - if not name: + if name is MISSING: raise InteractionException(11, message="Your command must have a name.") - if type == ApplicationCommandType.CHAT_INPUT and not description: + if type == ApplicationCommandType.CHAT_INPUT and description is MISSING: raise InteractionException( 11, message="Chat-input commands must have a description." ) @@ -376,8 +381,8 @@ def message_command( self, *, name: str, - scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, - default_permission: Optional[bool] = None, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = MISSING, + default_permission: Optional[bool] = MISSING, ) -> Callable[..., Any]: """ A decorator for registering a message context menu to the Discord API, @@ -406,9 +411,6 @@ async def context_menu_name(ctx): """ def decorator(coro: Coroutine) -> Callable[..., Any]: - if not name: - raise InteractionException(11, message="Your command must have a name.") - if not len(coro.__code__.co_varnames): raise InteractionException( 11, @@ -433,8 +435,8 @@ def user_command( self, *, name: str, - scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, - default_permission: Optional[bool] = None, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = MISSING, + default_permission: Optional[bool] = MISSING, ) -> Callable[..., Any]: """ A decorator for registering a user context menu to the Discord API, @@ -463,9 +465,6 @@ async def context_menu_name(ctx): """ def decorator(coro: Coroutine) -> Callable[..., Any]: - if not name: - raise InteractionException(11, message="Your command must have a name.") - if not len(coro.__code__.co_varnames): raise InteractionException( 11, diff --git a/interactions/client.pyi b/interactions/client.pyi index d2f91c125..54d2a4772 100644 --- a/interactions/client.pyi +++ b/interactions/client.pyi @@ -1,8 +1,9 @@ from asyncio import AbstractEventLoop from types import ModuleType -from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Coroutine, Dict, List, NoReturn, Optional, Tuple, Union -from interactions.api.models.gw import Presence +from .api.models.gw import Presence +from .models.misc import MISSING from .api.cache import Cache from .api.gateway import WebSocket @@ -32,48 +33,48 @@ class Client: self, token: str, **kwargs, - ) -> None: ... - def start(self) -> None: ... - def __register_events(self) -> None: ... - async def __compare_sync(self, data: dict) -> None: ... - async def __create_sync(self, data: dict) -> None: ... + ) -> NoReturn: ... + def start(self) -> NoReturn: ... + def __register_events(self) -> NoReturn: ... + async def __compare_sync(self, data: dict) -> NoReturn: ... + async def __create_sync(self, data: dict) -> NoReturn: ... async def __bulk_update_sync( self, data: List[dict], delete: Optional[bool] = False - ) -> None: ... - async def _synchronize(self, payload: Optional[dict] = None) -> None: ... - async def _ready(self) -> None: ... - async def _login(self) -> None: ... + ) -> NoReturn: ... + async def _synchronize(self, payload: Optional[dict] = None) -> NoReturn: ... + async def _ready(self) -> NoReturn: ... + async def _login(self) -> NoReturn: ... def event(self, coro: Coroutine, name: Optional[str] = None) -> Callable[..., Any]: ... def command( self, *, type: Optional[Union[str, int, ApplicationCommandType]] = ApplicationCommandType.CHAT_INPUT, - name: Optional[str] = None, - description: Optional[str] = None, - scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, - options: Optional[List[Option]] = None, - default_permission: Optional[bool] = None, + name: Optional[str] = MISSING, + description: Optional[str] = MISSING, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = MISSING, + options: Optional[List[Option]] = MISSING, + default_permission: Optional[bool] = MISSING, ) -> Callable[..., Any]: ... def message_command( self, *, - name: Optional[str] = None, - scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, - default_permission: Optional[bool] = None, + name: str, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = MISSING, + default_permission: Optional[bool] = MISSING, ) -> Callable[..., Any]: ... def user_command( self, *, - name: Optional[str] = None, - scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, - default_permission: Optional[bool] = None, + name: str, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = MISSING, + default_permission: Optional[bool] = MISSING, ) -> Callable[..., Any]: ... def component(self, component: Union[Button, SelectMenu]) -> Callable[..., Any]: ... def autocomplete(self, name: str) -> Callable[..., Any]: ... def modal(self, modal: Modal) -> Callable[..., Any]: ... - def load(self, name: str, package: Optional[str] = None) -> None: ... - def remove(self, name: str, package: Optional[str] = None) -> None: ... - def reload(self, name: str, package: Optional[str] = None) -> None: ... + def load(self, name: str, package: Optional[str] = None) -> NoReturn: ... + def remove(self, name: str, package: Optional[str] = None) -> NoReturn: ... + def reload(self, name: str, package: Optional[str] = None) -> NoReturn: ... async def raw_socket_create(self, data: Dict[Any, Any]) -> dict: ... async def raw_channel_create(self, message) -> dict: ... async def raw_message_create(self, message) -> dict: ... @@ -81,4 +82,4 @@ class Client: class Extension: client: Client - def __new__(cls, bot: Client) -> None: ... + def __new__(cls, bot: Client) -> NoReturn: ... diff --git a/interactions/decor.py b/interactions/decor.py index 43520c3db..9282b1906 100644 --- a/interactions/decor.py +++ b/interactions/decor.py @@ -1,21 +1,20 @@ from typing import Any, Dict, List, Optional, Union -from interactions.models.component import Button, SelectMenu - from .api.models.guild import Guild from .enums import ApplicationCommandType from .models.command import ApplicationCommand, Option -from .models.component import Component +from .models.component import Button, Component, SelectMenu +from .models.misc import MISSING def command( *, type: Optional[Union[int, ApplicationCommandType]] = ApplicationCommandType.CHAT_INPUT, - name: Optional[str] = None, - description: Optional[str] = None, - scope: Optional[Union[int, Guild, List[int], List[Guild]]] = None, - options: Optional[Union[Dict[str, Any], List[Dict[str, Any]], Option, List[Option]]] = None, - default_permission: Optional[bool] = None, + name: Optional[str] = MISSING, + description: Optional[str] = MISSING, + scope: Optional[Union[int, Guild, List[int], List[Guild]]] = MISSING, + options: Optional[Union[Dict[str, Any], List[Dict[str, Any]], Option, List[Option]]] = MISSING, + default_permission: Optional[bool] = MISSING, ) -> List[ApplicationCommand]: """ A wrapper designed to interpret the client-facing API for @@ -30,7 +29,7 @@ def command( else: _type: int = ApplicationCommandType(type).value - _description: str = "" if description is None else description + _description: str = "" if description is MISSING else description _options: list = [] if options: @@ -46,7 +45,7 @@ def command( else: _options = [options] - _default_permission: bool = True if default_permission is None else default_permission + _default_permission: bool = True if default_permission is MISSING else default_permission _scope: list = [] payloads: list = [] diff --git a/interactions/models/misc.py b/interactions/models/misc.py index 128e557d4..435dbb5bf 100644 --- a/interactions/models/misc.py +++ b/interactions/models/misc.py @@ -184,3 +184,9 @@ def __init__(self, **kwargs): self.channel_id = Snowflake(self.channel_id) if self._json.get("channel_id") else None self.member = Member(**self.member) if self._json.get("member") else None self.user = User(**self.user) if self._json.get("user") else None + + +class MISSING: + """A pseudosentinel based from an empty object. This does violate PEP, but, I don't care.""" + + ... From f599d41b83562da6b313b54e561dbf25fb86fc19 Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 19 Jan 2022 21:32:01 -0500 Subject: [PATCH 087/105] chore(client): update all method returns. --- interactions/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index bb3bf5bee..d2ee987bf 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -597,7 +597,7 @@ def decorator(coro: Coroutine) -> Any: return decorator - def load(self, name: str, package: Optional[str] = None) -> None: + def load(self, name: str, package: Optional[str] = None) -> NoReturn: """ "Loads" an extension off of the current client by adding a new class which is imported from the library. @@ -624,7 +624,7 @@ def load(self, name: str, package: Optional[str] = None) -> None: log.debug(f"Loaded extension {name}.") self._extensions[_name] = module - def remove(self, name: str, package: Optional[str] = None) -> None: + def remove(self, name: str, package: Optional[str] = None) -> NoReturn: """ Removes an extension out of the current client from an import resolve. @@ -643,7 +643,7 @@ def remove(self, name: str, package: Optional[str] = None) -> None: del sys.modules[_name] del self._extensions[_name] - def reload(self, name: str, package: Optional[str] = None) -> None: + def reload(self, name: str, package: Optional[str] = None) -> NoReturn: """ "Reloads" an extension off of current client from an import resolve. From e573bcfd5154afd4817f5d4263f282b213637d57 Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 19 Jan 2022 21:33:45 -0500 Subject: [PATCH 088/105] fix: choice recursion failing on `Option` spinup. --- interactions/models/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interactions/models/command.py b/interactions/models/command.py index 0139554c8..a19235c86 100644 --- a/interactions/models/command.py +++ b/interactions/models/command.py @@ -114,7 +114,7 @@ def __init__(self, **kwargs) -> None: self._json["options"] = [ option if isinstance(option, dict) else option._json for option in self.options ] - if self._json.get("choices"): + if all(isinstance(choice, dict) for choice in self.choices): if isinstance(self._json.get("choices"), dict): self._json["choices"] = list(self.choices) else: From ed599463ab4a822a8df674af236e7c52e299fb30 Mon Sep 17 00:00:00 2001 From: Meido no Hitsuji Date: Thu, 20 Jan 2022 09:36:37 +0700 Subject: [PATCH 089/105] fix(gateway,component): SelectMenu parse per SelectOption * (f) fix SelectMenu and addition add values in message component * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * (f) fix check_sub_auto Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- interactions/api/gateway.py | 4 +++- interactions/models/component.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index ad3db335d..4ef5fde40 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -261,7 +261,7 @@ def check_sub_auto(option: dict) -> tuple: if option["type"] == OptionType.SUB_COMMAND_GROUP: for group_option in option["options"]: if group_option.get("options"): - for sub_option in option["options"]: + for sub_option in group_option["options"]: if sub_option.get("focused"): return sub_option["name"], sub_option["value"] elif option["type"] == OptionType.SUB_COMMAND: @@ -314,6 +314,8 @@ def check_sub_auto(option: dict) -> tuple: ) elif data["type"] == InteractionType.MESSAGE_COMPONENT: _name = f"component_{context.data.custom_id}" + if context.data._json.get("values"): + _args.append(context.data.values) elif data["type"] == InteractionType.APPLICATION_COMMAND_AUTOCOMPLETE: _name = f"autocomplete_{context.data.id}" if context.data._json.get("options"): diff --git a/interactions/models/component.py b/interactions/models/component.py index 296cc67fa..b45cd26e7 100644 --- a/interactions/models/component.py +++ b/interactions/models/component.py @@ -85,10 +85,14 @@ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.type = ComponentType.SELECT self.options = ( - SelectOption(**option) - if not isinstance(option, SelectOption) - else self._json.get("options") - for option in self.options + [ + SelectOption(**option._json) + if isinstance(option, SelectOption) + else SelectOption(**option) + for option in self.options + ] + if self._json.get("options") + else None ) self._json.update({"type": self.type.value}) From 368fd5a562e46b3a5ba45c549291c31e1198d7cb Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Thu, 20 Jan 2022 03:39:58 +0100 Subject: [PATCH 090/105] refactor: Allow components during an edit; safeguard from missing HTTPClient. (#433) * refactor: - raise error if HTTPClient is not defined in helper methods to have a clearer error - add explanation of that error and fix to faq - began channel.purge * thing * Update faq.rst * Update channel.py * Update guild.py * Update member.py * Update message.py * Update role.py * give context.message the HTTPClient fix removal of message contents when editing --- docs/faq.rst | 20 +++ interactions/api/models/channel.py | 16 ++ interactions/api/models/channel.pyi | 1 + interactions/api/models/guild.py | 56 ++++-- interactions/api/models/member.py | 13 +- interactions/api/models/message.py | 260 +++++++++++++++++++++++++--- interactions/api/models/message.pyi | 4 +- interactions/api/models/role.py | 9 +- interactions/context.py | 12 +- 9 files changed, 349 insertions(+), 42 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index abf7f65c5..2b767b9a3 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -139,6 +139,26 @@ of discord.py bot developers frown upon doing, so this is at your own risk to co can take a page out of discord.js' book if you want to do this, since they've never heard of an external command handler framework before in their entire life. + +I'm getting "``AttributeError: HTTPClient not found!``" when I try to execute helper methods! +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Probably you are doing something like this: + +.. code-block:: python + + channel = interactions.Channel(**await bot.http.get_channel(channel_id)) + await channel.send("...") + +And the error occurs in the line where you try to send something. You can fix this easy by adding one argument: + +.. code-block:: python + + channel = interactions.Channel(**await bot.http.get_channel(channel_id), _client=bot.http) + await channel.send("...") + +You have to add this extra argument for every object you instantiate by yourself if you want to use it's methods + + My question is not answered on here! ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Please join our Discord server for any further support regarding our library and/or any integration code depending on it. diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 2bfb93c8f..6b93fc44b 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -207,6 +207,8 @@ async def send( :return: The sent message as an object. :rtype: Message """ + if not self._client: + raise AttributeError("HTTPClient not found!") from ...models.component import ActionRow, Button, SelectMenu from .message import Message @@ -340,6 +342,8 @@ async def delete(self) -> None: """ Deletes the channel. """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.delete_channel(channel_id=int(self.id)) async def modify( @@ -378,6 +382,8 @@ async def modify( :return: The modified channel as new object :rtype: Channel """ + if not self._client: + raise AttributeError("HTTPClient not found!") _name = self.name if not name else name _topic = self.topic if not topic else topic _bitrate = self.bitrate if not bitrate else bitrate @@ -418,6 +424,8 @@ async def add_member( :param member_id: The id of the member to add to the channel :type member_id: int """ + if not self._client: + raise AttributeError("HTTPClient not found!") if not self.thread_metadata: raise TypeError( "The Channel you specified is not a thread!" @@ -434,6 +442,8 @@ async def pin_message( :param message_id: The id of the message to pin :type message_id: int """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.pin_message(channel_id=int(self.id), message_id=message_id) @@ -447,6 +457,8 @@ async def unpin_message( :param message_id: The id of the message to unpin :type message_id: int """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.unpin_message(channel_id=int(self.id), message_id=message_id) @@ -461,6 +473,8 @@ async def publish_message( :return: The message published :rtype: Message """ + if not self._client: + raise AttributeError("HTTPClient not found!") from .message import Message res = await self._client.publish_message( @@ -474,6 +488,8 @@ async def get_pinned_messages(self): :return: A list of pinned message objects. :rtype: List[Message] """ + if not self._client: + raise AttributeError("HTTPClient not found!") from .message import Message res = await self._client.get_pinned_messages(int(self.id)) diff --git a/interactions/api/models/channel.pyi b/interactions/api/models/channel.pyi index 7b10095ff..5377ef639 100644 --- a/interactions/api/models/channel.pyi +++ b/interactions/api/models/channel.pyi @@ -99,4 +99,5 @@ class Channel(DictSerializerMixin): ) -> Message: ... async def get_pinned_messages(self) -> List[Message]: ... + class Thread(Channel): ... diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index f61bbd6a3..bd3976a56 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -313,6 +313,8 @@ async def ban( :param delete_message_days?: Number of days to delete messages, from 0 to 7. Defaults to 0 :type delete_message_days: Optional[int] """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.create_guild_ban( guild_id=int(self.id), user_id=member_id, @@ -332,6 +334,8 @@ async def remove_ban( :param reason?: The reason for the removal of the ban :type reason: Optional[str] """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.remove_guild_ban( guild_id=int(self.id), user_id=user_id, @@ -350,6 +354,8 @@ async def kick( :param reason?: The reason for the kick :type reason: Optional[str] """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.create_guild_kick( guild_id=int(self.id), user_id=member_id, @@ -371,6 +377,8 @@ async def add_member_role( :param reason?: The reason why the roles are added :type reason: Optional[str] """ + if not self._client: + raise AttributeError("HTTPClient not found!") if isinstance(role, Role): await self._client.add_member_role( guild_id=int(self.id), @@ -401,6 +409,8 @@ async def remove_member_role( :param reason?: The reason why the roles are removed :type reason: Optional[str] """ + if not self._client: + raise AttributeError("HTTPClient not found!") if isinstance(role, Role): await self._client.remove_member_role( guild_id=int(self.id), @@ -442,7 +452,8 @@ async def create_role( :return: The created Role :rtype: Role """ - + if not self._client: + raise AttributeError("HTTPClient not found!") payload = Role( name=name, color=color, @@ -467,6 +478,8 @@ async def get_member( :return: The member searched for :rtype: Member """ + if not self._client: + raise AttributeError("HTTPClient not found!") res = await self._client.get_member( guild_id=int(self.id), member_id=member_id, @@ -482,6 +495,8 @@ async def delete_channel( :param channel_id: The id of the channel to delete :type channel_id: int """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.delete_channel( channel_id=channel_id, ) @@ -498,7 +513,8 @@ async def delete_role( :param reason?: The reason of the deletion :type reason: Optional[str] """ - + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.delete_guild_role( guild_id=int(self.id), role_id=role_id, @@ -534,7 +550,8 @@ async def modify_role( :return: The modified role object :rtype: Role """ - + if not self._client: + raise AttributeError("HTTPClient not found!") roles = await self._client.get_all_roles(guild_id=int(self.id)) for i in roles: if int(i["id"]) == role_id: @@ -594,7 +611,8 @@ async def create_channel( :return: The created channel :rtype: Channel """ - + if not self._client: + raise AttributeError("HTTPClient not found!") if type in [ ChannelType.DM, ChannelType.DM.value, @@ -664,6 +682,8 @@ async def modify_channel( :return: The modified channel :rtype: Channel """ + if not self._client: + raise AttributeError("HTTPClient not found!") ch = Channel(**await self._client.get_channel(channel_id=channel_id)) _name = ch.name if not name else name @@ -730,7 +750,8 @@ async def modify_member( :return: The modified member :rtype: Member """ - + if not self._client: + raise AttributeError("HTTPClient not found!") payload = {} if nick: payload["nick"] = nick @@ -760,10 +781,14 @@ async def modify_member( async def get_preview(self) -> "GuildPreview": """Get the guild's preview.""" + if not self._client: + raise AttributeError("HTTPClient not found!") return GuildPreview(**await self._client.get_guild_preview(guild_id=int(self.id))) async def leave(self) -> None: """Removes the bot from the guild.""" + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.leave_guild(guild_id=int(self.id)) async def modify( @@ -833,7 +858,8 @@ async def modify( :return: The modified guild :rtype: Guild """ - + if not self._client: + raise AttributeError("HTTPClient not found!") if ( suppress_join_notifications is None and suppress_premium_subscriptions is None @@ -926,7 +952,8 @@ async def create_scheduled_event( :return: The created event :rtype: ScheduledEvents """ - + if not self._client: + raise AttributeError("HTTPClient not found!") if entity_type != EntityType.EXTERNAL and not channel_id: raise ValueError( "channel_id is required when entity_type is not external!" @@ -994,7 +1021,8 @@ async def modify_scheduled_event( :return: The modified event :rtype: ScheduledEvents """ - + if not self._client: + raise AttributeError("HTTPClient not found!") if entity_type == EntityType.EXTERNAL and not entity_metadata: raise ValueError( "entity_metadata is required for external events!" @@ -1037,6 +1065,8 @@ async def delete_scheduled_event(self, event_id: int) -> None: :param event_id: The id of the event to delete :type event_id: int """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.delete_scheduled_event( guild_id=self.id, guild_scheduled_event_id=Snowflake(event_id), @@ -1049,6 +1079,8 @@ async def get_all_channels(self) -> List[Channel]: :return: The channels of the guild. :rtype: List[Channel] """ + if not self._client: + raise AttributeError("HTTPClient not found!") res = self._client.get_all_channels(int(self.id)) channels = [Channel(**channel, _client=self._client) for channel in res] return channels @@ -1060,6 +1092,8 @@ async def get_all_roles(self) -> List[Role]: :return: The roles of the guild. :rtype: List[Role] """ + if not self._client: + raise AttributeError("HTTPClient not found!") res = self._client.get_all_roles(int(self.id)) roles = [Role(**role, _client=self._client) for role in res] return roles @@ -1082,7 +1116,8 @@ async def modify_role_position( :return: List of guild roles with updated hierarchy :rtype: List[Role] """ - + if not self._client: + raise AttributeError("HTTPClient not found!") _role_id = role_id.id if isinstance(role_id, Role) else role_id res = await self._client.modify_guild_role_position( guild_id=int(self.id), position=position, role_id=_role_id, reason=reason @@ -1097,7 +1132,8 @@ async def get_bans(self) -> List[dict]: :return: List of banned users with reasons :rtype: List[dict] """ - + if not self._client: + raise AttributeError("HTTPClient not found!") res = await self._client.get_guild_bans(int(self.id)) for ban in res: ban["user"] = User(**ban["user"]) diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 840183409..8f48be6c6 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -104,6 +104,8 @@ async def kick( :param reason?: The reason for the kick :type reason: Optional[str] """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.create_guild_kick( guild_id=guild_id, user_id=int(self.user.id), @@ -125,6 +127,8 @@ async def add_role( :param reason?: The reason why the roles are added :type reason: Optional[str] """ + if not self._client: + raise AttributeError("HTTPClient not found!") if isinstance(role, Role): await self._client.add_member_role( guild_id=guild_id, @@ -155,6 +159,8 @@ async def remove_role( :param reason?: The reason why the roles are removed :type reason: Optional[str] """ + if not self._client: + raise AttributeError("HTTPClient not found!") if isinstance(role, Role): await self._client.remove_member_role( guild_id=guild_id, @@ -196,6 +202,8 @@ async def send( :return: The sent message as an object. :rtype: Message """ + if not self._client: + raise AttributeError("HTTPClient not found!") from ...models.component import ActionRow, Button, SelectMenu from .channel import Channel from .message import Message @@ -360,7 +368,8 @@ async def modify( :return: The modified member object :rtype: Member """ - + if not self._client: + raise AttributeError("HTTPClient not found!") payload = {} if nick: payload["nick"] = nick @@ -398,6 +407,8 @@ async def add_to_thread( :param thread_id: The id of the thread to add the member to :type thread_id: int """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.add_member_to_thread( user_id=int(self.user.id), thread_id=thread_id, diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index 2ebdf4348..1faa68e60 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -285,6 +285,8 @@ async def get_channel(self) -> Channel: Gets the channel where the message was sent :rtype: Channel """ + if not self._client: + raise AttributeError("HTTPClient not found!") res = await self._client.get_channel(channel_id=int(self.channel_id)) return Channel(**res, _client=self._client) @@ -293,6 +295,8 @@ async def get_guild(self): Gets the guild where the message was sent :rtype: Guild """ + if not self._client: + raise AttributeError("HTTPClient not found!") from .guild import Guild res = await self._client.get_guild(guild_id=int(self.guild_id)) @@ -304,6 +308,8 @@ async def delete(self, reason: Optional[str] = None) -> None: :param reason: Optional reason to show up in the audit log. Defaults to `None`. :type reason: Optional[str] """ + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.delete_message( message_id=int(self.id), channel_id=int(self.channel_id), reason=reason ) @@ -317,6 +323,7 @@ async def edit( embeds: Optional[Union["Embed", List["Embed"]]] = None, allowed_mentions: Optional["MessageInteraction"] = None, message_reference: Optional["MessageReference"] = None, + components=None, ) -> "Message": """ This method edits a message. Only available for messages sent by the bot. @@ -329,20 +336,134 @@ async def edit( :type embeds: Optional[Union[Embed, List[Embed]]] :param allowed_mentions?: The message interactions/mention limits that the message can refer to. :type allowed_mentions: Optional[MessageInteraction] + :param components?: A component, or list of components for the message. If `[]` the components will be removed + :type components: Optional[Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]]] :return: The edited message as an object. :rtype: Message """ + if not self._client: + raise AttributeError("HTTPClient not found!") + from ...models.component import ActionRow, Button, SelectMenu - _content: str = "" if content is None else content + _content: str = self.content if content is None else content _tts: bool = True if bool(tts) else tts # _file = None if file is None else file - _embeds: list = ( - [] - if embeds is None - else ([embed._json for embed in embeds] if isinstance(embeds, list) else [embeds._json]) - ) + + if embeds is None: + _embeds = self.embeds + else: + _embeds: list = ( + [] + if embeds is None + else ( + [embed._json for embed in embeds] + if isinstance(embeds, list) + else [embeds._json] + ) + ) _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions _message_reference: dict = {} if message_reference is None else message_reference._json + if components == []: + _components = [] + # TODO: Break this obfuscation pattern down to a "builder" method. + elif components is not None and components != []: + _components = [] + if isinstance(components, list) and all( + isinstance(action_row, ActionRow) for action_row in components + ): + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in action_row.components + ], + } + for action_row in components + ] + elif isinstance(components, list) and all( + isinstance(component, (Button, SelectMenu)) for component in components + ): + for component in components: + if isinstance(component, SelectMenu): + component._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in component._json["options"] + ] + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components + ], + } + ] + elif isinstance(components, list) and all( + isinstance(action_row, (list, ActionRow)) for action_row in components + ): + _components = [] + for action_row in components: + for component in ( + action_row if isinstance(action_row, list) else action_row.components + ): + if isinstance(component, SelectMenu): + component._json["options"] = [ + option._json for option in component.options + ] + _components.append( + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") + or component._json.get("url") + else [] + ) + for component in ( + action_row + if isinstance(action_row, list) + else action_row.components + ) + ], + } + ) + elif isinstance(components, ActionRow): + _components[0]["components"] = [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components.components + ] + elif isinstance(components, Button): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + elif isinstance(components, SelectMenu): + components._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in components._json["options"] + ] + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + else: + _components = self.components payload: Message = Message( content=_content, @@ -351,10 +472,13 @@ async def edit( embeds=_embeds, allowed_mentions=_allowed_mentions, message_reference=_message_reference, + components=_components, ) await self._client.edit_message( - channel_id=int(self.channel_id), message_id=int(self.id), payload=payload._json + channel_id=int(self.channel_id), + message_id=int(self.id), + payload=payload._json, ) return payload @@ -380,11 +504,12 @@ async def reply( :param allowed_mentions?: The message interactions/mention limits that the message can refer to. :type allowed_mentions: Optional[MessageInteraction] :param components?: A component, or list of components for the message. - :type components: Optional[Union[Component, List[Component]]] + :type components: Optional[Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]]] :return: The sent message as an object. :rtype: Message """ - + if not self._client: + raise AttributeError("HTTPClient not found!") from ...models.component import ActionRow, Button, SelectMenu _content: str = "" if content is None else content @@ -397,18 +522,107 @@ async def reply( else ([embed._json for embed in embeds] if isinstance(embeds, list) else [embeds._json]) ) _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions - _components: list = [{"type": 1, "components": []}] _message_reference = MessageReference(message_id=int(self.id))._json - - if isinstance(components, ActionRow): - _components[0]["components"] = [component._json for component in components.components] - elif isinstance(components, Button): - _components[0]["components"] = [] if components is None else [components._json] - elif isinstance(components, SelectMenu): - components._json["options"] = [option._json for option in components.options] - _components[0]["components"] = [] if components is None else [components._json] + _components: List[dict] = [{"type": 1, "components": []}] + + # TODO: Break this obfuscation pattern down to a "builder" method. + if components: + if isinstance(components, list) and all( + isinstance(action_row, ActionRow) for action_row in components + ): + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in action_row.components + ], + } + for action_row in components + ] + elif isinstance(components, list) and all( + isinstance(component, (Button, SelectMenu)) for component in components + ): + for component in components: + if isinstance(component, SelectMenu): + component._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in component._json["options"] + ] + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components + ], + } + ] + elif isinstance(components, list) and all( + isinstance(action_row, (list, ActionRow)) for action_row in components + ): + _components = [] + for action_row in components: + for component in ( + action_row if isinstance(action_row, list) else action_row.components + ): + if isinstance(component, SelectMenu): + component._json["options"] = [ + option._json for option in component.options + ] + _components.append( + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") + or component._json.get("url") + else [] + ) + for component in ( + action_row + if isinstance(action_row, list) + else action_row.components + ) + ], + } + ) + elif isinstance(components, ActionRow): + _components[0]["components"] = [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components.components + ] + elif isinstance(components, Button): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) + elif isinstance(components, SelectMenu): + components._json["options"] = [ + options._json if not isinstance(options, dict) else options + for options in components._json["options"] + ] + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") + else [] + ) else: - _components = [] if components is None else [components] + _components = [] # TODO: post-v4: Add attachments into Message obj. payload = Message( @@ -429,12 +643,14 @@ async def reply( async def pin(self) -> None: """Pins the message to its channel""" - + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.pin_message(channel_id=int(self.channel_id), message_id=int(self.id)) async def unpin(self) -> None: """Unpins the message from its channel""" - + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.unpin_message(channel_id=int(self.channel_id), message_id=int(self.id)) async def publish(self) -> "Message": @@ -443,6 +659,8 @@ async def publish(self) -> "Message": :return: message object :rtype: Message """ + if not self._client: + raise AttributeError("HTTPClient not found!") res = await self._client.publish_message( channel_id=int(self.channel_id), message_id=int(self.id) ) diff --git a/interactions/api/models/message.pyi b/interactions/api/models/message.pyi index d2e0e8255..8f5b5e245 100644 --- a/interactions/api/models/message.pyi +++ b/interactions/api/models/message.pyi @@ -101,7 +101,7 @@ class Message(DictSerializerMixin): embeds: Optional[Union["Embed", List["Embed"]]] = None, allowed_mentions: Optional["MessageInteraction"] = None, message_reference: Optional["MessageReference"] = None, - components: Optional[Union[ActionRow, Button, SelectMenu]] = None, + components: Optional[Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]]] = None, ) -> "Message": ... async def reply(self, @@ -111,7 +111,7 @@ class Message(DictSerializerMixin): # attachments: Optional[List[Any]] = None embeds: Optional[Union["Embed", List["Embed"]]] = None, allowed_mentions: Optional["MessageInteraction"] = None, - components=None, + components: Optional[Union[ActionRow, Button, SelectMenu, List[Union[ActionRow, Button, SelectMenu]]]]=None, ) -> "Message": ... async def get_channel(self) -> Channel: ... async def get_guild(self) -> Guild: ... diff --git a/interactions/api/models/role.py b/interactions/api/models/role.py index 587072a2e..2a44d7cb0 100644 --- a/interactions/api/models/role.py +++ b/interactions/api/models/role.py @@ -75,7 +75,8 @@ async def delete( :param reason: The reason for the deletion :type reason: Optional[str] """ - + if not self._client: + raise AttributeError("HTTPClient not found!") await self._client.delete_guild_role( guild_id=guild_id, role_id=int(self.id), reason=reason ), @@ -109,7 +110,8 @@ async def modify( :return: The modified role object :rtype: Role """ - + if not self._client: + raise AttributeError("HTTPClient not found!") _name = self.name if not name else name _color = self.color if not color else color _hoist = self.hoist if not hoist else hoist @@ -143,7 +145,8 @@ async def modify_position( :return: List of guild roles with updated hierarchy :rtype: List[Role] """ - + if not self._client: + raise AttributeError("HTTPClient not found!") res = await self._client.modify_guild_role_position( guild_id=guild_id, position=position, role_id=int(self.id), reason=reason ) diff --git a/interactions/context.py b/interactions/context.py index ed9e537e6..a9595ab56 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -317,6 +317,7 @@ async def send( flags=_ephemeral, ) self.message = payload + self.message._client = self.client _payload: dict = {"type": self.callback.value, "data": payload._json} async def func(): @@ -338,7 +339,7 @@ async def func(): application_id=str(self.application_id), ) self.responded = True - self.message = Message(**res) + self.message = Message(**res, _client=self.client) else: await self.client._post_followup( data=payload._json, @@ -509,12 +510,12 @@ async def func(): application_id=str(self.application_id), ) self.responded = True - self.message = Message(**res) + self.message = Message(**res, _client=self.client) elif hasattr(self.message, "id") and self.message.id is not None: res = await self.client.edit_message( int(self.channel_id), int(self.message.id), payload=payload._json ) - self.message = Message(**res) + self.message = Message(**res, _client=self.client) else: res = await self.client.edit_interaction_response( token=self.token, @@ -525,11 +526,12 @@ async def func(): if res["flags"] == 64: log.warning("You can't edit hidden messages.") self.message = payload + self.message._client = self.client else: await self.client.edit_message( int(self.channel_id), res["id"], payload=payload._json ) - self.message = Message(**res) + self.message = Message(**res, _client=self.client) else: self.callback = ( InteractionCallbackType.UPDATE_MESSAGE @@ -547,7 +549,7 @@ async def func(): await self.client.edit_message( int(self.channel_id), res["id"], payload=payload._json ) - self.message = Message(**res) + self.message = Message(**res, _client=self.client) await func() return payload From b8b7856927b8a5930a420ccd4752981a81afcd53 Mon Sep 17 00:00:00 2001 From: Luyao Yang <12706268+snazzyfox@users.noreply.github.com> Date: Wed, 19 Jan 2022 18:42:31 -0800 Subject: [PATCH 091/105] chore: isolate dev dependencies into [dev] extra (#440) Move code quality and documentation dependencies into an the [dev] extra. Prevents unnecessarily force-installing these libraries for downstream users and potentially creating version conflicts in their projects. --- CONTRIBUTING.rst | 2 +- requirements-docs.txt | 2 ++ requirements-lint.txt | 4 ++++ requirements.txt | 8 +------- setup.py | 14 +++++++++----- 5 files changed, 17 insertions(+), 13 deletions(-) create mode 100644 requirements-docs.txt create mode 100644 requirements-lint.txt diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index d9facf0ca..18a266263 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -10,7 +10,7 @@ the library off of pip: .. code-block:: bash - pip install -U discord-py-interactions + pip install -U discord-py-interactions[dev] Once you have the library installed in Python, you are able to instantiate and run a basic bot with a logging level that is set for debugging purposes. This is recommend in order to make it easier diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 000000000..8d734362a --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,2 @@ +Sphinx==4.1.2 +sphinx-hoverxref==1.0.0 diff --git a/requirements-lint.txt b/requirements-lint.txt new file mode 100644 index 000000000..ac84521b7 --- /dev/null +++ b/requirements-lint.txt @@ -0,0 +1,4 @@ +black==21.11b1 +flake8==3.9.2 +isort==5.9.3 +pre-commit==2.16.0 diff --git a/requirements.txt b/requirements.txt index 9dd71bcc3..5ba54d59d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,3 @@ aiohttp==3.7.4.post0 -black==21.11b1 colorama==0.4.4 -flake8==3.9.2 -isort==5.9.3 -orjson -pre-commit==2.16.0 -Sphinx==4.1.2 -sphinx-hoverxref==1.0.0 +orjson==3.6.3 diff --git a/setup.py b/setup.py index 84e8899c4..7a074db10 100644 --- a/setup.py +++ b/setup.py @@ -12,14 +12,18 @@ with open(path.join(HERE, PACKAGE_NAME, "base.py"), encoding="utf-8") as fp: VERSION = re.search('__version__ = "([^"]+)"', fp.read()).group(1) + +def read_requirements(filename): + with open(filename, "r", encoding="utf-8") as fp: + return fp.read().strip().splitlines() + + extras = { - "lint": ["black", "flake8", "isort"], - "readthedocs": ["sphinx", "karma-sphinx-theme"], + "lint": read_requirements("requirements-lint.txt"), + "readthedocs": read_requirements("requirements-docs.txt"), } -extras["lint"] += extras["readthedocs"] extras["dev"] = extras["lint"] + extras["readthedocs"] - -requirements = open("requirements.txt").read().split("\n")[:-1] +requirements = read_requirements("requirements.txt") setup( name="discord-py-interactions", From d577cd0a81162917a55d7a55f4897338faef1301 Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Thu, 20 Jan 2022 03:43:52 +0100 Subject: [PATCH 092/105] fix: prevent NoneType closure during guild member check. (#439) - fixed a bug (prevented iterating the NoneType) --- interactions/api/models/guild.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index bd3976a56..86fed5e0f 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -292,11 +292,18 @@ def __init__(self, **kwargs): else None ) if not self.members and self._client: - members = self._client.cache.self_guilds.values[str(self.id)].members - if all(isinstance(member, Member) for member in members): - self.members = members + + if ( + not len(self._client.cache.self_guilds.view) > 1 + or not self._client.cache.self_guilds.values[str(self.id)].members + ): + pass else: - self.members = [Member(**member, _client=self._client) for member in members] + members = self._client.cache.self_guilds.values[str(self.id)].members + if all(isinstance(member, Member) for member in members): + self.members = members + else: + self.members = [Member(**member, _client=self._client) for member in members] async def ban( self, From 7be091e6c42bbc41623fe5fb96924f6a29258a15 Mon Sep 17 00:00:00 2001 From: James Walston Date: Wed, 19 Jan 2022 23:48:57 -0500 Subject: [PATCH 093/105] refactor(client,gateway,http): revert `NoReturn` types; implement saner HTTP ratelimiting. --- interactions/api/gateway.py | 4 +- interactions/api/http.py | 218 +++++++++++++++--------------------- interactions/client.py | 28 +++-- interactions/client.pyi | 26 ++--- interactions/decor.py | 4 +- simple_bot.py | 2 +- 6 files changed, 123 insertions(+), 159 deletions(-) diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index 4ef5fde40..d0f12122d 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -151,10 +151,10 @@ async def connect( :type presence: Optional[Presence] """ self.http = HTTPClient(token) - self.options["headers"] = {"User-Agent": self.http.req.headers["User-Agent"]} + self.options["headers"] = {"User-Agent": self.http.req._headers["User-Agent"]} url = await self.http.get_gateway() - async with self.http._req.session.ws_connect(url, **self.options) as self.session: + async with self.http._req._session.ws_connect(url, **self.options) as self.session: while not self.closed: stream = await self.recv() diff --git a/interactions/api/http.py b/interactions/api/http.py index ead7ad0e4..b8ef2278a 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -1,7 +1,8 @@ -from asyncio import AbstractEventLoop, Event, Lock, get_event_loop, sleep +from asyncio import AbstractEventLoop, get_event_loop from json import dumps from logging import Logger, getLogger from sys import version_info +from threading import Event from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union from urllib.parse import quote @@ -32,8 +33,8 @@ log: Logger = getLogger("http") -__all__ = ("Route", "Padlock", "Request", "HTTPClient") -session: ClientSession = ClientSession() +__all__ = ("Route", "Request", "HTTPClient") +_session: ClientSession = ClientSession() class Route: @@ -80,38 +81,6 @@ def bucket(self) -> str: return f"{self.channel_id}:{self.guild_id}:{self.path}" -class Padlock: - """ - A class representing ratelimited sessions as a "locked" event. - - :ivar Lock lock: The lock coroutine event. - :ivar bool keep_open: Whether the lock should stay open or not. - """ - - __slots__ = ("lock", "keep_open") - lock: Lock - keep_open: bool - - def __init__(self, lock: Lock) -> None: - """ - :param lock: The lock coroutine event. - :type lock: Lock - """ - self.lock = lock - self.keep_open = True - - def click(self) -> None: - """Re-closes the lock after the instantiation and invocation ends.""" - self.keep_open = False - - def __enter__(self) -> Any: - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - if self.keep_open: - self.lock.release() - - class Request: """ A class representing how HTTP requests are sent/read. @@ -124,13 +93,22 @@ class Request: :ivar Event lock: The ratelimit lock event. """ - __slots__ = ("token", "loop", "ratelimits", "headers", "session", "lock") + __slots__ = ( + "token", + "_loop", + "ratelimits", + "_headers", + "_session", + "_global_lock", + "_global_remaining", + ) token: str - loop: AbstractEventLoop + _loop: AbstractEventLoop ratelimits: dict - headers: dict - session: ClientSession - lock: Event + _headers: dict + _session: ClientSession + _global_lock: Event + _global_remaining: float def __init__(self, token: str) -> None: """ @@ -138,23 +116,29 @@ def __init__(self, token: str) -> None: :type token: str """ self.token = token - self.loop = get_event_loop() - self.session = session + self._loop = get_event_loop() self.ratelimits = {} - self.headers = { + self._headers = { "Authorization": f"Bot {self.token}", - "User-Agent": f"DiscordBot (https://github.com/goverfl0w/discord-interactions {__version__} " + "User-Agent": f"DiscordBot (https://github.com/goverfl0w/interactions.py {__version__} " f"Python/{version_info[0]}.{version_info[1]} " f"aiohttp/{http_version}", } - self.lock = Event() if version_info >= (3, 10) else Event(loop=self.loop) - - self.lock.set() + self._session = _session + self._global_lock = Event() + self._global_remaining = 0 - def check_session(self) -> None: + def _check_session(self) -> None: """Ensures that we have a valid connection session.""" - if self.session.closed: - self.session = ClientSession() + if self._session.closed: + self._session = ClientSession() + + async def _check_lock(self) -> None: + """Checks the global lock for its current state.""" + if self._global_lock.is_set(): + log.warning("The HTTP client is still globally locked, waiting for it to clear.") + self._global_lock.wait(self._global_remaining) + self._global_lock.clear() async def request(self, route: Route, **kwargs) -> Optional[Any]: r""" @@ -167,79 +151,58 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: :return: The contents of the request if any. :rtype: Optional[Any] """ - self.check_session() - - bucket: Optional[str] = route.bucket - - for _ in range(3): - ratelimit: Lock = self.ratelimits.get(bucket) - - if not self.lock.is_set(): - log.warning("The request is still locked, waiting for it to clear.") - await self.lock.wait() - - if ratelimit is None: - self.ratelimits[bucket] = Lock() - continue - - await ratelimit.acquire() - - with Padlock(ratelimit) as lock: # noqa: F841 - kwargs["headers"] = {**self.headers, **kwargs.get("headers", {})} - kwargs["headers"]["Content-Type"] = "application/json" - - reason = kwargs.pop("reason", None) - if reason: - kwargs["headers"]["X-Audit-Log-Reason"] = quote(reason, safe="/ ") - - async with self.session.request( - route.method, route.__api__ + route.path, **kwargs - ) as response: - log.debug(f"{route.method}: {route.__api__ + route.path}: {kwargs}") - data = await response.json(content_type=None) - log.debug(f"RETURN {response.status}: {dumps(data, indent=4, sort_keys=True)}") - - if "X-Ratelimit-Remaining" in response.headers.keys(): - remaining = response.headers["X-Ratelimit-Remaining"] - - if not int(remaining) and response.status != 429: - time_left = response.headers["X-Ratelimit-Reset-After"] - self.lock.clear() - log.warning( - f"The HTTP request has reached the maximum threshold. Cooling down for {time_left} seconds." - ) - await sleep(float(time_left)) - self.lock.set() - if response.status in (300, 401, 403, 404): - raise HTTPException(response.status) - if isinstance(data, dict): - if data.get("code"): - raise HTTPException(data["code"]) - elif response.status == 429: - retry_after = data["retry_after"] - - if "X-Ratelimit-Global" in response.headers.keys(): - self.lock.clear() - log.warning( - f"The HTTP request has encountered a global API ratelimit. Retrying in {retry_after} seconds." - ) - await sleep(retry_after) - self.lock.set() - else: - log.warning( - f"A local ratelimit with the bucket has been encountered. Retrying in {retry_after} seconds." - ) - await sleep(retry_after) - continue - return data - - if response is not None: - if response.status >= 500: - raise HTTPException( - response.status, message="The server had an error processing your request." - ) - - raise HTTPException(response.status) # Unknown, unparsed + self._check_session() + await self._check_lock() + bucket: str = route.bucket + ratelimit: Event = self.ratelimits.get(bucket) + + if ratelimit is None: + self.ratelimits[bucket] = Event() + + kwargs["headers"] = {**self._headers, **kwargs.get("headers", {})} + kwargs["headers"]["Content-Type"] = "application/json" + + reason = kwargs.pop("reason", None) + if reason: + kwargs["headers"]["X-Audit-Log-Reason"] = quote(reason, safe="/ ") + + async with self._session.request( + route.method, route.__api__ + route.path, **kwargs + ) as response: + data = await response.json(content_type=None) + reset_after: str = response.headers.get("X-Ratelimit-Reset-After") + remaining: str = response.headers.get("X-Ratelimit-Remaining") + bucket: str = response.headers.get("X-Ratelimit-Bucket") + is_global: bool = ( + True + if response.headers.get("X-Ratelimit-Global") or bool(data.get("global")) + else False + ) + + log.debug(f"{route.method}: {route.__api__ + route.path}: {kwargs}") + log.debug(f"RETURN {response.status}: {dumps(data, indent=4, sort_keys=True)}") + + if data.get("errors"): + raise HTTPException(data["code"], message=data["message"]) + elif remaining and not int(remaining): + if response.status != 429: + if bucket: + log.warning( + f"The requested HTTP endpoint is currently ratelimited. Waiting for {reset_after} seconds." + ) + self.ratelimits[bucket].wait(float(reset_after)) + else: + log.warning( + f"The HTTP client has reached the maximum amount of requests. Cooling down for {reset_after} seconds." + ) + self._global_lock.wait(float(reset_after)) + elif is_global: + log.warning( + f"The HTTP client has encountered a global ratelimit. Locking down future requests for {reset_after} seconds." + ) + self._global_lock.wait(float(reset_after)) + + return data async def close(self) -> None: """Closes the current session.""" @@ -248,12 +211,15 @@ async def close(self) -> None: class HTTPClient: """ - A WIP class that represents the http Client that handles all major endpoints to Discord API. + The user-facing client of the Web API for individual endpoints. + + :ivar str token: The token of the application. + :ivar Request _req: The requesting interface for endpoints. + :ivar Cache cache: The referenced cache. """ token: str - headers: dict - _req: Optional[Request] + _req: Request cache: Cache def __init__(self, token: str): diff --git a/interactions/client.py b/interactions/client.py index d2ee987bf..d44a65266 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -3,7 +3,7 @@ from importlib import import_module from importlib.util import resolve_name from logging import Logger, getLogger -from typing import Any, Callable, Coroutine, Dict, List, NoReturn, Optional, Union +from typing import Any, Callable, Coroutine, Dict, List, Optional, Union from .api.cache import Cache from .api.cache import Item as Build @@ -46,7 +46,7 @@ def __init__( self, token: str, **kwargs, - ) -> NoReturn: + ) -> None: r""" Establishes a client connection to the Web API and Gateway. @@ -94,11 +94,11 @@ def __init__( data = self._loop.run_until_complete(self._http.get_current_bot_information()) self.me = Application(**data) - def start(self) -> NoReturn: + def start(self) -> None: """Starts the client session.""" self._loop.run_until_complete(self._ready()) - def __register_events(self) -> NoReturn: + def __register_events(self) -> None: """Registers all raw gateway events to the known events.""" self._websocket.dispatch.register(self.__raw_socket_create) self._websocket.dispatch.register(self.__raw_channel_create, "on_channel_create") @@ -131,7 +131,7 @@ async def __compare_sync(self, data: dict, pool: List[dict]) -> bool: return clean - async def __create_sync(self, data: dict) -> NoReturn: + async def __create_sync(self, data: dict) -> None: """ Creates an application command during the synchronization process. @@ -149,9 +149,7 @@ async def __create_sync(self, data: dict) -> NoReturn: ) self._http.cache.interactions.add(Build(id=command.name, value=command)) - async def __bulk_update_sync( - self, data: List[dict], delete: Optional[bool] = False - ) -> NoReturn: + async def __bulk_update_sync(self, data: List[dict], delete: Optional[bool] = False) -> None: """ Bulk updates a list of application commands during the synchronization process. @@ -199,7 +197,7 @@ async def __bulk_update_sync( application_id=self.me.id, data=[] if delete else global_commands ) - async def _synchronize(self, payload: Optional[dict] = None) -> NoReturn: + async def _synchronize(self, payload: Optional[dict] = None) -> None: """ Synchronizes a command from the client-facing API to the Web API. @@ -236,7 +234,7 @@ async def _synchronize(self, payload: Optional[dict] = None) -> NoReturn: await self.__bulk_update_sync(to_sync) await self.__bulk_update_sync(to_delete, delete=True) - async def _ready(self) -> NoReturn: + async def _ready(self) -> None: """ Prepares the client with an internal "ready" check to ensure that all conditions have been met in a chronological order: @@ -269,7 +267,7 @@ async def _ready(self) -> NoReturn: log.debug("Client is now ready.") await self._login() - async def _login(self) -> NoReturn: + async def _login(self) -> None: """Makes a login with the Discord API.""" while not self._websocket.closed: await self._websocket.connect(self._token, self._shard, self._presence) @@ -355,7 +353,7 @@ def decorator(coro: Coroutine) -> Callable[..., Any]: raise InteractionException( 11, message="Your command needs at least one argument to return context." ) - if options and (len(coro.__code__.co_varnames) + 1) < len(options): + if options is not MISSING and len(coro.__code__.co_varnames) + 1 < len(options): raise InteractionException( 11, message="You must have the same amount of arguments as the options of the command.", @@ -597,7 +595,7 @@ def decorator(coro: Coroutine) -> Any: return decorator - def load(self, name: str, package: Optional[str] = None) -> NoReturn: + def load(self, name: str, package: Optional[str] = None) -> None: """ "Loads" an extension off of the current client by adding a new class which is imported from the library. @@ -624,7 +622,7 @@ def load(self, name: str, package: Optional[str] = None) -> NoReturn: log.debug(f"Loaded extension {name}.") self._extensions[_name] = module - def remove(self, name: str, package: Optional[str] = None) -> NoReturn: + def remove(self, name: str, package: Optional[str] = None) -> None: """ Removes an extension out of the current client from an import resolve. @@ -643,7 +641,7 @@ def remove(self, name: str, package: Optional[str] = None) -> NoReturn: del sys.modules[_name] del self._extensions[_name] - def reload(self, name: str, package: Optional[str] = None) -> NoReturn: + def reload(self, name: str, package: Optional[str] = None) -> None: """ "Reloads" an extension off of current client from an import resolve. diff --git a/interactions/client.pyi b/interactions/client.pyi index 54d2a4772..8bec5e6b3 100644 --- a/interactions/client.pyi +++ b/interactions/client.pyi @@ -33,17 +33,17 @@ class Client: self, token: str, **kwargs, - ) -> NoReturn: ... - def start(self) -> NoReturn: ... - def __register_events(self) -> NoReturn: ... - async def __compare_sync(self, data: dict) -> NoReturn: ... - async def __create_sync(self, data: dict) -> NoReturn: ... + ) -> None: ... + def start(self) -> None: ... + def __register_events(self) -> None: ... + async def __compare_sync(self, data: dict) -> None: ... + async def __create_sync(self, data: dict) -> None: ... async def __bulk_update_sync( self, data: List[dict], delete: Optional[bool] = False - ) -> NoReturn: ... - async def _synchronize(self, payload: Optional[dict] = None) -> NoReturn: ... - async def _ready(self) -> NoReturn: ... - async def _login(self) -> NoReturn: ... + ) -> None: ... + async def _synchronize(self, payload: Optional[dict] = None) -> None: ... + async def _ready(self) -> None: ... + async def _login(self) -> None: ... def event(self, coro: Coroutine, name: Optional[str] = None) -> Callable[..., Any]: ... def command( self, @@ -72,9 +72,9 @@ class Client: def component(self, component: Union[Button, SelectMenu]) -> Callable[..., Any]: ... def autocomplete(self, name: str) -> Callable[..., Any]: ... def modal(self, modal: Modal) -> Callable[..., Any]: ... - def load(self, name: str, package: Optional[str] = None) -> NoReturn: ... - def remove(self, name: str, package: Optional[str] = None) -> NoReturn: ... - def reload(self, name: str, package: Optional[str] = None) -> NoReturn: ... + def load(self, name: str, package: Optional[str] = None) -> None: ... + def remove(self, name: str, package: Optional[str] = None) -> None: ... + def reload(self, name: str, package: Optional[str] = None) -> None: ... async def raw_socket_create(self, data: Dict[Any, Any]) -> dict: ... async def raw_channel_create(self, message) -> dict: ... async def raw_message_create(self, message) -> dict: ... @@ -82,4 +82,4 @@ class Client: class Extension: client: Client - def __new__(cls, bot: Client) -> NoReturn: ... + def __new__(cls, bot: Client) -> None: ... diff --git a/interactions/decor.py b/interactions/decor.py index 9282b1906..e33da6b52 100644 --- a/interactions/decor.py +++ b/interactions/decor.py @@ -32,7 +32,7 @@ def command( _description: str = "" if description is MISSING else description _options: list = [] - if options: + if options is not MISSING: if all(isinstance(option, Option) for option in options): _options = [option._json for option in options] elif all( @@ -50,7 +50,7 @@ def command( payloads: list = [] - if scope: + if scope is not MISSING: if isinstance(scope, list): if all(isinstance(guild, Guild) for guild in scope): [_scope.append(guild.id) for guild in scope] diff --git a/simple_bot.py b/simple_bot.py index ef3e5add1..20d883b7e 100644 --- a/simple_bot.py +++ b/simple_bot.py @@ -5,7 +5,7 @@ logging.basicConfig(level=logging.DEBUG) -bot = interactions.Client(token=open("bot.token").read()) +bot = interactions.Client(token=open("bot.token").read(), disable_sync=True) @bot.event From 158df3a97250ab58aa2b58948c0c5e1f7e00b02b Mon Sep 17 00:00:00 2001 From: James Walston Date: Thu, 20 Jan 2022 00:01:02 -0500 Subject: [PATCH 094/105] fix(http): check bucket before sending a request for a cooldown. --- interactions/api/http.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index b8ef2278a..174bb4e78 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -157,7 +157,12 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: ratelimit: Event = self.ratelimits.get(bucket) if ratelimit is None: - self.ratelimits[bucket] = Event() + self.ratelimits[bucket] = {"lock": Event(), "remaining": 0} + else: + if ratelimit.is_set(): + log.warning("The requested HTTP endpoint is still locked, waiting for it to clear.") + ratelimit["lock"].wait(ratelimit["reset_after"]) + ratelimit["lock"].clear() kwargs["headers"] = {**self._headers, **kwargs.get("headers", {})} kwargs["headers"]["Content-Type"] = "application/json" From c4846af4faec01909611d53c1f858f9b7894271b Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Sat, 22 Jan 2022 04:44:30 +0100 Subject: [PATCH 095/105] docs: correct broken ivar/param formatting. (#446) * docs: - fix guild.py * docs: - fix channel.py * docs: - fix member.py * docs: - fix message.py * docs: - fix role.py * docs: - fix gateway.py * docs: - fix http.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- interactions/api/gateway.py | 7 ++ interactions/api/http.py | 106 +++++++++++++++++++++++++---- interactions/api/models/channel.py | 12 ++-- interactions/api/models/guild.py | 56 ++++++++++----- interactions/api/models/member.py | 14 ++-- interactions/api/models/message.py | 7 +- interactions/api/models/role.py | 8 ++- 7 files changed, 163 insertions(+), 47 deletions(-) diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index d0f12122d..0b10929fd 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -26,6 +26,7 @@ class Heartbeat(Thread): """ A class representing a consistent heartbeat connection with the gateway. + :ivar WebSocket ws: The WebSocket class to infer on. :ivar Union[int, float] interval: The heartbeat interval determined by the gateway. :ivar Event event: The multi-threading event. @@ -72,6 +73,7 @@ def stop(self) -> None: class WebSocket: """ A class representing a websocket connection with the gateway. + :ivar Intents intents: An instance of :class:`interactions.api.models.Intents`. :ivar AbstractEventLoop loop: The coroutine event loop established on. :ivar Request req: An instance of :class:`interactions.api.http.Request`. @@ -143,6 +145,7 @@ async def connect( ) -> None: """ Establishes a connection to the gateway. + :param token: The token to use for identifying. :type token: str :param shard?: The shard ID to identify under. @@ -172,6 +175,7 @@ async def handle_connection( ) -> None: """ Handles the connection to the gateway. + :param stream: The data stream from the gateway. :type stream: dict :param shard?: The shard ID to identify under. @@ -230,6 +234,7 @@ async def handle_connection( def handle_dispatch(self, event: str, data: dict) -> None: """ Handles the dispatched event data from a gateway event. + :param event: The name of the event. :type event: str :param data: The data of the event. @@ -344,6 +349,7 @@ def contextualize(self, data: dict) -> object: """ Takes raw data given back from the gateway and gives "context" based off of what it is. + :param data: The data from the gateway. :type data: dict :return: The context object. @@ -375,6 +381,7 @@ async def identify( ) -> None: """ Sends an ``IDENTIFY`` packet to the gateway. + :param shard?: The shard ID to identify under. :type shard: Optional[int] :param presence?: The presence to change the bot to on identify. diff --git a/interactions/api/http.py b/interactions/api/http.py index 174bb4e78..7049f6a1c 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -246,6 +246,7 @@ async def get_gateway(self) -> str: async def get_bot_gateway(self) -> Tuple[int, str]: """ This calls the BOT Gateway endpoint. + :return: A tuple denoting (shard, gateway_url), url from API v9 and JSON encoding """ @@ -290,15 +291,16 @@ async def get_self(self) -> dict: """ An alias to `get_user`, but only gets the current bot user. - :return A partial User object of the current bot user in the form of a dictionary. + :return: A partial User object of the current bot user in the form of a dictionary. """ return await self.get_user() async def get_user(self, user_id: Optional[int] = None) -> dict: """ Gets a user object for a given user ID. + :param user_id: A user snowflake ID. If omitted, this defaults to the current bot user. - :return A partial User object in the form of a dictionary. + :return: A partial User object in the form of a dictionary. """ if user_id is None: @@ -312,6 +314,7 @@ async def get_user(self, user_id: Optional[int] = None) -> dict: async def modify_self(self, payload: dict) -> dict: """ Modify the bot user account settings. + :param payload: The data to send. """ return await self._req.request(Route("PATCH", "/users/@me"), json=payload) @@ -332,6 +335,7 @@ async def modify_self_nick_in_guild(self, guild_id: int, nickname: Optional[str] async def create_dm(self, recipient_id: int) -> dict: """ Creates a new DM channel with a user. + :param recipient_id: User snowflake ID. :return: Returns a dictionary representing a DM Channel object. """ @@ -406,6 +410,7 @@ async def create_message(self, payload: dict, channel_id: int) -> dict: async def get_message(self, channel_id: int, message_id: int) -> Optional[dict]: """ Get a specific message in the channel. + :param channel_id: the channel this message belongs to :param message_id: the id of the message :return: message if it exists. @@ -418,7 +423,8 @@ async def delete_message( self, channel_id: int, message_id: int, reason: Optional[str] = None ) -> None: """ - Deletes a message from a specified channel + Deletes a message from a specified channel. + :param channel_id: Channel snowflake ID. :param message_id: Message snowflake ID. :param reason: Optional reason to show up in the audit log. Defaults to `None`. @@ -435,7 +441,8 @@ async def delete_messages( self, channel_id: int, message_ids: List[int], reason: Optional[str] = None ) -> None: """ - Deletes messages from a specified channel + Deletes messages from a specified channel. + :param channel_id: Channel snowflake ID. :param message_ids: An array of message snowflake IDs. :param reason: Optional reason to show up in the audit log. Defaults to `None`. @@ -468,21 +475,26 @@ async def edit_message(self, channel_id: int, message_id: int, payload: dict) -> ) async def pin_message(self, channel_id: int, message_id: int) -> None: - """Pin a message to a channel. + """ + Pin a message to a channel. + :param channel_id: Channel ID snowflake. :param message_id: Message ID snowflake. """ return await self._req.request(Route("PUT", f"/channels/{channel_id}/pins/{message_id}")) async def unpin_message(self, channel_id: int, message_id: int) -> None: - """Unpin a message to a channel + """ + Unpin a message to a channel. + :param channel_id: Channel ID snowflake. :param message_id: Message ID snowflake. """ return await self._req.request(Route("DELETE", f"/channels/{channel_id}/pins/{message_id}")) async def publish_message(self, channel_id: int, message_id: int) -> dict: - """Publishes (API calls it crossposts) a message in a News channel to any that is followed by. + """ + Publishes (API calls it crossposts) a message in a News channel to any that is followed by. :param channel_id: Channel the message is in :param message_id: The id of the message to publish @@ -511,6 +523,7 @@ async def get_self_guilds(self) -> list: async def get_guild(self, guild_id: int): """ Requests an individual guild from the API. + :param guild_id: The guild snowflake ID associated. :return: The guild object associated, if any. """ @@ -522,6 +535,7 @@ async def get_guild(self, guild_id: int): async def get_guild_preview(self, guild_id: int) -> GuildPreview: """ Get a guild's preview. + :param guild_id: Guild ID snowflake. :return: Guild Preview object associated with the snowflake """ @@ -566,6 +580,7 @@ async def delete_guild(self, guild_id: int) -> None: async def get_guild_widget(self, guild_id: int) -> dict: """ Returns the widget for the guild. + :param guild_id: Guild ID snowflake. :return: Guild Widget contents as a dict: {"enabled":bool, "channel_id": str} """ @@ -583,6 +598,7 @@ async def get_guild_widget_settings(self, guild_id: int) -> dict: async def get_guild_widget_image(self, guild_id: int, style: Optional[str] = None) -> str: """ Get an url representing a png image widget for the guild. + ..note:: See _ for list of styles. @@ -606,6 +622,7 @@ async def modify_guild_widget(self, guild_id: int, payload: dict) -> dict: async def get_guild_invites(self, guild_id: int) -> List[Invite]: """ Retrieves a list of invite objects with their own metadata. + :param guild_id: Guild ID snowflake. :return: A list of invite objects """ @@ -613,7 +630,8 @@ async def get_guild_invites(self, guild_id: int) -> List[Invite]: async def get_guild_welcome_screen(self, guild_id: int) -> WelcomeScreen: """ - Retrieves from the API a welcome screen associated with the guild + Retrieves from the API a welcome screen associated with the guild. + :param guild_id: Guild ID snowflake. :return: Welcome Screen object """ @@ -658,6 +676,7 @@ async def modify_vanity_code( async def get_guild_integrations(self, guild_id: int) -> List[dict]: """ Gets a list of integration objects associated with the Guild from the API. + :param guild_id: Guild ID snowflake. :return: An array of integration objects """ @@ -666,6 +685,7 @@ async def get_guild_integrations(self, guild_id: int) -> List[dict]: async def delete_guild_integration(self, guild_id: int, integration_id: int) -> None: """ Deletes an integration from the guild. + :param guild_id: Guild ID snowflake. :param integration_id: Integration ID snowflake. """ @@ -839,6 +859,7 @@ async def get_all_channels(self, guild_id: int) -> List[dict]: async def get_all_roles(self, guild_id: int) -> List[dict]: """ Gets all roles from a Guild. + :param guild_id: Guild ID snowflake :return: An array of Role objects as dictionaries. """ @@ -857,6 +878,7 @@ async def create_guild_role( ) -> Role: """ Create a new role for the guild. + :param guild_id: Guild ID snowflake. :param data: A dict containing metadata for the role. :param reason: The reason for this action, if given. @@ -875,6 +897,7 @@ async def modify_guild_role_position( ) -> List[Role]: """ Modify the position of a role in the guild. + :param guild_id: Guild ID snowflake. :param role_id: Role ID snowflake. :param position: The new position of the associated role. @@ -892,6 +915,7 @@ async def modify_guild_role( ) -> Role: """ Modify a given role for the guild. + :param guild_id: Guild ID snowflake. :param role_id: Role ID snowflake. :param data: A dict containing updated metadata for the role. @@ -905,6 +929,7 @@ async def modify_guild_role( async def delete_guild_role(self, guild_id: int, role_id: int, reason: str = None) -> None: """ Delete a guild role. + :param guild_id: Guild ID snowflake. :param role_id: Role ID snowflake. :param reason: The reason for this action, if any. @@ -940,6 +965,7 @@ async def create_guild_ban( ) -> None: """ Bans a person from the guild, and optionally deletes previous messages sent by them. + :param guild_id: Guild ID snowflake :param user_id: User ID snowflake :param delete_message_days: Number of days to delete messages, from 0 to 7. Defaults to 0 @@ -957,6 +983,7 @@ async def remove_guild_ban( ) -> None: """ Unbans someone using the API. + :param guild_id: Guild ID snowflake :param user_id: User ID snowflake :param reason: Optional reason to unban. @@ -971,6 +998,7 @@ async def remove_guild_ban( async def get_guild_bans(self, guild_id: int) -> List[dict]: """ Gets a list of banned users. + :param guild_id: Guild ID snowflake. :return: A list of banned users. """ @@ -979,6 +1007,7 @@ async def get_guild_bans(self, guild_id: int) -> List[dict]: async def get_user_ban(self, guild_id: int, user_id: int) -> Optional[dict]: """ Gets an object pertaining to the user, if it exists. Returns a 404 if it doesn't. + :param guild_id: Guild ID snowflake :param user_id: User ID snowflake. :return: Ban object if it exists. @@ -1031,6 +1060,7 @@ async def remove_guild_member( ) -> None: """ A low level method of removing a member from a guild. This is different from banning them. + :param guild_id: Guild ID snowflake. :param user_id: User ID snowflake. :param reason: Reason to send to audit log, if any. @@ -1044,6 +1074,7 @@ async def get_guild_prune_count( ) -> dict: """ Retrieves a dict from an API that results in how many members would be pruned given the amount of days. + :param guild_id: Guild ID snowflake. :param days: Number of days to count. Defaults to ``7``. :param include_roles: Role IDs to include, if given. @@ -1062,6 +1093,7 @@ async def get_guild_prune_count( async def get_member(self, guild_id: int, member_id: int) -> Optional[Member]: """ Uses the API to fetch a member from a guild. + :param guild_id: Guild ID snowflake. :param member_id: Member ID snowflake. :return: A member object, if any. @@ -1155,7 +1187,7 @@ async def modify_member( ): """ Edits a member. - This can nick them, change their roles, mute/deafen (and its contrary), and moving them across channels and/or disconnect them + This can nick them, change their roles, mute/deafen (and its contrary), and moving them across channels and/or disconnect them. :param user_id: Member ID snowflake. :param guild_id: Guild ID snowflake. @@ -1176,7 +1208,8 @@ async def modify_member( async def get_channel(self, channel_id: int) -> dict: """ - Gets a channel by ID. If the channel is a thread, it also includes thread members (and other thread attributes) + Gets a channel by ID. If the channel is a thread, it also includes thread members (and other thread attributes). + :param channel_id: Channel ID snowflake. :return: Dictionary of the channel object. """ @@ -1300,6 +1333,7 @@ async def modify_channel( ) -> Channel: """ Update a channel's settings. + :param channel_id: Channel ID snowflake. :param data: Data representing updated settings. :param reason: Reason, if any. @@ -1312,6 +1346,7 @@ async def modify_channel( async def get_channel_invites(self, channel_id: int) -> List[Invite]: """ Get the invites for the channel. + :param channel_id: Channel ID snowflake. :return: List of invite objects """ @@ -1338,6 +1373,7 @@ async def create_channel_invite( async def delete_invite(self, invite_code: str, reason: Optional[str] = None) -> dict: """ Delete an invite. + :param invite_code: The code of the invite to delete :param reason: Reason to show in the audit log, if any. :return: The deleted invite object @@ -1389,6 +1425,7 @@ async def trigger_typing(self, channel_id: int) -> None: ..note: By default, this lib doesn't use this endpoint, however, this is listed for third-party implementation. + :param channel_id: Channel ID snowflake. """ return await self._req.request(Route("POST", f"/channels/{channel_id}/typing")) @@ -1396,6 +1433,7 @@ async def trigger_typing(self, channel_id: int) -> None: async def get_pinned_messages(self, channel_id: int) -> List[Message]: """ Get all pinned messages from a channel. + :param channel_id: Channel ID snowflake. :return: A list of pinned message objects. """ @@ -1474,6 +1512,7 @@ async def delete_stage_instance(self, channel_id: int, reason: Optional[str] = N async def join_thread(self, thread_id: int) -> None: """ Have the bot user join a thread. + :param thread_id: The thread to join. """ return await self._req.request(Route("PUT", f"/channels/{thread_id}/thread-members/@me")) @@ -1481,6 +1520,7 @@ async def join_thread(self, thread_id: int) -> None: async def leave_thread(self, thread_id: int) -> None: """ Have the bot user leave a thread. + :param thread_id: The thread to leave. """ return await self._req.request(Route("DELETE", f"/channels/{thread_id}/thread-members/@me")) @@ -1488,6 +1528,7 @@ async def leave_thread(self, thread_id: int) -> None: async def add_member_to_thread(self, thread_id: int, user_id: int) -> None: """ Add another user to a thread. + :param thread_id: The ID of the thread :param user_id: The ID of the user to add """ @@ -1498,6 +1539,7 @@ async def add_member_to_thread(self, thread_id: int, user_id: int) -> None: async def remove_member_from_thread(self, thread_id: int, user_id: int) -> None: """ Remove another user from a thread. + :param thread_id: The ID of the thread :param user_id: The ID of the user to remove """ @@ -1521,6 +1563,7 @@ async def get_member_from_thread(self, thread_id: int, user_id: int) -> dict: async def list_thread_members(self, thread_id: int) -> List[dict]: """ Get a list of members in the thread. + :param thread_id: the id of the thread :return: a list of thread member objects """ @@ -1551,6 +1594,7 @@ async def list_private_archived_threads( ) -> List[dict]: """ Get a list of archived private threads in a channel. + :param channel_id: The channel to get threads from :param limit: Optional limit of threads to :param before: Get threads before this Thread snowflake ID @@ -1570,6 +1614,7 @@ async def list_joined_private_archived_threads( ) -> List[dict]: """ Get a list of archived private threads in a channel that the bot has joined. + :param channel_id: The channel to get threads from :param limit: Optional limit of threads to :param before: Get threads before this snowflake ID @@ -1587,6 +1632,7 @@ async def list_joined_private_archived_threads( async def list_active_threads(self, guild_id: int) -> List[dict]: """ List active threads within a guild. + :param guild_id: the guild id to get threads from :return: A list of active threads """ @@ -1643,6 +1689,7 @@ async def create_thread( async def create_reaction(self, channel_id: int, message_id: int, emoji: str) -> None: """ Create a reaction for a message. + :param channel_id: Channel snowflake ID. :param message_id: Message snowflake ID. :param emoji: The emoji to use (format: `name:id`) @@ -1660,6 +1707,7 @@ async def create_reaction(self, channel_id: int, message_id: int, emoji: str) -> async def remove_self_reaction(self, channel_id: int, message_id: int, emoji: str) -> None: """ Remove bot user's reaction from a message. + :param channel_id: Channel snowflake ID. :param message_id: Message snowflake ID. :param emoji: The emoji to remove (format: `name:id`) @@ -1678,7 +1726,7 @@ async def remove_user_reaction( self, channel_id: int, message_id: int, emoji: str, user_id: int ) -> None: """ - Remove user's reaction from a message + Remove user's reaction from a message. :param channel_id: The channel this is taking place in :param message_id: The message to remove the reaction on. @@ -1717,6 +1765,7 @@ async def remove_all_reactions_of_emoji( ) -> None: """ Remove all reactions of a certain emoji from a message. + :param channel_id: Channel snowflake ID. :param message_id: Message snowflake ID. :param emoji: The emoji to remove (format: `name:id`) @@ -1736,6 +1785,7 @@ async def get_reactions_of_emoji( ) -> List[User]: """ Gets the users who reacted to the emoji. + :param channel_id: Channel snowflake ID. :param message_id: Message snowflake ID. :param emoji: The emoji to get (format: `name:id`) @@ -1756,6 +1806,7 @@ async def get_reactions_of_emoji( async def get_sticker(self, sticker_id: int) -> dict: """ Get a specific sticker. + :param sticker_id: The id of the sticker :return: Sticker or None """ @@ -1764,6 +1815,7 @@ async def get_sticker(self, sticker_id: int) -> dict: async def list_nitro_sticker_packs(self) -> list: """ Gets the list of sticker packs available to Nitro subscribers. + :return: List of sticker packs """ return await self._req.request(Route("GET", "/sticker-packs")) @@ -1771,6 +1823,7 @@ async def list_nitro_sticker_packs(self) -> list: async def list_guild_stickers(self, guild_id: int) -> List[dict]: """ Get the stickers for a guild. + :param guild_id: The guild to get stickers from :return: List of Stickers or None """ @@ -1779,6 +1832,7 @@ async def list_guild_stickers(self, guild_id: int) -> List[dict]: async def get_guild_sticker(self, guild_id: int, sticker_id: int) -> dict: """ Get a sticker from a guild. + :param guild_id: The guild to get stickers from :param sticker_id: The sticker to get from the guild :return: Sticker or None @@ -1790,6 +1844,7 @@ async def create_guild_sticker( ): """ Create a new sticker for the guild. Requires the MANAGE_EMOJIS_AND_STICKERS permission. + :param payload: the payload to send. :param guild_id: The guild to create sticker at. :param reason: The reason for this action. @@ -1804,6 +1859,7 @@ async def modify_guild_sticker( ): """ Modify the given sticker. Requires the MANAGE_EMOJIS_AND_STICKERS permission. + :param payload: the payload to send. :param guild_id: The guild of the target sticker. :param sticker_id: The sticker to modify. @@ -1819,6 +1875,7 @@ async def delete_guild_sticker( ) -> None: """ Delete the given sticker. Requires the MANAGE_EMOJIS_AND_STICKERS permission. + :param guild_id: The guild of the target sticker. :param sticker_id: The sticker to delete. :param reason: The reason for this action. @@ -1836,7 +1893,8 @@ async def get_application_command( self, application_id: Union[int, Snowflake], guild_id: Optional[int] = None ) -> List[dict]: """ - Get all application commands from an application + Get all application commands from an application. + :param application_id: Application ID snowflake :param guild_id: Guild to get commands from, if specified. Defaults to global (None) :return: A list of Application commands. @@ -1964,7 +2022,7 @@ async def edit_application_command_permissions( self, application_id: int, guild_id: int, command_id: int, data: List[dict] ) -> dict: """ - Edits permissions for an application command + Edits permissions for an application command. :param application_id: Application ID snowflake :param guild_id: Guild ID snowflake @@ -2050,6 +2108,7 @@ async def get_original_interaction_response( ) -> dict: """ Gets an existing interaction message. + :param token: token :param application_id: Application ID snowflake. :param message_id: Message ID snowflake. Defaults to `@original` which represents the initial response msg. @@ -2065,6 +2124,7 @@ async def edit_interaction_response( ) -> dict: """ Edits an existing interaction message, but token needs to be manually called. + :param data: A dictionary containing the new response. :param token: the token of the interaction :param application_id: Application ID snowflake. @@ -2082,6 +2142,7 @@ async def delete_interaction_response( ) -> None: """ Deletes an existing interaction message. + :param token: the token of the interaction :param application_id: Application ID snowflake. :param message_id: Message ID snowflake. Defaults to `@original` which represents the initial response msg. @@ -2097,6 +2158,7 @@ async def delete_interaction_response( async def _post_followup(self, data: dict, token: str, application_id: str) -> None: """ Send a followup to an interaction. + :param data: the payload to send :param application_id: the id of the application :param token: the token of the interaction @@ -2113,6 +2175,7 @@ async def _post_followup(self, data: dict, token: str, application_id: str) -> N async def create_webhook(self, channel_id: int, name: str, avatar: Any = None) -> dict: """ Create a new webhook. + :param channel_id: Channel ID snowflake. :param name: Name of the webhook (1-80 characters) :param avatar: The image for the default webhook avatar, if given. @@ -2126,6 +2189,7 @@ async def create_webhook(self, channel_id: int, name: str, avatar: Any = None) - async def get_channel_webhooks(self, channel_id: int) -> List[dict]: """ Return a list of channel webhook objects. + :param channel_id: Channel ID snowflake. :return:List of webhook objects """ @@ -2134,6 +2198,7 @@ async def get_channel_webhooks(self, channel_id: int) -> List[dict]: async def get_guild_webhooks(self, guild_id: int) -> List[dict]: """ Return a list of guild webhook objects. + :param guild_id: Guild ID snowflake :return: List of webhook objects @@ -2143,6 +2208,7 @@ async def get_guild_webhooks(self, guild_id: int) -> List[dict]: async def get_webhook(self, webhook_id: int, webhook_token: str = None) -> dict: """ Return the new webhook object for the given id. + :param webhook_id: Webhook ID snowflake. :param webhook_token: Webhook Token, if given. @@ -2162,6 +2228,7 @@ async def modify_webhook( ) -> dict: """ Modify a webhook. + :param webhook_id: Webhook ID snowflake :param name: the default name of the webhook :param avatar: image for the default webhook avatar @@ -2179,7 +2246,8 @@ async def modify_webhook( async def delete_webhook(self, webhook_id: int, webhook_token: str = None): """ - Delete a webhook + Delete a webhook. + :param webhook_id: Webhook ID snowflake. :param webhook_token: The token for the webhook, if given. """ @@ -2328,6 +2396,7 @@ async def get_all_emoji(self, guild_id: int) -> List[Emoji]: async def get_guild_emoji(self, guild_id: int, emoji_id: int) -> Emoji: """ Gets an emote from a guild. + :param guild_id: Guild ID snowflake. :param emoji_id: Emoji ID snowflake. :return: Emoji object @@ -2339,6 +2408,7 @@ async def create_guild_emoji( ) -> Emoji: """ Creates an emoji. + :param guild_id: Guild ID snowflake. :param data: Emoji parameters. :param reason: Optionally, give a reason. @@ -2353,6 +2423,7 @@ async def modify_guild_emoji( ) -> Emoji: """ Modifies an emoji. + :param guild_id: Guild ID snowflake. :param emoji_id: Emoji ID snowflake :param data: Emoji parameters with updated attributes @@ -2368,6 +2439,7 @@ async def delete_guild_emoji( ) -> None: """ Deletes an emoji. + :param guild_id: Guild ID snowflake. :param emoji_id: Emoji ID snowflake :param reason: Optionally, give a reason. @@ -2381,6 +2453,7 @@ async def delete_guild_emoji( async def create_scheduled_event(self, guild_id: Snowflake, data: dict) -> dict: """ Creates a scheduled event. + :param guild_id: Guild ID snowflake. :param data: The dictionary containing the parameters and values to edit the associated event. :return A dictionary containing the new guild scheduled event object on success. @@ -2408,6 +2481,7 @@ async def get_scheduled_event( ) -> dict: """ Gets a guild scheduled event. + :param guild_id: Guild ID snowflake. :param guild_scheduled_event_id: Guild Scheduled Event ID snowflake. :param with_user_count: A boolean to include number of users subscribed to the associated event, if given. @@ -2431,6 +2505,7 @@ async def get_scheduled_event( async def get_scheduled_events(self, guild_id: Snowflake, with_user_count: bool) -> List[dict]: """ Gets all guild scheduled events in a guild. + :param guild_id: Guild ID snowflake. :param with_user_count: A boolean to include number of users subscribed to the associated event, if given. :return A List of a dictionary containing the guild scheduled event objects on success. @@ -2449,6 +2524,7 @@ async def modify_scheduled_event( ) -> dict: """ Modifies a scheduled event. + :param guild_id: Guild ID snowflake. :param guild_scheduled_event_id: Guild Scheduled Event ID snowflake. :param data: The dictionary containing the parameters and values to edit the associated event. @@ -2481,6 +2557,7 @@ async def delete_scheduled_event( ) -> None: """ Deletes a guild scheduled event. + :param guild_id: Guild ID snowflake. :param guild_scheduled_event_id: Guild Scheduled Event ID snowflake. :return Nothing on success. @@ -2507,6 +2584,7 @@ async def get_scheduled_event_users( ) -> dict: """ Get the registered users of a scheduled event. + :param guild_id: Guild ID snowflake. :param guild_scheduled_event_id: Guild Scheduled Event snowflake. :param limit: Limit of how many users to pull from the event. Defaults to 100. diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 6b93fc44b..700ce5caa 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -192,7 +192,7 @@ async def send( components=None, ): """ - Sends a message in the channel + Sends a message in the channel. :param content?: The contents of the message as a string or string-converted value. :type content: Optional[str] @@ -360,7 +360,8 @@ async def modify( reason: Optional[str] = None, ) -> "Channel": """ - Edits the channel + Edits the channel. + :param name?: The name of the channel, defaults to the current value of the channel :type name: str :param topic?: The topic of that channel, defaults to the current value of the channel @@ -419,7 +420,7 @@ async def add_member( member_id: int, ) -> None: """ - This adds a member to the channel, if the channel is a thread + This adds a member to the channel, if the channel is a thread. :param member_id: The id of the member to add to the channel :type member_id: int @@ -437,7 +438,7 @@ async def pin_message( message_id: int, ) -> None: """ - Pins a message to the channel + Pins a message to the channel. :param message_id: The id of the message to pin :type message_id: int @@ -452,7 +453,7 @@ async def unpin_message( message_id: int, ) -> None: """ - Unpins a message from the channel + Unpins a message from the channel. :param message_id: The id of the message to unpin :type message_id: int @@ -485,6 +486,7 @@ async def publish_message( async def get_pinned_messages(self): """ Get all pinned messages from the channel. + :return: A list of pinned message objects. :rtype: List[Message] """ diff --git a/interactions/api/models/guild.py b/interactions/api/models/guild.py index 86fed5e0f..62559bddf 100644 --- a/interactions/api/models/guild.py +++ b/interactions/api/models/guild.py @@ -312,7 +312,8 @@ async def ban( delete_message_days: Optional[int] = 0, ) -> None: """ - Bans a member from the guild + Bans a member from the guild. + :param member_id: The id of the member to ban :type member_id: int :param reason?: The reason of the ban @@ -335,7 +336,8 @@ async def remove_ban( reason: Optional[str] = None, ) -> None: """ - Removes the ban of a user + Removes the ban of a user. + :param user_id: The id of the user to remove the ban from :type user_id: int :param reason?: The reason for the removal of the ban @@ -355,7 +357,8 @@ async def kick( reason: Optional[str] = None, ) -> None: """ - Kicks a member from the guild + Kicks a member from the guild. + :param member_id: The id of the member to kick :type member_id: int :param reason?: The reason for the kick @@ -376,7 +379,8 @@ async def add_member_role( reason: Optional[str], ) -> None: """ - This method adds a role to a member + This method adds a role to a member. + :param role: The role to add. Either ``Role`` object or role_id :type role Union[Role, int] :param member_id: The id of the member to add the roles to @@ -408,7 +412,8 @@ async def remove_member_role( reason: Optional[str], ) -> None: """ - This method removes a or multiple role(s) from a member + This method removes a or multiple role(s) from a member. + :param role: The role to remove. Either ``Role`` object or role_id :type role: Union[Role, int] :param member_id: The id of the member to remove the roles from @@ -445,7 +450,8 @@ async def create_role( reason: Optional[str] = None, ) -> Role: """ - Creates a new role in the guild + Creates a new role in the guild. + :param name: The name of the role :type name: str :param color?: RGB color value as integer, default ``0`` @@ -479,7 +485,8 @@ async def get_member( member_id: int, ) -> Member: """ - Searches for the member with specified id in the guild and returns the member as member object + Searches for the member with specified id in the guild and returns the member as member object. + :param member_id: The id of the member to search for :type member_id: int :return: The member searched for @@ -498,7 +505,8 @@ async def delete_channel( channel_id: int, ) -> None: """ - Deletes a channel from the guild + Deletes a channel from the guild. + :param channel_id: The id of the channel to delete :type channel_id: int """ @@ -514,7 +522,8 @@ async def delete_role( reason: Optional[str] = None, ) -> None: """ - Deletes a role from the guild + Deletes a role from the guild. + :param role_id: The id of the role to delete :type role_id: int :param reason?: The reason of the deletion @@ -541,7 +550,8 @@ async def modify_role( reason: Optional[str] = None, ) -> Role: """ - Edits a role in the guild + Edits a role in the guild. + :param role_id: The id of the role to edit :type role_id: int :param name?: The name of the role, defaults to the current value of the role @@ -594,7 +604,8 @@ async def create_channel( reason: Optional[str] = None, ) -> Channel: """ - Creates a channel in the guild + Creates a channel in the guild. + :param name: The name of the channel :type name: str :param type: The type of the channel @@ -665,7 +676,8 @@ async def modify_channel( reason: Optional[str] = None, ) -> Channel: """ - Edits a channel of the guild + Edits a channel of the guild. + :param channel_id: The id of the channel to modify :type channel_id: int :param name?: The name of the channel, defaults to the current value of the channel @@ -787,9 +799,17 @@ async def modify_member( return Member(**res, _client=self._client) async def get_preview(self) -> "GuildPreview": - """Get the guild's preview.""" + + """ + Get the guild's preview. + + :return: the guild preview as object + :rtype: GuildPreview + """ + if not self._client: raise AttributeError("HTTPClient not found!") + return GuildPreview(**await self._client.get_guild_preview(guild_id=int(self.id))) async def leave(self) -> None: @@ -1067,7 +1087,7 @@ async def modify_scheduled_event( async def delete_scheduled_event(self, event_id: int) -> None: """ - Deletes a scheduled event of the guild + Deletes a scheduled event of the guild. :param event_id: The id of the event to delete :type event_id: int @@ -1081,7 +1101,7 @@ async def delete_scheduled_event(self, event_id: int) -> None: async def get_all_channels(self) -> List[Channel]: """ - Gets all channels of the guild as list + Gets all channels of the guild as list. :return: The channels of the guild. :rtype: List[Channel] @@ -1094,7 +1114,7 @@ async def get_all_channels(self) -> List[Channel]: async def get_all_roles(self) -> List[Role]: """ - Gets all roles of the guild as list + Gets all roles of the guild as list. :return: The roles of the guild. :rtype: List[Role] @@ -1112,7 +1132,7 @@ async def modify_role_position( reason: Optional[str] = None, ) -> List[Role]: """ - Modifies the position of a role in the guild + Modifies the position of a role in the guild. :param role_id: The id of the role to modify the position of :type role_id: Union[Role, int] @@ -1134,7 +1154,7 @@ async def modify_role_position( async def get_bans(self) -> List[dict]: """ - Gets a list of banned users + Gets a list of banned users. :return: List of banned users with reasons :rtype: List[dict] diff --git a/interactions/api/models/member.py b/interactions/api/models/member.py index 8f48be6c6..4837ab04d 100644 --- a/interactions/api/models/member.py +++ b/interactions/api/models/member.py @@ -77,7 +77,8 @@ async def ban( delete_message_days: Optional[int] = 0, ) -> None: """ - Bans the member from a guild + Bans the member from a guild. + :param guild_id: The id of the guild to ban the member from :type guild_id: int :param reason?: The reason of the ban @@ -98,7 +99,8 @@ async def kick( reason: Optional[str] = None, ) -> None: """ - Kicks the member from a guild + Kicks the member from a guild. + :param guild_id: The id of the guild to kick the member from :type guild_id: int :param reason?: The reason for the kick @@ -119,7 +121,8 @@ async def add_role( reason: Optional[str], ) -> None: """ - This method adds a role to a member + This method adds a role to a member. + :param role: The role to add. Either ``Role`` object or role_id :type role: Union[Role, int] :param guild_id: The id of the guild to add the roles to the member @@ -151,7 +154,8 @@ async def remove_role( reason: Optional[str], ) -> None: """ - This method removes a role from a member + This method removes a role from a member. + :param role: The role to remove. Either ``Role`` object or role_id :type role: Union[Role, int] :param guild_id: The id of the guild to remove the roles of the member @@ -187,7 +191,7 @@ async def send( allowed_mentions=None, ): """ - Sends a DM to the member + Sends a DM to the member. :param content?: The contents of the message as a string or string-converted value. :type content: Optional[str] diff --git a/interactions/api/models/message.py b/interactions/api/models/message.py index 1faa68e60..5a69a5989 100644 --- a/interactions/api/models/message.py +++ b/interactions/api/models/message.py @@ -282,7 +282,8 @@ def __init__(self, **kwargs): async def get_channel(self) -> Channel: """ - Gets the channel where the message was sent + Gets the channel where the message was sent. + :rtype: Channel """ if not self._client: @@ -292,7 +293,8 @@ async def get_channel(self) -> Channel: async def get_guild(self): """ - Gets the guild where the message was sent + Gets the guild where the message was sent. + :rtype: Guild """ if not self._client: @@ -305,6 +307,7 @@ async def get_guild(self): async def delete(self, reason: Optional[str] = None) -> None: """ Deletes the message. + :param reason: Optional reason to show up in the audit log. Defaults to `None`. :type reason: Optional[str] """ diff --git a/interactions/api/models/role.py b/interactions/api/models/role.py index 2a44d7cb0..61f4cc933 100644 --- a/interactions/api/models/role.py +++ b/interactions/api/models/role.py @@ -69,7 +69,8 @@ async def delete( reason: Optional[str] = None, ) -> None: """ - Deletes the role from the guild + Deletes the role from the guild. + :param guild_id: The id of the guild to delete the role from :type guild_id: int :param reason: The reason for the deletion @@ -94,7 +95,8 @@ async def modify( reason: Optional[str] = None, ) -> "Role": """ - Edits the role in a guild + Edits the role in a guild. + :param guild_id: The id of the guild to edit the role on :type guild_id: int :param name?: The name of the role, defaults to the current value of the role @@ -134,7 +136,7 @@ async def modify_position( reason: Optional[str] = None, ) -> List["Role"]: """ - Modifies the position of a role in the guild + Modifies the position of a role in the guild. :param guild_id: The id of the guild to modify the role position on :type guild_id: int From 7cdd8d74c0386c2ed424f9bdd5b69ba6345dd35c Mon Sep 17 00:00:00 2001 From: James Walston Date: Sat, 22 Jan 2022 15:41:42 -0500 Subject: [PATCH 096/105] refactor(http): implement yet again another rate limiter. --- interactions/api/http.py | 169 ++++++++++++++++++++++++--------------- interactions/client.py | 1 - simple_bot.py | 5 ++ 3 files changed, 108 insertions(+), 67 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index 174bb4e78..acacdcf30 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -1,8 +1,7 @@ -from asyncio import AbstractEventLoop, get_event_loop +from asyncio import AbstractEventLoop, Lock, get_event_loop, get_running_loop from json import dumps from logging import Logger, getLogger from sys import version_info -from threading import Event from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union from urllib.parse import quote @@ -10,6 +9,7 @@ from aiohttp import __version__ as http_version import interactions.api.cache +from interactions.models.misc import MISSING from ..api.cache import Cache, Item from ..api.error import HTTPException @@ -81,16 +81,48 @@ def bucket(self) -> str: return f"{self.channel_id}:{self.guild_id}:{self.path}" +class Limiter: + """ + A class representing a limitation for an HTTP request. + + :ivar Lock lock: The "lock" or controller of the request. + :ivar List[str] hashes: The known hashes of the request. + :ivar float reset_after: The remaining time before the request can be ran. + """ + + lock: Lock + hashes: List[str] + reset_after: float + + def __init__(self, *, lock: Lock, reset_after: Optional[float] = MISSING) -> None: + """ + :param lock: The asynchronous lock to control limits for. + :type lock: Lock + :param reset_after: The remaining time to run the limited lock on. Defaults to ``0``. + :type reset_after: Optional[float] + """ + self.lock = lock + self.reset_after = 0 if reset_after is MISSING else reset_after + self.hashes = [] + + async def __aenter__(self) -> "Limiter": + await self.lock.acquire() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + return self.lock.release() + + class Request: """ A class representing how HTTP requests are sent/read. :ivar str token: The current application token. - :ivar AbstractEventLoop loop: The current coroutine event loop. - :ivar dict ratelimits: The current ratelimits from the Discord API. - :ivar dict headers: The current headers for an HTTP request. - :ivar ClientSession session: The current session for making requests. - :ivar Event lock: The ratelimit lock event. + :ivar AbstractEventLoop _loop: The current coroutine event loop. + :ivar Dict[Route, Limiter] ratelimits: The current per-route rate limiters from the API. + :ivar dict _headers: The current headers for an HTTP request. + :ivar ClientSession _session: The current session for making requests. + :ivar Limiter _global_lock: The global rate limiter. """ __slots__ = ( @@ -100,15 +132,13 @@ class Request: "_headers", "_session", "_global_lock", - "_global_remaining", ) token: str _loop: AbstractEventLoop - ratelimits: dict + ratelimits: Dict[Route, Limiter] _headers: dict _session: ClientSession - _global_lock: Event - _global_remaining: float + _global_lock: Limiter def __init__(self, token: str) -> None: """ @@ -116,7 +146,7 @@ def __init__(self, token: str) -> None: :type token: str """ self.token = token - self._loop = get_event_loop() + self._loop = get_event_loop() if version_info < (3, 10) else get_running_loop() self.ratelimits = {} self._headers = { "Authorization": f"Bot {self.token}", @@ -125,8 +155,7 @@ def __init__(self, token: str) -> None: f"aiohttp/{http_version}", } self._session = _session - self._global_lock = Event() - self._global_remaining = 0 + self._global_lock = Limiter(lock=Lock(loop=self._loop)) def _check_session(self) -> None: """Ensures that we have a valid connection session.""" @@ -135,10 +164,10 @@ def _check_session(self) -> None: async def _check_lock(self) -> None: """Checks the global lock for its current state.""" - if self._global_lock.is_set(): + if self._global_lock.lock.locked(): log.warning("The HTTP client is still globally locked, waiting for it to clear.") - self._global_lock.wait(self._global_remaining) - self._global_lock.clear() + await self._global_lock.lock.acquire() + self._global_lock.reset_after = 0 async def request(self, route: Route, **kwargs) -> Optional[Any]: r""" @@ -153,61 +182,69 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: """ self._check_session() await self._check_lock() - bucket: str = route.bucket - ratelimit: Event = self.ratelimits.get(bucket) - if ratelimit is None: - self.ratelimits[bucket] = {"lock": Event(), "remaining": 0} + # This is the per-route check. We check BEFORE the request is made + # to see if there's a rate limit for it. If there is, we'll call this + # later in the event loop and reset the remaining time. Otherwise, + # we'll set a "limiter" for it respective to that bucket. The hashes will + # be checked later. + if self.ratelimits.get(route): + bucket: Limiter = self.ratelimits.get(route) + if bucket.lock.locked(): + log.warning( + f"The current bucket is still under a rate limit. Calling later in {bucket.reset_after} seconds." + ) + self._loop.call_later(bucket.reset_after, bucket.lock) + await bucket.lock.acquire() + bucket.reset_after = 0 else: - if ratelimit.is_set(): - log.warning("The requested HTTP endpoint is still locked, waiting for it to clear.") - ratelimit["lock"].wait(ratelimit["reset_after"]) - ratelimit["lock"].clear() - - kwargs["headers"] = {**self._headers, **kwargs.get("headers", {})} - kwargs["headers"]["Content-Type"] = "application/json" - - reason = kwargs.pop("reason", None) - if reason: - kwargs["headers"]["X-Audit-Log-Reason"] = quote(reason, safe="/ ") - - async with self._session.request( - route.method, route.__api__ + route.path, **kwargs - ) as response: - data = await response.json(content_type=None) - reset_after: str = response.headers.get("X-Ratelimit-Reset-After") - remaining: str = response.headers.get("X-Ratelimit-Remaining") - bucket: str = response.headers.get("X-Ratelimit-Bucket") - is_global: bool = ( - True - if response.headers.get("X-Ratelimit-Global") or bool(data.get("global")) - else False - ) - - log.debug(f"{route.method}: {route.__api__ + route.path}: {kwargs}") - log.debug(f"RETURN {response.status}: {dumps(data, indent=4, sort_keys=True)}") - - if data.get("errors"): - raise HTTPException(data["code"], message=data["message"]) - elif remaining and not int(remaining): - if response.status != 429: - if bucket: + self.ratelimits.update({route: Limiter(lock=Lock(loop=self._loop))}) + + # We're controlling our HTTP request with the route as its own + # separate lock here. This way, we can control the request of the + # route as an asynchronous method. This way, if the event loop is to call on this later, + # this will temporarily block but still allow to process the original request + # we wanted to make. + async with self.ratelimits.get(route) as _lock: + kwargs["headers"] = {**self._headers, **kwargs.get("headers", {})} + kwargs["headers"]["Content-Type"] = "application/json" + + reason = kwargs.pop("reason", None) + if reason: + kwargs["headers"]["X-Audit-Log-Reason"] = quote(reason, safe="/ ") + + async with self._session.request( + route.method, route.__api__ + route.path, **kwargs + ) as response: + data = await response.json(content_type=None) + reset_after: str = response.headers.get("X-RateLimit-Reset-After") + remaining: str = response.headers.get("X-RateLimit-Remaining") + bucket: str = response.headers.get("X-RateLimit-Bucket") + is_global: bool = response.headers.get("X-RateLimit-Global", False) + + log.debug(f"{route.method}: {route.__api__ + route.path}: {kwargs}") + log.debug(f"RETURN {response.status}: {dumps(data, indent=4, sort_keys=True)}") + + if bucket not in _lock.hashes: + _lock.hashes.append(bucket) + + if isinstance(data, dict) and data.get("errors"): + raise HTTPException(data["code"], message=data["message"]) + elif remaining and not int(remaining): + if response.status == 429: log.warning( - f"The requested HTTP endpoint is currently ratelimited. Waiting for {reset_after} seconds." + f"The HTTP client has encountered a per-route ratelimit. Locking down future requests for {reset_after} seconds." ) - self.ratelimits[bucket].wait(float(reset_after)) - else: + _lock.reset_after = reset_after + self._loop.call_later(_lock.reset_after, _lock.lock) + elif is_global: log.warning( - f"The HTTP client has reached the maximum amount of requests. Cooling down for {reset_after} seconds." + f"The HTTP client has encountered a global ratelimit. Locking down future requests for {reset_after} seconds." ) - self._global_lock.wait(float(reset_after)) - elif is_global: - log.warning( - f"The HTTP client has encountered a global ratelimit. Locking down future requests for {reset_after} seconds." - ) - self._global_lock.wait(float(reset_after)) - - return data + self._global_lock.reset_after = reset_after + self._loop.call_later(self._global_lock.reset_after, self._globl_lock.lock) + + return data async def close(self) -> None: """Closes the current session.""" diff --git a/interactions/client.py b/interactions/client.py index d44a65266..b1dedf9ac 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -103,7 +103,6 @@ def __register_events(self) -> None: self._websocket.dispatch.register(self.__raw_socket_create) self._websocket.dispatch.register(self.__raw_channel_create, "on_channel_create") self._websocket.dispatch.register(self.__raw_message_create, "on_message_create") - self._websocket.dispatch.register(self.__raw_message_create, "on_message_update") self._websocket.dispatch.register(self.__raw_guild_create, "on_guild_create") async def __compare_sync(self, data: dict, pool: List[dict]) -> bool: diff --git a/simple_bot.py b/simple_bot.py index 20d883b7e..fe31a0287 100644 --- a/simple_bot.py +++ b/simple_bot.py @@ -13,6 +13,11 @@ async def on_ready(): print("bot is now online.") +@bot.event +async def on_message_create(message: interactions.Message): + await bot._http.send_message(channel_id=852402668294766615, content=message.content) + + @bot.command( type=interactions.ApplicationCommandType.MESSAGE, name="simple testing command", From b51983d3beb775df65bc9f6ecf36bac3dfd25335 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Sat, 22 Jan 2022 18:37:04 -0500 Subject: [PATCH 097/105] fix!: Fix @client.event invocation and choice iteration if not given. --- interactions/client.py | 4 ++-- interactions/models/command.py | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/interactions/client.py b/interactions/client.py index b1dedf9ac..b1b4a28bf 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -278,12 +278,12 @@ def event(self, coro: Coroutine, name: Optional[str] = MISSING) -> Callable[..., :param coro: The coroutine of the event. :type coro: Coroutine - :param name(?): The name of the event. + :param name(?): The name of the event. If not given, this defaults to the coroutine's name. :type name: Optional[str] :return: A callable response. :rtype: Callable[..., Any] """ - self._websocket.dispatch.register(coro, name) + self._websocket.dispatch.register(coro, name if name is not MISSING else coro.__name__) return coro def command( diff --git a/interactions/models/command.py b/interactions/models/command.py index a19235c86..bec5d4afd 100644 --- a/interactions/models/command.py +++ b/interactions/models/command.py @@ -114,13 +114,15 @@ def __init__(self, **kwargs) -> None: self._json["options"] = [ option if isinstance(option, dict) else option._json for option in self.options ] - if all(isinstance(choice, dict) for choice in self.choices): - if isinstance(self._json.get("choices"), dict): - self._json["choices"] = list(self.choices) - else: - self._json["choices"] = [ - choice if isinstance(choice, dict) else choice._json for choice in self.choices - ] + if self.choices: + if all(isinstance(choice, dict) for choice in self.choices): + if isinstance(self._json.get("choices"), dict): + self._json["choices"] = list(self.choices) + else: + self._json["choices"] = [ + choice if isinstance(choice, dict) else choice._json + for choice in self.choices + ] class Permission(DictSerializerMixin): From c7e68a865db679ee80cbe6717b0feddbae8f1673 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Sat, 22 Jan 2022 21:34:13 -0500 Subject: [PATCH 098/105] feat(http)!: Implement sane rate limiting locks, retries, and account for errors, including refactored logic. --- interactions/api/http.py | 163 +++++++++++++++++++++++++-------------- 1 file changed, 104 insertions(+), 59 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index c2f1e81b6..e4af74bb4 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -1,3 +1,5 @@ +import asyncio +import traceback from asyncio import AbstractEventLoop, Lock, get_event_loop, get_running_loop from json import dumps from logging import Logger, getLogger @@ -80,6 +82,19 @@ def bucket(self) -> str: """ return f"{self.channel_id}:{self.guild_id}:{self.path}" + @property + def hashbucket(self) -> str: + """ + Returns the route's full bucket, reproducible for paeudo-hashing. + This contains both bucket properties, but also the METHOD attribute. + Note, that this does NOT contain the hash. + + :return: The route bucket. + :rtype: str + """ + + return f"{self.method}::{self.bucket}" + class Limiter: """ @@ -135,7 +150,7 @@ class Request: ) token: str _loop: AbstractEventLoop - ratelimits: Dict[Route, Limiter] + ratelimits: Dict[str, Limiter] # hashbucket: Limiter _headers: dict _session: ClientSession _global_lock: Limiter @@ -180,71 +195,101 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: :return: The contents of the request if any. :rtype: Optional[Any] """ - self._check_session() - await self._check_lock() - # This is the per-route check. We check BEFORE the request is made - # to see if there's a rate limit for it. If there is, we'll call this - # later in the event loop and reset the remaining time. Otherwise, - # we'll set a "limiter" for it respective to that bucket. The hashes will - # be checked later. - if self.ratelimits.get(route): - bucket: Limiter = self.ratelimits.get(route) + kwargs["headers"] = {**self._headers, **kwargs.get("headers", {})} + kwargs["headers"]["Content-Type"] = "application/json" + + reason = kwargs.pop("reason", None) + if reason: + kwargs["headers"]["X-Audit-Log-Reason"] = quote(reason, safe="/ ") + + # Huge credit and thanks to LordOfPolls for the lock/retry logic. + + # This section generates the bucket through the hashbucket attr, + # which essentially contains path, method, and major params. + + if self.ratelimits.get(route.hashbucket): + bucket: Limiter = self.ratelimits.get(route.hashbucket) if bucket.lock.locked(): log.warning( f"The current bucket is still under a rate limit. Calling later in {bucket.reset_after} seconds." ) - self._loop.call_later(bucket.reset_after, bucket.lock) - await bucket.lock.acquire() + self._loop.call_later(bucket.reset_after, bucket.lock.release) bucket.reset_after = 0 else: - self.ratelimits.update({route: Limiter(lock=Lock(loop=self._loop))}) - - # We're controlling our HTTP request with the route as its own - # separate lock here. This way, we can control the request of the - # route as an asynchronous method. This way, if the event loop is to call on this later, - # this will temporarily block but still allow to process the original request - # we wanted to make. - async with self.ratelimits.get(route) as _lock: - kwargs["headers"] = {**self._headers, **kwargs.get("headers", {})} - kwargs["headers"]["Content-Type"] = "application/json" - - reason = kwargs.pop("reason", None) - if reason: - kwargs["headers"]["X-Audit-Log-Reason"] = quote(reason, safe="/ ") - - async with self._session.request( - route.method, route.__api__ + route.path, **kwargs - ) as response: - data = await response.json(content_type=None) - reset_after: str = response.headers.get("X-RateLimit-Reset-After") - remaining: str = response.headers.get("X-RateLimit-Remaining") - bucket: str = response.headers.get("X-RateLimit-Bucket") - is_global: bool = response.headers.get("X-RateLimit-Global", False) - - log.debug(f"{route.method}: {route.__api__ + route.path}: {kwargs}") - log.debug(f"RETURN {response.status}: {dumps(data, indent=4, sort_keys=True)}") - - if bucket not in _lock.hashes: - _lock.hashes.append(bucket) - - if isinstance(data, dict) and data.get("errors"): - raise HTTPException(data["code"], message=data["message"]) - elif remaining and not int(remaining): - if response.status == 429: - log.warning( - f"The HTTP client has encountered a per-route ratelimit. Locking down future requests for {reset_after} seconds." - ) - _lock.reset_after = reset_after - self._loop.call_later(_lock.reset_after, _lock.lock) - elif is_global: - log.warning( - f"The HTTP client has encountered a global ratelimit. Locking down future requests for {reset_after} seconds." - ) - self._global_lock.reset_after = reset_after - self._loop.call_later(self._global_lock.reset_after, self._globl_lock.lock) - - return data + self.ratelimits.update({route.hashbucket: Limiter(lock=Lock(loop=self._loop))}) + bucket: Limiter = self.ratelimits.get(route.hashbucket) + + await bucket.lock.acquire() + + # Implement retry logic. The common seems to be 5, so this is hardcoded, for the most part. + + for tries in range(5): # 3, 5? 5 seems to be common + try: + self._check_session() + await self._check_lock() + + async with self._session.request( + route.method, route.__api__ + route.path, **kwargs + ) as response: + + data = await response.json(content_type=None) + reset_after: float = float( + response.headers.get("X-RateLimit-Reset-After", "0.0") + ) + remaining: str = response.headers.get("X-RateLimit-Remaining") + _bucket: str = response.headers.get("X-RateLimit-Bucket") + is_global: bool = response.headers.get("X-RateLimit-Global", False) + + log.debug(f"{route.method}: {route.__api__ + route.path}: {kwargs}") + + if _bucket not in bucket.hashes: + bucket.hashes.append(_bucket) + + if isinstance(data, dict) and data.get("errors"): + raise HTTPException(data["code"], message=data["message"]) + elif remaining and not int(remaining): + if response.status == 429: + log.warning( + f"The HTTP client has encountered a per-route ratelimit. Locking down future requests for {reset_after} seconds." + ) + bucket.reset_after = reset_after + await asyncio.sleep(bucket.reset_after) + continue + elif is_global: + log.warning( + f"The HTTP client has encountered a global ratelimit. Locking down future requests for {reset_after} seconds." + ) + self._global_lock.reset_after = reset_after + self._loop.call_later( + self._global_lock.reset_after, self._global_lock.lock.release + ) + + log.debug(f"RETURN {response.status}: {dumps(data, indent=4, sort_keys=True)}") + return data + + # These account for general/specific exceptions. (Windows...) + except OSError as e: + if tries < 4 and e.errno in (54, 10054): + await asyncio.sleep(2 * tries + 1) + continue + try: + bucket.lock.release() + except RuntimeError: + pass + raise + + # For generic exceptions we give a traceback for debug reasons. + except Exception as e: + try: + bucket.lock.release() + except RuntimeError: + pass + log.error("".join(traceback.format_exception(type(e), e, e.__traceback__))) + break + + if bucket.lock.locked(): + bucket.lock.release() async def close(self) -> None: """Closes the current session.""" From 9568b6a0df983cdcfdcc55f19c9dbb2ea0742d5b Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Sun, 23 Jan 2022 20:56:41 -0500 Subject: [PATCH 099/105] fix: Implement missing attributes for Application model. --- interactions/api/models/team.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/interactions/api/models/team.py b/interactions/api/models/team.py index 34c3fa526..64c228d1a 100644 --- a/interactions/api/models/team.py +++ b/interactions/api/models/team.py @@ -101,6 +101,8 @@ class Application(DictSerializerMixin): "type", "hook", "tags", # TODO: document/investigate what it does. + "install_params", + "custom_install_url", ) def __init__(self, **kwargs): From 145d3c85a5c2fe9bf64b23f0d201ada783f7d120 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Mon, 24 Jan 2022 12:41:19 -0500 Subject: [PATCH 100/105] docs: Fix typehint for ratelimit dict. Also add back the response on error. --- interactions/api/http.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index e4af74bb4..05f685f55 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -134,7 +134,7 @@ class Request: :ivar str token: The current application token. :ivar AbstractEventLoop _loop: The current coroutine event loop. - :ivar Dict[Route, Limiter] ratelimits: The current per-route rate limiters from the API. + :ivar Dict[str, Limiter] ratelimits: The current per-route rate limiters from the API. :ivar dict _headers: The current headers for an HTTP request. :ivar ClientSession _session: The current session for making requests. :ivar Limiter _global_lock: The global rate limiter. @@ -247,6 +247,11 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: bucket.hashes.append(_bucket) if isinstance(data, dict) and data.get("errors"): + log.debug( + f"RETURN {response.status}: {dumps(data, indent=4, sort_keys=True)}" + ) + # This "redundant" debug line is for debug use and tracing back the error codes. + raise HTTPException(data["code"], message=data["message"]) elif remaining and not int(remaining): if response.status == 429: From f0f857b158dae713c04f9c038485da2df8d9c4a0 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Mon, 24 Jan 2022 13:32:10 -0500 Subject: [PATCH 101/105] feat(http): Utilise per-bucket rate-limiting/locking. --- interactions/api/http.py | 77 ++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index 05f685f55..9ff4ce519 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -72,28 +72,32 @@ def __init__(self, method: str, path: str, **kwargs) -> None: self.channel_id = kwargs.get("channel_id") self.guild_id = kwargs.get("guild_id") - @property - def bucket(self) -> str: + def get_bucket(self, shared_bucket: Optional[str] = None) -> str: """ - Returns the route's bucket. + Returns the route's bucket. If shared_bucket is None, returns the path with major parameters. + Otherwise, it relies on Discord's given bucket. + + :param shared_bucket: The bucket that Discord provides, if available. + :type shared_bucket: Optional[str] :return: The route bucket. :rtype: str """ - return f"{self.channel_id}:{self.guild_id}:{self.path}" + return ( + f"{self.channel_id}:{self.guild_id}:{self.path}" + if shared_bucket is None + else f"{self.channel_id}:{self.guild_id}:{shared_bucket}" + ) @property - def hashbucket(self) -> str: + def endpoint(self) -> str: """ - Returns the route's full bucket, reproducible for paeudo-hashing. - This contains both bucket properties, but also the METHOD attribute. - Note, that this does NOT contain the hash. + Returns the route's endpoint. - :return: The route bucket. + :return: The route endpoint. :rtype: str """ - - return f"{self.method}::{self.bucket}" + return f"{self.method}:{self.path}" class Limiter: @@ -101,12 +105,10 @@ class Limiter: A class representing a limitation for an HTTP request. :ivar Lock lock: The "lock" or controller of the request. - :ivar List[str] hashes: The known hashes of the request. :ivar float reset_after: The remaining time before the request can be ran. """ lock: Lock - hashes: List[str] reset_after: float def __init__(self, *, lock: Lock, reset_after: Optional[float] = MISSING) -> None: @@ -118,7 +120,6 @@ def __init__(self, *, lock: Lock, reset_after: Optional[float] = MISSING) -> Non """ self.lock = lock self.reset_after = 0 if reset_after is MISSING else reset_after - self.hashes = [] async def __aenter__(self) -> "Limiter": await self.lock.acquire() @@ -135,6 +136,7 @@ class Request: :ivar str token: The current application token. :ivar AbstractEventLoop _loop: The current coroutine event loop. :ivar Dict[str, Limiter] ratelimits: The current per-route rate limiters from the API. + :ivar Dict[str, str] buckets: The current endpoint to shared_bucket cache from the API. :ivar dict _headers: The current headers for an HTTP request. :ivar ClientSession _session: The current session for making requests. :ivar Limiter _global_lock: The global rate limiter. @@ -144,13 +146,15 @@ class Request: "token", "_loop", "ratelimits", + "buckets", "_headers", "_session", "_global_lock", ) token: str _loop: AbstractEventLoop - ratelimits: Dict[str, Limiter] # hashbucket: Limiter + ratelimits: Dict[str, Limiter] # bucket: Limiter + buckets: Dict[str, str] # endpoint: shared_bucket _headers: dict _session: ClientSession _global_lock: Limiter @@ -163,6 +167,7 @@ def __init__(self, token: str) -> None: self.token = token self._loop = get_event_loop() if version_info < (3, 10) else get_running_loop() self.ratelimits = {} + self.buckets = {} self._headers = { "Authorization": f"Bot {self.token}", "User-Agent": f"DiscordBot (https://github.com/goverfl0w/interactions.py {__version__} " @@ -205,22 +210,25 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: # Huge credit and thanks to LordOfPolls for the lock/retry logic. - # This section generates the bucket through the hashbucket attr, - # which essentially contains path, method, and major params. + bucket = route.get_bucket( + self.buckets.get(route.endpoint) + ) # string returning path OR prioritised hash bucket metadata. + + # The idea is that its regulated by the priority of Discord's bucket header and not just self-computation. - if self.ratelimits.get(route.hashbucket): - bucket: Limiter = self.ratelimits.get(route.hashbucket) - if bucket.lock.locked(): + if self.ratelimits.get(bucket): + _limiter: Limiter = self.ratelimits.get(bucket) + if _limiter.lock.locked(): log.warning( - f"The current bucket is still under a rate limit. Calling later in {bucket.reset_after} seconds." + f"The current bucket is still under a rate limit. Calling later in {_limiter.reset_after} seconds." ) - self._loop.call_later(bucket.reset_after, bucket.lock.release) - bucket.reset_after = 0 + self._loop.call_later(_limiter.reset_after, _limiter.lock.release) + _limiter.reset_after = 0 else: - self.ratelimits.update({route.hashbucket: Limiter(lock=Lock(loop=self._loop))}) - bucket: Limiter = self.ratelimits.get(route.hashbucket) + self.ratelimits.update({bucket: Limiter(lock=Lock(loop=self._loop))}) + _limiter: Limiter = self.ratelimits.get(bucket) - await bucket.lock.acquire() + await _limiter.lock.acquire() # _limiter is the per shared bucket/route endpoint # Implement retry logic. The common seems to be 5, so this is hardcoded, for the most part. @@ -243,8 +251,9 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: log.debug(f"{route.method}: {route.__api__ + route.path}: {kwargs}") - if _bucket not in bucket.hashes: - bucket.hashes.append(_bucket) + if _bucket is not None: + self.buckets[route.endpoint] = _bucket + # real-time replacement/update/add if needed. if isinstance(data, dict) and data.get("errors"): log.debug( @@ -258,8 +267,8 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: log.warning( f"The HTTP client has encountered a per-route ratelimit. Locking down future requests for {reset_after} seconds." ) - bucket.reset_after = reset_after - await asyncio.sleep(bucket.reset_after) + _limiter.reset_after = reset_after + await asyncio.sleep(_limiter.reset_after) continue elif is_global: log.warning( @@ -279,7 +288,7 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: await asyncio.sleep(2 * tries + 1) continue try: - bucket.lock.release() + _limiter.lock.release() except RuntimeError: pass raise @@ -287,14 +296,14 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: # For generic exceptions we give a traceback for debug reasons. except Exception as e: try: - bucket.lock.release() + _limiter.lock.release() except RuntimeError: pass log.error("".join(traceback.format_exception(type(e), e, e.__traceback__))) break - if bucket.lock.locked(): - bucket.lock.release() + if _limiter.lock.locked(): + _limiter.lock.release() async def close(self) -> None: """Closes the current session.""" From ca93602665fb6ad9f12b09001968993653f1015f Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Mon, 24 Jan 2022 14:32:23 -0500 Subject: [PATCH 102/105] fix: Suppress bucket cooldown warnings when cooldown is zero. --- interactions/api/http.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index 9ff4ce519..b5da15444 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -219,9 +219,12 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: if self.ratelimits.get(bucket): _limiter: Limiter = self.ratelimits.get(bucket) if _limiter.lock.locked(): - log.warning( - f"The current bucket is still under a rate limit. Calling later in {_limiter.reset_after} seconds." - ) + if ( + _limiter.reset_after != 0 + ): # Just saying 0 seconds isn't helpful, so this is suppressed. + log.warning( + f"The current bucket is still under a rate limit. Calling later in {_limiter.reset_after} seconds." + ) self._loop.call_later(_limiter.reset_after, _limiter.lock.release) _limiter.reset_after = 0 else: From f0e4d77a273da88b370c3360b10deaf428ee7cfc Mon Sep 17 00:00:00 2001 From: Ventus <29584664+V3ntus@users.noreply.github.com> Date: Mon, 24 Jan 2022 13:40:32 -0600 Subject: [PATCH 103/105] refactor: Logger parse structure (#431) * fix: set default logging handlers to NullHandlers * fix: replace log instancing with new handler funct * ref: pre-commit hook * fix: pass errors and higher by default * fix: add docstring and fix typo * fix: reference conflicts with formatter * pre-commit: fix complaint from isort --- interactions/api/dispatch.py | 6 +++-- interactions/api/gateway.py | 5 ++-- interactions/api/http.py | 6 ++--- interactions/api/models/misc.py | 6 +++-- interactions/base.py | 41 +++++++++++++++++++++++++-------- interactions/client.py | 5 ++-- interactions/context.py | 5 ++-- 7 files changed, 52 insertions(+), 22 deletions(-) diff --git a/interactions/api/dispatch.py b/interactions/api/dispatch.py index bc4aa31ee..e15c11bf8 100644 --- a/interactions/api/dispatch.py +++ b/interactions/api/dispatch.py @@ -1,8 +1,10 @@ from asyncio import get_event_loop -from logging import Logger, getLogger +from logging import Logger from typing import Coroutine, Optional -log: Logger = getLogger("dispatch") +from interactions.base import get_logger + +log: Logger = get_logger("dispatch") class Listener: diff --git a/interactions/api/gateway.py b/interactions/api/gateway.py index 0b10929fd..c4d2b2e4d 100644 --- a/interactions/api/gateway.py +++ b/interactions/api/gateway.py @@ -1,7 +1,7 @@ import sys from asyncio import get_event_loop, run_coroutine_threadsafe from json import dumps -from logging import Logger, getLogger +from logging import Logger from random import random from threading import Event, Thread from typing import Any, List, Optional, Union @@ -10,6 +10,7 @@ from orjson import loads from interactions.api.models.gw import Presence +from interactions.base import get_logger from interactions.enums import InteractionType, OptionType from .dispatch import Listener @@ -18,7 +19,7 @@ from .http import HTTPClient from .models.flags import Intents -log: Logger = getLogger("gateway") +log: Logger = get_logger("gateway") __all__ = ("Heartbeat", "WebSocket") diff --git a/interactions/api/http.py b/interactions/api/http.py index b5da15444..26ab63790 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -2,7 +2,7 @@ import traceback from asyncio import AbstractEventLoop, Lock, get_event_loop, get_running_loop from json import dumps -from logging import Logger, getLogger +from logging import Logger from sys import version_info from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union from urllib.parse import quote @@ -11,6 +11,7 @@ from aiohttp import __version__ as http_version import interactions.api.cache +from interactions.base import __version__, get_logger from interactions.models.misc import MISSING from ..api.cache import Cache, Item @@ -31,9 +32,8 @@ User, WelcomeScreen, ) -from ..base import __version__ -log: Logger = getLogger("http") +log: Logger = get_logger("http") __all__ = ("Route", "Request", "HTTPClient") _session: ClientSession = ClientSession() diff --git a/interactions/api/models/misc.py b/interactions/api/models/misc.py index de4d56e82..b555cc2b7 100644 --- a/interactions/api/models/misc.py +++ b/interactions/api/models/misc.py @@ -4,11 +4,13 @@ # TODO: Reorganise mixins to its own thing, currently placed here because circular import sucks. # also, it should be serialiser* but idk, fl0w'd say something if I left it like that. /shrug import datetime -import logging +from logging import Logger from math import floor from typing import Union -log = logging.getLogger("mixin") +from interactions.base import get_logger + +log: Logger = get_logger("mixin") class DictSerializerMixin(object): diff --git a/interactions/base.py b/interactions/base.py index f8191a99d..bae7cd2f0 100644 --- a/interactions/base.py +++ b/interactions/base.py @@ -1,5 +1,5 @@ import logging -from typing import ClassVar +from typing import ClassVar, List, Optional, Union from colorama import Fore, Style, init @@ -18,21 +18,44 @@ class Data: - """A class representing constants for the library.""" + """A class representing constants for the library. - LOGGER: ClassVar[int] = logging.WARNING + :ivar LOG_LEVEL ClassVar[int]: The default level of logging as an integer + :ivar LOGGERS List[str]: A list of all loggers registered from this library + """ + + LOG_LEVEL: ClassVar[int] = logging.ERROR + LOGGERS: List[str] = [] + + +def get_logger( + logger: Optional[Union[logging.Logger, str]] = None, + handler: Optional[logging.Handler] = logging.StreamHandler(), +) -> logging.Logger: + _logger = logging.getLogger(logger) if isinstance(logger, str) else logger + _logger_name = logger if isinstance(logger, str) else logger.name + if len(_logger.handlers) > 1: + _logger.removeHandler(_logger.handlers[0]) + _handler = handler + _handler.setFormatter(CustomFormatter) + _handler.setLevel(Data.LOG_LEVEL) + _logger.addHandler(_handler) + _logger.propagate = True + + Data.LOGGERS.append(_logger_name) + return _logger class CustomFormatter(logging.Formatter): """A class that allows for customized logged outputs from the library.""" - format: str = "%(levelname)s:%(name)s:(ln.%(lineno)d):%(message)s" + format_str: str = "%(levelname)s:%(name)s:(ln.%(lineno)d):%(message)s" formats: dict = { - logging.DEBUG: Fore.CYAN + format + Fore.RESET, - logging.INFO: Fore.GREEN + format + Fore.RESET, - logging.WARNING: Fore.YELLOW + format + Fore.RESET, - logging.ERROR: Fore.RED + format + Fore.RESET, - logging.CRITICAL: Style.BRIGHT + Fore.RED + format + Fore.RESET + Style.NORMAL, + logging.DEBUG: Fore.CYAN + format_str + Fore.RESET, + logging.INFO: Fore.GREEN + format_str + Fore.RESET, + logging.WARNING: Fore.YELLOW + format_str + Fore.RESET, + logging.ERROR: Fore.RED + format_str + Fore.RESET, + logging.CRITICAL: Style.BRIGHT + Fore.RED + format_str + Fore.RESET + Style.NORMAL, } def __init__(self): diff --git a/interactions/client.py b/interactions/client.py index b1b4a28bf..c183e7cb1 100644 --- a/interactions/client.py +++ b/interactions/client.py @@ -2,7 +2,7 @@ from asyncio import get_event_loop from importlib import import_module from importlib.util import resolve_name -from logging import Logger, getLogger +from logging import Logger from typing import Any, Callable, Coroutine, Dict, List, Optional, Union from .api.cache import Cache @@ -15,6 +15,7 @@ from .api.models.guild import Guild from .api.models.misc import Snowflake from .api.models.team import Application +from .base import get_logger from .decor import command from .decor import component as _component from .enums import ApplicationCommandType @@ -22,7 +23,7 @@ from .models.component import Button, Modal, SelectMenu from .models.misc import MISSING -log: Logger = getLogger("client") +log: Logger = get_logger("client") _token: str = "" # noqa _cache: Optional[Cache] = None diff --git a/interactions/context.py b/interactions/context.py index a9595ab56..49c04ce0f 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -1,4 +1,4 @@ -from logging import Logger, getLogger +from logging import Logger from typing import List, Optional, Union from .api.models.channel import Channel @@ -7,12 +7,13 @@ from .api.models.message import Embed, Message, MessageInteraction, MessageReference from .api.models.misc import DictSerializerMixin, Snowflake from .api.models.user import User +from .base import get_logger from .enums import InteractionCallbackType, InteractionType from .models.command import Choice from .models.component import ActionRow, Button, Modal, SelectMenu from .models.misc import InteractionData -log: Logger = getLogger("context") +log: Logger = get_logger("context") class Context(DictSerializerMixin): From ce62812a6ac00be082958e5d52d1c79ea2d21830 Mon Sep 17 00:00:00 2001 From: DeltaXWizard <33706469+deltaxwizard@users.noreply.github.com> Date: Mon, 24 Jan 2022 14:43:10 -0500 Subject: [PATCH 104/105] fix: Add Python 3.10 support to Limiter/Lock objects. --- interactions/api/http.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/interactions/api/http.py b/interactions/api/http.py index 26ab63790..03399a824 100644 --- a/interactions/api/http.py +++ b/interactions/api/http.py @@ -175,7 +175,9 @@ def __init__(self, token: str) -> None: f"aiohttp/{http_version}", } self._session = _session - self._global_lock = Limiter(lock=Lock(loop=self._loop)) + self._global_lock = ( + Limiter(lock=Lock(loop=self._loop)) if version_info < (3, 10) else Limiter(lock=Lock()) + ) def _check_session(self) -> None: """Ensures that we have a valid connection session.""" @@ -228,7 +230,11 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]: self._loop.call_later(_limiter.reset_after, _limiter.lock.release) _limiter.reset_after = 0 else: - self.ratelimits.update({bucket: Limiter(lock=Lock(loop=self._loop))}) + self.ratelimits[bucket] = ( + Limiter(lock=Lock(loop=self._loop)) + if version_info < (3, 10) + else Limiter(lock=Lock()) + ) _limiter: Limiter = self.ratelimits.get(bucket) await _limiter.lock.acquire() # _limiter is the per shared bucket/route endpoint From 50b0f155ea6ab1b7d07bf64b33bd7db27b5f3d5f Mon Sep 17 00:00:00 2001 From: EdVraz <88881326+EdVraz@users.noreply.github.com> Date: Mon, 24 Jan 2022 20:50:26 +0100 Subject: [PATCH 105/105] fix!: allow ctx.edit to edit component responses when not deferred (#436) * fix!: - prevent removal of message contents when editing messages - fix editing components without deferring * Update context.py * style: correct tab usage. * style: correct tab usage. * pre-commit: allow black formatting. Co-authored-by: fl0w <41456914+goverfl0w@users.noreply.github.com> --- interactions/context.py | 215 ++++++++++++++++++++++++---------------- 1 file changed, 128 insertions(+), 87 deletions(-) diff --git a/interactions/context.py b/interactions/context.py index 49c04ce0f..d8f65820e 100644 --- a/interactions/context.py +++ b/interactions/context.py @@ -183,15 +183,29 @@ async def send( :return: The sent message as an object. :rtype: Message """ - _content: str = "" if content is None else content + if ( + content is None + and self.message + and self.callback == InteractionCallbackType.DEFERRED_UPDATE_MESSAGE + ): + _content = self.message.content + else: + _content: str = "" if content is None else content _tts: bool = False if tts is None else tts # _file = None if file is None else file # _attachments = [] if attachments else None - _embeds: list = ( - [] - if embeds is None - else ([embed._json for embed in embeds] if isinstance(embeds, list) else [embeds._json]) - ) + if embeds is None and self.message: + _embeds = self.message.embeds + else: + _embeds: list = ( + [] + if embeds is None + else ( + [embed._json for embed in embeds] + if isinstance(embeds, list) + else [embeds._json] + ) + ) _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions _components: List[dict] = [{"type": 1, "components": []}] @@ -291,6 +305,8 @@ async def send( if components._json.get("custom_id") or components._json.get("url") else [] ) + elif components is None and self.message: + _components = self.message.components else: _components = [] @@ -380,70 +396,37 @@ async def edit( :return: The edited message as an object. :rtype: Message """ - _content: str = "" if content is None else content + _content: str = self.message.content if content is None else content _tts: bool = False if tts is None else tts # _file = None if file is None else file - _embeds: list = ( - [] - if embeds is None - else ([embed._json for embed in embeds] if isinstance(embeds, list) else [embeds._json]) - ) + + if embeds is None: + _embeds = self.message.embeds + else: + _embeds: list = ( + [] + if embeds is None + else ( + [embed._json for embed in embeds] + if isinstance(embeds, list) + else [embeds._json] + ) + ) _allowed_mentions: dict = {} if allowed_mentions is None else allowed_mentions _message_reference: dict = {} if message_reference is None else message_reference._json - _components: list = [{"type": 1, "components": []}] - if ( - isinstance(components, list) - and components - and all(isinstance(action_row, ActionRow) for action_row in components) - ): - _components = [ - { - "type": 1, - "components": [ - ( - component._json - if component._json.get("custom_id") or component._json.get("url") - else [] - ) - for component in action_row.components - ], - } - for action_row in components - ] - elif ( - isinstance(components, list) - and components - and all(isinstance(component, (Button, SelectMenu)) for component in components) - ): - if isinstance(components[0], SelectMenu): - components[0]._json["options"] = [option._json for option in components[0].options] - _components = [ - { - "type": 1, - "components": [ - ( - component._json - if component._json.get("custom_id") or component._json.get("url") - else [] - ) - for component in components - ], - } - ] - elif ( - isinstance(components, list) - and components - and all(isinstance(action_row, (list, ActionRow)) for action_row in components) - ): + if components is None: + _components = self.message.components + elif components == []: _components = [] - for action_row in components: - for component in ( - action_row if isinstance(action_row, list) else action_row.components - ): - if isinstance(component, SelectMenu): - component._json["options"] = [option._json for option in component.options] - _components.append( + else: + _components: list = [{"type": 1, "components": []}] + if ( + isinstance(components, list) + and components + and all(isinstance(action_row, ActionRow) for action_row in components) + ): + _components = [ { "type": 1, "components": [ @@ -452,33 +435,82 @@ async def edit( if component._json.get("custom_id") or component._json.get("url") else [] ) - for component in ( - action_row - if isinstance(action_row, list) - else action_row.components + for component in action_row.components + ], + } + for action_row in components + ] + elif ( + isinstance(components, list) + and components + and all(isinstance(component, (Button, SelectMenu)) for component in components) + ): + if isinstance(components[0], SelectMenu): + components[0]._json["options"] = [ + option._json for option in components[0].options + ] + _components = [ + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] ) + for component in components ], } - ) - elif isinstance(components, ActionRow): - _components[0]["components"] = [ - ( - component._json - if component._json.get("custom_id") or component._json.get("url") + ] + elif ( + isinstance(components, list) + and components + and all(isinstance(action_row, (list, ActionRow)) for action_row in components) + ): + _components = [] + for action_row in components: + for component in ( + action_row if isinstance(action_row, list) else action_row.components + ): + if isinstance(component, SelectMenu): + component._json["options"] = [ + option._json for option in component.options + ] + _components.append( + { + "type": 1, + "components": [ + ( + component._json + if component._json.get("custom_id") + or component._json.get("url") + else [] + ) + for component in ( + action_row + if isinstance(action_row, list) + else action_row.components + ) + ], + } + ) + elif isinstance(components, ActionRow): + _components[0]["components"] = [ + ( + component._json + if component._json.get("custom_id") or component._json.get("url") + else [] + ) + for component in components.components + ] + elif isinstance(components, (Button, SelectMenu)): + _components[0]["components"] = ( + [components._json] + if components._json.get("custom_id") or components._json.get("url") else [] ) - for component in components.components - ] - elif isinstance(components, (Button, SelectMenu)): - _components[0]["components"] = ( - [components._json] - if components._json.get("custom_id") or components._json.get("url") - else [] - ) - elif components is None: - _components = None - else: - _components = [] + else: + _components = [] payload: Message = Message( content=_content, @@ -491,7 +523,16 @@ async def edit( ) async def func(): - if self.deferred: + if not self.deferred and self.type == InteractionType.MESSAGE_COMPONENT: + self.callback = InteractionCallbackType.UPDATE_MESSAGE + await self.client.create_interaction_response( + data={"type": self.callback.value, "data": payload._json}, + token=self.token, + application_id=int(self.id), + ) + self.message = payload + self.responded = True + elif self.deferred: if ( self.type == InteractionType.MESSAGE_COMPONENT and self.callback != InteractionCallbackType.DEFERRED_UPDATE_MESSAGE