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/__init__.py +74 -0
- zynko/_events.py +123 -0
- zynko/bits.py +62 -0
- zynko/client.py +471 -0
- zynko/gateway.py +171 -0
- zynko/interaction.py +106 -0
- zynko/message.py +121 -0
- zynko/models.py +387 -0
- zynko/permissions.py +52 -0
- zynko/player.py +413 -0
- zynko/py.typed +0 -0
- zynko/rest.py +309 -0
- zynko/voice.py +298 -0
- zynko-0.1.0.dist-info/METADATA +67 -0
- zynko-0.1.0.dist-info/RECORD +16 -0
- zynko-0.1.0.dist-info/WHEEL +4 -0
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
|