disagreement 0.0.2__py3-none-any.whl → 0.1.0rc1__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 +176 -6
- disagreement/color.py +50 -0
- disagreement/components.py +2 -2
- disagreement/errors.py +13 -8
- disagreement/event_dispatcher.py +102 -45
- disagreement/ext/commands/__init__.py +9 -1
- disagreement/ext/commands/core.py +7 -0
- disagreement/ext/commands/decorators.py +72 -30
- disagreement/ext/loader.py +12 -1
- disagreement/ext/tasks.py +101 -8
- disagreement/gateway.py +56 -13
- disagreement/http.py +104 -3
- disagreement/models.py +308 -1
- disagreement/shard_manager.py +2 -0
- disagreement/utils.py +10 -0
- disagreement/voice_client.py +42 -0
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc1.dist-info}/METADATA +9 -2
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc1.dist-info}/RECORD +23 -20
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc1.dist-info}/WHEEL +0 -0
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc1.dist-info}/licenses/LICENSE +0 -0
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc1.dist-info}/top_level.txt +0 -0
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
@@ -20,7 +20,7 @@ from . import __version__ # For User-Agent
|
|
20
20
|
|
21
21
|
if TYPE_CHECKING:
|
22
22
|
from .client import Client
|
23
|
-
from .models import Message
|
23
|
+
from .models import Message, Webhook, File
|
24
24
|
from .interactions import ApplicationCommand, InteractionResponsePayload, Snowflake
|
25
25
|
|
26
26
|
# Discord API constants
|
@@ -60,7 +60,9 @@ class HTTPClient:
|
|
60
60
|
self,
|
61
61
|
method: str,
|
62
62
|
endpoint: str,
|
63
|
-
payload: Optional[
|
63
|
+
payload: Optional[
|
64
|
+
Union[Dict[str, Any], List[Dict[str, Any]], aiohttp.FormData]
|
65
|
+
] = None,
|
64
66
|
params: Optional[Dict[str, Any]] = None,
|
65
67
|
is_json: bool = True,
|
66
68
|
use_auth_header: bool = True,
|
@@ -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,18 @@ class HTTPClient:
|
|
286
348
|
f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}",
|
287
349
|
)
|
288
350
|
|
351
|
+
async def bulk_delete_messages(
|
352
|
+
self, channel_id: "Snowflake", messages: List["Snowflake"]
|
353
|
+
) -> List["Snowflake"]:
|
354
|
+
"""Bulk deletes messages in a channel and returns their IDs."""
|
355
|
+
|
356
|
+
await self.request(
|
357
|
+
"POST",
|
358
|
+
f"/channels/{channel_id}/messages/bulk-delete",
|
359
|
+
payload={"messages": messages},
|
360
|
+
)
|
361
|
+
return messages
|
362
|
+
|
289
363
|
async def delete_channel(
|
290
364
|
self, channel_id: str, reason: Optional[str] = None
|
291
365
|
) -> None:
|
@@ -310,6 +384,33 @@ class HTTPClient:
|
|
310
384
|
"""Fetches a channel by ID."""
|
311
385
|
return await self.request("GET", f"/channels/{channel_id}")
|
312
386
|
|
387
|
+
async def create_webhook(
|
388
|
+
self, channel_id: "Snowflake", payload: Dict[str, Any]
|
389
|
+
) -> "Webhook":
|
390
|
+
"""Creates a webhook in the specified channel."""
|
391
|
+
|
392
|
+
data = await self.request(
|
393
|
+
"POST", f"/channels/{channel_id}/webhooks", payload=payload
|
394
|
+
)
|
395
|
+
from .models import Webhook
|
396
|
+
|
397
|
+
return Webhook(data)
|
398
|
+
|
399
|
+
async def edit_webhook(
|
400
|
+
self, webhook_id: "Snowflake", payload: Dict[str, Any]
|
401
|
+
) -> "Webhook":
|
402
|
+
"""Edits an existing webhook."""
|
403
|
+
|
404
|
+
data = await self.request("PATCH", f"/webhooks/{webhook_id}", payload=payload)
|
405
|
+
from .models import Webhook
|
406
|
+
|
407
|
+
return Webhook(data)
|
408
|
+
|
409
|
+
async def delete_webhook(self, webhook_id: "Snowflake") -> None:
|
410
|
+
"""Deletes a webhook."""
|
411
|
+
|
412
|
+
await self.request("DELETE", f"/webhooks/{webhook_id}")
|
413
|
+
|
313
414
|
async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]:
|
314
415
|
"""Fetches a user object for a given user ID."""
|
315
416
|
return await self.request("GET", f"/users/{user_id}")
|