disagreement 0.0.2__py3-none-any.whl → 0.1.0rc2__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.
disagreement/ext/tasks.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import datetime
2
3
  from typing import Any, Awaitable, Callable, Optional
3
4
 
4
5
  __all__ = ["loop", "Task"]
@@ -7,18 +8,73 @@ __all__ = ["loop", "Task"]
7
8
  class Task:
8
9
  """Simple repeating task."""
9
10
 
10
- def __init__(self, coro: Callable[..., Awaitable[Any]], *, seconds: float) -> None:
11
+ def __init__(
12
+ self,
13
+ coro: Callable[..., Awaitable[Any]],
14
+ *,
15
+ seconds: float = 0.0,
16
+ minutes: float = 0.0,
17
+ hours: float = 0.0,
18
+ delta: Optional[datetime.timedelta] = None,
19
+ time_of_day: Optional[datetime.time] = None,
20
+ on_error: Optional[Callable[[Exception], Awaitable[None]]] = None,
21
+ before_loop: Optional[Callable[[], Awaitable[None] | None]] = None,
22
+ after_loop: Optional[Callable[[], Awaitable[None] | None]] = None,
23
+ ) -> None:
11
24
  self._coro = coro
12
- self._seconds = float(seconds)
13
25
  self._task: Optional[asyncio.Task[None]] = None
26
+ if time_of_day is not None and (
27
+ seconds or minutes or hours or delta is not None
28
+ ):
29
+ raise ValueError("time_of_day cannot be used with an interval")
30
+
31
+ if delta is not None:
32
+ if not isinstance(delta, datetime.timedelta):
33
+ raise TypeError("delta must be a datetime.timedelta")
34
+ interval_seconds = delta.total_seconds()
35
+ else:
36
+ interval_seconds = seconds + minutes * 60.0 + hours * 3600.0
37
+
38
+ self._seconds = float(interval_seconds)
39
+ self._time_of_day = time_of_day
40
+ self._on_error = on_error
41
+ self._before_loop = before_loop
42
+ self._after_loop = after_loop
43
+
44
+ def _seconds_until_time(self) -> float:
45
+ assert self._time_of_day is not None
46
+ now = datetime.datetime.now()
47
+ target = datetime.datetime.combine(now.date(), self._time_of_day)
48
+ if target <= now:
49
+ target += datetime.timedelta(days=1)
50
+ return (target - now).total_seconds()
14
51
 
15
52
  async def _run(self, *args: Any, **kwargs: Any) -> None:
16
53
  try:
54
+ if self._before_loop is not None:
55
+ await _maybe_call_no_args(self._before_loop)
56
+
57
+ first = True
17
58
  while True:
18
- await self._coro(*args, **kwargs)
19
- await asyncio.sleep(self._seconds)
59
+ if self._time_of_day is not None:
60
+ await asyncio.sleep(self._seconds_until_time())
61
+ elif not first:
62
+ await asyncio.sleep(self._seconds)
63
+
64
+ try:
65
+ await self._coro(*args, **kwargs)
66
+ except Exception as exc: # noqa: BLE001
67
+ if self._on_error is not None:
68
+ await _maybe_call(self._on_error, exc)
69
+ else:
70
+ raise
71
+
72
+ first = False
20
73
  except asyncio.CancelledError:
21
74
  pass
75
+ finally:
76
+ if self._after_loop is not None:
77
+ await _maybe_call_no_args(self._after_loop)
22
78
 
23
79
  def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
24
80
  if self._task is None or self._task.done():
@@ -35,12 +91,43 @@ class Task:
35
91
  return self._task is not None and not self._task.done()
36
92
 
37
93
 
94
+ async def _maybe_call(
95
+ func: Callable[[Exception], Awaitable[None] | None], exc: Exception
96
+ ) -> None:
97
+ result = func(exc)
98
+ if asyncio.iscoroutine(result):
99
+ await result
100
+
101
+
102
+ async def _maybe_call_no_args(func: Callable[[], Awaitable[None] | None]) -> None:
103
+ result = func()
104
+ if asyncio.iscoroutine(result):
105
+ await result
106
+
107
+
38
108
  class _Loop:
