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