Skip to content

Commit 8d337b5

Browse files
artem30801hpenney2LordOfPolls
authored
Add components sending and handling support (#200)
* Added components support * Added manage_components to init * Added default emoji support to buttons * Bump version * Fixed wait_for_any_component, added method to edit component message as part of interaction * Creating unspecified custom_id for buttons as str of uuid * Added select and select-option generation functions * Updated button generation code * Fixed processing component context from ephemeral message * Add/edit docs for new stuff + code edits * Better wait_for_component and wait_for_any_component parameters * Fix docs for origin_message_id * Add cooldown and max conc support ++ Fixes kwarg issue in invoke * add error decorator support * add cog support for error dec * Fix typo * Updated project description and config * Fix typing syntax * Updated readme * Reverted readme and docs attribution changes Co-authored-by: hpenney2 <hpenney1010@gmail.com> Co-authored-by: LordOfPolls <ddavidallen13@gmail.com>
1 parent d6670e4 commit 8d337b5

File tree

11 files changed

+767
-117
lines changed

11 files changed

+767
-117
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,4 @@ This library is based on gateway event. If you are looking for webserver based,
8383
[dispike](https://github.com/ms7m/dispike)
8484
[discord-interactions-python](https://github.com/discord/discord-interactions-python)
8585
Or for other languages:
86-
[discord-api-docs Community Resources: Interactions](https://discord.com/developers/docs/topics/community-resources#interactions)
86+
[discord-api-docs Community Resources: Interactions](https://discord.com/developers/docs/topics/community-resources#interactions)

discord_slash/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from .client import SlashCommand
1212
from .model import SlashCommandOptionType
1313
from .context import SlashContext
14+
from .context import ComponentContext
15+
from .dpy_overrides import ComponentMessage
1416
from .utils import manage_commands
17+
from .utils import manage_components
1518

1619
__version__ = "1.2.2"

discord_slash/client.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from . import model
1010
from . import error
1111
from . import context
12+
from . import dpy_overrides
1213
from .utils import manage_commands
1314

1415

@@ -869,8 +870,21 @@ async def invoke_command(self, func, ctx, args):
869870
:param args: Args. Can be list or dict.
870871
"""
871872
try:
872-
await func.invoke(ctx, args)
873+
if isinstance(args, dict):
874+
await func.invoke(ctx, **args)
875+
else:
876+
await func.invoke(ctx, *args)
873877
except Exception as ex:
878+
if hasattr(func, "on_error"):
879+
if func.on_error is not None:
880+
try:
881+
if hasattr(func, "cog"):
882+
await func.on_error(func.cog, ctx, ex)
883+
else:
884+
await func.on_error(ctx, ex)
885+
return
886+
except Exception as e:
887+
self.logger.error(f"{ctx.command}:: Error using error decorator: {e}")
874888
await self.on_slash_command_error(ctx, ex)
875889

876890
async def on_socket_response(self, msg):
@@ -886,10 +900,19 @@ async def on_socket_response(self, msg):
886900
return
887901

888902
to_use = msg["d"]
903+
interaction_type = to_use["type"]
904+
if interaction_type in (1, 2):
905+
return await self._on_slash(to_use)
906+
if interaction_type == 3:
907+
return await self._on_component(to_use)
908+
909+
raise NotImplementedError
889910

890-
if to_use["type"] not in (1, 2):
891-
return # to only process ack and slash-commands and exclude other interactions like buttons
911+
async def _on_component(self, to_use):
912+
ctx = context.ComponentContext(self.req, to_use, self._discord, self.logger)
913+
self._discord.dispatch("component", ctx)
892914

915+
async def _on_slash(self, to_use):
893916
if to_use["data"]["name"] in self.commands:
894917

895918
ctx = context.SlashContext(self.req, to_use, self._discord, self.logger)

discord_slash/context.py

Lines changed: 153 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,29 @@
1+
import datetime
12
import typing
23
import asyncio
34
from warnings import warn
45

56
import discord
67
from contextlib import suppress
78
from discord.ext import commands
9+
from discord.utils import snowflake_time
10+
811
from . import http
912
from . import error
1013
from . import model
14+
from . dpy_overrides import ComponentMessage
1115

1216

13-
class SlashContext:
17+
class InteractionContext:
1418
"""
15-
Context of the slash command.\n
19+
Base context for interactions.\n
1620
Kinda similar with discord.ext.commands.Context.
1721
1822
.. warning::
1923
Do not manually init this model.
2024
2125
:ivar message: Message that invoked the slash command.
22-
:ivar name: Name of the command.
23-
:ivar args: List of processed arguments invoked with the command.
24-
:ivar kwargs: Dictionary of processed arguments invoked with the command.
25-
:ivar subcommand_name: Subcommand of the command.
26-
:ivar subcommand_group: Subcommand group of the command.
2726
:ivar interaction_id: Interaction ID of the command message.
28-
:ivar command_id: ID of the command.
2927
:ivar bot: discord.py client.
3028
:ivar _http: :class:`.http.SlashCommandRequest` of the client.
3129
:ivar _logger: Logger instance.
@@ -43,15 +41,9 @@ def __init__(self,
4341
_json: dict,
4442
_discord: typing.Union[discord.Client, commands.Bot],
4543
logger):
46-
self.__token = _json["token"]
44+
self._token = _json["token"]
4745
self.message = None # Should be set later.
48-
self.name = self.command = self.invoked_with = _json["data"]["name"]
49-
self.args = []
50-
self.kwargs = {}
51-
self.subcommand_name = self.invoked_subcommand = self.subcommand_passed = None
52-
self.subcommand_group = self.invoked_subcommand_group = self.subcommand_group_passed = None
5346
self.interaction_id = _json["id"]
54-
self.command_id = _json["data"]["id"]
5547
self._http = _http
5648
self.bot = _discord
5749
self._logger = logger
@@ -67,6 +59,7 @@ def __init__(self,
6759
self.author = discord.User(data=_json["member"]["user"], state=self.bot._connection)
6860
else:
6961
self.author = discord.User(data=_json["user"], state=self.bot._connection)
62+
self.created_at: datetime.datetime = snowflake_time(int(self.interaction_id))
7063

7164
@property
7265
def _deffered_hidden(self):
@@ -118,7 +111,7 @@ async def defer(self, hidden: bool = False):
118111
if hidden:
119112
base["data"] = {"flags": 64}
120113
self._deferred_hidden = True
121-
await self._http.post_initial_response(base, self.interaction_id, self.__token)
114+
await self._http.post_initial_response(base, self.interaction_id, self._token)
122115
self.deferred = True
123116

124117
async def send(self,
@@ -130,7 +123,9 @@ async def send(self,
130123
files: typing.List[discord.File] = None,
131124
allowed_mentions: discord.AllowedMentions = None,
132125
hidden: bool = False,
133-
delete_after: float = None) -> model.SlashMessage:
126+
delete_after: float = None,
127+
components: typing.List[dict] = None,
128+
) -> model.SlashMessage:
134129
"""
135130
Sends response of the slash command.
136131
@@ -157,6 +152,8 @@ async def send(self,
157152
:type hidden: bool
158153
:param delete_after: If provided, the number of seconds to wait in the background before deleting the message we just sent. If the deletion fails, then it is silently ignored.
159154
:type delete_after: float
155+
:param components: Message components in the response. The top level must be made of ActionRows.
156+
:type components: List[dict]
160157
:return: Union[discord.Message, dict]
161158
"""
162159
if embed and embeds:
@@ -174,13 +171,16 @@ async def send(self,
174171
files = [file]
175172
if delete_after and hidden:
176173
raise error.IncorrectFormat("You can't delete a hidden message!")
174+
if components and not all(comp.get("type") == 1 for comp in components):
175+
raise error.IncorrectFormat("The top level of the components list must be made of ActionRows!")
177176

178177
base = {
179178
"content": content,
180179
"tts": tts,
181180
"embeds": [x.to_dict() for x in embeds] if embeds else [],
182181
"allowed_mentions": allowed_mentions.to_dict() if allowed_mentions
183-
else self.bot.allowed_mentions.to_dict() if self.bot.allowed_mentions else {}
182+
else self.bot.allowed_mentions.to_dict() if self.bot.allowed_mentions else {},
183+
"components": components or [],
184184
}
185185
if hidden:
186186
base["flags"] = 64
@@ -196,21 +196,21 @@ async def send(self,
196196
"Deferred response might not be what you set it to! (hidden / visible) "
197197
"This is because it was deferred in a different state."
198198
)
199-
resp = await self._http.edit(base, self.__token, files=files)
199+
resp = await self._http.edit(base, self._token, files=files)
200200
self.deferred = False
201201
else:
202202
json_data = {
203203
"type": 4,
204204
"data": base
205205
}
206-
await self._http.post_initial_response(json_data, self.interaction_id, self.__token)
206+
await self._http.post_initial_response(json_data, self.interaction_id, self._token)
207207
if not hidden:
208-
resp = await self._http.edit({}, self.__token)
208+
resp = await self._http.edit({}, self._token)
209209
else:
210210
resp = {}
211211
self.responded = True
212212
else:
213-
resp = await self._http.post_followup(base, self.__token, files=files)
213+
resp = await self._http.post_followup(base, self._token, files=files)
214214
if files:
215215
for file in files:
216216
file.close()
@@ -219,11 +219,141 @@ async def send(self,
219219
data=resp,
220220
channel=self.channel or discord.Object(id=self.channel_id),
221221
_http=self._http,
222-
interaction_token=self.__token)
222+
interaction_token=self._token)
223223
if delete_after:
224224
self.bot.loop.create_task(smsg.delete(delay=delete_after))
225225
if initial_message:
226226
self.message = smsg
227227
return smsg
228228
else:
229229
return resp
230+
231+
232+
class SlashContext(InteractionContext):
233+
"""
234+
Context of a slash command. Has all attributes from :class:`InteractionContext`, plus the slash-command-specific ones below.
235+
236+
:ivar name: Name of the command.
237+
:ivar args: List of processed arguments invoked with the command.
238+
:ivar kwargs: Dictionary of processed arguments invoked with the command.
239+
:ivar subcommand_name: Subcommand of the command.
240+
:ivar subcommand_group: Subcommand group of the command.
241+
:ivar command_id: ID of the command.
242+
"""
243+
def __init__(self,
244+
_http: http.SlashCommandRequest,
245+
_json: dict,
246+
_discord: typing.Union[discord.Client, commands.Bot],
247+
logger):
248+
self.name = self.command = self.invoked_with = _json["data"]["name"]
249+
self.args = []
250+
self.kwargs = {}
251+
self.subcommand_name = self.invoked_subcommand = self.subcommand_passed = None
252+
self.subcommand_group = self.invoked_subcommand_group = self.subcommand_group_passed = None
253+
self.command_id = _json["data"]["id"]
254+
255+
super().__init__(_http=_http, _json=_json, _discord=_discord, logger=logger)
256+
257+
258+
class ComponentContext(InteractionContext):
259+
"""
260+
Context of a component interaction. Has all attributes from :class:`InteractionContext`, plus the component-specific ones below.
261+
262+
:ivar custom_id: The custom ID of the component.
263+
:ivar component_type: The type of the component.
264+
:ivar origin_message: The origin message of the component. Not available if the origin message was ephemeral.
265+
:ivar origin_message_id: The ID of the origin message.
266+
"""
267+
def __init__(self,
268+
_http: http.SlashCommandRequest,
269+
_json: dict,
270+
_discord: typing.Union[discord.Client, commands.Bot],
271+
logger):
272+
self.custom_id = self.component_id = _json["data"]["custom_id"]
273+
self.component_type = _json["data"]["component_type"]
274+
super().__init__(_http=_http, _json=_json, _discord=_discord, logger=logger)
275+
self.origin_message = None
276+
self.origin_message_id = int(_json["message"]["id"]) if "message" in _json.keys() else None
277+
278+
if self.origin_message_id and (_json["message"]["flags"] & 64) != 64:
279+
self.origin_message = ComponentMessage(state=self.bot._connection, channel=self.channel,
280+
data=_json["message"])
281+
282+
async def defer(self, hidden: bool = False, edit_origin: bool = False):
283+
"""
284+
'Defers' the response, showing a loading state to the user
285+
286+
:param hidden: Whether the deferred response should be ephemeral . Default ``False``.
287+
:param edit_origin: Whether the response is editing the origin message. If ``False``, the deferred response will be for a follow up message. Defaults ``False``.
288+
"""
289+
if self.deferred or self.responded:
290+
raise error.AlreadyResponded("You have already responded to this command!")
291+
base = {"type": 6 if edit_origin else 5}
292+
if hidden and not edit_origin:
293+
base["data"] = {"flags": 64}
294+
self._deferred_hidden = True
295+
await self._http.post_initial_response(base, self.interaction_id, self._token)
296+
self.deferred = True
297+
298+
async def edit_origin(self, **fields):
299+
"""
300+
Edits the origin message of the component.
301+
Refer to :meth:`discord.Message.edit` and :meth:`InteractionContext.send` for fields.
302+
"""
303+
_resp = {}
304+
305+
content = fields.get("content")
306+
if content:
307+
_resp["content"] = str(content)
308+
309+
embed = fields.get("embed")
310+
embeds = fields.get("embeds")
311+
file = fields.get("file")
312+
files = fields.get("files")
313+
components = fields.get("components")
314+
315+
if components:
316+
_resp["components"] = components
317+
318+
if embed and embeds:
319+
raise error.IncorrectFormat("You can't use both `embed` and `embeds`!")
320+
if file and files:
321+
raise error.IncorrectFormat("You can't use both `file` and `files`!")
322+
if file:
323+
files = [file]
324+
if embed:
325+
embeds = [embed]
326+
if embeds:
327+
if not isinstance(embeds, list):
328+
raise error.IncorrectFormat("Provide a list of embeds.")
329+
elif len(embeds) > 10:
330+
raise error.IncorrectFormat("Do not provide more than 10 embeds.")
331+
_resp["embeds"] = [x.to_dict() for x in embeds]
332+
333+
allowed_mentions = fields.get("allowed_mentions")
334+
_resp["allowed_mentions"] = allowed_mentions.to_dict() if allowed_mentions else \
335+
self.bot.allowed_mentions.to_dict() if self.bot.allowed_mentions else {}
336+
337+
if not self.responded:
338+
if files and not self.deferred:
339+
await self.defer(edit_origin=True)
340+
if self.deferred:
341+
_json = await self._http.edit(_resp, self._token, files=files)
342+
self.deferred = False
343+
else:
344+
json_data = {
345+
"type": 7,
346+
"data": _resp
347+
}
348+
_json = await self._http.post_initial_response(json_data, self.interaction_id, self._token)
349+
self.responded = True
350+
else:
351+
raise error.IncorrectFormat("Already responded")
352+
353+
if files:
354+
for file in files:
355+
file.close()
356+
357+
# Commented out for now as sometimes (or at least, when not deferred) _json is an empty string?
358+
# self.origin_message = ComponentMessage(state=self.bot._connection, channel=self.channel,
359+
# data=_json)

0 commit comments

Comments
 (0)