39
- def __init__(self, func: Callable[..., Awaitable[Any]], seconds: float) -> None:
109
+ def __init__(
110
+ self,
111
+ func: Callable[..., Awaitable[Any]],
112
+ *,
113
+ seconds: float = 0.0,
114
+ minutes: float = 0.0,
115
+ hours: float = 0.0,
116
+ delta: Optional[datetime.timedelta] = None,
117
+ time_of_day: Optional[datetime.time] = None,
118
+ on_error: Optional[Callable[[Exception], Awaitable[None]]] = None,
119
+ ) -> None:
40
120
  self.func = func
41
121
  self.seconds = seconds
122
+ self.minutes = minutes
123
+ self.hours = hours
124
+ self.delta = delta
125
+ self.time_of_day = time_of_day
126
+ self.on_error = on_error
42
127
  self._task: Optional[Task] = None
43
128
  self._owner: Any = None
129
+ self._before_loop: Optional[Callable[..., Awaitable[Any]]] = None
130
+ self._after_loop: Optional[Callable[..., Awaitable[Any]]] = None
44
131
 
45
132
  def __get__(self, obj: Any, objtype: Any) -> "_BoundLoop":
46
133
  return _BoundLoop(self, obj)
@@ -50,8 +137,44 @@ class _Loop:
50
137
  return self.func(*args, **kwargs)
51
138
  return self.func(self._owner, *args, **kwargs)
52
139
 
140
+ def before_loop(
141
+ self, func: Callable[..., Awaitable[Any]]
142
+ ) -> Callable[..., Awaitable[Any]]:
143
+ self._before_loop = func
144
+ return func
145
+
146
+ def after_loop(
147
+ self, func: Callable[..., Awaitable[Any]]
148
+ ) -> Callable[..., Awaitable[Any]]:
149
+ self._after_loop = func
150
+ return func
151
+
53
152
  def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
54
- self._task = Task(self._coro, seconds=self.seconds)
153
+ def call_before() -> Awaitable[None] | None:
154
+ if self._before_loop is None:
155
+ return None
156
+ if self._owner is not None:
157
+ return self._before_loop(self._owner)
158
+ return self._before_loop()
159
+
160
+ def call_after() -> Awaitable[None] | None:
161
+ if self._after_loop is None:
162
+ return None
163
+ if self._owner is not None:
164
+ return self._after_loop(self._owner)
165
+ return self._after_loop()
166
+
167
+ self._task = Task(
168
+ self._coro,
169
+ seconds=self.seconds,
170
+ minutes=self.minutes,
171
+ hours=self.hours,
172
+ delta=self.delta,
173
+ time_of_day=self.time_of_day,
174
+ on_error=self.on_error,
175
+ before_loop=call_before,
176
+ after_loop=call_after,
177
+ )
55
178
  return self._task.start(*args, **kwargs)
56
179
 
57
180
  def stop(self) -> None:
@@ -80,10 +203,26 @@ class _BoundLoop:
80
203
  return self._parent.running
81
204
 
82
205
 
83
- def loop(*, seconds: float) -> Callable[[Callable[..., Awaitable[Any]]], _Loop]:
206
+ def loop(
207
+ *,
208
+ seconds: float = 0.0,
209
+ minutes: float = 0.0,
210
+ hours: float = 0.0,
211
+ delta: Optional[datetime.timedelta] = None,
212
+ time_of_day: Optional[datetime.time] = None,
213
+ on_error: Optional[Callable[[Exception], Awaitable[None]]] = None,
214
+ ) -> Callable[[Callable[..., Awaitable[Any]]], _Loop]:
84
215
  """Decorator to create a looping task."""
85
216
 
86
217
  def decorator(func: Callable[..., Awaitable[Any]]) -> _Loop:
87
- return _Loop(func, seconds)
218
+ return _Loop(
219
+ func,
220
+ seconds=seconds,
221
+ minutes=minutes,
222
+ hours=hours,
223
+ delta=delta,
224
+ time_of_day=time_of_day,
225
+ on_error=on_error,
226
+ )
88
227
 
89
228
  return decorator
disagreement/gateway.py CHANGED
@@ -10,6 +10,7 @@ import aiohttp
10
10
  import json
11
11
  import zlib
