disagreement 0.2.0rc1__py3-none-any.whl → 0.4.0__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.
Files changed (38) hide show
  1. disagreement/__init__.py +2 -4
  2. disagreement/audio.py +42 -5
  3. disagreement/cache.py +43 -4
  4. disagreement/caching.py +121 -0
  5. disagreement/client.py +1682 -1535
  6. disagreement/enums.py +10 -3
  7. disagreement/error_handler.py +5 -1
  8. disagreement/errors.py +1341 -3
  9. disagreement/event_dispatcher.py +3 -5
  10. disagreement/ext/__init__.py +1 -0
  11. disagreement/ext/app_commands/__init__.py +0 -2
  12. disagreement/ext/app_commands/commands.py +0 -2
  13. disagreement/ext/app_commands/context.py +0 -2
  14. disagreement/ext/app_commands/converters.py +2 -4
  15. disagreement/ext/app_commands/decorators.py +5 -7
  16. disagreement/ext/app_commands/handler.py +1 -3
  17. disagreement/ext/app_commands/hybrid.py +0 -2
  18. disagreement/ext/commands/__init__.py +63 -61
  19. disagreement/ext/commands/cog.py +0 -2
  20. disagreement/ext/commands/converters.py +16 -5
  21. disagreement/ext/commands/core.py +728 -563
  22. disagreement/ext/commands/decorators.py +294 -219
  23. disagreement/ext/commands/errors.py +0 -2
  24. disagreement/ext/commands/help.py +0 -2
  25. disagreement/ext/commands/view.py +1 -3
  26. disagreement/gateway.py +632 -586
  27. disagreement/http.py +1362 -1041
  28. disagreement/interactions.py +0 -2
  29. disagreement/models.py +2682 -2263
  30. disagreement/shard_manager.py +0 -2
  31. disagreement/ui/view.py +167 -165
  32. disagreement/voice_client.py +263 -162
  33. {disagreement-0.2.0rc1.dist-info → disagreement-0.4.0.dist-info}/METADATA +33 -6
  34. disagreement-0.4.0.dist-info/RECORD +55 -0
  35. disagreement-0.2.0rc1.dist-info/RECORD +0 -54
  36. {disagreement-0.2.0rc1.dist-info → disagreement-0.4.0.dist-info}/WHEEL +0 -0
  37. {disagreement-0.2.0rc1.dist-info → disagreement-0.4.0.dist-info}/licenses/LICENSE +0 -0
  38. {disagreement-0.2.0rc1.dist-info → disagreement-0.4.0.dist-info}/top_level.txt +0 -0
