disagreement 0.1.0rc3__py3-none-any.whl → 0.3.0b1__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/http.py CHANGED
@@ -1,875 +1,1120 @@
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
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
- ):
44
- self.token = token
45
- self._session: Optional[aiohttp.ClientSession] = (
46
- client_session # Can be externally managed
47
- )
48
- self.user_agent = f"DiscordBot (https://github.com/yourusername/disagreement, {__version__})" # Customize URL
49
-
50
- self.verbose = verbose
51
-
52
- self._rate_limiter = RateLimiter()
53
-
54
- async def _ensure_session(self):
55
- if self._session is None or self._session.closed:
56
- self._session = aiohttp.ClientSession()
57
-
58
- async def close(self):
59
- """Closes the underlying aiohttp.ClientSession."""
60
- if self._session and not self._session.closed:
61
- await self._session.close()
62
-
63
- async def request(
64
- self,
65
- method: str,
66
- endpoint: str,
67
- payload: Optional[
68
- Union[Dict[str, Any], List[Dict[str, Any]], aiohttp.FormData]
69
- ] = None,
70
- params: Optional[Dict[str, Any]] = None,
71
- is_json: bool = True,
72
- use_auth_header: bool = True,
73
- custom_headers: Optional[Dict[str, str]] = None,
74
- ) -> Any:
75
- """Makes an HTTP request to the Discord API."""
76
- await self._ensure_session()
77
-
78
- url = f"{API_BASE_URL}{endpoint}"
79
- final_headers: Dict[str, str] = { # Renamed to final_headers
80
- "User-Agent": self.user_agent,
81
- }
82
- if use_auth_header:
83
- final_headers["Authorization"] = f"Bot {self.token}"
84
-
85
- if is_json and payload:
86
- final_headers["Content-Type"] = "application/json"
87
-
88
- if custom_headers: # Merge custom headers
89
- final_headers.update(custom_headers)
90
-
91
- if self.verbose:
92
- logger.debug(
93
- "HTTP REQUEST: %s %s | payload=%s params=%s",
94
- method,
95
- url,
96
- payload,
97
- params,
98
- )
99
-
100
- route = f"{method.upper()}:{endpoint}"
101
-
102
- for attempt in range(5): # Max 5 retries for rate limits
103
- await self._rate_limiter.acquire(route)
104
- assert self._session is not None, "ClientSession not initialized"
105
- async with self._session.request(
106
- method,
107
- url,
108
- json=payload if is_json else None,
109
- data=payload if not is_json else None,
110
- headers=final_headers,
111
- params=params,
112
- ) as response:
113
-
114
- data = None
115
- try:
116
- if response.headers.get("Content-Type", "").startswith(
117
- "application/json"
118
- ):
119
- data = await response.json()
120
- else:
121
- # For non-JSON responses, like fetching images or other files
122
- # We might return the raw response or handle it differently
123
- # For now, let's assume most API calls expect JSON
124
- data = await response.text()
125
- except (aiohttp.ContentTypeError, json.JSONDecodeError):
126
- data = (
127
- await response.text()
128
- ) # Fallback to text if JSON parsing fails
129
-
130
- if self.verbose:
131
- logger.debug(
132
- "HTTP RESPONSE: %s %s | %s", response.status, url, data
133
- )
134
-
135
- self._rate_limiter.release(route, response.headers)
136
-
137
- if 200 <= response.status < 300:
138
- if response.status == 204:
139
- return None
140
- return data
141
-
142
- # Rate limit handling
143
- if response.status == 429: # Rate limited
144
- retry_after_str = response.headers.get("Retry-After", "1")
145
- try:
146
- retry_after = float(retry_after_str)
147
- except ValueError:
148
- retry_after = 1.0 # Default retry if header is malformed
149
-
150
- is_global = (
151
- response.headers.get("X-RateLimit-Global", "false").lower()
152
- == "true"
153
- )
154
-
155
- error_message = f"Rate limited on {method} {endpoint}."
156
- if data and isinstance(data, dict) and "message" in data:
157
- error_message += f" Discord says: {data['message']}"
158
-
159
- await self._rate_limiter.handle_rate_limit(
160
- route, retry_after, is_global
161
- )
162
-
163
- if attempt < 4: # Don't log on the last attempt before raising
164
- logger.warning(
165
- "%s Retrying after %ss (Attempt %s/5). Global: %s",
166
- error_message,
167
- retry_after,
168
- attempt + 1,
169
- is_global,
170
- )
171
- continue # Retry the request
172
- else: # Last attempt failed
173
- raise RateLimitError(
174
- response,
175
- message=error_message,
176
- retry_after=retry_after,
177
- is_global=is_global,
178
- )
179
-
180
- # Other error handling
181
- if response.status == 401: # Unauthorized
182
- raise AuthenticationError(response, "Invalid token provided.")
183
- if response.status == 403: # Forbidden
184
- raise HTTPException(
185
- response,
186
- "Missing permissions or access denied.",
187
- status=response.status,
188
- text=str(data),
189
- )
190
-
191
- # General HTTP error
192
- error_text = str(data) if data else "Unknown error"
193
- discord_error_code = (
194
- data.get("code") if isinstance(data, dict) else None
195
- )
196
- raise HTTPException(
197
- response,
198
- f"API Error on {method} {endpoint}: {error_text}",
199
- status=response.status,
200
- text=error_text,
201
- error_code=discord_error_code,
202
- )
203
-
204
- # Should not be reached if retries are exhausted by RateLimitError
205
- raise DisagreementException(
206
- f"Failed request to {method} {endpoint} after multiple retries."
207
- )
208
-
209
- # --- Specific API call methods ---
210
-
211
- async def get_gateway_bot(self) -> Dict[str, Any]:
212
- """Gets the WSS URL and sharding information for the Gateway."""
213
- return await self.request("GET", "/gateway/bot")
214
-
215
- async def send_message(
216
- self,
217
- channel_id: str,
218
- content: Optional[str] = None,
219
- tts: bool = False,
220
- embeds: Optional[List[Dict[str, Any]]] = None,
221
- components: Optional[List[Dict[str, Any]]] = None,
222
- allowed_mentions: Optional[dict] = None,
223
- message_reference: Optional[Dict[str, Any]] = None,
224
- attachments: Optional[List[Any]] = None,
225
- files: Optional[List[Any]] = None,
226
- flags: Optional[int] = None,
227
- ) -> Dict[str, Any]:
228
- """Sends a message to a channel.
229
-
230
- Parameters
231
- ----------
232
- attachments:
233
- A list of attachment payloads to include with the message.
234
- files:
235
- A list of :class:`File` objects containing binary data to upload.
236
-
237
- Returns
238
- -------
239
- Dict[str, Any]
240
- The created message data.
241
- """
242
- payload: Dict[str, Any] = {}
243
- if content is not None: # Content is optional if embeds/components are present
244
- payload["content"] = content
245
- if tts:
246
- payload["tts"] = True
247
- if embeds:
248
- payload["embeds"] = embeds
249
- if components:
250
- payload["components"] = components
251
- if allowed_mentions:
252
- payload["allowed_mentions"] = allowed_mentions
253
- all_files: List["File"] = []
254
- if attachments is not None:
255
- payload["attachments"] = []
256
- for a in attachments:
257
- if hasattr(a, "data") and hasattr(a, "filename"):
258
- idx = len(all_files)
259
- all_files.append(a)
260
- payload["attachments"].append({"id": idx, "filename": a.filename})
261
- else:
262
- payload["attachments"].append(
263
- a.to_dict() if hasattr(a, "to_dict") else a
264
- )
265
- if files is not None:
266
- for f in files:
267
- if hasattr(f, "data") and hasattr(f, "filename"):
268
- idx = len(all_files)
269
- all_files.append(f)
270
- if "attachments" not in payload:
271
- payload["attachments"] = []
272
- payload["attachments"].append({"id": idx, "filename": f.filename})
273
- else:
274
- raise TypeError("files must be File objects")
275
- if flags:
276
- payload["flags"] = flags
277
- if message_reference:
278
- payload["message_reference"] = message_reference
279
-
280
- if not payload:
281
- raise ValueError("Message must have content, embeds, or components.")
282
-
283
- if all_files:
284
- form = aiohttp.FormData()
285
- form.add_field(
286
- "payload_json", json.dumps(payload), content_type="application/json"
287
- )
288
- for idx, f in enumerate(all_files):
289
- form.add_field(
290
- f"files[{idx}]",
291
- f.data,
292
- filename=f.filename,
293
- content_type="application/octet-stream",
294
- )
295
- return await self.request(
296
- "POST",
297
- f"/channels/{channel_id}/messages",
298
- payload=form,
299
- is_json=False,
300
- )
301
-
302
- return await self.request(
303
- "POST", f"/channels/{channel_id}/messages", payload=payload
304
- )
305
-
306
- async def edit_message(
307
- self,
308
- channel_id: str,
309
- message_id: str,
310
- payload: Dict[str, Any],
311
- ) -> Dict[str, Any]:
312
- """Edits a message in a channel."""
313
-
314
- return await self.request(
315
- "PATCH",
316
- f"/channels/{channel_id}/messages/{message_id}",
317
- payload=payload,
318
- )
319
-
320
- async def get_message(
321
- self, channel_id: "Snowflake", message_id: "Snowflake"
322
- ) -> Dict[str, Any]:
323
- """Fetches a message from a channel."""
324
-
325
- return await self.request(
326
- "GET", f"/channels/{channel_id}/messages/{message_id}"
327
- )
328
-
329
- async def delete_message(
330
- self, channel_id: "Snowflake", message_id: "Snowflake"
331
- ) -> None:
332
- """Deletes a message in a channel."""
333
-
334
- await self.request("DELETE", f"/channels/{channel_id}/messages/{message_id}")
335
-
336
- async def create_reaction(
337
- self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
338
- ) -> None:
339
- """Adds a reaction to a message as the current user."""
340
- encoded = quote(emoji)
341
- await self.request(
342
- "PUT",
343
- f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/@me",
344
- )
345
-
346
- async def delete_reaction(
347
- self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
348
- ) -> None:
349
- """Removes the current user's reaction from a message."""
350
- encoded = quote(emoji)
351
- await self.request(
352
- "DELETE",
353
- f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/@me",
354
- )
355
-
356
- async def get_reactions(
357
- self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
358
- ) -> List[Dict[str, Any]]:
359
- """Fetches the users that reacted with a specific emoji."""
360
- encoded = quote(emoji)
361
- return await self.request(
362
- "GET",
363
- f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}",
364
- )
365
-
366
- async def clear_reactions(
367
- self, channel_id: "Snowflake", message_id: "Snowflake"
368
- ) -> None:
369
- """Removes all reactions from a message."""
370
-
371
- await self.request(
372
- "DELETE",
373
- f"/channels/{channel_id}/messages/{message_id}/reactions",
374
- )
375
-
376
- async def bulk_delete_messages(
377
- self, channel_id: "Snowflake", messages: List["Snowflake"]
378
- ) -> List["Snowflake"]:
379
- """Bulk deletes messages in a channel and returns their IDs."""
380
-
381
- await self.request(
382
- "POST",
383
- f"/channels/{channel_id}/messages/bulk-delete",
384
- payload={"messages": messages},
385
- )
386
- return messages
387
-
388
- async def delete_channel(
389
- self, channel_id: str, reason: Optional[str] = None
390
- ) -> None:
391
- """Deletes a channel.
392
-
393
- If the channel is a guild channel, requires the MANAGE_CHANNELS permission.
394
- If the channel is a thread, requires the MANAGE_THREADS permission (if locked) or
395
- be the thread creator (if not locked).
396
- Deleting a category does not delete its child channels.
397
- """
398
- custom_headers = {}
399
- if reason:
400
- custom_headers["X-Audit-Log-Reason"] = reason
401
-
402
- await self.request(
403
- "DELETE",
404
- f"/channels/{channel_id}",
405
- custom_headers=custom_headers if custom_headers else None,
406
- )
407
-
408
- async def get_channel(self, channel_id: str) -> Dict[str, Any]:
409
- """Fetches a channel by ID."""
410
- return await self.request("GET", f"/channels/{channel_id}")
411
-
412
- async def create_webhook(
413
- self, channel_id: "Snowflake", payload: Dict[str, Any]
414
- ) -> "Webhook":
415
- """Creates a webhook in the specified channel."""
416
-
417
- data = await self.request(
418
- "POST", f"/channels/{channel_id}/webhooks", payload=payload
419
- )
420
- from .models import Webhook
421
-
422
- return Webhook(data)
423
-
424
- async def edit_webhook(
425
- self, webhook_id: "Snowflake", payload: Dict[str, Any]
426
- ) -> "Webhook":
427
- """Edits an existing webhook."""
428
-
429
- data = await self.request("PATCH", f"/webhooks/{webhook_id}", payload=payload)
430
- from .models import Webhook
431
-
432
- return Webhook(data)
433
-
434
- async def delete_webhook(self, webhook_id: "Snowflake") -> None:
435
- """Deletes a webhook."""
436
-
437
- await self.request("DELETE", f"/webhooks/{webhook_id}")
438
-
439
- async def execute_webhook(
440
- self,
441
- webhook_id: "Snowflake",
442
- token: str,
443
- *,
444
- content: Optional[str] = None,
445
- tts: bool = False,
446
- embeds: Optional[List[Dict[str, Any]]] = None,
447
- components: Optional[List[Dict[str, Any]]] = None,
448
- allowed_mentions: Optional[dict] = None,
449
- attachments: Optional[List[Any]] = None,
450
- files: Optional[List[Any]] = None,
451
- flags: Optional[int] = None,
452
- username: Optional[str] = None,
453
- avatar_url: Optional[str] = None,
454
- ) -> Dict[str, Any]:
455
- """Executes a webhook and returns the created message."""
456
-
457
- payload: Dict[str, Any] = {}
458
- if content is not None:
459
- payload["content"] = content
460
- if tts:
461
- payload["tts"] = True
462
- if embeds:
463
- payload["embeds"] = embeds
464
- if components:
465
- payload["components"] = components
466
- if allowed_mentions:
467
- payload["allowed_mentions"] = allowed_mentions
468
- if username:
469
- payload["username"] = username
470
- if avatar_url:
471
- payload["avatar_url"] = avatar_url
472
-
473
- all_files: List["File"] = []
474
- if attachments is not None:
475
- payload["attachments"] = []
476
- for a in attachments:
477
- if hasattr(a, "data") and hasattr(a, "filename"):
478
- idx = len(all_files)
479
- all_files.append(a)
480
- payload["attachments"].append({"id": idx, "filename": a.filename})
481
- else:
482
- payload["attachments"].append(
483
- a.to_dict() if hasattr(a, "to_dict") else a
484
- )
485
- if files is not None:
486
- for f in files:
487
- if hasattr(f, "data") and hasattr(f, "filename"):
488
- idx = len(all_files)
489
- all_files.append(f)
490
- if "attachments" not in payload:
491
- payload["attachments"] = []
492
- payload["attachments"].append({"id": idx, "filename": f.filename})
493
- else:
494
- raise TypeError("files must be File objects")
495
- if flags:
496
- payload["flags"] = flags
497
-
498
- if all_files:
499
- form = aiohttp.FormData()
500
- form.add_field(
501
- "payload_json", json.dumps(payload), content_type="application/json"
502
- )
503
- for idx, f in enumerate(all_files):
504
- form.add_field(
505
- f"files[{idx}]",
506
- f.data,
507
- filename=f.filename,
508
- content_type="application/octet-stream",
509
- )
510
- return await self.request(
511
- "POST",
512
- f"/webhooks/{webhook_id}/{token}",
513
- payload=form,
514
- is_json=False,
515
- use_auth_header=False,
516
- )
517
-
518
- return await self.request(
519
- "POST",
520
- f"/webhooks/{webhook_id}/{token}",
521
- payload=payload,
522
- use_auth_header=False,
523
- )
524
-
525
- async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]:
526
- """Fetches a user object for a given user ID."""
527
- return await self.request("GET", f"/users/{user_id}")
528
-
529
- async def get_guild_member(
530
- self, guild_id: "Snowflake", user_id: "Snowflake"
531
- ) -> Dict[str, Any]:
532
- """Returns a guild member object for the specified user."""
533
- return await self.request("GET", f"/guilds/{guild_id}/members/{user_id}")
534
-
535
- async def kick_member(
536
- self, guild_id: "Snowflake", user_id: "Snowflake", reason: Optional[str] = None
537
- ) -> None:
538
- """Kicks a member from the guild."""
539
- headers = {"X-Audit-Log-Reason": reason} if reason else None
540
- await self.request(
541
- "DELETE",
542
- f"/guilds/{guild_id}/members/{user_id}",
543
- custom_headers=headers,
544
- )
545
-
546
- async def ban_member(
547
- self,
548
- guild_id: "Snowflake",
549
- user_id: "Snowflake",
550
- *,
551
- delete_message_seconds: int = 0,
552
- reason: Optional[str] = None,
553
- ) -> None:
554
- """Bans a member from the guild."""
555
- payload = {}
556
- if delete_message_seconds:
557
- payload["delete_message_seconds"] = delete_message_seconds
558
- headers = {"X-Audit-Log-Reason": reason} if reason else None
559
- await self.request(
560
- "PUT",
561
- f"/guilds/{guild_id}/bans/{user_id}",
562
- payload=payload if payload else None,
563
- custom_headers=headers,
564
- )
565
-
566
- async def timeout_member(
567
- self,
568
- guild_id: "Snowflake",
569
- user_id: "Snowflake",
570
- *,
571
- until: Optional[str],
572
- reason: Optional[str] = None,
573
- ) -> Dict[str, Any]:
574
- """Times out a member until the given ISO8601 timestamp."""
575
- payload = {"communication_disabled_until": until}
576
- headers = {"X-Audit-Log-Reason": reason} if reason else None
577
- return await self.request(
578
- "PATCH",
579
- f"/guilds/{guild_id}/members/{user_id}",
580
- payload=payload,
581
- custom_headers=headers,
582
- )
583
-
584
- async def get_guild_roles(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
585
- """Returns a list of role objects for the guild."""
586
- return await self.request("GET", f"/guilds/{guild_id}/roles")
587
-
588
- async def get_guild(self, guild_id: "Snowflake") -> Dict[str, Any]:
589
- """Fetches a guild object for a given guild ID."""
590
- return await self.request("GET", f"/guilds/{guild_id}")
591
-
592
- # Add other methods like:
593
- # async def get_guild(self, guild_id: str) -> Dict[str, Any]: ...
594
- # async def create_reaction(self, channel_id: str, message_id: str, emoji: str) -> None: ...
595
- # etc.
596
- # --- Application Command Endpoints ---
597
-
598
- # Global Application Commands
599
- async def get_global_application_commands(
600
- self, application_id: "Snowflake", with_localizations: bool = False
601
- ) -> List["ApplicationCommand"]:
602
- """Fetches all global commands for your application."""
603
- params = {"with_localizations": str(with_localizations).lower()}
604
- data = await self.request(
605
- "GET", f"/applications/{application_id}/commands", params=params
606
- )
607
- from .interactions import ApplicationCommand # Ensure constructor is available
608
-
609
- return [ApplicationCommand(cmd_data) for cmd_data in data]
610
-
611
- async def create_global_application_command(
612
- self, application_id: "Snowflake", payload: Dict[str, Any]
613
- ) -> "ApplicationCommand":
614
- """Creates a new global command."""
615
- data = await self.request(
616
- "POST", f"/applications/{application_id}/commands", payload=payload
617
- )
618
- from .interactions import ApplicationCommand
619
-
620
- return ApplicationCommand(data)
621
-
622
- async def get_global_application_command(
623
- self, application_id: "Snowflake", command_id: "Snowflake"
624
- ) -> "ApplicationCommand":
625
- """Fetches a specific global command."""
626
- data = await self.request(
627
- "GET", f"/applications/{application_id}/commands/{command_id}"
628
- )
629
- from .interactions import ApplicationCommand
630
-
631
- return ApplicationCommand(data)
632
-
633
- async def edit_global_application_command(
634
- self,
635
- application_id: "Snowflake",
636
- command_id: "Snowflake",
637
- payload: Dict[str, Any],
638
- ) -> "ApplicationCommand":
639
- """Edits a specific global command."""
640
- data = await self.request(
641
- "PATCH",
642
- f"/applications/{application_id}/commands/{command_id}",
643
- payload=payload,
644
- )
645
- from .interactions import ApplicationCommand
646
-
647
- return ApplicationCommand(data)
648
-
649
- async def delete_global_application_command(
650
- self, application_id: "Snowflake", command_id: "Snowflake"
651
- ) -> None:
652
- """Deletes a specific global command."""
653
- await self.request(
654
- "DELETE", f"/applications/{application_id}/commands/{command_id}"
655
- )
656
-
657
- async def bulk_overwrite_global_application_commands(
658
- self, application_id: "Snowflake", payload: List[Dict[str, Any]]
659
- ) -> List["ApplicationCommand"]:
660
- """Bulk overwrites all global commands for your application."""
661
- data = await self.request(
662
- "PUT", f"/applications/{application_id}/commands", payload=payload
663
- )
664
- from .interactions import ApplicationCommand
665
-
666
- return [ApplicationCommand(cmd_data) for cmd_data in data]
667
-
668
- # Guild Application Commands
669
- async def get_guild_application_commands(
670
- self,
671
- application_id: "Snowflake",
672
- guild_id: "Snowflake",
673
- with_localizations: bool = False,
674
- ) -> List["ApplicationCommand"]:
675
- """Fetches all commands for your application for a specific guild."""
676
- params = {"with_localizations": str(with_localizations).lower()}
677
- data = await self.request(
678
- "GET",
679
- f"/applications/{application_id}/guilds/{guild_id}/commands",
680
- params=params,
681
- )
682
- from .interactions import ApplicationCommand
683
-
684
- return [ApplicationCommand(cmd_data) for cmd_data in data]
685
-
686
- async def create_guild_application_command(
687
- self,
688
- application_id: "Snowflake",
689
- guild_id: "Snowflake",
690
- payload: Dict[str, Any],
691
- ) -> "ApplicationCommand":
692
- """Creates a new guild command."""
693
- data = await self.request(
694
- "POST",
695
- f"/applications/{application_id}/guilds/{guild_id}/commands",
696
- payload=payload,
697
- )
698
- from .interactions import ApplicationCommand
699
-
700
- return ApplicationCommand(data)
701
-
702
- async def get_guild_application_command(
703
- self,
704
- application_id: "Snowflake",
705
- guild_id: "Snowflake",
706
- command_id: "Snowflake",
707
- ) -> "ApplicationCommand":
708
- """Fetches a specific guild command."""
709
- data = await self.request(
710
- "GET",
711
- f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
712
- )
713
- from .interactions import ApplicationCommand
714
-
715
- return ApplicationCommand(data)
716
-
717
- async def edit_guild_application_command(
718
- self,
719
- application_id: "Snowflake",
720
- guild_id: "Snowflake",
721
- command_id: "Snowflake",
722
- payload: Dict[str, Any],
723
- ) -> "ApplicationCommand":
724
- """Edits a specific guild command."""
725
- data = await self.request(
726
- "PATCH",
727
- f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
728
- payload=payload,
729
- )
730
- from .interactions import ApplicationCommand
731
-
732
- return ApplicationCommand(data)
733
-
734
- async def delete_guild_application_command(
735
- self,
736
- application_id: "Snowflake",
737
- guild_id: "Snowflake",
738
- command_id: "Snowflake",
739
- ) -> None:
740
- """Deletes a specific guild command."""
741
- await self.request(
742
- "DELETE",
743
- f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
744
- )
745
-
746
- async def bulk_overwrite_guild_application_commands(
747
- self,
748
- application_id: "Snowflake",
749
- guild_id: "Snowflake",
750
- payload: List[Dict[str, Any]],
751
- ) -> List["ApplicationCommand"]:
752
- """Bulk overwrites all commands for your application for a specific guild."""
753
- data = await self.request(
754
- "PUT",
755
- f"/applications/{application_id}/guilds/{guild_id}/commands",
756
- payload=payload,
757
- )
758
- from .interactions import ApplicationCommand
759
-
760
- return [ApplicationCommand(cmd_data) for cmd_data in data]
761
-
762
- # --- Interaction Response Endpoints ---
763
- # Note: These methods return Dict[str, Any] representing the Message data.
764
- # The caller (e.g., AppCommandHandler) will be responsible for constructing Message models
765
- # if needed, as Message model instantiation requires a `client_instance`.
766
-
767
- async def create_interaction_response(
768
- self,
769
- interaction_id: "Snowflake",
770
- interaction_token: str,
771
- payload: Union["InteractionResponsePayload", Dict[str, Any]],
772
- *,
773
- ephemeral: bool = False,
774
- ) -> None:
775
- """Creates a response to an Interaction.
776
-
777
- Parameters
778
- ----------
779
- ephemeral: bool
780
- Ignored parameter for test compatibility.
781
- """
782
- # Interaction responses do not use the bot token in the Authorization header.
783
- # They are authenticated by the interaction_token in the URL.
784
- payload_data: Dict[str, Any]
785
- if isinstance(payload, InteractionResponsePayload):
786
- payload_data = payload.to_dict()
787
- else:
788
- payload_data = payload
789
-
790
- await self.request(
791
- "POST",
792
- f"/interactions/{interaction_id}/{interaction_token}/callback",
793
- payload=payload_data,
794
- use_auth_header=False,
795
- )
796
-
797
- async def get_original_interaction_response(
798
- self, application_id: "Snowflake", interaction_token: str
799
- ) -> Dict[str, Any]:
800
- """Gets the initial Interaction response."""
801
- # This endpoint uses the bot token for auth.
802
- return await self.request(
803
- "GET", f"/webhooks/{application_id}/{interaction_token}/messages/@original"
804
- )
805
-
806
- async def edit_original_interaction_response(
807
- self,
808
- application_id: "Snowflake",
809
- interaction_token: str,
810
- payload: Dict[str, Any],
811
- ) -> Dict[str, Any]:
812
- """Edits the initial Interaction response."""
813
- return await self.request(
814
- "PATCH",
815
- f"/webhooks/{application_id}/{interaction_token}/messages/@original",
816
- payload=payload,
817
- use_auth_header=False,
818
- ) # Docs imply webhook-style auth
819
-
820
- async def delete_original_interaction_response(
821
- self, application_id: "Snowflake", interaction_token: str
822
- ) -> None:
823
- """Deletes the initial Interaction response."""
824
- await self.request(
825
- "DELETE",
826
- f"/webhooks/{application_id}/{interaction_token}/messages/@original",
827
- use_auth_header=False,
828
- ) # Docs imply webhook-style auth
829
-
830
- async def create_followup_message(
831
- self,
832
- application_id: "Snowflake",
833
- interaction_token: str,
834
- payload: Dict[str, Any],
835
- ) -> Dict[str, Any]:
836
- """Creates a followup message for an Interaction."""
837
- # Followup messages are sent to a webhook endpoint.
838
- return await self.request(
839
- "POST",
840
- f"/webhooks/{application_id}/{interaction_token}",
841
- payload=payload,
842
- use_auth_header=False,
843
- ) # Docs imply webhook-style auth
844
-
845
- async def edit_followup_message(
846
- self,
847
- application_id: "Snowflake",
848
- interaction_token: str,
849
- message_id: "Snowflake",
850
- payload: Dict[str, Any],
851
- ) -> Dict[str, Any]:
852
- """Edits a followup message for an Interaction."""
853
- return await self.request(
854
- "PATCH",
855
- f"/webhooks/{application_id}/{interaction_token}/messages/{message_id}",
856
- payload=payload,
857
- use_auth_header=False,
858
- ) # Docs imply webhook-style auth
859
-
860
- async def delete_followup_message(
861
- self,
862
- application_id: "Snowflake",
863
- interaction_token: str,
864
- message_id: "Snowflake",
865
- ) -> None:
866
- """Deletes a followup message for an Interaction."""
867
- await self.request(
868
- "DELETE",
869
- f"/webhooks/{application_id}/{interaction_token}/messages/{message_id}",
870
- use_auth_header=False,
871
- )
872
-
873
- async def trigger_typing(self, channel_id: str) -> None:
874
- """Sends a typing indicator to the specified channel."""
875
- await self.request("POST", f"/channels/{channel_id}/typing")
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 delete_user_reaction(
372
+ self,
373
+ channel_id: "Snowflake",
374
+ message_id: "Snowflake",
375
+ emoji: str,
376
+ user_id: "Snowflake",
377
+ ) -> None:
378
+ """Removes another user's reaction from a message."""
379
+ encoded = quote(emoji)
380
+ await self.request(
381
+ "DELETE",
382
+ f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/{user_id}",
383
+ )
384
+
385
+ async def get_reactions(
386
+ self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
387
+ ) -> List[Dict[str, Any]]:
388
+ """Fetches the users that reacted with a specific emoji."""
389
+ encoded = quote(emoji)
390
+ return await self.request(
391
+ "GET",
392
+ f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}",
393
+ )
394
+
395
+ async def clear_reactions(
396
+ self, channel_id: "Snowflake", message_id: "Snowflake"
397
+ ) -> None:
398
+ """Removes all reactions from a message."""
399
+
400
+ await self.request(
401
+ "DELETE",
402
+ f"/channels/{channel_id}/messages/{message_id}/reactions",
403
+ )
404
+
405
+ async def bulk_delete_messages(
406
+ self, channel_id: "Snowflake", messages: List["Snowflake"]
407
+ ) -> List["Snowflake"]:
408
+ """Bulk deletes messages in a channel and returns their IDs."""
409
+
410
+ await self.request(
411
+ "POST",
412
+ f"/channels/{channel_id}/messages/bulk-delete",
413
+ payload={"messages": messages},
414
+ )
415
+ return messages
416
+
417
+ async def get_pinned_messages(
418
+ self, channel_id: "Snowflake"
419
+ ) -> List[Dict[str, Any]]:
420
+ """Fetches all pinned messages in a channel."""
421
+
422
+ return await self.request("GET", f"/channels/{channel_id}/pins")
423
+
424
+ async def pin_message(
425
+ self, channel_id: "Snowflake", message_id: "Snowflake"
426
+ ) -> None:
427
+ """Pins a message in a channel."""
428
+
429
+ await self.request("PUT", f"/channels/{channel_id}/pins/{message_id}")
430
+
431
+ async def unpin_message(
432
+ self, channel_id: "Snowflake", message_id: "Snowflake"
433
+ ) -> None:
434
+ """Unpins a message from a channel."""
435
+
436
+ await self.request("DELETE", f"/channels/{channel_id}/pins/{message_id}")
437
+
438
+ async def delete_channel(
439
+ self, channel_id: str, reason: Optional[str] = None
440
+ ) -> None:
441
+ """Deletes a channel.
442
+
443
+ If the channel is a guild channel, requires the MANAGE_CHANNELS permission.
444
+ If the channel is a thread, requires the MANAGE_THREADS permission (if locked) or
445
+ be the thread creator (if not locked).
446
+ Deleting a category does not delete its child channels.
447
+ """
448
+ custom_headers = {}
449
+ if reason:
450
+ custom_headers["X-Audit-Log-Reason"] = reason
451
+
452
+ await self.request(
453
+ "DELETE",
454
+ f"/channels/{channel_id}",
455
+ custom_headers=custom_headers if custom_headers else None,
456
+ )
457
+
458
+ async def edit_channel(
459
+ self,
460
+ channel_id: "Snowflake",
461
+ payload: Dict[str, Any],
462
+ reason: Optional[str] = None,
463
+ ) -> Dict[str, Any]:
464
+ """Edits a channel."""
465
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
466
+ return await self.request(
467
+ "PATCH",
468
+ f"/channels/{channel_id}",
469
+ payload=payload,
470
+ custom_headers=headers,
471
+ )
472
+
473
+ async def get_channel(self, channel_id: str) -> Dict[str, Any]:
474
+ """Fetches a channel by ID."""
475
+ return await self.request("GET", f"/channels/{channel_id}")
476
+
477
+ async def get_channel_invites(
478
+ self, channel_id: "Snowflake"
479
+ ) -> List[Dict[str, Any]]:
480
+ """Fetches the invites for a channel."""
481
+
482
+ return await self.request("GET", f"/channels/{channel_id}/invites")
483
+
484
+ async def create_invite(
485
+ self, channel_id: "Snowflake", payload: Dict[str, Any]
486
+ ) -> "Invite":
487
+ """Creates an invite for a channel."""
488
+
489
+ data = await self.request(
490
+ "POST", f"/channels/{channel_id}/invites", payload=payload
491
+ )
492
+ from .models import Invite
493
+
494
+ return Invite.from_dict(data)
495
+
496
+ async def delete_invite(self, code: str) -> None:
497
+ """Deletes an invite by code."""
498
+
499
+ await self.request("DELETE", f"/invites/{code}")
500
+
501
+ async def create_webhook(
502
+ self, channel_id: "Snowflake", payload: Dict[str, Any]
503
+ ) -> "Webhook":
504
+ """Creates a webhook in the specified channel."""
505
+
506
+ data = await self.request(
507
+ "POST", f"/channels/{channel_id}/webhooks", payload=payload
508
+ )
509
+ from .models import Webhook
510
+
511
+ return Webhook(data)
512
+
513
+ async def edit_webhook(
514
+ self, webhook_id: "Snowflake", payload: Dict[str, Any]
515
+ ) -> "Webhook":
516
+ """Edits an existing webhook."""
517
+
518
+ data = await self.request("PATCH", f"/webhooks/{webhook_id}", payload=payload)
519
+ from .models import Webhook
520
+
521
+ return Webhook(data)
522
+
523
+ async def delete_webhook(self, webhook_id: "Snowflake") -> None:
524
+ """Deletes a webhook."""
525
+
526
+ await self.request("DELETE", f"/webhooks/{webhook_id}")
527
+
528
+ async def execute_webhook(
529
+ self,
530
+ webhook_id: "Snowflake",
531
+ token: str,
532
+ *,
533
+ content: Optional[str] = None,
534
+ tts: bool = False,
535
+ embeds: Optional[List[Dict[str, Any]]] = None,
536
+ components: Optional[List[Dict[str, Any]]] = None,
537
+ allowed_mentions: Optional[dict] = None,
538
+ attachments: Optional[List[Any]] = None,
539
+ files: Optional[List[Any]] = None,
540
+ flags: Optional[int] = None,
541
+ username: Optional[str] = None,
542
+ avatar_url: Optional[str] = None,
543
+ ) -> Dict[str, Any]:
544
+ """Executes a webhook and returns the created message."""
545
+
546
+ payload: Dict[str, Any] = {}
547
+ if content is not None:
548
+ payload["content"] = content
549
+ if tts:
550
+ payload["tts"] = True
551
+ if embeds:
552
+ payload["embeds"] = embeds
553
+ if components:
554
+ payload["components"] = components
555
+ if allowed_mentions:
556
+ payload["allowed_mentions"] = allowed_mentions
557
+ if username:
558
+ payload["username"] = username
559
+ if avatar_url:
560
+ payload["avatar_url"] = avatar_url
561
+
562
+ all_files: List["File"] = []
563
+ if attachments is not None:
564
+ payload["attachments"] = []
565
+ for a in attachments:
566
+ if hasattr(a, "data") and hasattr(a, "filename"):
567
+ idx = len(all_files)
568
+ all_files.append(a)
569
+ payload["attachments"].append({"id": idx, "filename": a.filename})
570
+ else:
571
+ payload["attachments"].append(
572
+ a.to_dict() if hasattr(a, "to_dict") else a
573
+ )
574
+ if files is not None:
575
+ for f in files:
576
+ if hasattr(f, "data") and hasattr(f, "filename"):
577
+ idx = len(all_files)
578
+ all_files.append(f)
579
+ if "attachments" not in payload:
580
+ payload["attachments"] = []
581
+ payload["attachments"].append({"id": idx, "filename": f.filename})
582
+ else:
583
+ raise TypeError("files must be File objects")
584
+ if flags:
585
+ payload["flags"] = flags
586
+
587
+ if all_files:
588
+ form = aiohttp.FormData()
589
+ form.add_field(
590
+ "payload_json", json.dumps(payload), content_type="application/json"
591
+ )
592
+ for idx, f in enumerate(all_files):
593
+ form.add_field(
594
+ f"files[{idx}]",
595
+ f.data,
596
+ filename=f.filename,
597
+ content_type="application/octet-stream",
598
+ )
599
+ return await self.request(
600
+ "POST",
601
+ f"/webhooks/{webhook_id}/{token}",
602
+ payload=form,
603
+ is_json=False,
604
+ use_auth_header=False,
605
+ )
606
+
607
+ return await self.request(
608
+ "POST",
609
+ f"/webhooks/{webhook_id}/{token}",
610
+ payload=payload,
611
+ use_auth_header=False,
612
+ )
613
+
614
+ async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]:
615
+ """Fetches a user object for a given user ID."""
616
+ return await self.request("GET", f"/users/{user_id}")
617
+
618
+ async def get_guild_member(
619
+ self, guild_id: "Snowflake", user_id: "Snowflake"
620
+ ) -> Dict[str, Any]:
621
+ """Returns a guild member object for the specified user."""
622
+ return await self.request("GET", f"/guilds/{guild_id}/members/{user_id}")
623
+
624
+ async def kick_member(
625
+ self, guild_id: "Snowflake", user_id: "Snowflake", reason: Optional[str] = None
626
+ ) -> None:
627
+ """Kicks a member from the guild."""
628
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
629
+ await self.request(
630
+ "DELETE",
631
+ f"/guilds/{guild_id}/members/{user_id}",
632
+ custom_headers=headers,
633
+ )
634
+
635
+ async def ban_member(
636
+ self,
637
+ guild_id: "Snowflake",
638
+ user_id: "Snowflake",
639
+ *,
640
+ delete_message_seconds: int = 0,
641
+ reason: Optional[str] = None,
642
+ ) -> None:
643
+ """Bans a member from the guild."""
644
+ payload = {}
645
+ if delete_message_seconds:
646
+ payload["delete_message_seconds"] = delete_message_seconds
647
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
648
+ await self.request(
649
+ "PUT",
650
+ f"/guilds/{guild_id}/bans/{user_id}",
651
+ payload=payload if payload else None,
652
+ custom_headers=headers,
653
+ )
654
+
655
+ async def timeout_member(
656
+ self,
657
+ guild_id: "Snowflake",
658
+ user_id: "Snowflake",
659
+ *,
660
+ until: Optional[str],
661
+ reason: Optional[str] = None,
662
+ ) -> Dict[str, Any]:
663
+ """Times out a member until the given ISO8601 timestamp."""
664
+ payload = {"communication_disabled_until": until}
665
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
666
+ return await self.request(
667
+ "PATCH",
668
+ f"/guilds/{guild_id}/members/{user_id}",
669
+ payload=payload,
670
+ custom_headers=headers,
671
+ )
672
+
673
+ async def get_guild_roles(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
674
+ """Returns a list of role objects for the guild."""
675
+ return await self.request("GET", f"/guilds/{guild_id}/roles")
676
+
677
+ async def get_guild(self, guild_id: "Snowflake") -> Dict[str, Any]:
678
+ """Fetches a guild object for a given guild ID."""
679
+ return await self.request("GET", f"/guilds/{guild_id}")
680
+
681
+ async def get_guild_templates(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
682
+ """Fetches all templates for the given guild."""
683
+ return await self.request("GET", f"/guilds/{guild_id}/templates")
684
+
685
+ async def create_guild_template(
686
+ self, guild_id: "Snowflake", payload: Dict[str, Any]
687
+ ) -> Dict[str, Any]:
688
+ """Creates a guild template."""
689
+ return await self.request(
690
+ "POST", f"/guilds/{guild_id}/templates", payload=payload
691
+ )
692
+
693
+ async def sync_guild_template(
694
+ self, guild_id: "Snowflake", template_code: str
695
+ ) -> Dict[str, Any]:
696
+ """Syncs a guild template to the guild's current state."""
697
+ return await self.request(
698
+ "PUT",
699
+ f"/guilds/{guild_id}/templates/{template_code}",
700
+ )
701
+
702
+ async def delete_guild_template(
703
+ self, guild_id: "Snowflake", template_code: str
704
+ ) -> None:
705
+ """Deletes a guild template."""
706
+ await self.request("DELETE", f"/guilds/{guild_id}/templates/{template_code}")
707
+
708
+ async def get_guild_scheduled_events(
709
+ self, guild_id: "Snowflake"
710
+ ) -> List[Dict[str, Any]]:
711
+ """Returns a list of scheduled events for the guild."""
712
+
713
+ return await self.request("GET", f"/guilds/{guild_id}/scheduled-events")
714
+
715
+ async def get_guild_scheduled_event(
716
+ self, guild_id: "Snowflake", event_id: "Snowflake"
717
+ ) -> Dict[str, Any]:
718
+ """Returns a guild scheduled event."""
719
+
720
+ return await self.request(
721
+ "GET", f"/guilds/{guild_id}/scheduled-events/{event_id}"
722
+ )
723
+
724
+ async def create_guild_scheduled_event(
725
+ self, guild_id: "Snowflake", payload: Dict[str, Any]
726
+ ) -> Dict[str, Any]:
727
+ """Creates a guild scheduled event."""
728
+
729
+ return await self.request(
730
+ "POST", f"/guilds/{guild_id}/scheduled-events", payload=payload
731
+ )
732
+
733
+ async def edit_guild_scheduled_event(
734
+ self, guild_id: "Snowflake", event_id: "Snowflake", payload: Dict[str, Any]
735
+ ) -> Dict[str, Any]:
736
+ """Edits a guild scheduled event."""
737
+
738
+ return await self.request(
739
+ "PATCH",
740
+ f"/guilds/{guild_id}/scheduled-events/{event_id}",
741
+ payload=payload,
742
+ )
743
+
744
+ async def delete_guild_scheduled_event(
745
+ self, guild_id: "Snowflake", event_id: "Snowflake"
746
+ ) -> None:
747
+ """Deletes a guild scheduled event."""
748
+
749
+ await self.request("DELETE", f"/guilds/{guild_id}/scheduled-events/{event_id}")
750
+
751
+ async def get_audit_logs(
752
+ self, guild_id: "Snowflake", **filters: Any
753
+ ) -> Dict[str, Any]:
754
+ """Fetches audit log entries for a guild."""
755
+ params = {k: v for k, v in filters.items() if v is not None}
756
+ return await self.request(
757
+ "GET",
758
+ f"/guilds/{guild_id}/audit-logs",
759
+ params=params if params else None,
760
+ )
761
+
762
+ # Add other methods like:
763
+ # async def get_guild(self, guild_id: str) -> Dict[str, Any]: ...
764
+ # async def create_reaction(self, channel_id: str, message_id: str, emoji: str) -> None: ...
765
+ # etc.
766
+ # --- Application Command Endpoints ---
767
+
768
+ # Global Application Commands
769
+ async def get_global_application_commands(
770
+ self, application_id: "Snowflake", with_localizations: bool = False
771
+ ) -> List["ApplicationCommand"]:
772
+ """Fetches all global commands for your application."""
773
+ params = {"with_localizations": str(with_localizations).lower()}
774
+ data = await self.request(
775
+ "GET", f"/applications/{application_id}/commands", params=params
776
+ )
777
+ from .interactions import ApplicationCommand # Ensure constructor is available
778
+
779
+ return [ApplicationCommand(cmd_data) for cmd_data in data]
780
+
781
+ async def create_global_application_command(
782
+ self, application_id: "Snowflake", payload: Dict[str, Any]
783
+ ) -> "ApplicationCommand":
784
+ """Creates a new global command."""
785
+ data = await self.request(
786
+ "POST", f"/applications/{application_id}/commands", payload=payload
787
+ )
788
+ from .interactions import ApplicationCommand
789
+
790
+ return ApplicationCommand(data)
791
+
792
+ async def get_global_application_command(
793
+ self, application_id: "Snowflake", command_id: "Snowflake"
794
+ ) -> "ApplicationCommand":
795
+ """Fetches a specific global command."""
796
+ data = await self.request(
797
+ "GET", f"/applications/{application_id}/commands/{command_id}"
798
+ )
799
+ from .interactions import ApplicationCommand
800
+
801
+ return ApplicationCommand(data)
802
+
803
+ async def edit_global_application_command(
804
+ self,
805
+ application_id: "Snowflake",
806
+ command_id: "Snowflake",
807
+ payload: Dict[str, Any],
808
+ ) -> "ApplicationCommand":
809
+ """Edits a specific global command."""
810
+ data = await self.request(
811
+ "PATCH",
812
+ f"/applications/{application_id}/commands/{command_id}",
813
+ payload=payload,
814
+ )
815
+ from .interactions import ApplicationCommand
816
+
817
+ return ApplicationCommand(data)
818
+
819
+ async def delete_global_application_command(
820
+ self, application_id: "Snowflake", command_id: "Snowflake"
821
+ ) -> None:
822
+ """Deletes a specific global command."""
823
+ await self.request(
824
+ "DELETE", f"/applications/{application_id}/commands/{command_id}"
825
+ )
826
+
827
+ async def bulk_overwrite_global_application_commands(
828
+ self, application_id: "Snowflake", payload: List[Dict[str, Any]]
829
+ ) -> List["ApplicationCommand"]:
830
+ """Bulk overwrites all global commands for your application."""
831
+ data = await self.request(
832
+ "PUT", f"/applications/{application_id}/commands", payload=payload
833
+ )
834
+ from .interactions import ApplicationCommand
835
+
836
+ return [ApplicationCommand(cmd_data) for cmd_data in data]
837
+
838
+ # Guild Application Commands
839
+ async def get_guild_application_commands(
840
+ self,
841
+ application_id: "Snowflake",
842
+ guild_id: "Snowflake",
843
+ with_localizations: bool = False,
844
+ ) -> List["ApplicationCommand"]:
845
+ """Fetches all commands for your application for a specific guild."""
846
+ params = {"with_localizations": str(with_localizations).lower()}
847
+ data = await self.request(
848
+ "GET",
849
+ f"/applications/{application_id}/guilds/{guild_id}/commands",
850
+ params=params,
851
+ )
852
+ from .interactions import ApplicationCommand
853
+
854
+ return [ApplicationCommand(cmd_data) for cmd_data in data]
855
+
856
+ async def create_guild_application_command(
857
+ self,
858
+ application_id: "Snowflake",
859
+ guild_id: "Snowflake",
860
+ payload: Dict[str, Any],
861
+ ) -> "ApplicationCommand":
862
+ """Creates a new guild command."""
863
+ data = await self.request(
864
+ "POST",
865
+ f"/applications/{application_id}/guilds/{guild_id}/commands",
866
+ payload=payload,
867
+ )
868
+ from .interactions import ApplicationCommand
869
+
870
+ return ApplicationCommand(data)
871
+
872
+ async def get_guild_application_command(
873
+ self,
874
+ application_id: "Snowflake",
875
+ guild_id: "Snowflake",
876
+ command_id: "Snowflake",
877
+ ) -> "ApplicationCommand":
878
+ """Fetches a specific guild command."""
879
+ data = await self.request(
880
+ "GET",
881
+ f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
882
+ )
883
+ from .interactions import ApplicationCommand
884
+
885
+ return ApplicationCommand(data)
886
+
887
+ async def edit_guild_application_command(
888
+ self,
889
+ application_id: "Snowflake",
890
+ guild_id: "Snowflake",
891
+ command_id: "Snowflake",
892
+ payload: Dict[str, Any],
893
+ ) -> "ApplicationCommand":
894
+ """Edits a specific guild command."""
895
+ data = await self.request(
896
+ "PATCH",
897
+ f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
898
+ payload=payload,
899
+ )
900
+ from .interactions import ApplicationCommand
901
+
902
+ return ApplicationCommand(data)
903
+
904
+ async def delete_guild_application_command(
905
+ self,
906
+ application_id: "Snowflake",
907
+ guild_id: "Snowflake",
908
+ command_id: "Snowflake",
909
+ ) -> None:
910
+ """Deletes a specific guild command."""
911
+ await self.request(
912
+ "DELETE",
913
+ f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
914
+ )
915
+
916
+ async def bulk_overwrite_guild_application_commands(
917
+ self,
918
+ application_id: "Snowflake",
919
+ guild_id: "Snowflake",
920
+ payload: List[Dict[str, Any]],
921
+ ) -> List["ApplicationCommand"]:
922
+ """Bulk overwrites all commands for your application for a specific guild."""
923
+ data = await self.request(
924
+ "PUT",
925
+ f"/applications/{application_id}/guilds/{guild_id}/commands",
926
+ payload=payload,
927
+ )
928
+ from .interactions import ApplicationCommand
929
+
930
+ return [ApplicationCommand(cmd_data) for cmd_data in data]
931
+
932
+ # --- Interaction Response Endpoints ---
933
+ # Note: These methods return Dict[str, Any] representing the Message data.
934
+ # The caller (e.g., AppCommandHandler) will be responsible for constructing Message models
935
+ # if needed, as Message model instantiation requires a `client_instance`.
936
+
937
+ async def create_interaction_response(
938
+ self,
939
+ interaction_id: "Snowflake",
940
+ interaction_token: str,
941
+ payload: Union["InteractionResponsePayload", Dict[str, Any]],
942
+ *,
943
+ ephemeral: bool = False,
944
+ ) -> None:
945
+ """Creates a response to an Interaction.
946
+
947
+ Parameters
948
+ ----------
949
+ ephemeral: bool
950
+ Ignored parameter for test compatibility.
951
+ """
952
+ # Interaction responses do not use the bot token in the Authorization header.
953
+ # They are authenticated by the interaction_token in the URL.
954
+ payload_data: Dict[str, Any]
955
+ if isinstance(payload, InteractionResponsePayload):
956
+ payload_data = payload.to_dict()
957
+ else:
958
+ payload_data = payload
959
+
960
+ await self.request(
961
+ "POST",
962
+ f"/interactions/{interaction_id}/{interaction_token}/callback",
963
+ payload=payload_data,
964
+ use_auth_header=False,
965
+ )
966
+
967
+ async def get_original_interaction_response(
968
+ self, application_id: "Snowflake", interaction_token: str
969
+ ) -> Dict[str, Any]:
970
+ """Gets the initial Interaction response."""
971
+ # This endpoint uses the bot token for auth.
972
+ return await self.request(
973
+ "GET", f"/webhooks/{application_id}/{interaction_token}/messages/@original"
974
+ )
975
+
976
+ async def edit_original_interaction_response(
977
+ self,
978
+ application_id: "Snowflake",
979
+ interaction_token: str,
980
+ payload: Dict[str, Any],
981
+ ) -> Dict[str, Any]:
982
+ """Edits the initial Interaction response."""
983
+ return await self.request(
984
+ "PATCH",
985
+ f"/webhooks/{application_id}/{interaction_token}/messages/@original",
986
+ payload=payload,
987
+ use_auth_header=False,
988
+ ) # Docs imply webhook-style auth
989
+
990
+ async def delete_original_interaction_response(
991
+ self, application_id: "Snowflake", interaction_token: str
992
+ ) -> None:
993
+ """Deletes the initial Interaction response."""
994
+ await self.request(
995
+ "DELETE",
996
+ f"/webhooks/{application_id}/{interaction_token}/messages/@original",
997
+ use_auth_header=False,
998
+ ) # Docs imply webhook-style auth
999
+
1000
+ async def create_followup_message(
1001
+ self,
1002
+ application_id: "Snowflake",
1003
+ interaction_token: str,
1004
+ payload: Dict[str, Any],
1005
+ ) -> Dict[str, Any]:
1006
+ """Creates a followup message for an Interaction."""
1007
+ # Followup messages are sent to a webhook endpoint.
1008
+ return await self.request(
1009
+ "POST",
1010
+ f"/webhooks/{application_id}/{interaction_token}",
1011
+ payload=payload,
1012
+ use_auth_header=False,
1013
+ ) # Docs imply webhook-style auth
1014
+
1015
+ async def edit_followup_message(
1016
+ self,
1017
+ application_id: "Snowflake",
1018
+ interaction_token: str,
1019
+ message_id: "Snowflake",
1020
+ payload: Dict[str, Any],
1021
+ ) -> Dict[str, Any]:
1022
+ """Edits a followup message for an Interaction."""
1023
+ return await self.request(
1024
+ "PATCH",
1025
+ f"/webhooks/{application_id}/{interaction_token}/messages/{message_id}",
1026
+ payload=payload,
1027
+ use_auth_header=False,
1028
+ ) # Docs imply webhook-style auth
1029
+
1030
+ async def delete_followup_message(
1031
+ self,
1032
+ application_id: "Snowflake",
1033
+ interaction_token: str,
1034
+ message_id: "Snowflake",
1035
+ ) -> None:
1036
+ """Deletes a followup message for an Interaction."""
1037
+ await self.request(
1038
+ "DELETE",
1039
+ f"/webhooks/{application_id}/{interaction_token}/messages/{message_id}",
1040
+ use_auth_header=False,
1041
+ )
1042
+
1043
+ async def trigger_typing(self, channel_id: str) -> None:
1044
+ """Sends a typing indicator to the specified channel."""
1045
+ await self.request("POST", f"/channels/{channel_id}/typing")
1046
+
1047
+ async def start_stage_instance(
1048
+ self, payload: Dict[str, Any], reason: Optional[str] = None
1049
+ ) -> "StageInstance":
1050
+ """Starts a stage instance."""
1051
+
1052
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
1053
+ data = await self.request(
1054
+ "POST", "/stage-instances", payload=payload, custom_headers=headers
1055
+ )
1056
+ from .models import StageInstance
1057
+
1058
+ return StageInstance(data)
1059
+
1060
+ async def edit_stage_instance(
1061
+ self,
1062
+ channel_id: "Snowflake",
1063
+ payload: Dict[str, Any],
1064
+ reason: Optional[str] = None,
1065
+ ) -> "StageInstance":
1066
+ """Edits an existing stage instance."""
1067
+
1068
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
1069
+ data = await self.request(
1070
+ "PATCH",
1071
+ f"/stage-instances/{channel_id}",
1072
+ payload=payload,
1073
+ custom_headers=headers,
1074
+ )
1075
+ from .models import StageInstance
1076
+
1077
+ return StageInstance(data)
1078
+
1079
+ async def end_stage_instance(
1080
+ self, channel_id: "Snowflake", reason: Optional[str] = None
1081
+ ) -> None:
1082
+ """Ends a stage instance."""
1083
+
1084
+ headers = {"X-Audit-Log-Reason": reason} if reason else None
1085
+ await self.request(
1086
+ "DELETE", f"/stage-instances/{channel_id}", custom_headers=headers
1087
+ )
1088
+
1089
+ async def get_voice_regions(self) -> List[Dict[str, Any]]:
1090
+ """Returns available voice regions."""
1091
+ return await self.request("GET", "/voice/regions")
1092
+
1093
+ async def start_thread_from_message(
1094
+ self,
1095
+ channel_id: "Snowflake",
1096
+ message_id: "Snowflake",
1097
+ payload: Dict[str, Any],
1098
+ ) -> Dict[str, Any]:
1099
+ """Starts a new thread from an existing message."""
1100
+ return await self.request(
1101
+ "POST",
1102
+ f"/channels/{channel_id}/messages/{message_id}/threads",
1103
+ payload=payload,
1104
+ )
1105
+
1106
+ async def start_thread_without_message(
1107
+ self, channel_id: "Snowflake", payload: Dict[str, Any]
1108
+ ) -> Dict[str, Any]:
1109
+ """Starts a new thread that is not attached to a message."""
1110
+ return await self.request(
1111
+ "POST", f"/channels/{channel_id}/threads", payload=payload
1112
+ )
1113
+
1114
+ async def join_thread(self, channel_id: "Snowflake") -> None:
1115
+ """Joins the current user to a thread."""
1116
+ await self.request("PUT", f"/channels/{channel_id}/thread-members/@me")
1117
+
1118
+ async def leave_thread(self, channel_id: "Snowflake") -> None:
1119
+ """Removes the current user from a thread."""
1120
+ await self.request("DELETE", f"/channels/{channel_id}/thread-members/@me")