12
12
  import time
13
+ import random
13
14
  from typing import Optional, TYPE_CHECKING, Any, Dict
14
15
 
15
16
  from .enums import GatewayOpcode, GatewayIntent
@@ -43,6 +44,8 @@ class GatewayClient:
43
44
  *,
44
45
  shard_id: Optional[int] = None,
45
46
  shard_count: Optional[int] = None,
47
+ max_retries: int = 5,
48
+ max_backoff: float = 60.0,
46
49
  ):
47
50
  self._http: "HTTPClient" = http_client
48
51
  self._dispatcher: "EventDispatcher" = event_dispatcher
@@ -52,6 +55,8 @@ class GatewayClient:
52
55
  self.verbose: bool = verbose
53
56
  self._shard_id: Optional[int] = shard_id
54
57
  self._shard_count: Optional[int] = shard_count
58
+ self._max_retries: int = max_retries
59
+ self._max_backoff: float = max_backoff
55
60
 
56
61
  self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
57
62
  self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
@@ -63,10 +68,33 @@ class GatewayClient:
63
68
  self._keep_alive_task: Optional[asyncio.Task] = None
64
69
  self._receive_task: Optional[asyncio.Task] = None
65
70
 
71
+ self._last_heartbeat_sent: Optional[float] = None
72
+ self._last_heartbeat_ack: Optional[float] = None
73
+
66
74
  # For zlib decompression
67
75
  self._buffer = bytearray()
68
76
  self._inflator = zlib.decompressobj()
69
77
 
78
+ async def _reconnect(self) -> None:
79
+ """Attempts to reconnect using exponential backoff with jitter."""
80
+ delay = 1.0
81
+ for attempt in range(self._max_retries):
82
+ try:
83
+ await self.connect()
84
+ return
85
+ except Exception as e: # noqa: BLE001
86
+ if attempt >= self._max_retries - 1:
87
+ print(f"Reconnect failed after {attempt + 1} attempts: {e}")
88
+ raise
89
+ jitter = random.uniform(0, delay)
90
+ wait_time = min(delay + jitter, self._max_backoff)
91
+ print(
92
+ f"Reconnect attempt {attempt + 1} failed: {e}. "
93
+ f"Retrying in {wait_time:.2f} seconds..."
94
+ )
95
+ await asyncio.sleep(wait_time)
96
+ delay = min(delay * 2, self._max_backoff)
97
+
70
98
  async def _decompress_message(
71
99
  self, message_bytes: bytes
72
100
  ) -> Optional[Dict[str, Any]]:
@@ -103,6 +131,7 @@ class GatewayClient:
103
131
 
104
132
  async def _heartbeat(self):
105
133
  """Sends a heartbeat to the Gateway."""
134
+ self._last_heartbeat_sent = time.monotonic()
106
135
  payload = {"op": GatewayOpcode.HEARTBEAT, "d": self._last_sequence}
107
136
  await self._send_json(payload)
108
137
  # print("Sent heartbeat.")
@@ -350,7 +379,7 @@ class GatewayClient:
350
379
  await self._heartbeat()
351
380
  elif op == GatewayOpcode.RECONNECT: # Server requests a reconnect
352
381
  print("Gateway requested RECONNECT. Closing and will attempt to reconnect.")
353
- await self.close(code=4000) # Use a non-1000 code to indicate reconnect
382
+ await self.close(code=4000, reconnect=True)
354
383
  elif op == GatewayOpcode.INVALID_SESSION:
355
384
  # The 'd' payload for INVALID_SESSION is a boolean indicating resumability
356
385
  can_resume = data.get("d") is True
@@ -359,9 +388,7 @@ class GatewayClient:
359
388
  self._session_id = None # Clear session_id to force re-identify
360
389
  self._last_sequence = None
361
390
  # Close and reconnect. The connect logic will decide to resume or identify.
362
- await self.close(
363
- code=4000 if can_resume else 4009
364
- ) # 4009 for non-resumable
391
+ await self.close(code=4000 if can_resume else 4009, reconnect=True)
365
392
  elif op == GatewayOpcode.HELLO:
366
393
  hello_d_payload = data.get("d")
