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.
- disagreement/__init__.py +36 -0
- disagreement/cache.py +55 -0
- disagreement/client.py +1144 -0
- disagreement/components.py +166 -0
- disagreement/enums.py +357 -0
- disagreement/error_handler.py +33 -0
- disagreement/errors.py +112 -0
- disagreement/event_dispatcher.py +243 -0
- disagreement/gateway.py +490 -0
- disagreement/http.py +657 -0
- disagreement/hybrid_context.py +32 -0
- disagreement/i18n.py +22 -0
- disagreement/interactions.py +572 -0
- disagreement/logging_config.py +26 -0
- disagreement/models.py +1642 -0
- disagreement/oauth.py +109 -0
- disagreement/permissions.py +99 -0
- disagreement/rate_limiter.py +75 -0
- disagreement/shard_manager.py +65 -0
- disagreement/typing.py +42 -0
- disagreement/ui/__init__.py +17 -0
- disagreement/ui/button.py +99 -0
- disagreement/ui/item.py +38 -0
- disagreement/ui/modal.py +132 -0
- disagreement/ui/select.py +92 -0
- disagreement/ui/view.py +165 -0
- disagreement/voice_client.py +120 -0
- disagreement-0.0.1.dist-info/METADATA +163 -0
- disagreement-0.0.1.dist-info/RECORD +32 -0
- disagreement-0.0.1.dist-info/WHEEL +5 -0
- disagreement-0.0.1.dist-info/licenses/LICENSE +26 -0
- disagreement-0.0.1.dist-info/top_level.txt +1 -0
disagreement/gateway.py
ADDED
@@ -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
|