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/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
- print(f"Reconnect failed after {attempt + 1} attempts: {e}")
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
- print(
92
- f"Reconnect attempt {attempt + 1} failed: {e}. "
93
- f"Retrying in {wait_time:.2f} seconds..."
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
- print(f"Zlib decompression error: {e}")
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
- print(f"JSON decode error after decompression: {e}")
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
- print(f"GATEWAY SEND: {payload}")
134
+ logger.debug("GATEWAY SEND: %s", payload)
127
135
  await self._ws.send_json(payload)
128
136
  else:
129
- print("Gateway send attempted but WebSocket is closed or not available.")
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
- print("Error: Heartbeat interval not set. Cannot start keep_alive.")
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
- print("Keep_alive task cancelled.")
163
+ logger.debug("Keep_alive task cancelled.")
154
164
  except Exception as e:
155
- print(f"Error in keep_alive loop: {e}")
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
- print("Sent IDENTIFY.")
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
- print("Cannot RESUME: session_id or last_sequence is missing.")
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
- print(
196
- f"Sent RESUME for session {self._session_id} at sequence {self._last_sequence}."
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
- print(
242
- f"Error: READY event 'd' payload is not a dict or is missing: {raw_event_d_payload}"
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
- print(
263
- f"Warning: Could not find application ID in READY payload. App commands may not work."
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
- print(
278
- f"Gateway READY. Bot User: {bot_user_obj.username}#{bot_user_obj.discriminator}. Session ID: {self._session_id}. App ID: {app_id_str}. Resume URL: {self._resume_gateway_url}"
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
- print(f"Error parsing bot user from READY payload: {e}")
282
- print(
283
- f"Gateway READY (user parse failed). Session ID: {self._session_id}. App ID: {app_id_str}. Resume URL: {self._resume_gateway_url}"
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
- print(
287
- f"Warning: Bot user object not found or invalid in READY payload."
288
- )
289
- print(
290
- f"Gateway READY (no user). Session ID: {self._session_id}. App ID: {app_id_str}. Resume URL: {self._resume_gateway_url}"
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
- print(
310
- "Warning: Client instance does not have process_interaction method for INTERACTION_CREATE."
331
+ logger.warning(
332
+ "Client instance does not have process_interaction method for INTERACTION_CREATE."
311
333
  )
312
334
  else:
313
- print(
314
- f"Error: INTERACTION_CREATE event 'd' payload is not a dict: {raw_event_d_payload}"
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
- print("Gateway RESUMED successfully.")
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
- print(f"Received dispatch with no event name: {data}")
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
- print(
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
- print("Failed to decompress or decode binary message from Gateway.")
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
- print(
353
- f"WebSocket error: {self._ws.exception() if self._ws else 'Unknown WSError'}"
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
- print(f"WebSocket connection closed by server. Code: {close_code}")
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
- print(f"Received unhandled WebSocket message type: {msg.type}")
397
+ logger.warning("Received unhandled WebSocket message type: %s", msg.type)
369
398
  return
370
399
 
371
400
  if self.verbose:
372
- print(f"GATEWAY RECV: {data}")
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
- print("Gateway requested RECONNECT. Closing and will attempt to reconnect.")
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
- print(f"Gateway indicated INVALID_SESSION. Resumable: {can_resume}")
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
- print(
399
- f"Error: HELLO event 'd' payload is invalid or missing heartbeat_interval: {hello_d_payload}"
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
- print(f"Gateway HELLO. Heartbeat interval: {self._heartbeat_interval}ms.")
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
- print("Attempting to RESUME session.")
448
+ logger.info("Attempting to RESUME session.")
413
449
  await self._resume()
414
450
  else:
415
- print("Performing initial IDENTIFY.")
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
- print(f"Received unhandled Gateway Opcode: {op} with data: {data}")
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
- print("Receive loop cannot start: WebSocket is not connected or closed.")
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
- print("Receive_loop task cancelled.")
474
+ logger.debug("Receive_loop task cancelled.")
435
475
  except aiohttp.ClientConnectionError as e:
436
- print(f"ClientConnectionError in receive_loop: {e}. Attempting reconnect.")
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
- print(f"Unexpected error in receive_loop: {e}")
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
- print("Receive_loop ended.")
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
- print("Gateway already connected or connecting.")
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
- print(f"Connecting to Gateway: {gateway_url}")
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
- print("Gateway WebSocket connection established.")
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
- print(f"Closing Gateway connection with code {code}...")
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
- print("Gateway WebSocket closed.")
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
- print("Clearing session state due to non-resumable invalid session.")
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."""