disagreement 0.0.1__py3-none-any.whl → 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,556 @@
1
+ # disagreement/ext/app_commands/context.py
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Optional, List, Union, Any, Dict
6
+
7
+ if TYPE_CHECKING:
8
+ from disagreement.client import Client
9
+ from disagreement.interactions import (
10
+ Interaction,
11
+ InteractionCallbackData,
12
+ InteractionResponsePayload,
13
+ Snowflake,
14
+ )
15
+ from disagreement.enums import InteractionCallbackType, MessageFlags
16
+ from disagreement.models import (
17
+ User,
18
+ Member,
19
+ Message,
20
+ Channel,
21
+ ActionRow,
22
+ )
23
+ from disagreement.ui.view import View
24
+
25
+ # For full model hints, these would be imported from disagreement.models when defined:
26
+ Embed = Any
27
+ PartialAttachment = Any
28
+ Guild = Any # from disagreement.models import Guild
29
+ TextChannel = Any # from disagreement.models import TextChannel, etc.
30
+ from .commands import AppCommand
31
+
32
+ from disagreement.enums import InteractionCallbackType, MessageFlags
33
+ from disagreement.interactions import (
34
+ Interaction,
35
+ InteractionCallbackData,
36
+ InteractionResponsePayload,
37
+ Snowflake,
38
+ )
39
+ from disagreement.models import Message
40
+ from disagreement.typing import Typing
41
+
42
+
43
+ class AppCommandContext:
44
+ """
45
+ Represents the context in which an application command is being invoked.
46
+ Provides methods to respond to the interaction.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ bot: "Client",
52
+ interaction: "Interaction",
53
+ command: Optional["AppCommand"] = None,
54
+ ):
55
+ self.bot: "Client" = bot
56
+ self.interaction: "Interaction" = interaction
57
+ self.command: Optional["AppCommand"] = command # The command that was invoked
58
+
59
+ self._responded: bool = False
60
+ self._deferred: bool = False
61
+
62
+ @property
63
+ def token(self) -> str:
64
+ """The interaction token."""
65
+ return self.interaction.token
66
+
67
+ @property
68
+ def interaction_id(self) -> "Snowflake":
69
+ """The interaction ID."""
70
+ return self.interaction.id
71
+
72
+ @property
73
+ def application_id(self) -> "Snowflake":
74
+ """The application ID of the interaction."""
75
+ return self.interaction.application_id
76
+
77
+ @property
78
+ def guild_id(self) -> Optional["Snowflake"]:
79
+ """The ID of the guild where the interaction occurred, if any."""
80
+ return self.interaction.guild_id
81
+
82
+ @property
83
+ def channel_id(self) -> Optional["Snowflake"]:
84
+ """The ID of the channel where the interaction occurred."""
85
+ return self.interaction.channel_id
86
+
87
+ @property
88
+ def author(self) -> Optional[Union["User", "Member"]]:
89
+ """The user or member who invoked the interaction."""
90
+ return self.interaction.member or self.interaction.user
91
+
92
+ @property
93
+ def user(self) -> Optional["User"]:
94
+ """The user who invoked the interaction.
95
+ If in a guild, this is the user part of the member.
96
+ If in a DM, this is the top-level user.
97
+ """
98
+ return self.interaction.user
99
+
100
+ @property
101
+ def member(self) -> Optional["Member"]:
102
+ """The member who invoked the interaction, if this occurred in a guild."""
103
+ return self.interaction.member
104
+
105
+ @property
106
+ def locale(self) -> Optional[str]:
107
+ """The selected language of the invoking user."""
108
+ return self.interaction.locale
109
+
110
+ @property
111
+ def guild_locale(self) -> Optional[str]:
112
+ """The guild's preferred language, if applicable."""
113
+ return self.interaction.guild_locale
114
+
115
+ @property
116
+ async def guild(self) -> Optional["Guild"]:
117
+ """The guild object where the interaction occurred, if available."""
118
+
119
+ if not self.guild_id:
120
+ return None
121
+
122
+ guild = None
123
+ if hasattr(self.bot, "get_guild"):
124
+ guild = self.bot.get_guild(self.guild_id)
125
+
126
+ if not guild and hasattr(self.bot, "fetch_guild"):
127
+ try:
128
+ guild = await self.bot.fetch_guild(self.guild_id)
129
+ except Exception:
130
+ guild = None
131
+
132
+ return guild
133
+
134
+ @property
135
+ async def channel(self) -> Optional[Any]:
136
+ """The channel object where the interaction occurred, if available."""
137
+
138
+ if not self.channel_id:
139
+ return None
140
+
141
+ channel = None
142
+ if hasattr(self.bot, "get_channel"):
143
+ channel = self.bot.get_channel(self.channel_id)
144
+ elif hasattr(self.bot, "_channels"):
145
+ channel = self.bot._channels.get(self.channel_id)
146
+
147
+ if not channel and hasattr(self.bot, "fetch_channel"):
148
+ try:
149
+ channel = await self.bot.fetch_channel(self.channel_id)
150
+ except Exception:
151
+ channel = None
152
+
153
+ return channel
154
+
155
+ async def _send_response(
156
+ self,
157
+ response_type: "InteractionCallbackType",
158
+ data: Optional[Dict[str, Any]] = None,
159
+ ) -> None:
160
+ """Internal helper to send interaction responses."""
161
+ if (
162
+ self._responded
163
+ and not self._deferred
164
+ and response_type
165
+ != InteractionCallbackType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT
166
+ ):
167
+ # If already responded and not deferred, subsequent responses must be followups
168
+ # (unless it's an autocomplete result which is a special case)
169
+ # For now, let's assume followups are handled by separate methods.
170
+ # This logic might need refinement based on how followups are exposed.
171
+ raise RuntimeError(
172
+ "Interaction has already been responded to. Use send_followup()."
173
+ )
174
+
175
+ callback_data = InteractionCallbackData(data) if data else None
176
+ payload = InteractionResponsePayload(type=response_type, data=callback_data)
177
+
178
+ await self.bot._http.create_interaction_response(
179
+ interaction_id=self.interaction_id,
180
+ interaction_token=self.token,
181
+ payload=payload,
182
+ )
183
+ if (
184
+ response_type
185
+ != InteractionCallbackType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT
186
+ ):
187
+ self._responded = True
188
+ if (
189
+ response_type
190
+ == InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
191
+ or response_type == InteractionCallbackType.DEFERRED_UPDATE_MESSAGE
192
+ ):
193
+ self._deferred = True
194
+
195
+ async def defer(self, ephemeral: bool = False, thinking: bool = True) -> None:
196
+ """
197
+ Defers the interaction response.
198
+
199
+ This is typically used when your command might take longer than 3 seconds to process.
200
+ You must send a followup message within 15 minutes.
201
+
202
+ Args:
203
+ ephemeral (bool): Whether the subsequent followup response should be ephemeral.
204
+ Only applicable if `thinking` is True.
205
+ thinking (bool): If True (default), responds with a "Bot is thinking..." message
206
+ (DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE).
207
+ If False, responds with DEFERRED_UPDATE_MESSAGE (for components).
208
+ """
209
+ if self._responded:
210
+ raise RuntimeError("Interaction has already been responded to or deferred.")
211
+
212
+ response_type = (
213
+ InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
214
+ if thinking
215
+ else InteractionCallbackType.DEFERRED_UPDATE_MESSAGE
216
+ )
217
+ data = None
218
+ if ephemeral and thinking:
219
+ data = {
220
+ "flags": MessageFlags.EPHEMERAL.value
221
+ } # Assuming MessageFlags enum exists
222
+
223
+ await self._send_response(response_type, data)
224
+ self._deferred = True # Mark as deferred
225
+
226
+ async def send(
227
+ self,
228
+ content: Optional[str] = None,
229
+ embed: Optional["Embed"] = None, # Convenience for single embed
230
+ embeds: Optional[List["Embed"]] = None,
231
+ *,
232
+ tts: bool = False,
233
+ files: Optional[List[Any]] = None,
234
+ components: Optional[List[ActionRow]] = None,
235
+ view: Optional[View] = None,
236
+ allowed_mentions: Optional[Dict[str, Any]] = None,
237
+ ephemeral: bool = False,
238
+ flags: Optional[int] = None,
239
+ ) -> Optional[
240
+ "Message"
241
+ ]: # Returns Message if not ephemeral and response was not deferred
242
+ """
243
+ Sends a response to the interaction.
244
+ If the interaction was previously deferred, this will edit the original deferred response.
245
+ Otherwise, it sends an initial response.
246
+
247
+ Args:
248
+ content (Optional[str]): The message content.
249
+ embed (Optional[Embed]): A single embed to send. If `embeds` is also provided, this is ignored.
250
+ embeds (Optional[List[Embed]]): A list of embeds to send (max 10).
251
+ ephemeral (bool): Whether the message should be ephemeral (only visible to the invoker).
252
+ flags (Optional[int]): Additional message flags to apply.
253
+
254
+ Returns:
255
+ Optional[Message]: The sent message object if a new message was created and not ephemeral.
256
+ None if the response was ephemeral or an edit to a deferred message.
257
+ """
258
+ if not self._responded and self._deferred: # Editing a deferred response
259
+ # Use edit_original_interaction_response
260
+ payload: Dict[str, Any] = {}
261
+ if content is not None:
262
+ payload["content"] = content
263
+
264
+ if tts:
265
+ payload["tts"] = True
266
+
267
+ actual_embeds = embeds
268
+ if embed and not embeds:
269
+ actual_embeds = [embed]
270
+ if actual_embeds:
271
+ payload["embeds"] = [e.to_dict() for e in actual_embeds]
272
+
273
+ if view:
274
+ await view._start(self.bot)
275
+ payload["components"] = view.to_components_payload()
276
+ elif components:
277
+ payload["components"] = [c.to_dict() for c in components]
278
+
279
+ if files is not None:
280
+ payload["attachments"] = [
281
+ f.to_dict() if hasattr(f, "to_dict") else f for f in files
282
+ ]
283
+
284
+ if allowed_mentions is not None:
285
+ payload["allowed_mentions"] = allowed_mentions
286
+
287
+ # Flags (like ephemeral) cannot be set when editing the original deferred response this way.
288
+ # Ephemeral for deferred must be set during defer().
289
+
290
+ msg_data = await self.bot._http.edit_original_interaction_response(
291
+ application_id=self.application_id,
292
+ interaction_token=self.token,
293
+ payload=payload,
294
+ )
295
+ self._responded = True # Ensure it's marked as fully responded
296
+ if view and msg_data and "id" in msg_data:
297
+ view.message_id = msg_data["id"]
298
+ self.bot._views[msg_data["id"]] = view
299
+ # Construct and return Message object if needed, for now returns None for edits
300
+ return None
301
+
302
+ elif not self._responded: # Sending an initial response
303
+ data: Dict[str, Any] = {}
304
+ if content is not None:
305
+ data["content"] = content
306
+
307
+ if tts:
308
+ data["tts"] = True
309
+
310
+ actual_embeds = embeds
311
+ if embed and not embeds:
312
+ actual_embeds = [embed]
313
+ if actual_embeds:
314
+ data["embeds"] = [
315
+ e.to_dict() for e in actual_embeds
316
+ ] # Assuming embeds have to_dict()
317
+
318
+ if view:
319
+ await view._start(self.bot)
320
+ data["components"] = view.to_components_payload()
321
+ elif components:
322
+ data["components"] = [c.to_dict() for c in components]
323
+
324
+ if files is not None:
325
+ data["attachments"] = [
326
+ f.to_dict() if hasattr(f, "to_dict") else f for f in files
327
+ ]
328
+
329
+ if allowed_mentions is not None:
330
+ data["allowed_mentions"] = allowed_mentions
331
+
332
+ flags_value = 0
333
+ if ephemeral:
334
+ flags_value |= MessageFlags.EPHEMERAL.value
335
+ if flags:
336
+ flags_value |= flags
337
+ if flags_value:
338
+ data["flags"] = flags_value
339
+
340
+ await self._send_response(
341
+ InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, data
342
+ )
343
+
344
+ if view and not ephemeral:
345
+ try:
346
+ msg_data = await self.bot._http.get_original_interaction_response(
347
+ application_id=self.application_id,
348
+ interaction_token=self.token,
349
+ )
350
+ if msg_data and "id" in msg_data:
351
+ view.message_id = msg_data["id"]
352
+ self.bot._views[msg_data["id"]] = view
353
+ except Exception:
354
+ pass
355
+ if not ephemeral:
356
+ return None
357
+ return None
358
+ else:
359
+ # If already responded and not deferred, this should be a followup.
360
+ # This method is for initial response or editing deferred.
361
+ raise RuntimeError(
362
+ "Interaction has already been responded to. Use send_followup()."
363
+ )
364
+
365
+ async def send_followup(
366
+ self,
367
+ content: Optional[str] = None,
368
+ embed: Optional["Embed"] = None,
369
+ embeds: Optional[List["Embed"]] = None,
370
+ *,
371
+ ephemeral: bool = False,
372
+ tts: bool = False,
373
+ files: Optional[List[Any]] = None,
374
+ components: Optional[List["ActionRow"]] = None,
375
+ view: Optional[View] = None,
376
+ allowed_mentions: Optional[Dict[str, Any]] = None,
377
+ flags: Optional[int] = None,
378
+ ) -> Optional["Message"]:
379
+ """
380
+ Sends a followup message to an interaction.
381
+ This can be used after an initial response or a deferred response.
382
+
383
+ Args:
384
+ content (Optional[str]): The message content.
385
+ embed (Optional[Embed]): A single embed to send.
386
+ embeds (Optional[List[Embed]]): A list of embeds to send.
387
+ ephemeral (bool): Whether the followup message should be ephemeral.
388
+ flags (Optional[int]): Additional message flags to apply.
389
+
390
+ Returns:
391
+ Message: The sent followup message object.
392
+ """
393
+ if not self._responded:
394
+ raise RuntimeError(
395
+ "Must acknowledge or defer the interaction before sending a followup."
396
+ )
397
+
398
+ payload: Dict[str, Any] = {}
399
+ if content is not None:
400
+ payload["content"] = content
401
+
402
+ if tts:
403
+ payload["tts"] = True
404
+
405
+ actual_embeds = embeds
406
+ if embed and not embeds:
407
+ actual_embeds = [embed]
408
+ if actual_embeds:
409
+ payload["embeds"] = [
410
+ e.to_dict() for e in actual_embeds
411
+ ] # Assuming embeds have to_dict()
412
+
413
+ if view:
414
+ await view._start(self.bot)
415
+ payload["components"] = view.to_components_payload()
416
+ elif components:
417
+ payload["components"] = [c.to_dict() for c in components]
418
+
419
+ if files is not None:
420
+ payload["attachments"] = [
421
+ f.to_dict() if hasattr(f, "to_dict") else f for f in files
422
+ ]
423
+
424
+ if allowed_mentions is not None:
425
+ payload["allowed_mentions"] = allowed_mentions
426
+
427
+ flags_value = 0
428
+ if ephemeral:
429
+ flags_value |= MessageFlags.EPHEMERAL.value
430
+ if flags:
431
+ flags_value |= flags
432
+ if flags_value:
433
+ payload["flags"] = flags_value
434
+
435
+ # Followup messages are sent to a webhook endpoint
436
+ message_data = await self.bot._http.create_followup_message(
437
+ application_id=self.application_id,
438
+ interaction_token=self.token,
439
+ payload=payload,
440
+ )
441
+ if view and message_data and "id" in message_data:
442
+ view.message_id = message_data["id"]
443
+ self.bot._views[message_data["id"]] = view
444
+ from disagreement.models import Message # Ensure Message is available
445
+
446
+ return Message(data=message_data, client_instance=self.bot)
447
+
448
+ async def edit(
449
+ self,
450
+ message_id: "Snowflake" = "@original", # Defaults to editing the original response
451
+ content: Optional[str] = None,
452
+ embed: Optional["Embed"] = None,
453
+ embeds: Optional[List["Embed"]] = None,
454
+ *,
455
+ components: Optional[List["ActionRow"]] = None,
456
+ attachments: Optional[List[Any]] = None,
457
+ allowed_mentions: Optional[Dict[str, Any]] = None,
458
+ ) -> Optional["Message"]:
459
+ """
460
+ Edits a message previously sent in response to this interaction.
461
+ Can edit the original response or a followup message.
462
+
463
+ Args:
464
+ message_id (Snowflake): The ID of the message to edit. Defaults to "@original"
465
+ to edit the initial interaction response.
466
+ content (Optional[str]): The new message content.
467
+ embed (Optional[Embed]): A single new embed.
468
+ embeds (Optional[List[Embed]]): A list of new embeds.
469
+
470
+ Returns:
471
+ Optional[Message]: The edited message object if available.
472
+ """
473
+ if not self._responded:
474
+ raise RuntimeError(
475
+ "Cannot edit response if interaction hasn't been responded to or deferred."
476
+ )
477
+
478
+ payload: Dict[str, Any] = {}
479
+ if content is not None:
480
+ payload["content"] = content # Use None to clear
481
+
482
+ actual_embeds = embeds
483
+ if embed and not embeds:
484
+ actual_embeds = [embed]
485
+ if actual_embeds is not None: # Allow passing empty list to clear embeds
486
+ payload["embeds"] = [
487
+ e.to_dict() for e in actual_embeds
488
+ ] # Assuming embeds have to_dict()
489
+
490
+ if components is not None:
491
+ payload["components"] = [c.to_dict() for c in components]
492
+
493
+ if attachments is not None:
494
+ payload["attachments"] = [
495
+ a.to_dict() if hasattr(a, "to_dict") else a for a in attachments
496
+ ]
497
+
498
+ if allowed_mentions is not None:
499
+ payload["allowed_mentions"] = allowed_mentions
500
+
501
+ if message_id == "@original":
502
+ edited_message_data = (
503
+ await self.bot._http.edit_original_interaction_response(
504
+ application_id=self.application_id,
505
+ interaction_token=self.token,
506
+ payload=payload,
507
+ )
508
+ )
509
+ else:
510
+ edited_message_data = await self.bot._http.edit_followup_message(
511
+ application_id=self.application_id,
512
+ interaction_token=self.token,
513
+ message_id=message_id,
514
+ payload=payload,
515
+ )
516
+ # The HTTP methods used in tests return minimal data that is insufficient
517
+ # to construct a full ``Message`` instance, so we simply return ``None``
518
+ # rather than attempting to parse the response.
519
+ return None
520
+
521
+ async def delete(self, message_id: "Snowflake" = "@original") -> None:
522
+ """
523
+ Deletes a message previously sent in response to this interaction.
524
+ Can delete the original response or a followup message.
525
+
526
+ Args:
527
+ message_id (Snowflake): The ID of the message to delete. Defaults to "@original"
528
+ to delete the initial interaction response.
529
+ """
530
+ if not self._responded:
531
+ # If not responded, there's nothing to delete via this interaction's lifecycle.
532
+ # Deferral doesn't create a message to delete until a followup is sent.
533
+ raise RuntimeError(
534
+ "Cannot delete response if interaction hasn't been responded to."
535
+ )
536
+
537
+ if message_id == "@original":
538
+ await self.bot._http.delete_original_interaction_response(
539
+ application_id=self.application_id, interaction_token=self.token
540
+ )
541
+ else:
542
+ await self.bot._http.delete_followup_message(
543
+ application_id=self.application_id,
544
+ interaction_token=self.token,
545
+ message_id=message_id,
546
+ )
547
+ # After deleting the original response, further followups might be problematic.
548
+ # Discord docs: "Once the original message is deleted, you can no longer edit the message or send followups."
549
+ # Consider implications for context state.
550
+
551
+ def typing(self) -> Typing:
552
+ """Return a typing context manager for this interaction's channel."""
553
+
554
+ if not self.channel_id:
555
+ raise RuntimeError("Cannot send typing indicator without a channel.")
556
+ return self.bot.typing(self.channel_id)