367
394
  if (
@@ -388,6 +415,7 @@ class GatewayClient:
388
415
  print("Performing initial IDENTIFY.")
389
416
  await self._identify()
390
417
  elif op == GatewayOpcode.HEARTBEAT_ACK:
418
+ self._last_heartbeat_ack = time.monotonic()
391
419
  # print("Received heartbeat ACK.")
392
420
  pass # Good, connection is alive
393
421
  else:
@@ -406,13 +434,11 @@ class GatewayClient:
406
434
  print("Receive_loop task cancelled.")
407
435
  except aiohttp.ClientConnectionError as e:
408
436
  print(f"ClientConnectionError in receive_loop: {e}. Attempting reconnect.")
409
- # This might be handled by an outer reconnect loop in the Client class
410
- await self.close(code=1006) # Abnormal closure
437
+ await self.close(code=1006, reconnect=True) # Abnormal closure
411
438
  except Exception as e:
412
439
  print(f"Unexpected error in receive_loop: {e}")
413
440
  traceback.print_exc()
414
- # Consider specific error types for more granular handling
415
- await self.close(code=1011) # Internal error
441
+ await self.close(code=1011, reconnect=True)
416
442
  finally:
417
443
  print("Receive_loop ended.")
418
444
  # If the loop ends unexpectedly (not due to explicit close),
@@ -460,7 +486,7 @@ class GatewayClient:
460
486
  f"An unexpected error occurred during Gateway connection: {e}"
461
487
  ) from e
462
488
 
463
- async def close(self, code: int = 1000):
489
+ async def close(self, code: int = 1000, *, reconnect: bool = False):
464
490
  """Closes the Gateway connection."""
465
491
  print(f"Closing Gateway connection with code {code}...")
466
492
  if self._keep_alive_task and not self._keep_alive_task.done():
@@ -471,11 +497,13 @@ class GatewayClient:
471
497
  pass # Expected
472
498
 
473
499
  if self._receive_task and not self._receive_task.done():
500
+ current = asyncio.current_task(loop=self._loop)
474
501
  self._receive_task.cancel()
475
- try:
476
- await self._receive_task
477
- except asyncio.CancelledError:
478
- pass # Expected
502
+ if self._receive_task is not current:
503
+ try:
504
+ await self._receive_task
505
+ except asyncio.CancelledError:
506
+ pass # Expected
479
507
 
480
508
  if self._ws and not self._ws.closed:
481
509
  await self._ws.close(code=code)
@@ -491,3 +519,18 @@ class GatewayClient:
491
519
  self._session_id = None
492
520
  self._last_sequence = None
493
521
  self._resume_gateway_url = None # This might be re-fetched anyway
522
+
523
+ @property
524
+ def latency(self) -> Optional[float]:
525
+ """Returns the latency between heartbeat and ACK in seconds."""
526
+ if self._last_heartbeat_sent is None or self._last_heartbeat_ack is None:
527
+ return None
528
+ return self._last_heartbeat_ack - self._last_heartbeat_sent
529
+
530
+ @property
531
+ def last_heartbeat_sent(self) -> Optional[float]:
532
+ return self._last_heartbeat_sent
533
+
534
+ @property
535
+ def last_heartbeat_ack(self) -> Optional[float]:
536
+ return self._last_heartbeat_ack
disagreement/http.py CHANGED
@@ -17,11 +17,13 @@ from .errors import (
17
17
  DisagreementException,
18
18
  )
19
19
  from . import __version__ # For User-Agent
20
+ from .rate_limiter import RateLimiter
21
+ from .interactions import InteractionResponsePayload
20
22
 
21
23
  if TYPE_CHECKING:
22
24
  from .client import Client
23
- from .models import Message
24
- from .interactions import ApplicationCommand, InteractionResponsePayload, Snowflake
25
+ from .models import Message, Webhook, File
26
+ from .interactions import ApplicationCommand, Snowflake
25
27
 
26
28
  # Discord API constants
27
29
  API_BASE_URL = "https://discord.com/api/v10" # Using API v10
@@ -44,8 +46,7 @@ class HTTPClient:
44
46
 
45
47
  self.verbose = verbose
46
48
 
