zynko 0.1.0__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.
zynko/client.py ADDED
@@ -0,0 +1,471 @@
1
+ """A Zynko bot: the gateway, the caches and the events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ from collections.abc import Callable
9
+ from typing import TYPE_CHECKING, Any, TypeVar, overload
10
+
11
+ from ._events import Emitter
12
+ from .bits import Intents, Permissions
13
+ from .gateway import Gateway, GatewayClosed
14
+ from .interaction import Interaction
15
+ from .message import Message
16
+ from .models import Card, Channel, Components, Emoji, Guild, Member, Role, User, VoiceState
17
+ from .permissions import compute_permissions
18
+ from .rest import FileInput, Rest
19
+
20
+ if TYPE_CHECKING:
21
+ from .voice import VoiceConnection
22
+
23
+ __all__ = ["Client"]
24
+
25
+ log = logging.getLogger("zynko")
26
+
27
+ F = TypeVar("F", bound=Callable[..., Any])
28
+
29
+ #: The events a client emits, and what each handler gets.
30
+ EVENTS = {
31
+ "ready": "user: User - connected (again): the caches were rebuilt from READY",
32
+ "message_create": "message: Message",
33
+ "message_update": "message: Message",
34
+ "message_delete": "data: {id, channel_id}",
35
+ "reaction_add": "data: {message_id, channel_id, user_id, emoji}",
36
+ "reaction_remove": "data: {message_id, channel_id, user_id, emoji}",
37
+ "typing_start": "data: {channel_id, user_id, timestamp}",
38
+ "guild_create": "guild: Guild - added to a guild, or each guild in READY",
39
+ "guild_update": "guild: Guild",
40
+ "guild_delete": "guild_id: str",
41
+ "channel_create": "channel: Channel",
42
+ "channel_update": "channel: Channel, previous: Channel | None",
43
+ "channel_delete": "channel: Channel",
44
+ "member_add": "member: Member",
45
+ "member_update": "member: Member, previous: Member | None",
46
+ "member_remove": "data: {guild_id, user_id}",
47
+ "presence_update": "data: {user_id, status}",
48
+ "voice_state_update": "state: VoiceState, previous: VoiceState | None",
49
+ "interaction": "interaction: Interaction",
50
+ "raw": "type: str, data: Any - every gateway event, as it came",
51
+ "reconnecting": "info: {code, reason, wait} - the link dropped; connecting again",
52
+ "error": "error: Exception - a handler failed, or the token was refused",
53
+ }
54
+
55
+
56
+ class Client:
57
+ """A Zynko bot: connects to the gateway, keeps guilds, channels and members up to date,
58
+ and turns events into handler calls::
59
+
60
+ client = zynko.Client(intents=Intents.GUILDS | Intents.MESSAGES)
61
+
62
+ @client.on("message_create")
63
+ async def ping(msg: zynko.Message):
64
+ if msg.content == "!ping":
65
+ await msg.reply("pong")
66
+
67
+ client.run(os.environ["ZYNKO_TOKEN"])
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ token: str | None = None,
73
+ *,
74
+ intents: int = Intents.GUILDS | Intents.MESSAGES,
75
+ api: str | None = None,
76
+ gateway: str | None = None,
77
+ ) -> None:
78
+ self.rest = Rest(token or "", api=api)
79
+ self.intents = Intents(intents)
80
+ self._gateway_url = gateway
81
+ #: The bot's account, once connected.
82
+ self.user: User | None = None
83
+ self.session_id = ""
84
+ #: Guilds (servidores) by id.
85
+ self.guilds: dict[str, Guild] = {}
86
+ #: Every channel the bot sees, DMs included.
87
+ self.channels: dict[str, Channel] = {}
88
+ self.users: dict[str, User] = {}
89
+ #: Who is in voice, by user id (needs VOICE_STATES).
90
+ self.voice_states: dict[str, VoiceState] = {}
91
+ #: The voice room the bot is in: one at a time, as for anyone.
92
+ self.voice: VoiceConnection | None = None
93
+ self._events = Emitter("zynko")
94
+ self._gateway: Gateway | None = None
95
+ self._first_ready: asyncio.Future[User] | None = None
96
+ self._closed: asyncio.Event | None = None
97
+
98
+ # ---------- events ----------
99
+
100
+ @overload
101
+ def on(self, event: str) -> Callable[[F], F]: ...
102
+ @overload
103
+ def on(self, event: str, handler: F) -> F: ...
104
+ def on(self, event: str, handler: F | None = None) -> Any:
105
+ """Registers a handler, as a call or a decorator: ``@client.on("message_create")``."""
106
+ return self._events.on(event, handler) if handler is not None else self._events.on(event)
107
+
108
+ def once(self, event: str, handler: F) -> F:
109
+ return self._events.once(event, handler)
110
+
111
+ def off(self, event: str, handler: Callable[..., Any]) -> None:
112
+ self._events.off(event, handler)
113
+
114
+ def event(self, handler: F) -> F:
115
+ """Registers ``on_<event>`` by its name: ``@client.event async def on_message_create(msg)``."""
116
+ name = handler.__name__
117
+ if not name.startswith("on_"):
118
+ raise ValueError(f"O nome do handler precisa começar com on_ (veio {name!r})")
119
+ self._events.on(name[3:], handler)
120
+ return handler
121
+
122
+ async def wait_for(self, event: str, check: Callable[..., bool] | None = None, timeout: float | None = 30.0) -> Any:
123
+ """Waits for the next ``event`` that passes ``check``; its argument (or a tuple of them).
124
+
125
+ Raises ``asyncio.TimeoutError`` after ``timeout`` seconds.
126
+ """
127
+ return await self._events.wait_for(event, check, timeout)
128
+
129
+ def _emit(self, event: str, *args: Any) -> None:
130
+ self._events.emit(event, *args)
131
+
132
+ # ---------- connection ----------
133
+
134
+ @property
135
+ def token(self) -> str:
136
+ return self.rest.token
137
+
138
+ async def login(self, token: str | None = None) -> User:
139
+ """Connects; returns the bot's account once READY arrives. The link stays up in the
140
+ background, reconnecting by itself, until :meth:`close`."""
141
+ if token:
142
+ self.rest.token = token
143
+ if not self.rest.token:
144
+ raise ValueError("Falta o token do bot (crie um em dev.zynko.org)")
145
+ loop = asyncio.get_running_loop()
146
+ self._closed = asyncio.Event()
147
+ self._first_ready = loop.create_future()
148
+ url = self._gateway_url or re.sub(r"^http", "ws", self.rest.api) + "/gateway"
149
+ self._gateway = Gateway(
150
+ url=url,
151
+ token=self.rest.token,
152
+ intents=self.intents,
153
+ session=lambda: self.rest.session,
154
+ on_dispatch=self._dispatch,
155
+ on_state=self._on_state,
156
+ )
157
+ task = self._gateway.start()
158
+ waiter = self._first_ready
159
+ pending: set[asyncio.Future[Any]] = {waiter, task}
160
+ done, _ = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
161
+ if waiter in done:
162
+ return waiter.result()
163
+ task.result()
164
+ raise RuntimeError("O gateway parou antes do READY")
165
+
166
+ async def start(self, token: str | None = None) -> None:
167
+ """Connects and runs until :meth:`close` (or a refused token)."""
168
+ await self.login(token)
169
+ assert self._closed is not None
170
+ await self._closed.wait()
171
+
172
+ def run(self, token: str | None = None) -> None:
173
+ """Connects and blocks until Ctrl+C or :meth:`close`. The simplest way to run a bot."""
174
+
175
+ async def main() -> None:
176
+ try:
177
+ await self.start(token)
178
+ finally:
179
+ await self.close()
180
+
181
+ try:
182
+ asyncio.run(main())
183
+ except KeyboardInterrupt:
184
+ pass
185
+
186
+ async def close(self) -> None:
187
+ """Leaves voice and closes the link."""
188
+ if self.voice is not None:
189
+ try:
190
+ await self.voice.leave()
191
+ except Exception: # noqa: BLE001
192
+ log.debug("leaving voice failed", exc_info=True)
193
+ if self._gateway is not None:
194
+ await self._gateway.close()
195
+ await self.rest.close()
196
+ if self._closed is not None:
197
+ self._closed.set()
198
+
199
+ #: Same as :meth:`close`, by the JavaScript library's name.
200
+ destroy = close
201
+
202
+ @property
203
+ def closed(self) -> bool:
204
+ return self._closed is not None and self._closed.is_set()
205
+
206
+ async def send_op(self, op: int, d: Any) -> bool:
207
+ """Sends a gateway op. Used by the voice connection (op 4)."""
208
+ return await self._gateway.send(op, d) if self._gateway else False
209
+
210
+ def _on_state(self, state: str, detail: Any) -> None:
211
+ if state == "reconnecting" and detail:
212
+ self._emit("reconnecting", detail)
213
+ elif state == "closed":
214
+ err = detail if isinstance(detail, Exception) else GatewayClosed("Gateway fechado")
215
+ if self._first_ready is not None and not self._first_ready.done():
216
+ self._first_ready.set_exception(err)
217
+ else:
218
+ self._emit("error", err)
219
+ if self._closed is not None:
220
+ self._closed.set()
221
+
222
+ # ---------- shortcuts ----------
223
+
224
+ async def send(
225
+ self,
226
+ channel_id: str,
227
+ content: str | None = None,
228
+ *,
229
+ reply_to: str | None = None,
230
+ files: list[FileInput] | None = None,
231
+ nonce: str | None = None,
232
+ cards: list[Card] | None = None,
233
+ components: Components | None = None,
234
+ ) -> Message:
235
+ """Sends a message to a channel; files are uploaded first."""
236
+ data = await self.rest.send(
237
+ channel_id, content, reply_to=reply_to, files=files, nonce=nonce, cards=cards, components=components
238
+ )
239
+ return Message(self, data)
240
+
241
+ async def send_dm(
242
+ self,
243
+ user_id: str,
244
+ content: str | None = None,
245
+ *,
246
+ files: list[FileInput] | None = None,
247
+ cards: list[Card] | None = None,
248
+ components: Components | None = None,
249
+ ) -> Message:
250
+ """Sends someone a direct message."""
251
+ dm = Channel.from_dict(await self.rest.dm(user_id))
252
+ self.channels[dm.id] = dm
253
+ return await self.send(dm.id, content, files=files, cards=cards, components=components)
254
+
255
+ async def set_commands(self, commands: list[Any]) -> list[dict[str, Any]]:
256
+ """Replaces all of the bot's "/" commands (they show up in the apps right away)."""
257
+ return await self.rest.set_commands(commands)
258
+
259
+ async def join_voice(
260
+ self,
261
+ channel_id: str,
262
+ *,
263
+ receive: bool = False,
264
+ relay: bool = False,
265
+ deaf: bool = False,
266
+ ffmpeg: str | None = None,
267
+ ytdlp: bool | str = False,
268
+ ) -> VoiceConnection:
269
+ """Joins a voice room to play (and optionally hear) audio. Needs ``zynko[voice]``.
270
+
271
+ - ``receive``: hear the room (``audio`` events with each person's sound);
272
+ - ``relay``: the "conexão alternativa", media only through the voice server's relay;
273
+ - ``deaf``: show the bot as deafened (it still plays);
274
+ - ``ffmpeg``, ``ytdlp``: see :class:`zynko.AudioPlayer`.
275
+ """
276
+ from .voice import VoiceConnection
277
+
278
+ if self.voice is not None and self.voice.channel_id == channel_id:
279
+ return self.voice
280
+ if self.voice is not None:
281
+ await self.voice.leave()
282
+ conn = VoiceConnection(self, channel_id, receive=receive, relay=relay, deaf=deaf, ffmpeg=ffmpeg, ytdlp=ytdlp)
283
+ self.voice = conn
284
+ try:
285
+ await conn.connect()
286
+ except BaseException:
287
+ if self.voice is conn:
288
+ self.voice = None
289
+ try:
290
+ await conn.leave()
291
+ except Exception: # noqa: BLE001
292
+ pass
293
+ raise
294
+ return conn
295
+
296
+ def permissions_in(self, channel_id: str, user_id: str) -> Permissions:
297
+ """What someone may do in a channel, the server's way: @everyone and their roles, then
298
+ the channel's overwrites. Owners and admins may do everything."""
299
+ channel = self.channels.get(channel_id)
300
+ guild = self.guilds.get(channel.guild_id) if channel and channel.guild_id else None
301
+ return compute_permissions(guild, channel, user_id)
302
+
303
+ def can(self, channel_id: str, user_id: str, permission: int) -> bool:
304
+ """Whether someone has a permission (or all of several) in a channel."""
305
+ return (int(self.permissions_in(channel_id, user_id)) & int(permission)) == int(permission)
306
+
307
+ def link(self, path: str | None) -> str | None:
308
+ """A full link to an avatar, emoji or other ``/api/...`` path the API hands out."""
309
+ if not path:
310
+ return None
311
+ return path if re.match(r"^https?:", path) else self.rest.base + path
312
+
313
+ # ---------- cache ----------
314
+
315
+ def _add_guild(self, d: dict[str, Any]) -> Guild:
316
+ g = Guild.from_dict(d)
317
+ for c in g.channels.values():
318
+ self.channels[c.id] = c
319
+ for m in g.members.values():
320
+ self.users[m.user.id] = m.user
321
+ self.guilds[g.id] = g
322
+ return g
323
+
324
+ def _drop_guild(self, guild_id: str) -> None:
325
+ g = self.guilds.pop(guild_id, None)
326
+ if g is None:
327
+ return
328
+ for c in g.channels:
329
+ self.channels.pop(c, None)
330
+
331
+ def _dispatch(self, t: str, d: Any) -> None:
332
+ try:
333
+ self._handle(t, d)
334
+ except Exception as e: # noqa: BLE001 - a bad event must not drop the link
335
+ log.exception("evento %s", t)
336
+ self._emit("error", e)
337
+
338
+ def _handle(self, t: str, d: Any) -> None:
339
+ self._emit("raw", t, d)
340
+ if t == "READY":
341
+ self.user = User.from_dict(d["user"])
342
+ self.session_id = d.get("session_id", "")
343
+ self.guilds.clear()
344
+ self.channels.clear()
345
+ self.voice_states.clear()
346
+ for g in d.get("guilds") or []:
347
+ self._add_guild(g)
348
+ for c in d.get("dm_channels") or []:
349
+ ch = Channel.from_dict(c)
350
+ self.channels[ch.id] = ch
351
+ for u in ch.recipients:
352
+ self.users[u.id] = u
353
+ for v in d.get("voice_states") or []:
354
+ vs = VoiceState.from_dict(v)
355
+ if vs.channel_id:
356
+ self.voice_states[vs.user_id] = vs
357
+ if self._first_ready is not None and not self._first_ready.done():
358
+ self._first_ready.set_result(self.user)
359
+ if self.voice is not None:
360
+ self.voice._resync()
361
+ self._emit("ready", self.user)
362
+ for g in list(self.guilds.values()):
363
+ self._emit("guild_create", g)
364
+ elif t == "GUILD_CREATE":
365
+ self._emit("guild_create", self._add_guild(d))
366
+ elif t == "GUILD_UPDATE":
367
+ g = self.guilds.get(d["id"])
368
+ if g is None:
369
+ return
370
+ g.name = d.get("name", g.name)
371
+ g.icon = d.get("icon")
372
+ g.owner_id = str(d.get("owner_id", g.owner_id))
373
+ self._emit("guild_update", g)
374
+ elif t == "GUILD_DELETE":
375
+ self._drop_guild(d["id"])
376
+ self._emit("guild_delete", d["id"])
377
+ elif t in ("GUILD_ROLE_CREATE", "GUILD_ROLE_UPDATE"):
378
+ g = self.guilds.get(d.get("guild_id", ""))
379
+ if g is not None:
380
+ r = Role.from_dict(d)
381
+ g.roles[r.id] = r
382
+ elif t == "GUILD_ROLE_DELETE":
383
+ g = self.guilds.get(d.get("guild_id", ""))
384
+ if g is not None:
385
+ g.roles.pop(d.get("role_id") or d.get("id"), None)
386
+ elif t == "GUILD_EMOJIS_UPDATE":
387
+ g = self.guilds.get(d.get("guild_id", ""))
388
+ if g is not None:
389
+ g.emojis = [Emoji.from_dict(e) for e in d.get("emojis") or []]
390
+ elif t in ("CHANNEL_CREATE", "CHANNEL_UPDATE"):
391
+ c = Channel.from_dict(d)
392
+ previous = self.channels.get(c.id)
393
+ if previous is not None and previous.recipients and not c.recipients:
394
+ c.recipients = previous.recipients
395
+ self.channels[c.id] = c
396
+ if c.guild_id and c.guild_id in self.guilds:
397
+ self.guilds[c.guild_id].channels[c.id] = c
398
+ for u in c.recipients:
399
+ self.users[u.id] = u
400
+ if t == "CHANNEL_CREATE":
401
+ self._emit("channel_create", c)
402
+ return
403
+ if self.voice is not None:
404
+ self.voice._on_channel_update(c)
405
+ self._emit("channel_update", c, previous)
406
+ elif t == "CHANNEL_DELETE":
407
+ c = Channel.from_dict(d)
408
+ self.channels.pop(c.id, None)
409
+ if c.guild_id and c.guild_id in self.guilds:
410
+ self.guilds[c.guild_id].channels.pop(c.id, None)
411
+ self._emit("channel_delete", c)
412
+ elif t in ("GUILD_MEMBER_ADD", "GUILD_MEMBER_UPDATE"):
413
+ m = Member.from_dict(d)
414
+ g = self.guilds.get(m.guild_id)
415
+ old_member = g.members.get(m.user.id) if g else None
416
+ if g is not None:
417
+ g.members[m.user.id] = m
418
+ self.users[m.user.id] = m.user
419
+ if t == "GUILD_MEMBER_ADD":
420
+ self._emit("member_add", m)
421
+ else:
422
+ self._emit("member_update", m, old_member)
423
+ elif t == "GUILD_MEMBER_REMOVE":
424
+ g = self.guilds.get(d.get("guild_id", ""))
425
+ if g is not None:
426
+ g.members.pop(d.get("user_id"), None)
427
+ self._emit("member_remove", d)
428
+ elif t == "PRESENCE_UPDATE":
429
+ uid = d.get("user_id")
430
+ for g in self.guilds.values():
431
+ if uid not in g.members:
432
+ continue
433
+ if d.get("status") == "online":
434
+ g.online.add(uid)
435
+ else:
436
+ g.online.discard(uid)
437
+ self._emit("presence_update", d)
438
+ elif t == "USER_UPDATE":
439
+ u = User.from_dict(d)
440
+ self.users[u.id] = u
441
+ if self.user is not None and u.id == self.user.id:
442
+ self.user = u
443
+ elif t == "MESSAGE_CREATE":
444
+ msg = Message(self, d)
445
+ c = self.channels.get(msg.channel_id)
446
+ if c is not None:
447
+ c.last_message_id = msg.id
448
+ self.users[msg.author.id] = msg.author
449
+ self._emit("message_create", msg)
450
+ elif t == "MESSAGE_UPDATE":
451
+ self._emit("message_update", Message(self, d))
452
+ elif t == "MESSAGE_DELETE":
453
+ self._emit("message_delete", d)
454
+ elif t == "MESSAGE_REACTION_ADD":
455
+ self._emit("reaction_add", d)
456
+ elif t == "MESSAGE_REACTION_REMOVE":
457
+ self._emit("reaction_remove", d)
458
+ elif t == "TYPING_START":
459
+ self._emit("typing_start", d)
460
+ elif t == "INTERACTION_CREATE":
461
+ self._emit("interaction", Interaction(self, d))
462
+ elif t == "VOICE_STATE_UPDATE":
463
+ v = VoiceState.from_dict(d)
464
+ old_state = self.voice_states.get(v.user_id)
465
+ if v.channel_id:
466
+ self.voice_states[v.user_id] = v
467
+ else:
468
+ self.voice_states.pop(v.user_id, None)
469
+ if self.voice is not None and self.user is not None and v.user_id == self.user.id:
470
+ self.voice._on_own_state(v)
471
+ self._emit("voice_state_update", v, old_state)
zynko/gateway.py ADDED
@@ -0,0 +1,171 @@
1
+ """The realtime link (docs.zynko.org/api/gateway).
2
+
3
+ HELLO, IDENTIFY with the token and the intents, heartbeats, and a fresh IDENTIFY after any
4
+ drop (there is no RESUME: READY always brings the whole state again).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import random
13
+ import time
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+
17
+ import aiohttp
18
+
19
+ log = logging.getLogger("zynko.gateway")
20
+
21
+ #: Close codes after which trying again cannot help.
22
+ FATAL = {4004}
23
+
24
+
25
+ def backoff(retry: int, jitter: float | None = None) -> float:
26
+ """Seconds to wait before reconnect number ``retry`` (0-based): 1, 2, 4... up to 30.
27
+
28
+ With some jitter (x0.75 to x1.25) so a server restart is not hit by every bot at once.
29
+ """
30
+ j = random.random() if jitter is None else jitter
31
+ return min(30.0, 2.0**retry) * (0.75 + j / 2)
32
+
33
+
34
+ class GatewayClosed(Exception):
35
+ """The gateway refused the token: trying again cannot help."""
36
+
37
+
38
+ class Gateway:
39
+ def __init__(
40
+ self,
41
+ *,
42
+ url: str,
43
+ token: str,
44
+ intents: int,
45
+ session: Callable[[], aiohttp.ClientSession],
46
+ on_dispatch: Callable[[str, Any], None],
47
+ on_state: Callable[[str, Any], None],
48
+ ) -> None:
49
+ self.url = url
50
+ self.token = token
51
+ self.intents = intents
52
+ self._session = session
53
+ self._on_dispatch = on_dispatch
54
+ self._on_state = on_state
55
+ self._ws: aiohttp.ClientWebSocketResponse[bool] | None = None
56
+ self._beat: asyncio.Task[None] | None = None
57
+ self._task: asyncio.Task[None] | None = None
58
+ self._stopped = False
59
+ self._retry = 0
60
+ self._last_ack = 0.0
61
+
62
+ @property
63
+ def open(self) -> bool:
64
+ return self._ws is not None and not self._ws.closed
65
+
66
+ def start(self) -> asyncio.Task[None]:
67
+ """Runs the link in the background until :meth:`close`."""
68
+ self._stopped = False
69
+ if self._task is None or self._task.done():
70
+ self._task = asyncio.ensure_future(self._run())
71
+ return self._task
72
+
73
+ async def wait_closed(self) -> None:
74
+ if self._task is not None:
75
+ await asyncio.shield(self._task)
76
+
77
+ async def send(self, op: int, d: Any) -> bool:
78
+ """Sends an op (4: voice state). Returns whether the link was open."""
79
+ ws = self._ws
80
+ if ws is None or ws.closed:
81
+ return False
82
+ try:
83
+ await ws.send_str(json.dumps({"op": op, "d": d}))
84
+ except (ConnectionError, RuntimeError, aiohttp.ClientError):
85
+ return False
86
+ return True
87
+
88
+ async def close(self) -> None:
89
+ self._stopped = True
90
+ self._stop_beat()
91
+ ws, self._ws = self._ws, None
92
+ if ws is not None and not ws.closed:
93
+ await ws.close(code=1000)
94
+ if self._task is not None and not self._task.done():
95
+ self._task.cancel()
96
+ try:
97
+ await self._task
98
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
99
+ pass
100
+
101
+ async def _run(self) -> None:
102
+ while not self._stopped:
103
+ self._on_state("reconnecting" if self._retry else "connecting", None)
104
+ code: int | None = None
105
+ reason = ""
106
+ try:
107
+ async with self._session().ws_connect(self.url, max_msg_size=0, autoping=True) as ws:
108
+ self._ws = ws
109
+ async for msg in ws:
110
+ if msg.type == aiohttp.WSMsgType.TEXT:
111
+ await self._on_message(msg.data)
112
+ elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
113
+ break
114
+ code = ws.close_code
115
+ reason = str(ws.exception() or "")
116
+ except asyncio.CancelledError:
117
+ raise
118
+ except Exception as e: # noqa: BLE001 - any failure to connect is retried
119
+ reason = str(e)
120
+ finally:
121
+ self._stop_beat()
122
+ self._ws = None
123
+ if self._stopped:
124
+ return
125
+ if code in FATAL:
126
+ self._stopped = True
127
+ self._on_state("closed", GatewayClosed(f"Token recusado pelo gateway ({code} {reason})".strip()))
128
+ return
129
+ wait = backoff(self._retry)
130
+ self._retry += 1
131
+ self._on_state("reconnecting", {"code": code or 0, "reason": reason, "wait": wait})
132
+ await asyncio.sleep(wait)
133
+
134
+ async def _on_message(self, raw: str) -> None:
135
+ try:
136
+ msg = json.loads(raw)
137
+ except ValueError:
138
+ return
139
+ op = msg.get("op")
140
+ if op == 10:
141
+ await self.send(2, {"token": self.token, "intents": int(self.intents)})
142
+ self._stop_beat()
143
+ self._last_ack = time.monotonic()
144
+ every = ((msg.get("d") or {}).get("heartbeat_interval") or 30_000) / 1000
145
+ self._beat = asyncio.ensure_future(self._heartbeat(every))
146
+ elif op == 11:
147
+ self._last_ack = time.monotonic()
148
+ elif op == 0:
149
+ self._last_ack = time.monotonic()
150
+ t = msg.get("t")
151
+ if t == "READY":
152
+ self._retry = 0
153
+ self._on_state("ready", None)
154
+ if t:
155
+ self._on_dispatch(t, msg.get("d"))
156
+
157
+ async def _heartbeat(self, every: float) -> None:
158
+ while True:
159
+ await asyncio.sleep(every)
160
+ # No answer to two beats in a row: the link is dead even if the socket says otherwise.
161
+ if time.monotonic() - self._last_ack > every * 2 + 5:
162
+ ws = self._ws
163
+ if ws is not None:
164
+ await ws.close(code=4900, message=b"heartbeat timeout")
165
+ return
166
+ await self.send(1, None)
167
+
168
+ def _stop_beat(self) -> None:
169
+ if self._beat is not None:
170
+ self._beat.cancel()
171
+ self._beat = None