disagreement 0.0.1__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.
@@ -0,0 +1,490 @@
1
+ # disagreement/gateway.py
2
+
3
+ """
4
+ Manages the WebSocket connection to the Discord Gateway.
5
+ """
6
+
7
+ import asyncio
8
+ import traceback
9
+ import aiohttp
10
+ import json
11
+ import zlib
12
+ import time
13
+ from typing import Optional, TYPE_CHECKING, Any, Dict
14
+
15
+ from .enums import GatewayOpcode, GatewayIntent
16
+ from .errors import GatewayException, DisagreementException, AuthenticationError
17
+ from .interactions import Interaction
18
+
19
+ if TYPE_CHECKING:
20
+ from .client import Client # For type hinting
21
+ from .event_dispatcher import EventDispatcher
22
+ from .http import HTTPClient
23
+ from .interactions import Interaction # Added for INTERACTION_CREATE
24
+
25
+ # ZLIB Decompression constants
26
+ ZLIB_SUFFIX = b"\x00\x00\xff\xff"
27
+ MAX_DECOMPRESSION_SIZE = 10 * 1024 * 1024 # 10 MiB, adjust as needed
28
+
29
+
30
+ class GatewayClient:
31
+ """
32
+ Handles the Discord Gateway WebSocket connection, heartbeating, and event dispatching.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ http_client: "HTTPClient",
38
+ event_dispatcher: "EventDispatcher",
39
+ token: str,
40
+ intents: int,
41
+ client_instance: "Client", # Pass the main client instance
42
+ verbose: bool = False,
43
+ *,
44
+ shard_id: Optional[int] = None,
45
+ shard_count: Optional[int] = None,
46
+ ):
47
+ self._http: "HTTPClient" = http_client
48
+ self._dispatcher: "EventDispatcher" = event_dispatcher
49
+ self._token: str = token
50
+ self._intents: int = intents
51
+ self._client_instance: "Client" = client_instance # Store client instance
52
+ self.verbose: bool = verbose
53
+ self._shard_id: Optional[int] = shard_id
54
+ self._shard_count: Optional[int] = shard_count
55
+
56
+ self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
57
+ self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
58
+ self._heartbeat_interval: Optional[float] = None
59
+ self._last_sequence: Optional[int] = None
60
+ self._session_id: Optional[str] = None
61
+ self._resume_gateway_url: Optional[str] = None
62
+
63
+ self._keep_alive_task: Optional[asyncio.Task] = None
64
+ self._receive_task: Optional[asyncio.Task] = None
65
+
66
+ # For zlib decompression
67
+ self._buffer = bytearray()
68
+ self._inflator = zlib.decompressobj()
69
+
70
+ async def _decompress_message(
71
+ self, message_bytes: bytes
72
+ ) -> Optional[Dict[str, Any]]:
73
+ """Decompresses a zlib-compressed message from the Gateway."""
74
+ self._buffer.extend(message_bytes)
75
+
76
+ if len(message_bytes) < 4 or message_bytes[-4:] != ZLIB_SUFFIX:
77
+ # Message is not complete or not zlib compressed in the expected way
78
+ return None
79
+ # Or handle partial messages if Discord ever sends them fragmented like this,
80
+ # but typically each binary message is a complete zlib stream.
81
+
82
+ try:
83
+ decompressed = self._inflator.decompress(self._buffer)
84
+ self._buffer.clear() # Reset buffer after successful decompression
85
+ return json.loads(decompressed.decode("utf-8"))
86
+ except zlib.error as e:
87
+ print(f"Zlib decompression error: {e}")
88
+ self._buffer.clear() # Clear buffer on error
89
+ self._inflator = zlib.decompressobj() # Reset inflator
90
+ return None
91
+ except json.JSONDecodeError as e:
92
+ print(f"JSON decode error after decompression: {e}")
93
+ return None
94
+
95
+ async def _send_json(self, payload: Dict[str, Any]):
96
+ if self._ws and not self._ws.closed:
97
+ if self.verbose:
98
+ print(f"GATEWAY SEND: {payload}")
99
+ await self._ws.send_json(payload)
100
+ else:
101
+ print("Gateway send attempted but WebSocket is closed or not available.")
102
+ # raise GatewayException("WebSocket is not connected.")
103
+
104
+ async def _heartbeat(self):
105
+ """Sends a heartbeat to the Gateway."""
106
+ payload = {"op": GatewayOpcode.HEARTBEAT, "d": self._last_sequence}
107
+ await self._send_json(payload)
108
+ # print("Sent heartbeat.")
109
+
110
+ async def _keep_alive(self):
111
+ """Manages the heartbeating loop."""
112
+ if self._heartbeat_interval is None:
113
+ # This should not happen if HELLO was processed correctly
114
+ print("Error: Heartbeat interval not set. Cannot start keep_alive.")
115
+ return
116
+
117
+ try:
118
+ while True:
119
+ await self._heartbeat()
120
+ await asyncio.sleep(
121
+ self._heartbeat_interval / 1000
122
+ ) # Interval is in ms
123
+ except asyncio.CancelledError:
124
+ print("Keep_alive task cancelled.")
125
+ except Exception as e:
126
+ print(f"Error in keep_alive loop: {e}")
127
+ # Potentially trigger a reconnect here or notify client
128
+ await self._client_instance.close_gateway(code=1000) # Generic close
129
+
130
+ async def _identify(self):
131
+ """Sends the IDENTIFY payload to the Gateway."""
132
+ payload = {
133
+ "op": GatewayOpcode.IDENTIFY,
134
+ "d": {
135
+ "token": self._token,
136
+ "intents": self._intents,
137
+ "properties": {
138
+ "$os": "python", # Or platform.system()
139
+ "$browser": "disagreement", # Library name
140
+ "$device": "disagreement", # Library name
141
+ },
142
+ "compress": True, # Request zlib compression
143
+ },
144
+ }
145
+ if self._shard_id is not None and self._shard_count is not None:
146
+ payload["d"]["shard"] = [self._shard_id, self._shard_count]
147
+ await self._send_json(payload)
148
+ print("Sent IDENTIFY.")
149
+
150
+ async def _resume(self):
151
+ """Sends the RESUME payload to the Gateway."""
152
+ if not self._session_id or self._last_sequence is None:
153
+ print("Cannot RESUME: session_id or last_sequence is missing.")
154
+ await self._identify() # Fallback to identify
155
+ return
156
+
157
+ payload = {
158
+ "op": GatewayOpcode.RESUME,
159
+ "d": {
160
+ "token": self._token,
161
+ "session_id": self._session_id,
162
+ "seq": self._last_sequence,
163
+ },
164
+ }
165
+ await self._send_json(payload)
166
+ print(
167
+ f"Sent RESUME for session {self._session_id} at sequence {self._last_sequence}."
168
+ )
169
+ async def update_presence(
170
+ self,
171
+ status: str,
172
+ activity_name: Optional[str] = None,
173
+ activity_type: int = 0,
174
+ since: int = 0,
175
+ afk: bool = False,
176
+ ):
177
+ """Sends the presence update payload to the Gateway."""
178
+ payload = {
179
+ "op": GatewayOpcode.PRESENCE_UPDATE,
180
+ "d": {
181
+ "since": since,
182
+ "activities": [
183
+ {
184
+ "name": activity_name,
185
+ "type": activity_type,
186
+ }
187
+ ]
188
+ if activity_name
189
+ else [],
190
+ "status": status,
191
+ "afk": afk,
192
+ },
193
+ }
194
+ await self._send_json(payload)
195
+
196
+ async def _handle_dispatch(self, data: Dict[str, Any]):
197
+ """Handles DISPATCH events (actual Discord events)."""
198
+ event_name = data.get("t")
199
+ sequence_num = data.get("s")
200
+ raw_event_d_payload = data.get(
201
+ "d"
202
+ ) # This is the 'd' field from the gateway event
203
+
204
+ if sequence_num is not None:
205
+ self._last_sequence = sequence_num
206
+
207
+ if event_name == "READY": # Special handling for READY
208
+ if not isinstance(raw_event_d_payload, dict):
209
+ print(
210
+ f"Error: READY event 'd' payload is not a dict or is missing: {raw_event_d_payload}"
211
+ )
212
+ # Consider raising an error or attempting a reconnect
213
+ return
214
+ self._session_id = raw_event_d_payload.get("session_id")
215
+ self._resume_gateway_url = raw_event_d_payload.get("resume_gateway_url")
216
+
217
+ app_id_str = "N/A"
218
+ # Store application_id on the client instance
219
+ if (
220
+ "application" in raw_event_d_payload
221
+ and isinstance(raw_event_d_payload["application"], dict)
222
+ and "id" in raw_event_d_payload["application"]
223
+ ):
224
+ app_id_value = raw_event_d_payload["application"]["id"]
225
+ self._client_instance.application_id = (
226
+ app_id_value # Snowflake can be str or int
227
+ )
228
+ app_id_str = str(app_id_value)
229
+ else:
230
+ print(
231
+ f"Warning: Could not find application ID in READY payload. App commands may not work."
232
+ )
233
+
234
+ # Parse and store the bot's own user object
235
+ if "user" in raw_event_d_payload and isinstance(
236
+ raw_event_d_payload["user"], dict
237
+ ):
238
+ try:
239
+ # Assuming Client has a parse_user method that takes user data dict
240
+ # and returns a User object, also caching it.
241
+ bot_user_obj = self._client_instance.parse_user(
242
+ raw_event_d_payload["user"]
243
+ )
244
+ self._client_instance.user = bot_user_obj
245
+ print(
246
+ 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}"
247
+ )
248
+ except Exception as e:
249
+ print(f"Error parsing bot user from READY payload: {e}")
250
+ print(
251
+ f"Gateway READY (user parse failed). Session ID: {self._session_id}. App ID: {app_id_str}. Resume URL: {self._resume_gateway_url}"
252
+ )
253
+ else:
254
+ print(
255
+ f"Warning: Bot user object not found or invalid in READY payload."
256
+ )
257
+ print(
258
+ f"Gateway READY (no user). Session ID: {self._session_id}. App ID: {app_id_str}. Resume URL: {self._resume_gateway_url}"
259
+ )
260
+
261
+ await self._dispatcher.dispatch(event_name, raw_event_d_payload)
262
+ elif event_name == "INTERACTION_CREATE":
263
+ # print(f"GATEWAY RECV INTERACTION_CREATE: {raw_event_d_payload}")
264
+ if isinstance(raw_event_d_payload, dict):
265
+ interaction = Interaction(
266
+ data=raw_event_d_payload, client_instance=self._client_instance
267
+ )
268
+ await self._dispatcher.dispatch(
269
+ "INTERACTION_CREATE", raw_event_d_payload
270
+ )
271
+ # Dispatch to a new client method that will then call AppCommandHandler
272
+ if hasattr(self._client_instance, "process_interaction"):
273
+ asyncio.create_task(
274
+ self._client_instance.process_interaction(interaction)
275
+ ) # type: ignore
276
+ else:
277
+ print(
278
+ "Warning: Client instance does not have process_interaction method for INTERACTION_CREATE."
279
+ )
280
+ else:
281
+ print(
282
+ f"Error: INTERACTION_CREATE event 'd' payload is not a dict: {raw_event_d_payload}"
283
+ )
284
+ elif event_name == "RESUMED":
285
+ print("Gateway RESUMED successfully.")
286
+ # RESUMED 'd' payload is often an empty object or debug info.
287
+ # Ensure it's a dict for the dispatcher.
288
+ event_data_to_dispatch = (
289
+ raw_event_d_payload if isinstance(raw_event_d_payload, dict) else {}
290
+ )
291
+ await self._dispatcher.dispatch(event_name, event_data_to_dispatch)
292
+ elif event_name:
293
+ # For other events, ensure 'd' is a dict, or pass {} if 'd' is null/missing.
294
+ # Models/parsers in EventDispatcher will need to handle potentially empty dicts.
295
+ event_data_to_dispatch = (
296
+ raw_event_d_payload if isinstance(raw_event_d_payload, dict) else {}
297
+ )
298
+ # print(f"GATEWAY RECV EVENT: {event_name} | DATA: {event_data_to_dispatch}")
299
+ await self._dispatcher.dispatch(event_name, event_data_to_dispatch)
300
+ else:
301
+ print(f"Received dispatch with no event name: {data}")
302
+
303
+ async def _process_message(self, msg: aiohttp.WSMessage):
304
+ """Processes a single message from the WebSocket."""
305
+ if msg.type == aiohttp.WSMsgType.TEXT:
306
+ try:
307
+ data = json.loads(msg.data)
308
+ except json.JSONDecodeError:
309
+ print(
310
+ f"Failed to decode JSON from Gateway: {msg.data[:200]}"
311
+ ) # Log snippet
312
+ return
313
+ elif msg.type == aiohttp.WSMsgType.BINARY:
314
+ decompressed_data = await self._decompress_message(msg.data)
315
+ if decompressed_data is None:
316
+ print("Failed to decompress or decode binary message from Gateway.")
317
+ return
318
+ data = decompressed_data
319
+ elif msg.type == aiohttp.WSMsgType.ERROR:
320
+ print(
321
+ f"WebSocket error: {self._ws.exception() if self._ws else 'Unknown WSError'}"
322
+ )
323
+ raise GatewayException(
324
+ f"WebSocket error: {self._ws.exception() if self._ws else 'Unknown WSError'}"
325
+ )
326
+ elif msg.type == aiohttp.WSMsgType.CLOSED:
327
+ close_code = (
328
+ self._ws.close_code
329
+ if self._ws and hasattr(self._ws, "close_code")
330
+ else "N/A"
331
+ )
332
+ print(f"WebSocket connection closed by server. Code: {close_code}")
333
+ # Raise an exception to signal the closure to the client's main run loop
334
+ raise GatewayException(f"WebSocket closed by server. Code: {close_code}")
335
+ else:
336
+ print(f"Received unhandled WebSocket message type: {msg.type}")
337
+ return
338
+
339
+ if self.verbose:
340
+ print(f"GATEWAY RECV: {data}")
341
+ op = data.get("op")
342
+ # 'd' payload (event_data) is handled specifically by each opcode handler below
343
+
344
+ if op == GatewayOpcode.DISPATCH:
345
+ await self._handle_dispatch(data) # _handle_dispatch will extract 'd'
346
+ elif op == GatewayOpcode.HEARTBEAT: # Server requests a heartbeat
347
+ await self._heartbeat()
348
+ elif op == GatewayOpcode.RECONNECT: # Server requests a reconnect
349
+ print("Gateway requested RECONNECT. Closing and will attempt to reconnect.")
350
+ await self.close(code=4000) # Use a non-1000 code to indicate reconnect
351
+ elif op == GatewayOpcode.INVALID_SESSION:
352
+ # The 'd' payload for INVALID_SESSION is a boolean indicating resumability
353
+ can_resume = data.get("d") is True
354
+ print(f"Gateway indicated INVALID_SESSION. Resumable: {can_resume}")
355
+ if not can_resume:
356
+ self._session_id = None # Clear session_id to force re-identify
357
+ self._last_sequence = None
358
+ # Close and reconnect. The connect logic will decide to resume or identify.
359
+ await self.close(
360
+ code=4000 if can_resume else 4009
361
+ ) # 4009 for non-resumable
362
+ elif op == GatewayOpcode.HELLO:
363
+ hello_d_payload = data.get("d")
364
+ if (
365
+ not isinstance(hello_d_payload, dict)
366
+ or "heartbeat_interval" not in hello_d_payload
367
+ ):
368
+ print(
369
+ f"Error: HELLO event 'd' payload is invalid or missing heartbeat_interval: {hello_d_payload}"
370
+ )
371
+ await self.close(code=1011) # Internal error, malformed HELLO
372
+ return
373
+ self._heartbeat_interval = hello_d_payload["heartbeat_interval"]
374
+ print(f"Gateway HELLO. Heartbeat interval: {self._heartbeat_interval}ms.")
375
+ # Start heartbeating
376
+ if self._keep_alive_task:
377
+ self._keep_alive_task.cancel()
378
+ self._keep_alive_task = self._loop.create_task(self._keep_alive())
379
+
380
+ # Identify or Resume
381
+ if self._session_id and self._resume_gateway_url: # Check if we can resume
382
+ print("Attempting to RESUME session.")
383
+ await self._resume()
384
+ else:
385
+ print("Performing initial IDENTIFY.")
386
+ await self._identify()
387
+ elif op == GatewayOpcode.HEARTBEAT_ACK:
388
+ # print("Received heartbeat ACK.")
389
+ pass # Good, connection is alive
390
+ else:
391
+ print(f"Received unhandled Gateway Opcode: {op} with data: {data}")
392
+
393
+ async def _receive_loop(self):
394
+ """Continuously receives and processes messages from the WebSocket."""
395
+ if not self._ws or self._ws.closed:
396
+ print("Receive loop cannot start: WebSocket is not connected or closed.")
397
+ return
398
+
399
+ try:
400
+ async for msg in self._ws:
401
+ await self._process_message(msg)
402
+ except asyncio.CancelledError:
403
+ print("Receive_loop task cancelled.")
404
+ except aiohttp.ClientConnectionError as e:
405
+ print(f"ClientConnectionError in receive_loop: {e}. Attempting reconnect.")
406
+ # This might be handled by an outer reconnect loop in the Client class
407
+ await self.close(code=1006) # Abnormal closure
408
+ except Exception as e:
409
+ print(f"Unexpected error in receive_loop: {e}")
410
+ traceback.print_exc()
411
+ # Consider specific error types for more granular handling
412
+ await self.close(code=1011) # Internal error
413
+ finally:
414
+ print("Receive_loop ended.")
415
+ # If the loop ends unexpectedly (not due to explicit close),
416
+ # the main client might want to try reconnecting.
417
+
418
+ async def connect(self):
419
+ """Connects to the Discord Gateway."""
420
+ if self._ws and not self._ws.closed:
421
+ print("Gateway already connected or connecting.")
422
+ return
423
+
424
+ gateway_url = (
425
+ self._resume_gateway_url or (await self._http.get_gateway_bot())["url"]
426
+ )
427
+ if not gateway_url.endswith("?v=10&encoding=json&compress=zlib-stream"):
428
+ gateway_url += "?v=10&encoding=json&compress=zlib-stream"
429
+
430
+ print(f"Connecting to Gateway: {gateway_url}")
431
+ try:
432
+ await self._http._ensure_session() # Ensure the HTTP client's session is active
433
+ assert (
434
+ self._http._session is not None
435
+ ), "HTTPClient session not initialized after ensure_session"
436
+ self._ws = await self._http._session.ws_connect(gateway_url, max_msg_size=0)
437
+ print("Gateway WebSocket connection established.")
438
+
439
+ if self._receive_task:
440
+ self._receive_task.cancel()
441
+ self._receive_task = self._loop.create_task(self._receive_loop())
442
+
443
+ except aiohttp.ClientConnectorError as e:
444
+ raise GatewayException(
445
+ f"Failed to connect to Gateway (Connector Error): {e}"
446
+ ) from e
447
+ except aiohttp.WSServerHandshakeError as e:
448
+ if e.status == 401: # Unauthorized during handshake
449
+ raise AuthenticationError(
450
+ f"Gateway handshake failed (401 Unauthorized): {e.message}. Check your bot token."
451
+ ) from e
452
+ raise GatewayException(
453
+ f"Gateway handshake failed (Status: {e.status}): {e.message}"
454
+ ) from e
455
+ except Exception as e: # Catch other potential errors during connection
456
+ raise GatewayException(
457
+ f"An unexpected error occurred during Gateway connection: {e}"
458
+ ) from e
459
+
460
+ async def close(self, code: int = 1000):
461
+ """Closes the Gateway connection."""
462
+ print(f"Closing Gateway connection with code {code}...")
463
+ if self._keep_alive_task and not self._keep_alive_task.done():
464
+ self._keep_alive_task.cancel()
465
+ try:
466
+ await self._keep_alive_task
467
+ except asyncio.CancelledError:
468
+ pass # Expected
469
+
470
+ if self._receive_task and not self._receive_task.done():
471
+ self._receive_task.cancel()
472
+ try:
473
+ await self._receive_task
474
+ except asyncio.CancelledError:
475
+ pass # Expected
476
+
477
+ if self._ws and not self._ws.closed:
478
+ await self._ws.close(code=code)
479
+ print("Gateway WebSocket closed.")
480
+
481
+ self._ws = None
482
+ # Do not reset session_id, last_sequence, or resume_gateway_url here
483
+ # if the close code indicates a resumable disconnect (e.g. 4000-4009, or server-initiated RECONNECT)
484
+ # The connect logic will decide whether to resume or re-identify.
485
+ # However, if it's a non-resumable close (e.g. Invalid Session non-resumable), clear them.
486
+ if code == 4009: # Invalid session, not resumable
487
+ print("Clearing session state due to non-resumable invalid session.")
488
+ self._session_id = None
489
+ self._last_sequence = None
490
+ self._resume_gateway_url = None # This might be re-fetched anyway