disagreement 0.1.0rc2__py3-none-any.whl → 0.2.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 +9 -5
- disagreement/client.py +198 -6
- disagreement/color.py +100 -1
- disagreement/enums.py +63 -0
- disagreement/ext/app_commands/commands.py +0 -2
- disagreement/ext/app_commands/handler.py +118 -34
- disagreement/ext/app_commands/hybrid.py +1 -1
- disagreement/ext/commands/__init__.py +4 -0
- disagreement/ext/commands/cog.py +15 -6
- disagreement/ext/commands/core.py +71 -11
- disagreement/ext/commands/decorators.py +27 -0
- disagreement/ext/commands/errors.py +8 -0
- disagreement/gateway.py +113 -63
- disagreement/http.py +191 -10
- disagreement/models.py +206 -10
- disagreement/py.typed +0 -0
- {disagreement-0.1.0rc2.dist-info → disagreement-0.2.0rc1.dist-info}/METADATA +17 -1
- {disagreement-0.1.0rc2.dist-info → disagreement-0.2.0rc1.dist-info}/RECORD +21 -20
- {disagreement-0.1.0rc2.dist-info → disagreement-0.2.0rc1.dist-info}/WHEEL +0 -0
- {disagreement-0.1.0rc2.dist-info → disagreement-0.2.0rc1.dist-info}/licenses/LICENSE +0 -0
- {disagreement-0.1.0rc2.dist-info → disagreement-0.2.0rc1.dist-info}/top_level.txt +0 -0
disagreement/gateway.py
CHANGED
@@ -5,6 +5,7 @@ Manages the WebSocket connection to the Discord Gateway.
|
|
5
5
|
"""
|
6
6
|
|
7
7
|
import asyncio
|
8
|
+
import logging
|
8
9
|
import traceback
|
9
10
|
import aiohttp
|
10
11
|
import json
|
@@ -28,6 +29,9 @@ ZLIB_SUFFIX = b"\x00\x00\xff\xff"
|
|
28
29
|
MAX_DECOMPRESSION_SIZE = 10 * 1024 * 1024 # 10 MiB, adjust as needed
|
29
30
|
|
30
31
|
|
32
|
+
logger = logging.getLogger(__name__)
|
33
|
+
|
34
|
+
|
31
35
|
class GatewayClient:
|
32
36
|
"""
|
33
37
|
Handles the Discord Gateway WebSocket connection, heartbeating, and event dispatching.
|
@@ -84,13 +88,17 @@ class GatewayClient:
|
|
84
88
|
return
|
85
89
|
except Exception as e: # noqa: BLE001
|
86
90
|
if attempt >= self._max_retries - 1:
|
87
|
-
|
91
|
+
logger.error(
|
92
|
+
"Reconnect failed after %s attempts: %s", attempt + 1, e
|
93
|
+
)
|
88
94
|
raise
|
89
95
|
jitter = random.uniform(0, delay)
|
90
96
|
wait_time = min(delay + jitter, self._max_backoff)
|
91
|
-
|
92
|
-
|
93
|
-
|
97
|
+
logger.warning(
|
98
|
+
"Reconnect attempt %s failed: %s. Retrying in %.2f seconds...",
|
99
|
+
attempt + 1,
|
100
|
+
e,
|
101
|
+
wait_time,
|
94
102
|
)
|
95
103
|
await asyncio.sleep(wait_time)
|
96
104
|
delay = min(delay * 2, self._max_backoff)
|
@@ -112,21 +120,23 @@ class GatewayClient:
|
|
112
120
|
self._buffer.clear() # Reset buffer after successful decompression
|
113
121
|
return json.loads(decompressed.decode("utf-8"))
|
114
122
|
except zlib.error as e:
|
115
|
-
|
123
|
+
logger.error("Zlib decompression error: %s", e)
|
116
124
|
self._buffer.clear() # Clear buffer on error
|
117
125
|
self._inflator = zlib.decompressobj() # Reset inflator
|
118
126
|
return None
|
119
127
|
except json.JSONDecodeError as e:
|
120
|
-
|
128
|
+
logger.error("JSON decode error after decompression: %s", e)
|
121
129
|
return None
|
122
130
|
|
123
131
|
async def _send_json(self, payload: Dict[str, Any]):
|
124
132
|
if self._ws and not self._ws.closed:
|
125
133
|
if self.verbose:
|
126
|
-
|
134
|
+
logger.debug("GATEWAY SEND: %s", payload)
|
127
135
|
await self._ws.send_json(payload)
|
128
136
|
else:
|
129
|
-
|
137
|
+
logger.warning(
|
138
|
+
"Gateway send attempted but WebSocket is closed or not available."
|
139
|
+
)
|
130
140
|
# raise GatewayException("WebSocket is not connected.")
|
131
141
|
|
132
142
|
async def _heartbeat(self):
|
@@ -140,7 +150,7 @@ class GatewayClient:
|
|
140
150
|
"""Manages the heartbeating loop."""
|
141
151
|
if self._heartbeat_interval is None:
|
142
152
|
# This should not happen if HELLO was processed correctly
|
143
|
-
|
153
|
+
logger.error("Heartbeat interval not set. Cannot start keep_alive.")
|
144
154
|
return
|
145
155
|
|
146
156
|
try:
|
@@ -150,9 +160,9 @@ class GatewayClient:
|
|
150
160
|
self._heartbeat_interval / 1000
|
151
161
|
) # Interval is in ms
|
152
162
|
except asyncio.CancelledError:
|
153
|
-
|
163
|
+
logger.debug("Keep_alive task cancelled.")
|
154
164
|
except Exception as e:
|
155
|
-
|
165
|
+
logger.error("Error in keep_alive loop: %s", e)
|
156
166
|
# Potentially trigger a reconnect here or notify client
|
157
167
|
await self._client_instance.close_gateway(code=1000) # Generic close
|
158
168
|
|
@@ -174,12 +184,12 @@ class GatewayClient:
|
|
174
184
|
if self._shard_id is not None and self._shard_count is not None:
|
175
185
|
payload["d"]["shard"] = [self._shard_id, self._shard_count]
|
176
186
|
await self._send_json(payload)
|
177
|
-
|
187
|
+
logger.info("Sent IDENTIFY.")
|
178
188
|
|
179
189
|
async def _resume(self):
|
180
190
|
"""Sends the RESUME payload to the Gateway."""
|
181
191
|
if not self._session_id or self._last_sequence is None:
|
182
|
-
|
192
|
+
logger.warning("Cannot RESUME: session_id or last_sequence is missing.")
|
183
193
|
await self._identify() # Fallback to identify
|
184
194
|
return
|
185
195
|
|
@@ -192,8 +202,10 @@ class GatewayClient:
|
|
192
202
|
},
|
193
203
|
}
|
194
204
|
await self._send_json(payload)
|
195
|
-
|
196
|
-
|
205
|
+
logger.info(
|
206
|
+
"Sent RESUME for session %s at sequence %s.",
|
207
|
+
self._session_id,
|
208
|
+
self._last_sequence,
|
197
209
|
)
|
198
210
|
|
199
211
|
async def update_presence(
|
@@ -238,8 +250,9 @@ class GatewayClient:
|
|
238
250
|
|
239
251
|
if event_name == "READY": # Special handling for READY
|
240
252
|
if not isinstance(raw_event_d_payload, dict):
|
241
|
-
|
242
|
-
|
253
|
+
logger.error(
|
254
|
+
"READY event 'd' payload is not a dict or is missing: %s",
|
255
|
+
raw_event_d_payload,
|
243
256
|
)
|
244
257
|
# Consider raising an error or attempting a reconnect
|
245
258
|
return
|
@@ -259,8 +272,8 @@ class GatewayClient:
|
|
259
272
|
)
|
260
273
|
app_id_str = str(app_id_value)
|
261
274
|
else:
|
262
|
-
|
263
|
-
|
275
|
+
logger.warning(
|
276
|
+
"Could not find application ID in READY payload. App commands may not work."
|
264
277
|
)
|
265
278
|
|
266
279
|
# Parse and store the bot's own user object
|
@@ -274,20 +287,29 @@ class GatewayClient:
|
|
274
287
|
raw_event_d_payload["user"]
|
275
288
|
)
|
276
289
|
self._client_instance.user = bot_user_obj
|
277
|
-
|
278
|
-
|
290
|
+
logger.info(
|
291
|
+
"Gateway READY. Bot User: %s#%s. Session ID: %s. App ID: %s. Resume URL: %s",
|
292
|
+
bot_user_obj.username,
|
293
|
+
bot_user_obj.discriminator,
|
294
|
+
self._session_id,
|
295
|
+
app_id_str,
|
296
|
+
self._resume_gateway_url,
|
279
297
|
)
|
280
298
|
except Exception as e:
|
281
|
-
|
282
|
-
|
283
|
-
|
299
|
+
logger.error("Error parsing bot user from READY payload: %s", e)
|
300
|
+
logger.info(
|
301
|
+
"Gateway READY (user parse failed). Session ID: %s. App ID: %s. Resume URL: %s",
|
302
|
+
self._session_id,
|
303
|
+
app_id_str,
|
304
|
+
self._resume_gateway_url,
|
284
305
|
)
|
285
306
|
else:
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
307
|
+
logger.warning("Bot user object not found or invalid in READY payload.")
|
308
|
+
logger.info(
|
309
|
+
"Gateway READY (no user). Session ID: %s. App ID: %s. Resume URL: %s",
|
310
|
+
self._session_id,
|
311
|
+
app_id_str,
|
312
|
+
self._resume_gateway_url,
|
291
313
|
)
|
292
314
|
|
293
315
|
await self._dispatcher.dispatch(event_name, raw_event_d_payload)
|
@@ -306,21 +328,25 @@ class GatewayClient:
|
|
306
328
|
self._client_instance.process_interaction(interaction)
|
307
329
|
) # type: ignore
|
308
330
|
else:
|
309
|
-
|
310
|
-
"
|
331
|
+
logger.warning(
|
332
|
+
"Client instance does not have process_interaction method for INTERACTION_CREATE."
|
311
333
|
)
|
312
334
|
else:
|
313
|
-
|
314
|
-
|
335
|
+
logger.error(
|
336
|
+
"INTERACTION_CREATE event 'd' payload is not a dict: %s",
|
337
|
+
raw_event_d_payload,
|
315
338
|
)
|
316
339
|
elif event_name == "RESUMED":
|
317
|
-
|
340
|
+
logger.info("Gateway RESUMED successfully.")
|
318
341
|
# RESUMED 'd' payload is often an empty object or debug info.
|
319
342
|
# Ensure it's a dict for the dispatcher.
|
320
343
|
event_data_to_dispatch = (
|
321
344
|
raw_event_d_payload if isinstance(raw_event_d_payload, dict) else {}
|
322
345
|
)
|
323
346
|
await self._dispatcher.dispatch(event_name, event_data_to_dispatch)
|
347
|
+
await self._dispatcher.dispatch(
|
348
|
+
"SHARD_RESUME", {"shard_id": self._shard_id}
|
349
|
+
)
|
324
350
|
elif event_name:
|
325
351
|
# For other events, ensure 'd' is a dict, or pass {} if 'd' is null/missing.
|
326
352
|
# Models/parsers in EventDispatcher will need to handle potentially empty dicts.
|
@@ -330,7 +356,7 @@ class GatewayClient:
|
|
330
356
|
# print(f"GATEWAY RECV EVENT: {event_name} | DATA: {event_data_to_dispatch}")
|
331
357
|
await self._dispatcher.dispatch(event_name, event_data_to_dispatch)
|
332
358
|
else:
|
333
|
-
|
359
|
+
logger.warning("Received dispatch with no event name: %s", data)
|
334
360
|
|
335
361
|
async def _process_message(self, msg: aiohttp.WSMessage):
|
336
362
|
"""Processes a single message from the WebSocket."""
|
@@ -338,19 +364,20 @@ class GatewayClient:
|
|
338
364
|
try:
|
339
365
|
data = json.loads(msg.data)
|
340
366
|
except json.JSONDecodeError:
|
341
|
-
|
342
|
-
f"Failed to decode JSON from Gateway: {msg.data[:200]}"
|
343
|
-
) # Log snippet
|
367
|
+
logger.error("Failed to decode JSON from Gateway: %s", msg.data[:200])
|
344
368
|
return
|
345
369
|
elif msg.type == aiohttp.WSMsgType.BINARY:
|
346
370
|
decompressed_data = await self._decompress_message(msg.data)
|
347
371
|
if decompressed_data is None:
|
348
|
-
|
372
|
+
logger.error(
|
373
|
+
"Failed to decompress or decode binary message from Gateway."
|
374
|
+
)
|
349
375
|
return
|
350
376
|
data = decompressed_data
|
351
377
|
elif msg.type == aiohttp.WSMsgType.ERROR:
|
352
|
-
|
353
|
-
|
378
|
+
logger.error(
|
379
|
+
"WebSocket error: %s",
|
380
|
+
self._ws.exception() if self._ws else "Unknown WSError",
|
354
381
|
)
|
355
382
|
raise GatewayException(
|
356
383
|
f"WebSocket error: {self._ws.exception() if self._ws else 'Unknown WSError'}"
|
@@ -361,15 +388,17 @@ class GatewayClient:
|
|
361
388
|
if self._ws and hasattr(self._ws, "close_code")
|
362
389
|
else "N/A"
|
363
390
|
)
|
364
|
-
|
391
|
+
logger.warning(
|
392
|
+
"WebSocket connection closed by server. Code: %s", close_code
|
393
|
+
)
|
365
394
|
# Raise an exception to signal the closure to the client's main run loop
|
366
395
|
raise GatewayException(f"WebSocket closed by server. Code: {close_code}")
|
367
396
|
else:
|
368
|
-
|
397
|
+
logger.warning("Received unhandled WebSocket message type: %s", msg.type)
|
369
398
|
return
|
370
399
|
|
371
400
|
if self.verbose:
|
372
|
-
|
401
|
+
logger.debug("GATEWAY RECV: %s", data)
|
373
402
|
op = data.get("op")
|
374
403
|
# 'd' payload (event_data) is handled specifically by each opcode handler below
|
375
404
|
|
@@ -378,12 +407,16 @@ class GatewayClient:
|
|
378
407
|
elif op == GatewayOpcode.HEARTBEAT: # Server requests a heartbeat
|
379
408
|
await self._heartbeat()
|
380
409
|
elif op == GatewayOpcode.RECONNECT: # Server requests a reconnect
|
381
|
-
|
410
|
+
logger.info(
|
411
|
+
"Gateway requested RECONNECT. Closing and will attempt to reconnect."
|
412
|
+
)
|
382
413
|
await self.close(code=4000, reconnect=True)
|
383
414
|
elif op == GatewayOpcode.INVALID_SESSION:
|
384
415
|
# The 'd' payload for INVALID_SESSION is a boolean indicating resumability
|
385
416
|
can_resume = data.get("d") is True
|
386
|
-
|
417
|
+
logger.warning(
|
418
|
+
"Gateway indicated INVALID_SESSION. Resumable: %s", can_resume
|
419
|
+
)
|
387
420
|
if not can_resume:
|
388
421
|
self._session_id = None # Clear session_id to force re-identify
|
389
422
|
self._last_sequence = None
|
@@ -395,13 +428,16 @@ class GatewayClient:
|
|
395
428
|
not isinstance(hello_d_payload, dict)
|
396
429
|
or "heartbeat_interval" not in hello_d_payload
|
397
430
|
):
|
398
|
-
|
399
|
-
|
431
|
+
logger.error(
|
432
|
+
"HELLO event 'd' payload is invalid or missing heartbeat_interval: %s",
|
433
|
+
hello_d_payload,
|
400
434
|
)
|
401
435
|
await self.close(code=1011) # Internal error, malformed HELLO
|
402
436
|
return
|
403
437
|
self._heartbeat_interval = hello_d_payload["heartbeat_interval"]
|
404
|
-
|
438
|
+
logger.info(
|
439
|
+
"Gateway HELLO. Heartbeat interval: %sms.", self._heartbeat_interval
|
440
|
+
)
|
405
441
|
# Start heartbeating
|
406
442
|
if self._keep_alive_task:
|
407
443
|
self._keep_alive_task.cancel()
|
@@ -409,45 +445,51 @@ class GatewayClient:
|
|
409
445
|
|
410
446
|
# Identify or Resume
|
411
447
|
if self._session_id and self._resume_gateway_url: # Check if we can resume
|
412
|
-
|
448
|
+
logger.info("Attempting to RESUME session.")
|
413
449
|
await self._resume()
|
414
450
|
else:
|
415
|
-
|
451
|
+
logger.info("Performing initial IDENTIFY.")
|
416
452
|
await self._identify()
|
417
453
|
elif op == GatewayOpcode.HEARTBEAT_ACK:
|
418
454
|
self._last_heartbeat_ack = time.monotonic()
|
419
455
|
# print("Received heartbeat ACK.")
|
420
456
|
pass # Good, connection is alive
|
421
457
|
else:
|
422
|
-
|
458
|
+
logger.warning(
|
459
|
+
"Received unhandled Gateway Opcode: %s with data: %s", op, data
|
460
|
+
)
|
423
461
|
|
424
462
|
async def _receive_loop(self):
|
425
463
|
"""Continuously receives and processes messages from the WebSocket."""
|
426
464
|
if not self._ws or self._ws.closed:
|
427
|
-
|
465
|
+
logger.warning(
|
466
|
+
"Receive loop cannot start: WebSocket is not connected or closed."
|
467
|
+
)
|
428
468
|
return
|
429
469
|
|
430
470
|
try:
|
431
471
|
async for msg in self._ws:
|
432
472
|
await self._process_message(msg)
|
433
473
|
except asyncio.CancelledError:
|
434
|
-
|
474
|
+
logger.debug("Receive_loop task cancelled.")
|
435
475
|
except aiohttp.ClientConnectionError as e:
|
436
|
-
|
476
|
+
logger.warning(
|
477
|
+
"ClientConnectionError in receive_loop: %s. Attempting reconnect.", e
|
478
|
+
)
|
437
479
|
await self.close(code=1006, reconnect=True) # Abnormal closure
|
438
480
|
except Exception as e:
|
439
|
-
|
481
|
+
logger.error("Unexpected error in receive_loop: %s", e)
|
440
482
|
traceback.print_exc()
|
441
483
|
await self.close(code=1011, reconnect=True)
|
442
484
|
finally:
|
443
|
-
|
485
|
+
logger.info("Receive_loop ended.")
|
444
486
|
# If the loop ends unexpectedly (not due to explicit close),
|
445
487
|
# the main client might want to try reconnecting.
|
446
488
|
|
447
489
|
async def connect(self):
|
448
490
|
"""Connects to the Discord Gateway."""
|
449
491
|
if self._ws and not self._ws.closed:
|
450
|
-
|
492
|
+
logger.warning("Gateway already connected or connecting.")
|
451
493
|
return
|
452
494
|
|
453
495
|
gateway_url = (
|
@@ -456,19 +498,23 @@ class GatewayClient:
|
|
456
498
|
if not gateway_url.endswith("?v=10&encoding=json&compress=zlib-stream"):
|
457
499
|
gateway_url += "?v=10&encoding=json&compress=zlib-stream"
|
458
500
|
|
459
|
-
|
501
|
+
logger.info("Connecting to Gateway: %s", gateway_url)
|
460
502
|
try:
|
461
503
|
await self._http._ensure_session() # Ensure the HTTP client's session is active
|
462
504
|
assert (
|
463
505
|
self._http._session is not None
|
464
506
|
), "HTTPClient session not initialized after ensure_session"
|
465
507
|
self._ws = await self._http._session.ws_connect(gateway_url, max_msg_size=0)
|
466
|
-
|
508
|
+
logger.info("Gateway WebSocket connection established.")
|
467
509
|
|
468
510
|
if self._receive_task:
|
469
511
|
self._receive_task.cancel()
|
470
512
|
self._receive_task = self._loop.create_task(self._receive_loop())
|
471
513
|
|
514
|
+
await self._dispatcher.dispatch(
|
515
|
+
"SHARD_CONNECT", {"shard_id": self._shard_id}
|
516
|
+
)
|
517
|
+
|
472
518
|
except aiohttp.ClientConnectorError as e:
|
473
519
|
raise GatewayException(
|
474
520
|
f"Failed to connect to Gateway (Connector Error): {e}"
|
@@ -488,7 +534,7 @@ class GatewayClient:
|
|
488
534
|
|
489
535
|
async def close(self, code: int = 1000, *, reconnect: bool = False):
|
490
536
|
"""Closes the Gateway connection."""
|
491
|
-
|
537
|
+
logger.info("Closing Gateway connection with code %s...", code)
|
492
538
|
if self._keep_alive_task and not self._keep_alive_task.done():
|
493
539
|
self._keep_alive_task.cancel()
|
494
540
|
try:
|
@@ -507,7 +553,7 @@ class GatewayClient:
|
|
507
553
|
|
508
554
|
if self._ws and not self._ws.closed:
|
509
555
|
await self._ws.close(code=code)
|
510
|
-
|
556
|
+
logger.info("Gateway WebSocket closed.")
|
511
557
|
|
512
558
|
self._ws = None
|
513
559
|
# Do not reset session_id, last_sequence, or resume_gateway_url here
|
@@ -515,11 +561,15 @@ class GatewayClient:
|
|
515
561
|
# The connect logic will decide whether to resume or re-identify.
|
516
562
|
# However, if it's a non-resumable close (e.g. Invalid Session non-resumable), clear them.
|
517
563
|
if code == 4009: # Invalid session, not resumable
|
518
|
-
|
564
|
+
logger.info("Clearing session state due to non-resumable invalid session.")
|
519
565
|
self._session_id = None
|
520
566
|
self._last_sequence = None
|
521
567
|
self._resume_gateway_url = None # This might be re-fetched anyway
|
522
568
|
|
569
|
+
await self._dispatcher.dispatch(
|
570
|
+
"SHARD_DISCONNECT", {"shard_id": self._shard_id}
|
571
|
+
)
|
572
|
+
|
523
573
|
@property
|
524
574
|
def latency(self) -> Optional[float]:
|
525
575
|
"""Returns the latency between heartbeat and ACK in seconds."""
|