47
- self._global_rate_limit_lock = asyncio.Event()
48
- self._global_rate_limit_lock.set() # Initially unlocked
49
+ self._rate_limiter = RateLimiter()
49
50
 
50
51
  async def _ensure_session(self):
51
52
  if self._session is None or self._session.closed:
@@ -60,7 +61,9 @@ class HTTPClient:
60
61
  self,
61
62
  method: str,
62
63
  endpoint: str,
63
- payload: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None,
64
+ payload: Optional[
65
+ Union[Dict[str, Any], List[Dict[str, Any]], aiohttp.FormData]
66
+ ] = None,
64
67
  params: Optional[Dict[str, Any]] = None,
65
68
  is_json: bool = True,
66
69
  use_auth_header: bool = True,
@@ -85,10 +88,10 @@ class HTTPClient:
85
88
  if self.verbose:
86
89
  print(f"HTTP REQUEST: {method} {url} | payload={payload} params={params}")
87
90
 
88
- # Global rate limit handling
89
- await self._global_rate_limit_lock.wait()
91
+ route = f"{method.upper()}:{endpoint}"
90
92
 
91
93
  for attempt in range(5): # Max 5 retries for rate limits
94
+ await self._rate_limiter.acquire(route)
92
95
  assert self._session is not None, "ClientSession not initialized"
93
96
  async with self._session.request(
94
97
  method,
@@ -118,6 +121,8 @@ class HTTPClient:
118
121
  if self.verbose:
119
122
  print(f"HTTP RESPONSE: {response.status} {url} | {data}")
120
123
 
124
+ self._rate_limiter.release(route, response.headers)
125
+
121
126
  if 200 <= response.status < 300:
122
127
  if response.status == 204:
123
128
  return None
@@ -140,12 +145,9 @@ class HTTPClient:
140
145
  if data and isinstance(data, dict) and "message" in data:
141
146
  error_message += f" Discord says: {data['message']}"
142
147
 
143
- if is_global:
144
- self._global_rate_limit_lock.clear()
145
- await asyncio.sleep(retry_after)
146
- self._global_rate_limit_lock.set()
147
- else:
148
- await asyncio.sleep(retry_after)
148
+ await self._rate_limiter.handle_rate_limit(
149
+ route, retry_after, is_global
150
+ )
149
151
 
150
152
  if attempt < 4: # Don't log on the last attempt before raising
151
153
  print(
@@ -204,11 +206,23 @@ class HTTPClient:
204
206
  components: Optional[List[Dict[str, Any]]] = None,
205
207
  allowed_mentions: Optional[dict] = None,
206
208
  message_reference: Optional[Dict[str, Any]] = None,
209
+ attachments: Optional[List[Any]] = None,
210
+ files: Optional[List[Any]] = None,
207
211
  flags: Optional[int] = None,
208
212
  ) -> Dict[str, Any]:
209
213
  """Sends a message to a channel.
210
214
 
211
- Returns the created message data as a dict.
215
+ Parameters
216
+ ----------
217
+ attachments:
218
+ A list of attachment payloads to include with the message.
219
+ files:
220
+ A list of :class:`File` objects containing binary data to upload.
221
+
222
+ Returns
223
+ -------
224
+ Dict[str, Any]
225
+ The created message data.
212
226
  """
213
227
  payload: Dict[str, Any] = {}
214
228
  if content is not None: # Content is optional if embeds/components are present
@@ -221,6 +235,28 @@ class HTTPClient:
221
235
  payload["components"] = components
222
236
  if allowed_mentions:
223
237
  payload["allowed_mentions"] = allowed_mentions
238
+ all_files: List["File"] = []
239
+ if attachments is not None:
240
+ payload["attachments"] = []
241
+ for a in attachments:
242
+ if hasattr(a, "data") and hasattr(a, "filename"):
243
+ idx = len(all_files)
244
+ all_files.append(a)
245
+ payload["attachments"].append({"id": idx, "filename": a.filename})
246
+ else:
247
+ payload["attachments"].append(
248
+ a.to_dict() if hasattr(a, "to_dict") else a
249
+ )
250
+ if files is not None:
251
+ for f in files:
252
+ if hasattr(f, "data") and hasattr(f, "filename"):
253
+ idx = len(all_files)
254
+ all_files.append(f)
255
+ if "attachments" not in payload:
256
+ payload["attachments"] = []
257
+ payload["attachments"].append({"id": idx, "filename": f.filename})
258
+ else:
259
+ raise TypeError("files must be File objects")
224
260
  if flags:
225
261
  payload["flags"] = flags
226
262
  if message_reference:
@@ -229,6 +265,25 @@ class HTTPClient:
229
265
  if not payload:
230
266
  raise ValueError("Message must have content, embeds, or components.")
231
267
 
268
+ if all_files:
269
+ form = aiohttp.FormData()
270
+ form.add_field(
271
+ "payload_json", json.dumps(payload), content_type="application/json"
272
+ )
273
+ for idx, f in enumerate(all_files):
274
+ form.add_field(
275
+ f"files[{idx}]",
276
+ f.data,
277
+ filename=f.filename,
278
+ content_type="application/octet-stream",
279
+ )
280
+ return await self.request(
281
+ "POST",
282
+ f"/channels/{channel_id}/messages",
283
+ payload=form,
284
+ is_json=False,
285
+ )
286
+
232
287
  return await self.request(
233
288
  "POST", f"/channels/{channel_id}/messages", payload=payload
234
289
  )
@@ -256,6 +311,13 @@ class HTTPClient:
256
311
  "GET", f"/channels/{channel_id}/messages/{message_id}"
257
312
  )