disagreement/http.py CHANGED
@@ -1,1041 +1,1362 @@
1
- # disagreement/http.py
2
-
3
- """
4
- HTTP client for interacting with the Discord REST API.
5
- """
6
-
7
- import asyncio
8
- import logging
9
- import aiohttp # pylint: disable=import-error
10
- import json
11
- from urllib.parse import quote
12
- from typing import Optional, Dict, Any, Union, TYPE_CHECKING, List
13
-
14
- from .errors import (
15
- HTTPException,
16
- RateLimitError,
17
- AuthenticationError,
18
- DisagreementException,
19
- )
20
- from . import __version__ # For User-Agent
21
- from .rate_limiter import RateLimiter
22
- from .interactions import InteractionResponsePayload
23
-
24
- if TYPE_CHECKING:
25
- from .client import Client
26
- from .models import Message, Webhook, File, StageInstance, Invite
27
- from .interactions import ApplicationCommand, Snowflake
28
-
29
- # Discord API constants
30
- API_BASE_URL = "https://discord.com/api/v10" # Using API v10
31
-
32
- logger = logging.getLogger(__name__)
33
-
34
-
35
- class HTTPClient:
36
- """Handles HTTP requests to the Discord API."""
37
-
38
- def __init__(
39
- self,
40
- token: str,
41
- client_session: Optional[aiohttp.ClientSession] = None,
42
- verbose: bool = False,
43
- **session_kwargs: Any,
44
- ):
45
- """Create a new HTTP client.
46
-
47
- Parameters
48
- ----------
49
- token:
50
- Bot token for authentication.
51
- client_session:
52
- Optional existing :class:`aiohttp.ClientSession`.
53
- verbose:
54
- If ``True``, log HTTP requests and responses.
55
- **session_kwargs:
56
- Additional options forwarded to :class:`aiohttp.ClientSession`, such
57
- as ``proxy`` or ``connector``.
58
- """
59
-
60
- self.token = token
61
- self._session: Optional[aiohttp.ClientSession] = client_session
62
- self._session_kwargs: Dict[str, Any] = session_kwargs
63
- self.user_agent = f"DiscordBot (https://github.com/Slipstreamm/disagreement, {__version__})" # Customize URL
64
-
65
- self.verbose = verbose
66
-
67
- self._rate_limiter = RateLimiter()
68
-
69
- async def _ensure_session(self):
70
- if self._session is None or self._session.closed:
71
- self._session = aiohttp.ClientSession(**self._session_kwargs)
72
-
73
- async def close(self):
74
- """Closes the underlying aiohttp.ClientSession."""
75
- if self._session and not self._session.closed:
76
- await self._session.close()
77
-
78
- async def request(
79
- self,
80
- method: str,
81
- endpoint: str,
82
- payload: Optional[
83
- Union[Dict[str, Any], List[Dict[str, Any]], aiohttp.FormData]
84
- ] = None,
85
- params: Optional[Dict[str, Any]] = None,
86
- is_json: bool = True,
87
- use_auth_header: bool = True,
88
- custom_headers: Optional[Dict[str, str]] = None,
89
- ) -> Any:
90
- """Makes an HTTP request to the Discord API."""
91
- await self._ensure_session()
92
-
93
- url = f"{API_BASE_URL}{endpoint}"
94
- final_headers: Dict[str, str] = { # Renamed to final_headers
95
- "User-Agent": self.user_agent,
96
- }
97
- if use_auth_header:
98
- final_headers["Authorization"] = f"Bot {self.token}"
99
-
100
- if is_json and payload:
101
- final_headers["Content-Type"] = "application/json"
102
-
103
- if custom_headers: # Merge custom headers
104
- final_headers.update(custom_headers)
105
-
106
- if self.verbose:
107
- logger.debug(
108
- "HTTP REQUEST: %s %s | payload=%s params=%s",
109
- method,
110
- url,
111
- payload,
112
- params,
113
- )
114
-
115
- route = f"{method.upper()}:{endpoint}"
116
-
117
- for attempt in range(5): # Max 5 retries for rate limits
118
- await self._rate_limiter.acquire(route)
119
- assert self._session is not None, "ClientSession not initialized"
120
- async with self._session.request(
121
- method,
122
- url,
123
- json=payload if is_json else None,
124
- data=payload if not is_json else None,
125
- headers=final_headers,
126
- params=params,
127
- ) as response:
128
-
129
- data = None
130
- try:
131
- if response.headers.get("Content-Type", "").startswith(
132
- "application/json"
133
- ):
134
- data = await response.json()
135
- else:
136
- # For non-JSON responses, like fetching images or other files
137
- # We might return the raw response or handle it differently
138
- # For now, let's assume most API calls expect JSON
139
- data = await response.text()
140
- except (aiohttp.ContentTypeError, json.JSONDecodeError):
141
- data = (
142
- await response.text()
143
- ) # Fallback to text if JSON parsing fails
144
-
145
- if self.verbose:
146
- logger.debug(
147
- "HTTP RESPONSE: %s %s | %s", response.status, url, data
148
- )
149
-
150
- self._rate_limiter.release(route, response.headers)
151
-
152
- if 200 <= response.status < 300:
153
- if response.status == 204:
154
- return None
155
- return data
156
-
157
- # Rate limit handling
158
- if response.status == 429: # Rate limited
159
- retry_after_str = response.headers.get("Retry-After", "1")
160
- try:
161
- retry_after = float(retry_after_str)
162
- except ValueError:
163
- retry_after = 1.0 # Default retry if header is malformed
164
-
165
- is_global = (
166
- response.headers.get("X-RateLimit-Global", "false").lower()
167
- == "true"
168
- )
169
-
170
- error_message = f"Rate limited on {method} {endpoint}."
171
- if data and isinstance(data, dict) and "message" in data:
172
- error_message += f" Discord says: {data['message']}"
173
-
174
- await self._rate_limiter.handle_rate_limit(
175
- route, retry_after, is_global
176
- )
177
-
178
- if attempt < 4: # Don't log on the last attempt before raising
179
- logger.warning(
180
- "%s Retrying after %ss (Attempt %s/5). Global: %s",
181
- error_message,
182
- retry_after,
183
- attempt + 1,
184
- is_global,
185
- )
186
- continue # Retry the request
187
- else: # Last attempt failed
188
- raise RateLimitError(
189
- response,
190
- message=error_message,
191
- retry_after=retry_after,
192
- is_global=is_global,
193
- )
194
-
195
- # Other error handling
196
- if response.status == 401: # Unauthorized
197
- raise AuthenticationError(response, "Invalid token provided.")
198
- if response.status == 403: # Forbidden
199
- raise HTTPException(
200
- response,
201
- "Missing permissions or access denied.",
202
- status=response.status,
203
- text=str(data),
204
- )
205
-
206
- # General HTTP error
207
- error_text = str(data) if data else "Unknown error"
208
- discord_error_code = (
209
- data.get("code") if isinstance(data, dict) else None
210
- )
211
- raise HTTPException(
212
- response,
213
- f"API Error on {method} {endpoint}: {error_text}",
214
- status=response.status,
215
- text=error_text,
216
- error_code=discord_error_code,
217
- )
218
-
219
- # Should not be reached if retries are exhausted by RateLimitError
220
- raise DisagreementException(
221
- f"Failed request to {method} {endpoint} after multiple retries."
222
- )
223
-
224
- # --- Specific API call methods ---
225
-
226
- async def get_gateway_bot(self) -> Dict[str, Any]:
227
- """Gets the WSS URL and sharding information for the Gateway."""
228
- return await self.request("GET", "/gateway/bot")
229
-
230
- async def send_message(
231
- self,
232
- channel_id: str,
233
- content: Optional[str] = None,
234
- tts: bool = False,
235
- embeds: Optional[List[Dict[str, Any]]] = None,
236
- components: Optional[List[Dict[str, Any]]] = None,
237
- allowed_mentions: Optional[dict] = None,
238
- message_reference: Optional[Dict[str, Any]] = None,
239
- attachments: Optional[List[Any]] = None,
240
- files: Optional[List[Any]] = None,
241
- flags: Optional[int] = None,
242
- ) -> Dict[str, Any]:
243
- """Sends a message to a channel.
244
-
245
- Parameters
246
- ----------
247
- attachments:
248
- A list of attachment payloads to include with the message.
249
- files:
250
- A list of :class:`File` objects containing binary data to upload.
251
-
252
- Returns
253
- -------
254
- Dict[str, Any]
255
- The created message data.
256
- """
257
- payload: Dict[str, Any] = {}
258
- if content is not None: # Content is optional if embeds/components are present
259
- payload["content"] = content
260
- if tts:
261
- payload["tts"] = True
262
- if embeds:
263
- payload["embeds"] = embeds
264
- if components:
265
- payload["components"] = components
266
- if allowed_mentions:
267
- payload["allowed_mentions"] = allowed_mentions
268
- all_files: List["File"] = []
269
- if attachments is not None:
270
- payload["attachments"] = []
271
- for a in attachments:
272
- if hasattr(a, "data") and hasattr(a, "filename"):
273
- idx = len(all_files)
274
- all_files.append(a)
275
- payload["attachments"].append({"id": idx, "filename": a.filename})
276
- else:
277
- payload["attachments"].append(
278
- a.to_dict() if hasattr(a, "to_dict") else a
279
- )
280
- if files is not None:
281
- for f in files:
282
- if hasattr(f, "data") and hasattr(f, "filename"):
283
- idx = len(all_files)
284
- all_files.append(f)
285
- if "attachments" not in payload:
286
- payload["attachments"] = []
287
- payload["attachments"].append({"id": idx, "filename": f.filename})
288
- else:
289
- raise TypeError("files must be File objects")
290
- if flags:
291
- payload["flags"] = flags
292
- if message_reference:
293
- payload["message_reference"] = message_reference
294
-
295
- if not payload:
296
- raise ValueError("Message must have content, embeds, or components.")
297
-
298
- if all_files:
299
- form = aiohttp.FormData()
300
- form.add_field(
301
- "payload_json", json.dumps(payload), content_type="application/json"
302
- )
303
- for idx, f in enumerate(all_files):
304
- form.add_field(
305
- f"files[{idx}]",
306
- f.data,
307
- filename=f.filename,
308
- content_type="application/octet-stream",
309
- )
310
- return await self.request(
311
- "POST",
312
- f"/channels/{channel_id}/messages",
313
- payload=form,
314
- is_json=False,
315
- )
316
-
317
- return await self.request(
318
- "POST", f"/channels/{channel_id}/messages", payload=payload
319
- )
320
-
321
- async def edit_message(
322
- self,
323
- channel_id: str,
324
- message_id: str,
325
- payload: Dict[str, Any],
326
- ) -> Dict[str, Any]:
327
- """Edits a message in a channel."""
328
-
329
- return await self.request(
330
- "PATCH",
331
- f"/channels/{channel_id}/messages/{message_id}",
332
- payload=payload,
333
- )
334
-
335
- async def get_message(
336
- self, channel_id: "Snowflake", message_id: "Snowflake"
337
- ) -> Dict[str, Any]:
338
- """Fetches a message from a channel."""
339
-
340
- return await self.request(
341
- "GET", f"/channels/{channel_id}/messages/{message_id}"
342
- )
343
-
344
- async def delete_message(
345
- self, channel_id: "Snowflake", message_id: "Snowflake"
346
- ) -> None:
347
- """Deletes a message in a channel."""
348
-
349
- await self.request("DELETE", f"/channels/{channel_id}/messages/{message_id}")
350
-
351
- async def create_reaction(
352
- self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
353
- ) -> None:
354
- """Adds a reaction to a message as the current user."""
355
- encoded = quote(emoji)
356
- await self.request(
357
- "PUT",
358
- f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/@me",
359
- )
360
-
361
- async def delete_reaction(
362
- self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
363
- ) -> None:
364
- """Removes the current user's reaction from a message."""
365
- encoded = quote(emoji)
366
- await self.request(
367
- "DELETE",
368
- f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/@me",
369
- )
370
-
371
- async def get_reactions(
372
- self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
373
- ) -> List[Dict[str, Any]]:
374
- """Fetches the users that reacted with a specific emoji."""
375
- encoded = quote(emoji)
376
- return await self.request(
377
- "GET",
378
- f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}",
379
- )
380
-
381
- async def clear_reactions(
382
- self, channel_id: "Snowflake", message_id: "Snowflake"
383
- ) -> None:
384
- """Removes all reactions from a message."""
385
-
386
- await self.request(
387
- "DELETE",
388
- f"/channels/{channel_id}/messages/{message_id}/reactions",
389
- )
390
-
391
- async def bulk_delete_messages(
392
- self, channel_id: "Snowflake", messages: List["Snowflake"]
393
- ) -> List["Snowflake"]:
394
- """Bulk deletes messages in a channel and returns their IDs."""
395
-
396
- await self.request(
397
- "POST",
398
- f"/channels/{channel_id}/messages/bulk-delete",
399
- payload={"messages": messages},
400
- )
401
- return messages
402
-
403
- async def delete_channel(
404
- self, channel_id: str, reason: Optional[str] = None
405
- ) -> None:
406
- """Deletes a channel.
407
-
408
- If the channel is a guild channel, requires the MANAGE_CHANNELS permission.
409
- If the channel is a thread, requires the MANAGE_THREADS permission (if locked) or
410
- be the thread creator (if not locked).
411
- Deleting a category does not delete its child channels.
412
- """
413
- custom_headers = {}
414
- if reason:
415
- custom_headers["X-Audit-Log-Reason"] = reason
416
-
417
- await self.request(
418
- "DELETE",
419
- f"/channels/{channel_id}",
420
- custom_headers=custom_headers if custom_headers else None,
421
- )
422
-
423
- async def get_channel(self, channel_id: str) -> Dict[str, Any]:
424
- """Fetches a channel by ID."""
425
- return await self.request("GET", f"/channels/{channel_id}")
426
-
427
- async def get_channel_invites(
428
- self, channel_id: "Snowflake"
429
- ) -> List[Dict[str, Any]]:
430
- """Fetches the invites for a channel."""
431
-
432
- return await self.request("GET", f"/channels/{channel_id}/invites")
433
-
434
- async def create_invite(
435
- self, channel_id: "Snowflake", payload: Dict[str, Any]
436
- ) -> "Invite":
437
- """Creates an invite for a channel."""
438
-
439
- data = await self.request(
440
- "POST", f"/channels/{channel_id}/invites", payload=payload
441
- )
442
- from .models import Invite
443
-
444
- return Invite.from_dict(data)
445
-
446
- async def delete_invite(self, code: str) -> None:
447
- """Deletes an invite by code."""
448
-
449
- await self.request("DELETE", f"/invites/{code}")
450
-
451
- async def create_webhook(
452
- self, channel_id: "Snowflake", payload: Dict[str, Any]
453
- ) -> "Webhook":
454
- """Creates a webhook in the specified channel."""
455
-
456
- data = await self.request(
457
- "POST", f"/channels/{channel_id}/webhooks", payload=payload
458
- )
459
- from .models import Webhook
460
-
461
- return Webhook(data)
462
-
463
- async def edit_webhook(
464
- self, webhook_id: "Snowflake", payload: Dict[str, Any]
465
- ) -> "Webhook":
466
- """Edits an existing webhook."""
467
-
468
- data = await self.request("PATCH", f"/webhooks/{webhook_id}", payload=payload)
469
- from .models import Webhook
470
-
471
- return Webhook(data)
472
-
473
- async def delete_webhook(self, webhook_id: "Snowflake") -> None:
474
- """Deletes a webhook."""
475
-
476
- await self.request("DELETE", f"/webhooks/{webhook_id}")
477
-
478
- async def execute_webhook(
479
- self,
480
- webhook_id: "Snowflake",
481
- token: str,
482
- *,
483
- content: Optional[str] = None,
484
- tts: bool = False,
485
- embeds: Optional[List[Dict[str, Any]]] = None,
486
- components: Optional[List[Dict[str, Any]]] = None,
487
- allowed_mentions: Optional[dict] = None,
488
- attachments: Optional[List[Any]] = None,
489
- files: Optional[List[Any]] = None,
490
- flags: Optional[int] = None,
491
- username: Optional[str] = None,
492
- avatar_url: Optional[str] = None,
493
- ) -> Dict[str, Any]:
494
- """Executes a webhook and returns the created message."""
495
-
496
- payload: Dict[str, Any] = {}
497
- if content is not None:
498
- payload["content"] = content
499
- if tts:
500
- payload["tts"] = True
501
- if embeds:
502
- payload["embeds"] = embeds
503
- if components:
504
- payload["components"] = components
505
- if allowed_mentions:
506
- payload["allowed_mentions"] = allowed_mentions
507
- if username:
508
- payload["username"] = username
509
- if avatar_url:
510
- payload["avatar_url"] = avatar_url
511
-
512
- all_files: List["File"] = []
513
- if attachments is not None:
514
- payload["attachments"] = []
515
- for a in attachments:
516
- if hasattr(a, "data") and hasattr(a, "filename"):
517
- idx = len(all_files)
518
- all_files.append(a)
519
- payload["attachments"].append({"id": idx, "filename": a.filename})
520
- else:
521
- payload["attachments"].append(
522
- a.to_dict() if hasattr(a, "to_dict") else a
523
- )
524
- if files is not None:
525
- for f in files:
526
- if hasattr(f, "data") and hasattr(f, "filename"):
527
- idx = len(all_files)
528
- all_files.append(f)
529
- if "attachments" not in payload:
530
- payload["attachments"] = []
531
- payload["attachments"].append({"id": idx, "filename": f.filename})
532
- else:
533
- raise TypeError("files must be File objects")
534
- if flags:
535
- payload["flags"] = flags
536
-
537
- if all_files:
538
- form = aiohttp.FormData()
539
- form.add_field(
540
- "payload_json", json.dumps(payload), content_type="application/json"
541
- )
542
- for idx, f in enumerate(all_files):
543
- form.add_field(
544
- f"files[{idx}]",
545
- f.data,
546
- filename=f.filename,
547
- content_type="application/octet-stream",
548
- )
549
- return await self.request(
550
- "POST",
551
- f"/webhooks/{webhook_id}/{token}",
552
- payload=form,
553
- is_json=False,
554
- use_auth_header=False,
555
- )
556
-
557
- return await self.request(
558
- "POST",
559
- f"/webhooks/{webhook_id}/{token}",
560
- payload=payload,
561
- use_auth_header=False,
562
- )
563
-
564
- async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]:
565
- """Fetches a user object for a given user ID."""
566
- return await self.request("GET", f"/users/{user_id}")
567
-
568
- async def get_guild_member(
569
- self, guild_id: "Snowflake", user_id: "Snowflake"
570
- ) -> Dict[str, Any]:
571
- """Returns a guild member object for the specified user."""
572
- return await self.request("GET", f"/guilds/{guild_id}/members/{user_id}")
573
-
574
- async def kick_member(
575
- self, guild_id: "Snowflake", user_id: "Snowflake", reason: Optional[str] = None
576
- ) -> None:
577
- """Kicks a member from the guild."""
578
- headers = {"X-Audit-Log-Reason": reason} if reason else None
579
- await self.request(
580
- "DELETE",
581
- f"/guilds/{guild_id}/members/{user_id}",
582
- custom_headers=headers,
583
- )
584
-
585
- async def ban_member(
586
- self,
587
- guild_id: "Snowflake",
588
- user_id: "Snowflake",
589
- *,
590
- delete_message_seconds: int = 0,
591
- reason: Optional[str] = None,
592
- ) -> None:
593
- """Bans a member from the guild."""
594
- payload = {}
595
- if delete_message_seconds:
596
- payload["delete_message_seconds"] = delete_message_seconds
597
- headers = {"X-Audit-Log-Reason": reason} if reason else None
598
- await self.request(
599
- "PUT",
600
- f"/guilds/{guild_id}/bans/{user_id}",
601
- payload=payload if payload else None,
602
- custom_headers=headers,
603
- )
604
-
605
- async def timeout_member(
606
- self,
607
- guild_id: "Snowflake",
608
- user_id: "Snowflake",
609
- *,
610
- until: Optional[str],
611
- reason: Optional[str] = None,
612
- ) -> Dict[str, Any]:
613
- """Times out a member until the given ISO8601 timestamp."""
614
- payload = {"communication_disabled_until": until}
615
- headers = {"X-Audit-Log-Reason": reason} if reason else None
616
- return await self.request(
617
- "PATCH",
618
- f"/guilds/{guild_id}/members/{user_id}",
619
- payload=payload,
620
- custom_headers=headers,
621
- )
622
-
623
- async def get_guild_roles(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
624
- """Returns a list of role objects for the guild."""
625
- return await self.request("GET", f"/guilds/{guild_id}/roles")
626
-
627
- async def get_guild(self, guild_id: "Snowflake") -> Dict[str, Any]:
628
- """Fetches a guild object for a given guild ID."""
629
- return await self.request("GET", f"/guilds/{guild_id}")
630
-
631
- async def get_guild_templates(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
632
- """Fetches all templates for the given guild."""
633
- return await self.request("GET", f"/guilds/{guild_id}/templates")
634
-
635
- async def create_guild_template(
636
- self, guild_id: "Snowflake", payload: Dict[str, Any]
637
- ) -> Dict[str, Any]:
638
- """Creates a guild template."""
639
- return await self.request(
640
- "POST", f"/guilds/{guild_id}/templates", payload=payload
641
- )
642
-
643
- async def sync_guild_template(
644
- self, guild_id: "Snowflake", template_code: str
645
- ) -> Dict[str, Any]:
646
- """Syncs a guild template to the guild's current state."""
647
- return await self.request(
648
- "PUT",
649
- f"/guilds/{guild_id}/templates/{template_code}",
650
- )
651
-
652
- async def delete_guild_template(
653
- self, guild_id: "Snowflake", template_code: str
654
- ) -> None:
655
- """Deletes a guild template."""
656
- await self.request("DELETE", f"/guilds/{guild_id}/templates/{template_code}")
657
-
658
- async def get_guild_scheduled_events(
659
- self, guild_id: "Snowflake"
660
- ) -> List[Dict[str, Any]]:
661
- """Returns a list of scheduled events for the guild."""
662
-
663
- return await self.request("GET", f"/guilds/{guild_id}/scheduled-events")
664
-
665
- async def get_guild_scheduled_event(
666
- self, guild_id: "Snowflake", event_id: "Snowflake"
667
- ) -> Dict[str, Any]:
668
- """Returns a guild scheduled event."""
669
-
670
- return await self.request(
671
- "GET", f"/guilds/{guild_id}/scheduled-events/{event_id}"
672
- )
673
-
674
- async def create_guild_scheduled_event(
675
- self, guild_id: "Snowflake", payload: Dict[str, Any]
676
- ) -> Dict[str, Any]:
677
- """Creates a guild scheduled event."""
678
-
679
- return await self.request(
680
- "POST", f"/guilds/{guild_id}/scheduled-events", payload=payload
681
- )
682
-
683
- async def edit_guild_scheduled_event(
684
- self, guild_id: "Snowflake", event_id: "Snowflake", payload: Dict[str, Any]
685
- ) -> Dict[str, Any]:
686
- """Edits a guild scheduled event."""
687
-
688
- return await self.request(
689
- "PATCH",
690
- f"/guilds/{guild_id}/scheduled-events/{event_id}",
691
- payload=payload,
692
- )
693
-
694
- async def delete_guild_scheduled_event(
695
- self, guild_id: "Snowflake", event_id: "Snowflake"
696
- ) -> None:
697
- """Deletes a guild scheduled event."""
698
-
699
- await self.request("DELETE", f"/guilds/{guild_id}/scheduled-events/{event_id}")
700
-
701
- async def get_audit_logs(
702
- self, guild_id: "Snowflake", **filters: Any
703
- ) -> Dict[str, Any]:
704
- """Fetches audit log entries for a guild."""
705
- params = {k: v for k, v in filters.items() if v is not None}
706
- return await self.request(
707
- "GET",
708
- f"/guilds/{guild_id}/audit-logs",
709
- params=params if params else None,
710
- )
711
-
712
- # Add other methods like:
713
- # async def get_guild(self, guild_id: str) -> Dict[str, Any]: ...
714
- # async def create_reaction(self, channel_id: str, message_id: str, emoji: str) -> None: ...
715
- # etc.
716
- # --- Application Command Endpoints ---
717
-
718
- # Global Application Commands
719
- async def get_global_application_commands(
720
- self, application_id: "Snowflake", with_localizations: bool = False
721
- ) -> List["ApplicationCommand"]:
722
- """Fetches all global commands for your application."""
723
- params = {"with_localizations": str(with_localizations).lower()}
724
- data = await self.request(
725
- "GET", f"/applications/{application_id}/commands", params=params
726
- )
727
- from .interactions import ApplicationCommand # Ensure constructor is available
728
-
729
- return [ApplicationCommand(cmd_data) for cmd_data in data]
730
-
731
- async def create_global_application_command(
732
- self, application_id: "Snowflake", payload: Dict[str, Any]
733
- ) -> "ApplicationCommand":
734
- """Creates a new global command."""
735
- data = await self.request(
736
- "POST", f"/applications/{application_id}/commands", payload=payload
737
- )
738
- from .interactions import ApplicationCommand
739
-
740
- return ApplicationCommand(data)
741
-
742
- async def get_global_application_command(
743
- self, application_id: "Snowflake", command_id: "Snowflake"
744
- ) -> "ApplicationCommand":
745
- """Fetches a specific global command."""
746
- data = await self.request(
747
- "GET", f"/applications/{application_id}/commands/{command_id}"
748
- )
749
- from .interactions import ApplicationCommand
750
-
751
- return ApplicationCommand(data)
752
-
753
- async def edit_global_application_command(
754
- self,
755
- application_id: "Snowflake",
756
- command_id: "Snowflake",
757
- payload: Dict[str, Any],
758
- ) -> "ApplicationCommand":
759
- """Edits a specific global command."""
760
- data = await self.request(
761
- "PATCH",
762
- f"/applications/{application_id}/commands/{command_id}",
763
- payload=payload,
764
- )
765
- from .interactions import ApplicationCommand
766
-
767
- return ApplicationCommand(data)
768
-
769
- async def delete_global_application_command(
770
- self, application_id: "Snowflake", command_id: "Snowflake"
771
- ) -> None:
772
- """Deletes a specific global command."""
773
- await self.request(
774
- "DELETE", f"/applications/{application_id}/commands/{command_id}"
775
- )
776
-
777
- async def bulk_overwrite_global_application_commands(
778
- self, application_id: "Snowflake", payload: List[Dict[str, Any]]
779
- ) -> List["ApplicationCommand"]:
780
- """Bulk overwrites all global commands for your application."""
781
- data = await self.request(
782
- "PUT", f"/applications/{application_id}/commands", payload=payload
783
- )
784
- from .interactions import ApplicationCommand
785
-
786
- return [ApplicationCommand(cmd_data) for cmd_data in data]
787
-
788
- # Guild Application Commands
789
- async def get_guild_application_commands(
790
- self,
791
- application_id: "Snowflake",
792
- guild_id: "Snowflake",
793
- with_localizations: bool = False,
794
- ) -> List["ApplicationCommand"]:
795
- """Fetches all commands for your application for a specific guild."""
796
- params = {"with_localizations": str(with_localizations).lower()}
797
- data = await self.request(
798
- "GET",
799
- f"/applications/{application_id}/guilds/{guild_id}/commands",
800
- params=params,
801
- )
802
- from .interactions import ApplicationCommand
803
-
804
- return [ApplicationCommand(cmd_data) for cmd_data in data]
805
-
806
- async def create_guild_application_command(
807
- self,
808
- application_id: "Snowflake",
809
- guild_id: "Snowflake",
810
- payload: Dict[str, Any],
811
- ) -> "ApplicationCommand":
812
- """Creates a new guild command."""
813
- data = await self.request(
814
- "POST",
815
- f"/applications/{application_id}/guilds/{guild_id}/commands",
816
- payload=payload,
817
- )
818
- from .interactions import ApplicationCommand
819
-
820
- return ApplicationCommand(data)
821
-
822
- async def get_guild_application_command(
823
- self,
824
- application_id: "Snowflake",
825
- guild_id: "Snowflake",
826
- command_id: "Snowflake",
827
- ) -> "ApplicationCommand":
828
- """Fetches a specific guild command."""
829
- data = await self.request(
830
- "GET",
831
- f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
832
- )
833
- from .interactions import ApplicationCommand
834
-
835
- return ApplicationCommand(data)
836
-
837
- async def edit_guild_application_command(
838
- self,
839
- application_id: "Snowflake",
840
- guild_id: "Snowflake",
841
- command_id: "Snowflake",
842
- payload: Dict[str, Any],
843
- ) -> "ApplicationCommand":
844
- """Edits a specific guild command."""
845
- data = await self.request(
846
- "PATCH",
847
- f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
848
- payload=payload,
849
- )
850
- from .interactions import ApplicationCommand
851
-
852
- return ApplicationCommand(data)
853
-
854
- async def delete_guild_application_command(
855
- self,
856
- application_id: "Snowflake",
857
- guild_id: "Snowflake",
858
- command_id: "Snowflake",
859
- ) -> None:
860
- """Deletes a specific guild command."""
861
- await self.request(
862
- "DELETE",
863
- f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
864
- )
865
-
866
- async def bulk_overwrite_guild_application_commands(
867
- self,
868
- application_id: "Snowflake",
869
- guild_id: "Snowflake",
870
- payload: List[Dict[str, Any]],
871
- ) -> List["ApplicationCommand"]:
872
- """Bulk overwrites all commands for your application for a specific guild."""
873
- data = await self.request(
874
- "PUT",
875
- f"/applications/{application_id}/guilds/{guild_id}/commands",
876
- payload=payload,
877
- )
878
- from .interactions import ApplicationCommand
879
-
880
- return [ApplicationCommand(cmd_data) for cmd_data in data]
881
-
882
- # --- Interaction Response Endpoints ---
883
- # Note: These methods return Dict[str, Any] representing the Message data.
884
- # The caller (e.g., AppCommandHandler) will be responsible for constructing Message models
885
- # if needed, as Message model instantiation requires a `client_instance`.
886
-
887
- async def create_interaction_response(
888
- self,
889
- interaction_id: "Snowflake",
890
- interaction_token: str,
891
- payload: Union["InteractionResponsePayload", Dict[str, Any]],
892
- *,
893
- ephemeral: bool = False,
894
- ) -> None:
895
- """Creates a response to an Interaction.
896
-
897
- Parameters
898
- ----------
899
- ephemeral: bool
900
- Ignored parameter for test compatibility.
901
- """
902
- # Interaction responses do not use the bot token in the Authorization header.
903
- # They are authenticated by the interaction_token in the URL.
904
- payload_data: Dict[str, Any]
905
- if isinstance(payload, InteractionResponsePayload):
906
- payload_data = payload.to_dict()
907
- else:
908
- payload_data = payload
909
-
910
- await self.request(
911
- "POST",
912
- f"/interactions/{interaction_id}/{interaction_token}/callback",
913
- payload=payload_data,
914
- use_auth_header=False,
915
- )
916
-
917
- async def get_original_interaction_response(
918
- self, application_id: "Snowflake", interaction_token: str
919
- ) -> Dict[str, Any]:
920
- """Gets the initial Interaction response."""
921
- # This endpoint uses the bot token for auth.
922
- return await self.request(
923
- "GET", f"/webhooks/{application_id}/{interaction_token}/messages/@original"
924
- )
925
-
926
- async def edit_original_interaction_response(
927
- self,
928
- application_id: "Snowflake",
929
- interaction_token: str,
930
- payload: Dict[str, Any],
931
- ) -> Dict[str, Any]:
932
- """Edits the initial Interaction response."""
933
- return await self.request(
934
- "PATCH",
935
- f"/webhooks/{application_id}/{interaction_token}/messages/@original",
936
- payload=payload,
937
- use_auth_header=False,
938
- ) # Docs imply webhook-style auth
939
-
940
- async def delete_original_interaction_response(
941
- self, application_id: "Snowflake", interaction_token: str
942
- ) -> None:
943
- """Deletes the initial Interaction response."""
944
- await self.request(
945
- "DELETE",
946
- f"/webhooks/{application_id}/{interaction_token}/messages/@original",
947
- use_auth_header=False,
948
- ) # Docs imply webhook-style auth
949
-
950
- async def create_followup_message(
951
- self,
952
- application_id: "Snowflake",
953
- interaction_token: str,
954
- payload: Dict[str, Any],
955
- ) -> Dict[str, Any]:
956
- """Creates a followup message for an Interaction."""
957
- # Followup messages are sent to a webhook endpoint.
958
- return await self.request(
959
- "POST",
960
- f"/webhooks/{application_id}/{interaction_token}",
961
- payload=payload,
962
- use_auth_header=False,
963
- ) # Docs imply webhook-style auth
964
-
965
- async def edit_followup_message(
966
- self,
967
- application_id: "Snowflake",
968
- interaction_token: str,
969
- message_id: "Snowflake",
970
- payload: Dict[str, Any],
971
- ) -> Dict[str, Any]:
972
- """Edits a followup message for an Interaction."""
973
- return await self.request(
974
- "PATCH",
975
- f"/webhooks/{application_id}/{interaction_token}/messages/{message_id}",
976
- payload=payload,
977
- use_auth_header=False,
978
- ) # Docs imply webhook-style auth
979
-
980
- async def delete_followup_message(
981
- self,
982
- application_id: "Snowflake",
983
- interaction_token: str,
984
- message_id: "Snowflake",
985
- ) -> None:
986
- """Deletes a followup message for an Interaction."""
987
- await self.request(
988
- "DELETE",
989
- f"/webhooks/{application_id}/{interaction_token}/messages/{message_id}",
990
- use_auth_header=False,
991
- )
992
-
993
- async def trigger_typing(self, channel_id: str) -> None:
994
- """Sends a typing indicator to the specified channel."""
995
- await self.request("POST", f"/channels/{channel_id}/typing")
996
-
997
- async def start_stage_instance(
998
- self, payload: Dict[str, Any], reason: Optional[str] = None
999
- ) -> "StageInstance":
1000
- """Starts a stage instance."""
1001
-
1002
- headers = {"X-Audit-Log-Reason": reason} if reason else None
1003
- data = await self.request(
1004
- "POST", "/stage-instances", payload=payload, custom_headers=headers
1005
- )
1006
- from .models import StageInstance
1007
-
1008
- return StageInstance(data)
1009
-
1010
- async def edit_stage_instance(
1011
- self,
1012
- channel_id: "Snowflake",
1013
- payload: Dict[str, Any],
1014
- reason: Optional[str] = None,
1015
- ) -> "StageInstance":
1016
- """Edits an existing stage instance."""
1017
-
1018
- headers = {"X-Audit-Log-Reason": reason} if reason else None
1019
- data = await self.request(
1020
- "PATCH",
1021
- f"/stage-instances/{channel_id}",
1022
- payload=payload,
1023
- custom_headers=headers,
1024
- )
1025
- from .models import StageInstance
1026
-
1027
- return StageInstance(data)
1028
-
1029
- async def end_stage_instance(
1030
- self, channel_id: "Snowflake", reason: Optional[str] = None
1031
- ) -> None:
1032
- """Ends a stage instance."""
1033
-
1034
- headers = {"X-Audit-Log-Reason": reason} if reason else None
1035
- await self.request(
1036
- "DELETE", f"/stage-instances/{channel_id}", custom_headers=headers
1037
- )
1038
-
1039
- async def get_voice_regions(self) -> List[Dict[str, Any]]:
1040
- """Returns available voice regions."""
1041
- return await self.request("GET", "/voice/regions")
1
+ """
2
+ HTTP client for interacting with the Discord REST API.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import aiohttp # pylint: disable=import-error
8
+ import json
9
+ from urllib.parse import quote
10
+ from typing import Optional, Dict, Any, Union, TYPE_CHECKING, List
11
+
12
+ from .errors import * # Import all custom exceptions
13
+ from . import __version__ # For User-Agent
14
+ from .rate_limiter import RateLimiter
15
+ from .interactions import InteractionResponsePayload
16
+
17
+ if TYPE_CHECKING:
18
+ from .client import Client
19
+ from .models import Message, Webhook, File, StageInstance, Invite
20
+ from .interactions import ApplicationCommand, Snowflake
21
+
22
+ # Discord API constants
23
+ API_BASE_URL = "https://discord.com/api/v10" # Using API v10
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ DISCORD_ERROR_CODE_TO_EXCEPTION = {
28
+ 0: GeneralError,
29
+ 10001: UnknownAccount,
30
+ 10002: UnknownApplication,
31
+ 10003: UnknownChannel,
32
+ 10004: UnknownGuild,
33
+ 10005: UnknownIntegration,
34
+ 10006: UnknownInvite,
35
+ 10007: UnknownMember,
36
+ 10008: UnknownMessage,
37
+ 10009: UnknownPermissionOverwrite,
38
+ 10010: UnknownProvider,
39
+ 10011: UnknownRole,
40
+ 10012: UnknownToken,
41
+ 10013: UnknownUser,
42
+ 10014: UnknownEmoji,
43
+ 10015: UnknownWebhook,
44
+ 10016: UnknownWebhookService,
45
+ 10020: UnknownSession,
46
+ 10021: UnknownAsset,
47
+ 10026: UnknownBan,
48
+ 10027: UnknownSKU,
49
+ 10028: UnknownStoreListing,
50
+ 10029: UnknownEntitlement,
51
+ 10030: UnknownBuild,
52
+ 10031: UnknownLobby,
53
+ 10032: UnknownBranch,
54
+ 10033: UnknownStoreDirectoryLayout,
55
+ 10036: UnknownRedistributable,
56
+ 10038: UnknownGiftCode,
57
+ 10049: UnknownStream,
58
+ 10050: UnknownPremiumServerSubscribeCooldown,
59
+ 10057: UnknownGuildTemplate,
60
+ 10059: UnknownDiscoverableServerCategory,
61
+ 10060: UnknownSticker,
62
+ 10061: UnknownStickerPack,
63
+ 10062: UnknownInteraction,
64
+ 10063: UnknownApplicationCommand,
65
+ 10065: UnknownVoiceState,
66
+ 10066: UnknownApplicationCommandPermissions,
67
+ 10067: UnknownStageInstance,
68
+ 10068: UnknownGuildMemberVerificationForm,
69
+ 10069: UnknownGuildWelcomeScreen,
70
+ 10070: UnknownGuildScheduledEvent,
71
+ 10071: UnknownGuildScheduledEventUser,
72
+ 10087: UnknownTag,
73
+ 10097: UnknownSound,
74
+ 20001: BotsCannotUseThisEndpoint,
75
+ 20002: OnlyBotsCanUseThisEndpoint,
76
+ 20009: ExplicitContentCannotBeSentToTheDesiredRecipients,
77
+ 20012: NotAuthorizedToPerformThisActionOnThisApplication,
78
+ 20016: ActionCannotBePerformedDueToSlowmodeRateLimit,
79
+ 20018: OnlyTheOwnerOfThisAccountCanPerformThisAction,
80
+ 20022: MessageCannotBeEditedDueToAnnouncementRateLimits,
81
+ 20024: UnderMinimumAge,
82
+ 20028: ChannelHitWriteRateLimit,
83
+ 20029: ServerHitWriteRateLimit,
84
+ 20031: DisallowedWordsInStageTopicOrNames,
85
+ 20035: GuildPremiumSubscriptionLevelTooLow,
86
+ 30001: MaximumNumberOfGuildsReached,
87
+ 30002: MaximumNumberOfFriendsReached,
88
+ 30003: MaximumNumberOfPinsReached,
89
+ 30004: MaximumNumberOfRecipientsReached,
90
+ 30005: MaximumNumberOfGuildRolesReached,
91
+ 30007: MaximumNumberOfWebhooksReached,
92
+ 30008: MaximumNumberOfEmojisReached,
93
+ 30010: MaximumNumberOfReactionsReached,
94
+ 30011: MaximumNumberOfGroupDMsReached,
95
+ 30013: MaximumNumberOfGuildChannelsReached,
96
+ 30015: MaximumNumberOfAttachmentsInAMessageReached,
97
+ 30016: MaximumNumberOfInvitesReached,
98
+ 30018: MaximumNumberOfAnimatedEmojisReached,
99
+ 30019: MaximumNumberOfServerMembersReached,
100
+ 30030: MaximumNumberOfServerCategoriesReached,
101
+ 30031: GuildAlreadyHasATemplate,
102
+ 30032: MaximumNumberOfApplicationCommandsReached,
103
+ 30033: MaximumNumberOfThreadParticipantsReached,
104
+ 30034: MaximumNumberOfDailyApplicationCommandCreatesReached,
105
+ 30035: MaximumNumberOfBansForNonGuildMembersExceeded,
106
+ 30037: MaximumNumberOfBansFetchesReached,
107
+ 30038: MaximumNumberOfUncompletedGuildScheduledEventsReached,
108
+ 30039: MaximumNumberOfStickersReached,
109
+ 30040: MaximumNumberOfPruneRequestsReached,
110
+ 30042: MaximumNumberOfGuildWidgetSettingsUpdatesReached,
111
+ 30045: MaximumNumberOfSoundboardSoundsReached,
112
+ 30046: MaximumNumberOfEditsToMessagesOlderThan1HourReached,
113
+ 30047: MaximumNumberOfPinnedThreadsInAForumChannelReached,
114
+ 30048: MaximumNumberOfTagsInAForumChannelReached,
115
+ 30052: BitrateIsTooHighForChannelOfThisType,
116
+ 30056: MaximumNumberOfPremiumEmojisReached,
117
+ 30058: MaximumNumberOfWebhooksPerGuildReached,
118
+ 30061: MaximumNumberOfChannelPermissionOverwritesReached,
119
+ 30062: TheChannelsForThisGuildAreTooLarge,
120
+ 40001: Unauthorized,
121
+ 40002: YouNeedToVerifyYourAccount,
122
+ 40003: YouAreOpeningDirectMessagesTooFast,
123
+ 40004: SendMessagesHasBeenTemporarilyDisabled,
124
+ 40005: RequestEntityTooLarge,
125
+ 40006: ThisFeatureHasBeenTemporarilyDisabledServerSide,
126
+ 40007: TheUserIsBannedFromThisGuild,
127
+ 40012: ConnectionHasBeenRevoked,
128
+ 40018: OnlyConsumableSKUsCanBeConsumed,
129
+ 40019: YouCanOnlyDeleteSandboxEntitlements,
130
+ 40032: TargetUserIsNotConnectedToVoice,
131
+ 40033: ThisMessageHasAlreadyBeenCrossposted,
132
+ 40041: AnApplicationCommandWithThatNameAlreadyExists,
133
+ 40043: ApplicationInteractionFailedToSend,
134
+ 40058: CannotSendAMessageInAForumChannel,
135
+ 40060: InteractionHasAlreadyBeenAcknowledged,
136
+ 40061: TagNamesMustBeUnique,
137
+ 40062: ServiceResourceIsBeingRateLimited,
138
+ 40066: ThereAreNoTagsAvailableThatCanBeSetByNonModerators,
139
+ 40067: ATagIsRequiredToCreateAForumPostInThisChannel,
140
+ 40074: AnEntitlementHasAlreadyBeenGrantedForThisResource,
141
+ 40094: ThisInteractionHasHitTheMaximumNumberOfFollowUpMessages,
142
+ 40333: CloudflareIsBlockingYourRequest,
143
+ 50001: MissingAccess,
144
+ 50002: InvalidAccountType,
145
+ 50003: CannotExecuteActionOnADMChannel,
146
+ 50004: GuildWidgetDisabled,
147
+ 50005: CannotEditAMessageAuthoredByAnotherUser,
148
+ 50006: CannotSendAnEmptyMessage,
149
+ 50007: CannotSendMessagesToThisUser,
150
+ 50008: CannotSendMessagesInANonTextChannel,
151
+ 50009: ChannelVerificationLevelIsTooHighForYouToGainAccess,
152
+ 50010: OAuth2ApplicationDoesNotHaveABot,
153
+ 50011: OAuth2ApplicationLimitReached,
154
+ 50012: InvalidOAuth2State,
155
+ 50013: YouLackPermissionsToPerformThatAction,
156
+ 50014: InvalidAuthenticationTokenProvided,
157
+ 50015: NoteWasTooLong,
158
+ 50016: ProvidedTooFewOrTooManyMessagesToDelete,
159
+ 50017: InvalidMFALevel,
160
+ 50019: AMessageCanOnlyBePinnedToTheChannelItWasSentIn,
161
+ 50020: InviteCodeWasEitherInvalidOrTaken,
162
+ 50021: CannotExecuteActionOnASystemMessage,
163
+ 50024: CannotExecuteActionOnThisChannelType,
164
+ 50025: InvalidOAuth2AccessTokenProvided,
165
+ 50026: MissingRequiredOAuth2Scope,
166
+ 50027: InvalidWebhookTokenProvided,
167
+ 50028: InvalidRole,
168
+ 50033: InvalidRecipients,
169
+ 50034: AMessageProvidedWasTooOldToBulkDelete,
170
+ 50035: InvalidFormBody,
171
+ 50036: AnInviteWasAcceptedToAGuildTheApplicationBotIsNotIn,
172
+ 50039: InvalidActivityAction,
173
+ 50041: InvalidAPIVersionProvided,
174
+ 50045: FileUploadedExceedsTheMaximumSize,
175
+ 50046: InvalidFileUploaded,
176
+ 50054: CannotSelfRedeemThisGift,
177
+ 50055: InvalidGuild,
178
+ 50057: InvalidSKU,
179
+ 50067: InvalidRequestOrigin,
180
+ 50068: InvalidMessageType,
181
+ 50070: PaymentSourceRequiredToRedeemGift,
182
+ 50073: CannotModifyASystemWebhook,
183
+ 50074: CannotDeleteAChannelRequiredForCommunityGuilds,
184
+ 50080: CannotEditStickersWithinAMessage,
185
+ 50081: InvalidStickerSent,
186
+ 50083: TriedToPerformAnOperationOnAnArchivedThread,
187
+ 50085: InvalidThreadNotificationSettings,
188
+ 50086: BeforeValueIsEarlierThanTheThreadCreationDate,
189
+ 50087: CommunityServerChannelsMustBeTextChannels,
190
+ 50091: TheEntityTypeOfTheEventIsDifferentFromTheEntityYouAreTryingToStartTheEventFor,
191
+ 50095: ThisServerIsNotAvailableInYourLocation,
192
+ 50097: ThisServerNeedsMonetizationEnabledInOrderToPerformThisAction,
193
+ 50101: ThisServerNeedsMoreBoostsToPerformThisAction,
194
+ 50109: TheRequestBodyContainsInvalidJSON,
195
+ 50110: TheProvidedFileIsInvalid,
196
+ 50123: TheProvidedFileTypeIsInvalid,
197
+ 50124: TheProvidedFileDurationExceedsMaximumOf52Seconds,
198
+ 50131: OwnerCannotBePendingMember,
199
+ 50132: OwnershipCannotBeTransferredToABotUser,
200
+ 50138: FailedToResizeAssetBelowTheMaximumSize,
201
+ 50144: CannotMixSubscriptionAndNonSubscriptionRolesForAnEmoji,
202
+ 50145: CannotConvertBetweenPremiumEmojiAndNormalEmoji,
203
+ 50146: UploadedFileNotFound,
204
+ 50151: TheSpecifiedEmojiIsInvalid,
205
+ 50159: VoiceMessagesDoNotSupportAdditionalContent,
206
+ 50160: VoiceMessagesMustHaveASingleAudioAttachment,
207
+ 50161: VoiceMessagesMustHaveSupportingMetadata,
208
+ 50162: VoiceMessagesCannotBeEdited,
209
+ 50163: CannotDeleteGuildSubscriptionIntegration,
210
+ 50173: YouCannotSendVoiceMessagesInThisChannel,
211
+ 50178: TheUserAccountMustFirstBeVerified,
212
+ 50192: TheProvidedFileDoesNotHaveAValidDuration,
213
+ 50600: YouDoNotHavePermissionToSendThisSticker,
214
+ 60003: TwoFactorIsRequiredForThisOperation,
215
+ 80004: NoUsersWithDiscordTagExist,
216
+ 90001: ReactionWasBlocked,
217
+ 90002: UserCannotUseBurstReactions,
218
+ 110001: ApplicationNotYetAvailable,
219
+ 130000: APIResourceIsCurrentlyOverloaded,
220
+ 150006: TheStageIsAlreadyOpen,
221
+ 160002: CannotReplyWithoutPermissionToReadMessageHistory,
222
+ 160004: AThreadHasAlreadyBeenCreatedForThisMessage,
223
+ 160005: ThreadIsLocked,
224
+ 160006: MaximumNumberOfActiveThreadsReached,
225
+ 160007: MaximumNumberOfActiveAnnouncementThreadsReached,
226
+ 170001: InvalidJSONForUploadedLottieFile,
227
+ 170002: UploadedLottiesCannotContainRasterizedImages,
228
+ 170003: StickerMaximumFramerateExceeded,
229
+ 170004: StickerFrameCountExceedsMaximumOf1000Frames,
230
+ 170005: LottieAnimationMaximumDimensionsExceeded,
231
+ 170006: StickerFrameRateIsEitherTooSmallOrTooLarge,
232
+ 170007: StickerAnimationDurationExceedsMaximumOf5Seconds,
233
+ 180000: CannotUpdateAFinishedEvent,
234
+ 180002: FailedToCreateStageNeededForStageEvent,
235
+ 200000: MessageWasBlockedByAutomaticModeration,
236
+ 200001: TitleWasBlockedByAutomaticModeration,
237
+ 220001: WebhooksPostedToForumChannelsMustHaveAThreadNameOrThreadId,
238
+ 220002: WebhooksPostedToForumChannelsCannotHaveBothAThreadNameAndThreadId,
239
+ 220003: WebhooksCanOnlyCreateThreadsInForumChannels,
240
+ 220004: WebhookServicesCannotBeUsedInForumChannels,
241
+ 240000: MessageBlockedByHarmfulLinksFilter,
242
+ 350000: CannotEnableOnboardingRequirementsAreNotMet,
243
+ 350001: CannotUpdateOnboardingWhileBelowRequirements,
244
+ 500000: FailedToBanUsers,
245
+ 520000: PollVotingBlocked,
246
+ 520001: PollExpired,
247
+ 520002: InvalidChannelTypeForPollCreation,
248
+ 520003: CannotEditAPollMessage,
249
+ 520004: CannotUseAnEmojiIncludedWithThePoll,
250
+ 520006: CannotExpireANonPollMessage,
251
+ }
252
+
253
+
254
+ class HTTPClient:
255
+ """Handles HTTP requests to the Discord API."""
256
+
257
+ def __init__(
258
+ self,
259
+ token: str,
260
+ client_session: Optional[aiohttp.ClientSession] = None,
261
+ verbose: bool = False,
262
+ **session_kwargs: Any,
263
+ ):
264
+ """Create a new HTTP client.
265
+
266
+ Parameters
267
+ ----------
268
+ token:
269
+ Bot token for authentication.
270
+ client_session:
271
+ Optional existing :class:`aiohttp.ClientSession`.
272
+ verbose:
273
+ If ``True``, log HTTP requests and responses.
274
+ **session_kwargs:
275
+ Additional options forwarded to :class:`aiohttp.ClientSession`, such
276
+ as ``proxy`` or ``connector``.
277
+ """
278
+
279
+ self.token = token
280
+ self._session: Optional[aiohttp.ClientSession] = client_session
281
+ self._session_kwargs: Dict[str, Any] = session_kwargs
282
+ self.user_agent = f"DiscordBot (https://github.com/Slipstreamm/disagreement, {__version__})" # Customize URL
283
+
284
+ self.verbose = verbose
285
+
286
+ self._rate_limiter = RateLimiter()
287
+
288
+ async def _ensure_session(self):
289
+ if self._session is None or self._session.closed:
290
+ self._session = aiohttp.ClientSession(**self._session_kwargs)
291
+
292
+ async def close(self):
293
+ """Closes the underlying aiohttp.ClientSession."""
294
+ if self._session and not self._session.closed:
295
+ await self._session.close()
296
+
297
+ async def request(
298
+ self,
299
+ method: str,
300
+ endpoint: str,
301
+ payload: Optional[
302
+ Union[Dict[str, Any], List[Dict[str, Any]], aiohttp.FormData]
303
+ ] = None,
304
+ params: Optional[Dict[str, Any]] = None,
305
+ is_json: bool = True,
306
+ use_auth_header: bool = True,
307
+ custom_headers: Optional[Dict[str, str]] = None,
308
+ ) -> Any:
309
+ """Makes an HTTP request to the Discord API."""
310
+ await self._ensure_session()
311
+
312
+ url = f"{API_BASE_URL}{endpoint}"
313
+ final_headers: Dict[str, str] = { # Renamed to final_headers
314
+ "User-Agent": self.user_agent,
315
+ }
316
+ if use_auth_header:
317
+ final_headers["Authorization"] = f"Bot {self.token}"
318
+
319
+ if is_json and payload:
320
+ final_headers["Content-Type"] = "application/json"
321
+
322
+ if custom_headers: # Merge custom headers
323
+ final_headers.update(custom_headers)
324
+
325
+ if self.verbose:
326
+ logger.debug(
327
+ "HTTP REQUEST: %s %s | payload=%s params=%s",
328
+ method,
329
+ url,
330
+ payload,
331
+ params,
332
+ )
333
+
334
+ route = f"{method.upper()}:{endpoint}"
335
+
336
+ for attempt in range(5): # Max 5 retries for rate limits
337
+ await self._rate_limiter.acquire(route)
338
+ assert self._session is not None, "ClientSession not initialized"
339
+ async with self._session.request(
340
+ method,
341
+ url,
342
+ json=payload if is_json else None,
343
+ data=payload if not is_json else None,
344
+ headers=final_headers,
345
+ params=params,
346
+ ) as response:
347
+
348
+ data = None
349
+ try:
350
+ if response.headers.get("Content-Type", "").startswith(
351
+ "application/json"
352
+ ):
353
+ data = await response.json()
354
+ else:
355
+ # For non-JSON responses, like fetching images or other files
356
+ # We might return the raw response or handle it differently
357
+ # For now, let's assume most API calls expect JSON
358
+ data = await response.text()
359
+ except (aiohttp.ContentTypeError, json.JSONDecodeError):
360
+ data = (
361
+ await response.text()
362
+ ) # Fallback to text if JSON parsing fails
363
+
364
+ if self.verbose:
365
+ logger.debug(
366
+ "HTTP RESPONSE: %s %s | %s", response.status, url, data
367
+ )
368
+
369
+ self._rate_limiter.release(route, response.headers)
370
+
371
+ if 200 <= response.status < 300:
372
+ if response.status == 204:
373
+ return None
374
+ return data
375
+
376
+ # Rate limit handling
377
+ if response.status == 429: # Rate limited
378
+ retry_after_str = response.headers.get("Retry-After", "1")
379
+ try:
380
+ retry_after = float(retry_after_str)
381
+ except ValueError:
382
+ retry_after = 1.0 # Default retry if header is malformed
383
+
384
+ is_global = (
385
+ response.headers.get("X-RateLimit-Global", "false").lower()
386
+ == "true"
387
+ )
388
+
389
+ error_message = f"Rate limited on {method} {endpoint}."
390
+ if data and isinstance(data, dict) and "message" in data:
391
+ error_message += f" Discord says: {data['message']}"
392
+
393
+ await self._rate_limiter.handle_rate_limit(
394
+ route, retry_after, is_global
395
+ )
396
+
397
+ if attempt < 4: # Don't log on the last attempt before raising
398
+ logger.warning(
399
+ "%s Retrying after %ss (Attempt %s/5). Global: %s",
400
+ error_message,
401
+ retry_after,
402
+ attempt + 1,
403
+ is_global,
404
+ )
405
+ continue # Retry the request
406
+ else: # Last attempt failed
407
+ raise RateLimitError(
408
+ response,
409
+ message=error_message,
410
+ retry_after=retry_after,
411
+ is_global=is_global,
412
+ )
413
+
414
+ # Other error handling
415
+ if response.status == 401: # Unauthorized
416
+ raise AuthenticationError(response, "Invalid token provided.")
417
+ if response.status == 403: # Forbidden
418
+ raise HTTPException(
419
+ response,
420
+ "Missing permissions or access denied.",
421
+ status=response.status,
422
+ text=str(data),
423
+ )
424
+
425
+ # General HTTP error
426
+ error_text = str(data) if data else "Unknown error"
427
+ discord_error_code = (
428
+ data.get("code") if isinstance(data, dict) else None
429
+ )
430
+
431
+ if discord_error_code in DISCORD_ERROR_CODE_TO_EXCEPTION:
432
+ exc_class = DISCORD_ERROR_CODE_TO_EXCEPTION[discord_error_code]
433
+ raise exc_class(
434
+ response,
435
+ f"API Error on {method} {endpoint}: {error_text}",
436
+ status=response.status,
437
+ text=error_text,
438
+ error_code=discord_error_code,
439
+ )
440
+
441
+ raise HTTPException(
442
+ response,
443
+ f"API Error on {method} {endpoint}: {error_text}",
444
+ status=response.status,
445
+ text=error_text,
446
+ error_code=discord_error_code,
447
+ )
448
+ raise DisagreementException(
449
+ f"Failed request to {method} {endpoint} after multiple retries."
450
+ )
451
+
452
+ # --- Specific API call methods ---
453
+
454
+ async def get_gateway_bot(self) -> Dict[str, Any]:
455
+ """Gets the WSS URL and sharding information for the Gateway."""
456
+ return await self.request("GET", "/gateway/bot")
457
+
458
+ async def send_message(
459
+ self,
460
+ channel_id: str,
461
+ content: Optional[str] = None,
462
+ tts: bool = False,
463
+ embeds: Optional[List[Dict[str, Any]]] = None,
464
+ components: Optional[List[Dict[str, Any]]] = None,
465
+ allowed_mentions: Optional[dict] = None,
466
+ message_reference: Optional[Dict[str, Any]] = None,
467
+ attachments: Optional[List[Any]] = None,
468
+ files: Optional[List[Any]] = None,
469
+ flags: Optional[int] = None,
470
+ ) -> Dict[str, Any]:
471
+ """Sends a message to a channel.
472
+
473
+ Parameters
474
+ ----------
475
+ attachments:
476
+ A list of attachment payloads to include with the message.
477
+ files:
478
+ A list of :class:`File` objects containing binary data to upload.
479
+
480
+ Returns
481
+ -------
482
+ Dict[str, Any]
483
+ The created message data.
484
+ """
485
+ payload: Dict[str, Any] = {}
486
+ if content is not None: # Content is optional if embeds/components are present
487
+ payload["content"] = content
488
+ if tts:
489
+ payload["tts"] = True
490
+ if embeds:
491
+ payload["embeds"] = embeds
492
+ if components:
493
+ payload["components"] = components
494
+ if allowed_mentions:
495
+ payload["allowed_mentions"] = allowed_mentions
496
+ all_files: List["File"] = []
497
+ if attachments is not None:
498
+ payload["attachments"] = []
499
+ for a in attachments:
500
+ if hasattr(a, "data") and hasattr(a, "filename"):
501
+ idx = len(all_files)
502
+ all_files.append(a)
503
+ payload["attachments"].append({"id": idx, "filename": a.filename})
504
+ else:
505
+ payload["attachments"].append(
506
+ a.to_dict() if hasattr(a, "to_dict") else a
507
+ )
508
+ if files is not None:
509
+ for f in files:
510
+ if hasattr(f, "data") and hasattr(f, "filename"):
511
+ idx = len(all_files)
512
+ all_files.append(f)
513
+ if "attachments" not in payload:
514
+ payload["attachments"] = []
515
+ payload["attachments"].append({"id": idx, "filename": f.filename})
516
+ else:
517
+ raise TypeError("files must be File objects")
518
+ if flags:
519
+ payload["flags"] = flags
520
+ if message_reference:
521
+ payload["message_reference"] = message_reference
522
+
523
+ if not payload:
524
+ raise ValueError("Message must have content, embeds, or components.")
525
+
526
+ if all_files:
527
+ form = aiohttp.FormData()
528
+ form.add_field(
529
+ "payload_json", json.dumps(payload), content_type="application/json"
530
+ )
531
+ for idx, f in enumerate(all_files):
532
+ form.add_field(
533
+ f"files[{idx}]",
534
+ f.data,
535
+ filename=f.filename,
536
+ content_type="application/octet-stream",
537
+ )
538
+ return await self.request(
539
+ "POST",
540
+ f"/channels/{channel_id}/messages",
541
+ payload=form,
542
+ is_json=False,
543
+ )
544
+
545
+ return await self.request(
546
+ "POST", f"/channels/{channel_id}/messages", payload=payload
547
+ )
548
+
549
+ async def edit_message(
550
+ self,
551
+ channel_id: str,
552
+ message_id: str,
553
+ payload: Dict[str, Any],
554
+ ) -> Dict[str, Any]:
555
+ """Edits a message in a channel."""
556
+
557
+ return await self.request(
558
+ "PATCH",
559
+ f"/channels/{channel_id}/messages/{message_id}",
560
+ payload=payload,
561
+ )
562
+
563
+ async def get_message(
564
+ self, channel_id: "Snowflake", message_id: "Snowflake"
565
+ ) -> Dict[str, Any]:
566
+ """Fetches a message from a channel."""
567
+
568
+ return await self.request(
569
+ "GET", f"/channels/{channel_id}/messages/{message_id}"
570
+ )
571
+
572
+ async def delete_message(
573
+ self, channel_id: "Snowflake", message_id: "Snowflake"
574
+ ) -> None:
575
+ """Deletes a message in a channel."""
576
+
577
+ await self.request("DELETE", f"/channels/{channel_id}/messages/{message_id}")
578
+
579
+ async def create_reaction(
580
+ self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
581
+ ) -> None:
582
+ """Adds a reaction to a message as the current user."""
583
+ encoded = quote(emoji)
584
+ await self.request(
585
+ "PUT",
586
+ f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/@me",
587
+ )
588
+
589
+ async def delete_reaction(
590
+ self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
591
+ ) -> None:
592
+ """Removes the current user's reaction from a message."""
593
+ encoded = quote(emoji)
594
+ await self.request(
595
+ "DELETE",
596
+ f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/@me",
597
+ )
598
+
599
+ async def delete_user_reaction(
600
+ self,
601
+ channel_id: "Snowflake",
602
+ message_id: "Snowflake",
603
+ emoji: str,
604
+ user_id: "Snowflake",
605
+ ) -> None:
606
+ """Removes another user's reaction from a message."""
607
+ encoded = quote(emoji)
608
+ await self.request(
609
+ "DELETE",
610
+ f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/{user_id}",
611
+ )
612
+
613
+ async def get_reactions(
614
+ self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
615
+ ) -> List[Dict[str, Any]]:
616
+ """Fetches the users that reacted with a specific emoji."""
617
+ encoded = quote(emoji)
618
+ return await self.request(
619
+ "GET",
620
+ f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}",
621
+ )
622
+
623
+ async def clear_reactions(
624
+ self, channel_id: "Snowflake", message_id: "Snowflake"
625
+ ) -> None:
626
+ """Removes all reactions from a message."""
627
+
628
+ await self.request(
629
+ "DELETE",
630
+ f"/channels/{channel_id}/messages/{message_id}/reactions",
631
+ )
632
+
633
+ async def bulk_delete_messages(
634
+ self, channel_id: "Snowflake", messages: List["Snowflake"]
635
+ ) -> List["Snowflake"]:
636
+ """Bulk deletes messages in a channel and returns their IDs."""
637
+
638
+ await self.request(
639
+ "POST",
640
+ f"/channels/{channel_id}/messages/bulk-delete",
641
+ payload={"messages": messages},
642
+ )
643
+ return messages
644
+
645
+ async def get_pinned_messages(
646
+ self, channel_id: "Snowflake"
647
+ ) -> List[Dict[str, Any]]:
648
+ """Fetches all pinned messages in a channel."""
649
+
650
+ return await self.request("GET", f"/channels/{channel_id}/pins")
651
+
652
+ async def pin_message(
653
+ self, channel_id: "Snowflake", message_id: "Snowflake"
654
+ ) -> None:
655
+ """Pins a message in a channel."""
656
+
657
+ await self.request("PUT", f"/channels/{channel_id}/pins/{message_id}")
658
+
659
+ async def unpin_message(
660
+ self, channel_id: "Snowflake", message_id: "Snowflake"
661
+ ) -> None:
662
+ """Unpins a message from a channel."""
663
+
664
+ await self.request("DELETE", f"/channels/{channel_id}/pins/{message_id}")
665
+
666
+ async def delete_channel(
667
+ self, channel_id: str, reason: Optional[str] = None
668
+ ) -> None:
669
+ """Deletes a channel.
670
+
671
+ If the channel is a guild channel, requires the MANAGE_CHANNELS permission.
672
+ If the channel is a thread, requires the MANAGE_THREADS permission (if locked) or
673
+ be the thread creator (if not locked).
674
+ Deleting a category does not delete its child channels.
675
+ """
676
+ custom_headers = {}
677
+ if reason:
678
+ custom_headers["X-Audit-Log-Reason"] = reason
679
+
680
+ await self.request(
681
+ "DELETE",
682
+ f"/channels/{channel_id}",
683
+ custom_headers=custom_headers if custom_headers else None,
684
+ )
685
+
686
+ async def edit_channel(
687
+ self,
688
+ channel_id: "Snowflake",
689
+ payload: Dict[str, Any],
690
+ reason: Optional[str] = None,
691
+ ) -> Dict[str, Any]:
692
+ """Edits a channel."""
693
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
694
+ return await self.request(
695
+ "PATCH",
696
+ f"/channels/{channel_id}",
697
+ payload=payload,
698
+ custom_headers=headers,
699
+ )
700
+
701
+ async def get_channel(self, channel_id: str) -> Dict[str, Any]:
702
+ """Fetches a channel by ID."""
703
+ return await self.request("GET", f"/channels/{channel_id}")
704
+
705
+ async def get_channel_invites(
706
+ self, channel_id: "Snowflake"
707
+ ) -> List[Dict[str, Any]]:
708
+ """Fetches the invites for a channel."""
709
+
710
+ return await self.request("GET", f"/channels/{channel_id}/invites")
711
+
712
+ async def create_invite(
713
+ self, channel_id: "Snowflake", payload: Dict[str, Any]
714
+ ) -> "Invite":
715
+ """Creates an invite for a channel."""
716
+
717
+ data = await self.request(
718
+ "POST", f"/channels/{channel_id}/invites", payload=payload
719
+ )
720
+ from .models import Invite
721
+
722
+ return Invite.from_dict(data)
723
+
724
+ async def delete_invite(self, code: str) -> None:
725
+ """Deletes an invite by code."""
726
+
727
+ await self.request("DELETE", f"/invites/{code}")
728
+
729
+ async def create_webhook(
730
+ self, channel_id: "Snowflake", payload: Dict[str, Any]
731
+ ) -> "Webhook":
732
+ """Creates a webhook in the specified channel."""
733
+
734
+ data = await self.request(
735
+ "POST", f"/channels/{channel_id}/webhooks", payload=payload
736
+ )
737
+ from .models import Webhook
738
+
739
+ return Webhook(data)
740
+
741
+ async def edit_webhook(
742
+ self, webhook_id: "Snowflake", payload: Dict[str, Any]
743
+ ) -> "Webhook":
744
+ """Edits an existing webhook."""
745
+
746
+ data = await self.request("PATCH", f"/webhooks/{webhook_id}", payload=payload)
747
+ from .models import Webhook
748
+
749
+ return Webhook(data)
750
+
751
+ async def delete_webhook(self, webhook_id: "Snowflake") -> None:
752
+ """Deletes a webhook."""
753
+
754
+ await self.request("DELETE", f"/webhooks/{webhook_id}")
755
+
756
+ async def execute_webhook(
757
+ self,
758
+ webhook_id: "Snowflake",
759
+ token: str,
760
+ *,
761
+ content: Optional[str] = None,
762
+ tts: bool = False,
763
+ embeds: Optional[List[Dict[str, Any]]] = None,
764
+ components: Optional[List[Dict[str, Any]]] = None,
765
+ allowed_mentions: Optional[dict] = None,
766
+ attachments: Optional[List[Any]] = None,
767
+ files: Optional[List[Any]] = None,
768
+ flags: Optional[int] = None,
769
+ username: Optional[str] = None,
770
+ avatar_url: Optional[str] = None,
771
+ ) -> Dict[str, Any]:
772
+ """Executes a webhook and returns the created message."""
773
+
774
+ payload: Dict[str, Any] = {}
775
+ if content is not None:
776
+ payload["content"] = content
777
+ if tts:
778
+ payload["tts"] = True
779
+ if embeds:
780
+ payload["embeds"] = embeds
781
+ if components:
782
+ payload["components"] = components
783
+ if allowed_mentions:
784
+ payload["allowed_mentions"] = allowed_mentions
785
+ if username:
786
+ payload["username"] = username
787
+ if avatar_url:
788
+ payload["avatar_url"] = avatar_url
789
+
790
+ all_files: List["File"] = []
791
+ if attachments is not None:
792
+ payload["attachments"] = []
793
+ for a in attachments:
794
+ if hasattr(a, "data") and hasattr(a, "filename"):
795
+ idx = len(all_files)
796
+ all_files.append(a)
797
+ payload["attachments"].append({"id": idx, "filename": a.filename})
798
+ else:
799
+ payload["attachments"].append(
800
+ a.to_dict() if hasattr(a, "to_dict") else a
801
+ )
802
+ if files is not None:
803
+ for f in files:
804
+ if hasattr(f, "data") and hasattr(f, "filename"):
805
+ idx = len(all_files)
806
+ all_files.append(f)
807
+ if "attachments" not in payload:
808
+ payload["attachments"] = []
809
+ payload["attachments"].append({"id": idx, "filename": f.filename})
810
+ else:
811
+ raise TypeError("files must be File objects")
812
+ if flags:
813
+ payload["flags"] = flags
814
+
815
+ if all_files:
816
+ form = aiohttp.FormData()
817
+ form.add_field(
818
+ "payload_json", json.dumps(payload), content_type="application/json"
819
+ )
820
+ for idx, f in enumerate(all_files):
821
+ form.add_field(
822
+ f"files[{idx}]",
823
+ f.data,
824
+ filename=f.filename,
825
+ content_type="application/octet-stream",
826
+ )
827
+ return await self.request(
828
+ "POST",
829
+ f"/webhooks/{webhook_id}/{token}",
830
+ payload=form,
831
+ is_json=False,
832
+ use_auth_header=False,
833
+ )
834
+
835
+ return await self.request(
836
+ "POST",
837
+ f"/webhooks/{webhook_id}/{token}",
838
+ payload=payload,
839
+ use_auth_header=False,
840
+ )
841
+
842
+ async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]:
843
+ """Fetches a user object for a given user ID."""
844
+ return await self.request("GET", f"/users/{user_id}")
845
+
846
+ async def get_guild_member(
847
+ self, guild_id: "Snowflake", user_id: "Snowflake"
848
+ ) -> Dict[str, Any]:
849
+ """Returns a guild member object for the specified user."""
850
+ return await self.request("GET", f"/guilds/{guild_id}/members/{user_id}")
851
+
852
+ async def kick_member(
853
+ self, guild_id: "Snowflake", user_id: "Snowflake", reason: Optional[str] = None
854
+ ) -> None:
855
+ """Kicks a member from the guild."""
856
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
857
+ await self.request(
858
+ "DELETE",
859
+ f"/guilds/{guild_id}/members/{user_id}",
860
+ custom_headers=headers,
861
+ )
862
+
863
+ async def ban_member(
864
+ self,
865
+ guild_id: "Snowflake",
866
+ user_id: "Snowflake",
867
+ *,
868
+ delete_message_seconds: int = 0,
869
+ reason: Optional[str] = None,
870
+ ) -> None:
871
+ """Bans a member from the guild."""
872
+ payload = {}
873
+ if delete_message_seconds:
874
+ payload["delete_message_seconds"] = delete_message_seconds
875
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
876
+ await self.request(
877
+ "PUT",
878
+ f"/guilds/{guild_id}/bans/{user_id}",
879
+ payload=payload if payload else None,
880
+ custom_headers=headers,
881
+ )
882
+
883
+ async def timeout_member(
884
+ self,
885
+ guild_id: "Snowflake",
886
+ user_id: "Snowflake",
887
+ *,
888
+ until: Optional[str],
889
+ reason: Optional[str] = None,
890
+ ) -> Dict[str, Any]:
891
+ """Times out a member until the given ISO8601 timestamp."""
892
+ payload = {"communication_disabled_until": until}
893
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
894
+ return await self.request(
895
+ "PATCH",
896
+ f"/guilds/{guild_id}/members/{user_id}",
897
+ payload=payload,
898
+ custom_headers=headers,
899
+ )
900
+
901
+ async def get_guild_roles(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
902
+ """Returns a list of role objects for the guild."""
903
+ return await self.request("GET", f"/guilds/{guild_id}/roles")
904
+
905
+ async def get_guild(self, guild_id: "Snowflake") -> Dict[str, Any]:
906
+ """Fetches a guild object for a given guild ID."""
907
+ return await self.request("GET", f"/guilds/{guild_id}")
908
+
909
+ async def get_guild_widget(self, guild_id: "Snowflake") -> Dict[str, Any]:
910
+ """Fetches the guild widget settings."""
911
+
912
+ return await self.request("GET", f"/guilds/{guild_id}/widget")
913
+
914
+ async def edit_guild_widget(
915
+ self, guild_id: "Snowflake", payload: Dict[str, Any]
916
+ ) -> Dict[str, Any]:
917
+ """Edits the guild widget settings."""
918
+
919
+ return await self.request(
920
+ "PATCH", f"/guilds/{guild_id}/widget", payload=payload
921
+ )
922
+
923
+ async def get_guild_templates(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
924
+ """Fetches all templates for the given guild."""
925
+ return await self.request("GET", f"/guilds/{guild_id}/templates")
926
+
927
+ async def create_guild_template(
928
+ self, guild_id: "Snowflake", payload: Dict[str, Any]
929
+ ) -> Dict[str, Any]:
930
+ """Creates a guild template."""
931
+ return await self.request(
932
+ "POST", f"/guilds/{guild_id}/templates", payload=payload
933
+ )
934
+
935
+ async def sync_guild_template(
936
+ self, guild_id: "Snowflake", template_code: str
937
+ ) -> Dict[str, Any]:
938
+ """Syncs a guild template to the guild's current state."""
939
+ return await self.request(
940
+ "PUT",
941
+ f"/guilds/{guild_id}/templates/{template_code}",
942
+ )
943
+
944
+ async def delete_guild_template(
945
+ self, guild_id: "Snowflake", template_code: str
946
+ ) -> None:
947
+ """Deletes a guild template."""
948
+ await self.request("DELETE", f"/guilds/{guild_id}/templates/{template_code}")
949
+
950
+ async def get_guild_scheduled_events(
951
+ self, guild_id: "Snowflake"
952
+ ) -> List[Dict[str, Any]]:
953
+ """Returns a list of scheduled events for the guild."""
954
+
955
+ return await self.request("GET", f"/guilds/{guild_id}/scheduled-events")
956
+
957
+ async def get_guild_scheduled_event(
958
+ self, guild_id: "Snowflake", event_id: "Snowflake"
959
+ ) -> Dict[str, Any]:
960
+ """Returns a guild scheduled event."""
961
+
962
+ return await self.request(
963
+ "GET", f"/guilds/{guild_id}/scheduled-events/{event_id}"
964
+ )
965
+
966
+ async def create_guild_scheduled_event(
967
+ self, guild_id: "Snowflake", payload: Dict[str, Any]
968
+ ) -> Dict[str, Any]:
969
+ """Creates a guild scheduled event."""
970
+
971
+ return await self.request(
972
+ "POST", f"/guilds/{guild_id}/scheduled-events", payload=payload
973
+ )
974
+
975
+ async def edit_guild_scheduled_event(
976
+ self, guild_id: "Snowflake", event_id: "Snowflake", payload: Dict[str, Any]
977
+ ) -> Dict[str, Any]:
978
+ """Edits a guild scheduled event."""
979
+
980
+ return await self.request(
981
+ "PATCH",
982
+ f"/guilds/{guild_id}/scheduled-events/{event_id}",
983
+ payload=payload,
984
+ )
985
+
986
+ async def delete_guild_scheduled_event(
987
+ self, guild_id: "Snowflake", event_id: "Snowflake"
988
+ ) -> None:
989
+ """Deletes a guild scheduled event."""
990
+
991
+ await self.request("DELETE", f"/guilds/{guild_id}/scheduled-events/{event_id}")
992
+
993
+ async def get_audit_logs(
994
+ self, guild_id: "Snowflake", **filters: Any
995
+ ) -> Dict[str, Any]:
996
+ """Fetches audit log entries for a guild."""
997
+ params = {k: v for k, v in filters.items() if v is not None}
998
+ return await self.request(
999
+ "GET",
1000
+ f"/guilds/{guild_id}/audit-logs",
1001
+ params=params if params else None,
1002
+ )
1003
+
1004
+ # Add other methods like:
1005
+ # async def get_guild(self, guild_id: str) -> Dict[str, Any]: ...
1006
+ # async def create_reaction(self, channel_id: str, message_id: str, emoji: str) -> None: ...
1007
+ # etc.
1008
+ # --- Application Command Endpoints ---
1009
+
1010
+ # Global Application Commands
1011
+ async def get_global_application_commands(
1012
+ self, application_id: "Snowflake", with_localizations: bool = False
1013
+ ) -> List["ApplicationCommand"]:
1014
+ """Fetches all global commands for your application."""
1015
+ params = {"with_localizations": str(with_localizations).lower()}
1016
+ data = await self.request(
1017
+ "GET", f"/applications/{application_id}/commands", params=params
1018
+ )
1019
+ from .interactions import ApplicationCommand # Ensure constructor is available
1020
+
1021
+ return [ApplicationCommand(cmd_data) for cmd_data in data]
1022
+
1023
+ async def create_global_application_command(
1024
+ self, application_id: "Snowflake", payload: Dict[str, Any]
1025
+ ) -> "ApplicationCommand":
1026
+ """Creates a new global command."""
1027
+ data = await self.request(
1028
+ "POST", f"/applications/{application_id}/commands", payload=payload
1029
+ )
1030
+ from .interactions import ApplicationCommand
1031
+
1032
+ return ApplicationCommand(data)
1033
+
1034
+ async def get_global_application_command(
1035
+ self, application_id: "Snowflake", command_id: "Snowflake"
1036
+ ) -> "ApplicationCommand":
1037
+ """Fetches a specific global command."""
1038
+ data = await self.request(
1039
+ "GET", f"/applications/{application_id}/commands/{command_id}"
1040
+ )
1041
+ from .interactions import ApplicationCommand
1042
+
1043
+ return ApplicationCommand(data)
1044
+
1045
+ async def edit_global_application_command(
1046
+ self,
1047
+ application_id: "Snowflake",
1048
+ command_id: "Snowflake",
1049
+ payload: Dict[str, Any],
1050
+ ) -> "ApplicationCommand":
1051
+ """Edits a specific global command."""
1052
+ data = await self.request(
1053
+ "PATCH",
1054
+ f"/applications/{application_id}/commands/{command_id}",
1055
+ payload=payload,
1056
+ )
1057
+ from .interactions import ApplicationCommand
1058
+
1059
+ return ApplicationCommand(data)
1060
+
1061
+ async def delete_global_application_command(
1062
+ self, application_id: "Snowflake", command_id: "Snowflake"
1063
+ ) -> None:
1064
+ """Deletes a specific global command."""
1065
+ await self.request(
1066
+ "DELETE", f"/applications/{application_id}/commands/{command_id}"
1067
+ )
1068
+
1069
+ async def bulk_overwrite_global_application_commands(
1070
+ self, application_id: "Snowflake", payload: List[Dict[str, Any]]
1071
+ ) -> List["ApplicationCommand"]:
1072
+ """Bulk overwrites all global commands for your application."""
1073
+ data = await self.request(
1074
+ "PUT", f"/applications/{application_id}/commands", payload=payload
1075
+ )
1076
+ from .interactions import ApplicationCommand
1077
+
1078
+ return [ApplicationCommand(cmd_data) for cmd_data in data]
1079
+
1080
+ # Guild Application Commands
1081
+ async def get_guild_application_commands(
1082
+ self,
1083
+ application_id: "Snowflake",
1084
+ guild_id: "Snowflake",
1085
+ with_localizations: bool = False,
1086
+ ) -> List["ApplicationCommand"]:
1087
+ """Fetches all commands for your application for a specific guild."""
1088
+ params = {"with_localizations": str(with_localizations).lower()}
1089
+ data = await self.request(
1090
+ "GET",
1091
+ f"/applications/{application_id}/guilds/{guild_id}/commands",
1092
+ params=params,
1093
+ )
1094
+ from .interactions import ApplicationCommand
1095
+
1096
+ return [ApplicationCommand(cmd_data) for cmd_data in data]
1097
+
1098
+ async def create_guild_application_command(
1099
+ self,
1100
+ application_id: "Snowflake",
1101
+ guild_id: "Snowflake",
1102
+ payload: Dict[str, Any],
1103
+ ) -> "ApplicationCommand":
1104
+ """Creates a new guild command."""
1105
+ data = await self.request(
1106
+ "POST",
1107
+ f"/applications/{application_id}/guilds/{guild_id}/commands",
1108
+ payload=payload,
1109
+ )
1110
+ from .interactions import ApplicationCommand
1111
+
1112
+ return ApplicationCommand(data)
1113
+
1114
+ async def get_guild_application_command(
1115
+ self,
1116
+ application_id: "Snowflake",
1117
+ guild_id: "Snowflake",
1118
+ command_id: "Snowflake",
1119
+ ) -> "ApplicationCommand":
1120
+ """Fetches a specific guild command."""
1121
+ data = await self.request(
1122
+ "GET",
1123
+ f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
1124
+ )
1125
+ from .interactions import ApplicationCommand
1126
+
1127
+ return ApplicationCommand(data)
1128
+
1129
+ async def edit_guild_application_command(
1130
+ self,
1131
+ application_id: "Snowflake",
1132
+ guild_id: "Snowflake",
1133
+ command_id: "Snowflake",
1134
+ payload: Dict[str, Any],
1135
+ ) -> "ApplicationCommand":
1136
+ """Edits a specific guild command."""
1137
+ data = await self.request(
1138
+ "PATCH",
1139
+ f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
1140
+ payload=payload,
1141
+ )
1142
+ from .interactions import ApplicationCommand
1143
+
1144
+ return ApplicationCommand(data)
1145
+
1146
+ async def delete_guild_application_command(
1147
+ self,
1148
+ application_id: "Snowflake",
1149
+ guild_id: "Snowflake",
1150
+ command_id: "Snowflake",
1151
+ ) -> None:
1152
+ """Deletes a specific guild command."""
1153
+ await self.request(
1154
+ "DELETE",
1155
+ f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
1156
+ )
1157
+
1158
+ async def bulk_overwrite_guild_application_commands(
1159
+ self,
1160
+ application_id: "Snowflake",
1161
+ guild_id: "Snowflake",
1162
+ payload: List[Dict[str, Any]],
1163
+ ) -> List["ApplicationCommand"]:
1164
+ """Bulk overwrites all commands for your application for a specific guild."""
1165
+ data = await self.request(
1166
+ "PUT",
1167
+ f"/applications/{application_id}/guilds/{guild_id}/commands",
1168
+ payload=payload,
1169
+ )
1170
+ from .interactions import ApplicationCommand
1171
+
1172
+ return [ApplicationCommand(cmd_data) for cmd_data in data]
1173
+
1174
+ # --- Interaction Response Endpoints ---
1175
+ # Note: These methods return Dict[str, Any] representing the Message data.
1176
+ # The caller (e.g., AppCommandHandler) will be responsible for constructing Message models
1177
+ # if needed, as Message model instantiation requires a `client_instance`.
1178
+
1179
+ async def create_interaction_response(
1180
+ self,
1181
+ interaction_id: "Snowflake",
1182
+ interaction_token: str,
1183
+ payload: Union["InteractionResponsePayload", Dict[str, Any]],
1184
+ *,
1185
+ ephemeral: bool = False,
1186
+ ) -> None:
1187
+ """Creates a response to an Interaction.
1188
+
1189
+ Parameters
1190
+ ----------
1191
+ ephemeral: bool
1192
+ Ignored parameter for test compatibility.
1193
+ """
1194
+ # Interaction responses do not use the bot token in the Authorization header.
1195
+ # They are authenticated by the interaction_token in the URL.
1196
+ payload_data: Dict[str, Any]
1197
+ if isinstance(payload, InteractionResponsePayload):
1198
+ payload_data = payload.to_dict()
1199
+ else:
1200
+ payload_data = payload
1201
+
1202
+ await self.request(
1203
+ "POST",
1204
+ f"/interactions/{interaction_id}/{interaction_token}/callback",
1205
+ payload=payload_data,
1206
+ use_auth_header=False,
1207
+ )
1208
+
1209
+ async def get_original_interaction_response(
1210
+ self, application_id: "Snowflake", interaction_token: str
1211
+ ) -> Dict[str, Any]:
1212
+ """Gets the initial Interaction response."""
1213
+ # This endpoint uses the bot token for auth.
1214
+ return await self.request(
1215
+ "GET", f"/webhooks/{application_id}/{interaction_token}/messages/@original"
1216
+ )
1217
+
1218
+ async def edit_original_interaction_response(
1219
+ self,
1220
+ application_id: "Snowflake",
1221
+ interaction_token: str,
1222
+ payload: Dict[str, Any],
1223
+ ) -> Dict[str, Any]:
1224
+ """Edits the initial Interaction response."""
1225
+ return await self.request(
1226
+ "PATCH",
1227
+ f"/webhooks/{application_id}/{interaction_token}/messages/@original",
1228
+ payload=payload,
1229
+ use_auth_header=False,
1230
+ ) # Docs imply webhook-style auth
1231
+
1232
+ async def delete_original_interaction_response(
1233
+ self, application_id: "Snowflake", interaction_token: str
1234
+ ) -> None:
1235
+ """Deletes the initial Interaction response."""
1236
+ await self.request(
1237
+ "DELETE",
1238
+ f"/webhooks/{application_id}/{interaction_token}/messages/@original",
1239
+ use_auth_header=False,
1240
+ ) # Docs imply webhook-style auth
1241
+
1242
+ async def create_followup_message(
1243
+ self,
1244
+ application_id: "Snowflake",
1245
+ interaction_token: str,
1246
+ payload: Dict[str, Any],
1247
+ ) -> Dict[str, Any]:
1248
+ """Creates a followup message for an Interaction."""
1249
+ # Followup messages are sent to a webhook endpoint.
1250
+ return await self.request(
1251
+ "POST",
1252
+ f"/webhooks/{application_id}/{interaction_token}",
1253
+ payload=payload,
1254
+ use_auth_header=False,
1255
+ ) # Docs imply webhook-style auth
1256
+
1257
+ async def edit_followup_message(
1258
+ self,
1259
+ application_id: "Snowflake",
1260
+ interaction_token: str,
1261
+ message_id: "Snowflake",
1262
+ payload: Dict[str, Any],
1263
+ ) -> Dict[str, Any]:
1264
+ """Edits a followup message for an Interaction."""
1265
+ return await self.request(
1266
+ "PATCH",
1267
+ f"/webhooks/{application_id}/{interaction_token}/messages/{message_id}",
1268
+ payload=payload,
1269
+ use_auth_header=False,
1270
+ ) # Docs imply webhook-style auth
1271
+
1272
+ async def delete_followup_message(
1273
+ self,
1274
+ application_id: "Snowflake",
1275
+ interaction_token: str,
1276
+ message_id: "Snowflake",
1277
+ ) -> None:
1278
+ """Deletes a followup message for an Interaction."""
1279
+ await self.request(
1280
+ "DELETE",
1281
+ f"/webhooks/{application_id}/{interaction_token}/messages/{message_id}",
1282
+ use_auth_header=False,
1283
+ )
1284
+
1285
+ async def trigger_typing(self, channel_id: str) -> None:
1286
+ """Sends a typing indicator to the specified channel."""
1287
+ await self.request("POST", f"/channels/{channel_id}/typing")
1288
+
1289
+ async def start_stage_instance(
1290
+ self, payload: Dict[str, Any], reason: Optional[str] = None
1291
+ ) -> "StageInstance":
1292
+ """Starts a stage instance."""
1293
+
1294
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
1295
+ data = await self.request(
1296
+ "POST", "/stage-instances", payload=payload, custom_headers=headers
1297
+ )
1298
+ from .models import StageInstance
1299
+
1300
+ return StageInstance(data)
1301
+
1302
+ async def edit_stage_instance(
1303
+ self,
1304
+ channel_id: "Snowflake",
1305
+ payload: Dict[str, Any],
1306
+ reason: Optional[str] = None,
1307
+ ) -> "StageInstance":
1308
+ """Edits an existing stage instance."""
1309
+
1310
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
1311
+ data = await self.request(
1312
+ "PATCH",
1313
+ f"/stage-instances/{channel_id}",
1314
+ payload=payload,
1315
+ custom_headers=headers,
1316
+ )
1317
+ from .models import StageInstance
1318
+
1319
+ return StageInstance(data)
1320
+
1321
+ async def end_stage_instance(
1322
+ self, channel_id: "Snowflake", reason: Optional[str] = None
1323
+ ) -> None:
1324
+ """Ends a stage instance."""
1325
+
1326
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
1327
+ await self.request(
1328
+ "DELETE", f"/stage-instances/{channel_id}", custom_headers=headers
1329
+ )
1330
+
1331
+ async def get_voice_regions(self) -> List[Dict[str, Any]]:
1332
+ """Returns available voice regions."""
1333
+ return await self.request("GET", "/voice/regions")
1334
+
1335
+ async def start_thread_from_message(
1336
+ self,
1337
+ channel_id: "Snowflake",
1338
+ message_id: "Snowflake",
1339
+ payload: Dict[str, Any],
1340
+ ) -> Dict[str, Any]:
1341
+ """Starts a new thread from an existing message."""
1342
+ return await self.request(
1343
+ "POST",
1344
+ f"/channels/{channel_id}/messages/{message_id}/threads",
1345
+ payload=payload,
1346
+ )
1347
+
1348
+ async def start_thread_without_message(
1349
+ self, channel_id: "Snowflake", payload: Dict[str, Any]
1350
+ ) -> Dict[str, Any]:
1351
+ """Starts a new thread that is not attached to a message."""
1352
+ return await self.request(
1353
+ "POST", f"/channels/{channel_id}/threads", payload=payload
1354
+ )
1355
+
1356
+ async def join_thread(self, channel_id: "Snowflake") -> None:
1357
+ """Joins the current user to a thread."""
1358
+ await self.request("PUT", f"/channels/{channel_id}/thread-members/@me")
1359
+
1360
+ async def leave_thread(self, channel_id: "Snowflake") -> None:
1361
+ """Removes the current user from a thread."""
1362
+ await self.request("DELETE", f"/channels/{channel_id}/thread-members/@me")