258
313
 
314
+ async def delete_message(
315
+ self, channel_id: "Snowflake", message_id: "Snowflake"
316
+ ) -> None:
317
+ """Deletes a message in a channel."""
318
+
319
+ await self.request("DELETE", f"/channels/{channel_id}/messages/{message_id}")
320
+
259
321
  async def create_reaction(
260
322
  self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
261
323
  ) -> None:
@@ -286,6 +348,28 @@ class HTTPClient:
286
348
  f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}",
287
349
  )
288
350
 
351
+ async def clear_reactions(
352
+ self, channel_id: "Snowflake", message_id: "Snowflake"
353
+ ) -> None:
354
+ """Removes all reactions from a message."""
355
+
356
+ await self.request(
357
+ "DELETE",
358
+ f"/channels/{channel_id}/messages/{message_id}/reactions",
359
+ )
360
+
361
+ async def bulk_delete_messages(
362
+ self, channel_id: "Snowflake", messages: List["Snowflake"]
363
+ ) -> List["Snowflake"]:
364
+ """Bulk deletes messages in a channel and returns their IDs."""
365
+
366
+ await self.request(
367
+ "POST",
368
+ f"/channels/{channel_id}/messages/bulk-delete",
369
+ payload={"messages": messages},
370
+ )
371
+ return messages
372
+
289
373
  async def delete_channel(
290
374
  self, channel_id: str, reason: Optional[str] = None
291
375
  ) -> None:
@@ -310,6 +394,119 @@ class HTTPClient:
310
394
  """Fetches a channel by ID."""
311
395
  return await self.request("GET", f"/channels/{channel_id}")
312
396
 
397
+ async def create_webhook(
398
+ self, channel_id: "Snowflake", payload: Dict[str, Any]
399
+ ) -> "Webhook":
400
+ """Creates a webhook in the specified channel."""
401
+
402
+ data = await self.request(
403
+ "POST", f"/channels/{channel_id}/webhooks", payload=payload
404
+ )
405
+ from .models import Webhook
406
+
407
+ return Webhook(data)
408
+
409
+ async def edit_webhook(
410
+ self, webhook_id: "Snowflake", payload: Dict[str, Any]
411
+ ) -> "Webhook":
412
+ """Edits an existing webhook."""
413
+
414
+ data = await self.request("PATCH", f"/webhooks/{webhook_id}", payload=payload)
415
+ from .models import Webhook
416
+
417
+ return Webhook(data)
418
+
419
+ async def delete_webhook(self, webhook_id: "Snowflake") -> None:
420
+ """Deletes a webhook."""
421
+
422
+ await self.request("DELETE", f"/webhooks/{webhook_id}")
423
+
424
+ async def execute_webhook(
425
+ self,
426
+ webhook_id: "Snowflake",
427
+ token: str,
428
+ *,
429
+ content: Optional[str] = None,
430
+ tts: bool = False,
431
+ embeds: Optional[List[Dict[str, Any]]] = None,
432
+ components: Optional[List[Dict[str, Any]]] = None,
433
+ allowed_mentions: Optional[dict] = None,
434
+ attachments: Optional[List[Any]] = None,
435
+ files: Optional[List[Any]] = None,
436
+ flags: Optional[int] = None,
437
+ username: Optional[str] = None,
438
+ avatar_url: Optional[str] = None,
439
+ ) -> Dict[str, Any]:
440
+ """Executes a webhook and returns the created message."""
441
+
442
+ payload: Dict[str, Any] = {}
443
+ if content is not None:
444
+ payload["content"] = content
445
+ if tts:
446
+ payload["tts"] = True
447
+ if embeds:
448
+ payload["embeds"] = embeds
449
+ if components:
450
+ payload["components"] = components
451
+ if allowed_mentions:
452
+ payload["allowed_mentions"] = allowed_mentions
453
+ if username:
454
+ payload["username"] = username
455
+ if avatar_url:
456
+ payload["avatar_url"] = avatar_url
457
+
458
+ all_files: List["File"] = []
459
+ if attachments is not None:
460
+ payload["attachments"] = []
461
+ for a in attachments:
462
+ if hasattr(a, "data") and hasattr(a, "filename"):
463
+ idx = len(all_files)
464
+ all_files.append(a)
465
+ payload["attachments"].append({"id": idx, "filename": a.filename})
466
+ else:
467
+ payload["attachments"].append(
468
+ a.to_dict() if hasattr(a, "to_dict") else a
469
+ )
470
+ if files is not None:
471
+ for f in files:
472
+ if hasattr(f, "data") and hasattr(f, "filename"):
473
+ idx = len(all_files)
474
+ all_files.append(f)
475
+ if "attachments" not in payload:
476
+ payload["attachments"] = []
477
+ payload["attachments"].append({"id": idx, "filename": f.filename})
478
+ else:
479
+ raise TypeError("files must be File objects")
480
+ if flags:
481
+ payload["flags"] = flags
482
+
483
+ if all_files:
484
+ form = aiohttp.FormData()
485
+ form.add_field(
486
+ "payload_json", json.dumps(payload), content_type="application/json"
487
+ )
488
+ for idx, f in enumerate(all_files):
489
+ form.add_field(
490
+ f"files[{idx}]",
491
+ f.data,
492
+ filename=f.filename,
493
+ content_type="application/octet-stream",
494
+ )
495
+ return await self.request(
496
+ "POST",
497
+ f"/webhooks/{webhook_id}/{token}",
498
+ payload=form,
499
+ is_json=False,
500
+ use_auth_header=False,
501
+ )
502
+
503
+ return await self.request(
504
+ "POST",
505
+ f"/webhooks/{webhook_id}/{token}",
506
+ payload=payload,
507
+ use_auth_header=False,
508
+ )
509
+
313
510
  async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]:
314
511
  """Fetches a user object for a given user ID."""
315
512
  return await self.request("GET", f"/users/{user_id}")
@@ -556,7 +753,7 @@ class HTTPClient:
556
753
  self,
557
754
  interaction_id: "Snowflake",
558
755
  interaction_token: str,
559
- payload: "InteractionResponsePayload",
756
+ payload: Union["InteractionResponsePayload", Dict[str, Any]],
560
757
  *,
561
758
  ephemeral: bool = False,
562
759
  ) -> None:
@@ -569,10 +766,16 @@ class HTTPClient:
569
766
  """
570
767
  # Interaction responses do not use the bot token in the Authorization header.
571
768
  # They are authenticated by the interaction_token in the URL.
769
+ payload_data: Dict[str, Any]
770
+ if isinstance(payload, InteractionResponsePayload):
771
+ payload_data = payload.to_dict()
772
+ else:
773
+ payload_data = payload
774
+
572
775
  await self.request(
573
776
  "POST",
574
777
  f"/interactions/{interaction_id}/{interaction_token}/callback",
575
- payload=payload.to_dict(),
778
+ payload=payload_data,
576
779
  use_auth_header=False,
577
780
  )
578
781