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/rest.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""The HTTP API with a bot's token (docs.zynko.org/api/rotas).
|
|
2
|
+
|
|
3
|
+
Every route is reachable through :meth:`Rest.request`; the common ones have their own method.
|
|
4
|
+
Methods return the API's JSON as it came (dicts and lists); :class:`zynko.Client` wraps
|
|
5
|
+
messages in :class:`zynko.Message`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Union
|
|
17
|
+
from urllib.parse import quote, urlencode
|
|
18
|
+
|
|
19
|
+
import aiohttp
|
|
20
|
+
|
|
21
|
+
from .models import Card, Command, Components
|
|
22
|
+
|
|
23
|
+
__all__ = ["Rest", "ZynkoError", "File", "FileInput", "DEFAULT_API"]
|
|
24
|
+
|
|
25
|
+
DEFAULT_API = "https://zynko.org/api"
|
|
26
|
+
JSON = Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ZynkoError(Exception):
|
|
30
|
+
"""An answer the API refused (docs.zynko.org/api/#erros)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
#: HTTP status.
|
|
35
|
+
self.status = status
|
|
36
|
+
#: ``bad_request``, ``forbidden``, ``not_found``, ``rate_limited``...
|
|
37
|
+
self.code = code
|
|
38
|
+
self.message = message
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
return f"ZynkoError(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class File:
|
|
46
|
+
"""A file to send with a message."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
data: bytes
|
|
50
|
+
content_type: str | None = None
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_path(cls, path: str | os.PathLike[str], name: str | None = None) -> File:
|
|
54
|
+
p = Path(path)
|
|
55
|
+
return cls(name=name or p.name, data=p.read_bytes())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
#: A :class:`File`, or a path to read.
|
|
59
|
+
FileInput = Union[File, str, "os.PathLike[str]"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _as_file(f: FileInput) -> File:
|
|
63
|
+
return f if isinstance(f, File) else File.from_path(f)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _drop_none(d: dict[str, Any]) -> dict[str, Any]:
|
|
67
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Rest:
|
|
71
|
+
"""The HTTP API with a bot's token."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, token: str, *, api: str | None = None, retries: int = 3) -> None:
|
|
74
|
+
self.token = token
|
|
75
|
+
#: Where the API is: https://zynko.org/api unless you run your own server.
|
|
76
|
+
self.api = (api or DEFAULT_API).rstrip("/")
|
|
77
|
+
#: Waits out a 429 and tries again, up to this many times (0: never).
|
|
78
|
+
self.retries = retries
|
|
79
|
+
self._session: aiohttp.ClientSession | None = None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def base(self) -> str:
|
|
83
|
+
"""The server's address without /api, to turn ``/api/media/...`` paths into links."""
|
|
84
|
+
return self.api[: -len("/api")] if self.api.endswith("/api") else self.api
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def session(self) -> aiohttp.ClientSession:
|
|
88
|
+
"""The HTTP session, made on first use (it needs a running event loop)."""
|
|
89
|
+
if self._session is None or self._session.closed:
|
|
90
|
+
self._session = aiohttp.ClientSession()
|
|
91
|
+
return self._session
|
|
92
|
+
|
|
93
|
+
async def close(self) -> None:
|
|
94
|
+
if self._session is not None and not self._session.closed:
|
|
95
|
+
await self._session.close()
|
|
96
|
+
self._session = None
|
|
97
|
+
|
|
98
|
+
async def request(
|
|
99
|
+
self,
|
|
100
|
+
method: str,
|
|
101
|
+
path: str,
|
|
102
|
+
body: Any = None,
|
|
103
|
+
*,
|
|
104
|
+
form: aiohttp.FormData | Callable[[], aiohttp.FormData] | None = None,
|
|
105
|
+
) -> JSON:
|
|
106
|
+
"""Calls a route. ``body`` goes as JSON; ``form`` as multipart (uploads).
|
|
107
|
+
|
|
108
|
+
A form can be sent once only: pass a function that builds it to have uploads retried
|
|
109
|
+
after a 429 too.
|
|
110
|
+
"""
|
|
111
|
+
for attempt in range(self.retries + 1):
|
|
112
|
+
headers = {"Authorization": f"Bot {self.token}"}
|
|
113
|
+
data: Any = None
|
|
114
|
+
if form is not None:
|
|
115
|
+
data = form() if callable(form) else form
|
|
116
|
+
elif body is not None:
|
|
117
|
+
headers["Content-Type"] = "application/json"
|
|
118
|
+
data = json.dumps(body)
|
|
119
|
+
async with self.session.request(method, self.api + path, data=data, headers=headers) as res:
|
|
120
|
+
text = await res.text()
|
|
121
|
+
if res.ok:
|
|
122
|
+
return json.loads(text) if text else None
|
|
123
|
+
err: dict[str, Any] = {}
|
|
124
|
+
try:
|
|
125
|
+
parsed = json.loads(text)
|
|
126
|
+
if isinstance(parsed, dict):
|
|
127
|
+
err = parsed
|
|
128
|
+
except ValueError:
|
|
129
|
+
pass
|
|
130
|
+
if res.status == 429 and attempt < self.retries and err.get("code") == "rate_limited":
|
|
131
|
+
wait_ms = err.get("retry_after_ms")
|
|
132
|
+
if wait_ms is None:
|
|
133
|
+
wait_ms = float(res.headers.get("Retry-After", "1")) * 1000
|
|
134
|
+
if not isinstance(form, aiohttp.FormData):
|
|
135
|
+
await asyncio.sleep((float(wait_ms) + 50) / 1000)
|
|
136
|
+
continue
|
|
137
|
+
raise ZynkoError(res.status, err.get("code") or f"http_{res.status}", err.get("message") or res.reason or "")
|
|
138
|
+
raise AssertionError("unreachable")
|
|
139
|
+
|
|
140
|
+
async def get(self, path: str) -> JSON:
|
|
141
|
+
return await self.request("GET", path)
|
|
142
|
+
|
|
143
|
+
async def post(self, path: str, body: Any = None) -> JSON:
|
|
144
|
+
return await self.request("POST", path, {} if body is None else body)
|
|
145
|
+
|
|
146
|
+
async def patch(self, path: str, body: Any) -> JSON:
|
|
147
|
+
return await self.request("PATCH", path, body)
|
|
148
|
+
|
|
149
|
+
async def put(self, path: str, body: Any = None) -> JSON:
|
|
150
|
+
return await self.request("PUT", path, body)
|
|
151
|
+
|
|
152
|
+
async def delete(self, path: str) -> JSON:
|
|
153
|
+
return await self.request("DELETE", path)
|
|
154
|
+
|
|
155
|
+
# ---------- messages ----------
|
|
156
|
+
|
|
157
|
+
async def send(
|
|
158
|
+
self,
|
|
159
|
+
channel_id: str,
|
|
160
|
+
content: str | None = None,
|
|
161
|
+
*,
|
|
162
|
+
reply_to: str | None = None,
|
|
163
|
+
files: list[FileInput] | None = None,
|
|
164
|
+
nonce: str | None = None,
|
|
165
|
+
cards: list[Card] | None = None,
|
|
166
|
+
components: Components | None = None,
|
|
167
|
+
) -> JSON:
|
|
168
|
+
"""Sends a message; files are uploaded first (up to 10)."""
|
|
169
|
+
attachment_ids = [(await self.upload(channel_id, f))["id"] for f in files or []]
|
|
170
|
+
body: dict[str, Any] = {"content": content or ""}
|
|
171
|
+
if reply_to:
|
|
172
|
+
body["reply_to"] = reply_to
|
|
173
|
+
if nonce:
|
|
174
|
+
body["nonce"] = nonce
|
|
175
|
+
if attachment_ids:
|
|
176
|
+
body["attachment_ids"] = attachment_ids
|
|
177
|
+
if cards:
|
|
178
|
+
body["cards"] = cards
|
|
179
|
+
if components:
|
|
180
|
+
body["components"] = components
|
|
181
|
+
return await self.post(f"/channels/{channel_id}/messages", body)
|
|
182
|
+
|
|
183
|
+
async def upload(self, channel_id: str, file: FileInput) -> JSON:
|
|
184
|
+
"""Uploads a file to a channel, to be sent in a message (see :meth:`send`)."""
|
|
185
|
+
f = _as_file(file)
|
|
186
|
+
|
|
187
|
+
def form() -> aiohttp.FormData:
|
|
188
|
+
fd = aiohttp.FormData()
|
|
189
|
+
fd.add_field("file", f.data, filename=f.name, content_type=f.content_type or "application/octet-stream")
|
|
190
|
+
return fd
|
|
191
|
+
|
|
192
|
+
return await self.request("POST", f"/channels/{channel_id}/attachments", form=form)
|
|
193
|
+
|
|
194
|
+
async def edit_message(
|
|
195
|
+
self,
|
|
196
|
+
channel_id: str,
|
|
197
|
+
message_id: str,
|
|
198
|
+
content: str | None = None,
|
|
199
|
+
*,
|
|
200
|
+
cards: list[Card] | None = None,
|
|
201
|
+
components: Components | None = None,
|
|
202
|
+
) -> JSON:
|
|
203
|
+
"""Changes the bot's own message: whatever is given (text, cards, buttons)."""
|
|
204
|
+
body = _drop_none({"content": content, "cards": cards, "components": components})
|
|
205
|
+
return await self.patch(f"/channels/{channel_id}/messages/{message_id}", body)
|
|
206
|
+
|
|
207
|
+
async def delete_message(self, channel_id: str, message_id: str) -> JSON:
|
|
208
|
+
return await self.delete(f"/channels/{channel_id}/messages/{message_id}")
|
|
209
|
+
|
|
210
|
+
async def messages(
|
|
211
|
+
self, channel_id: str, *, before: str | None = None, after: str | None = None, limit: int | None = None
|
|
212
|
+
) -> list[JSON]:
|
|
213
|
+
"""Messages, newest first; ``before``/``after`` an id, up to ``limit`` (100)."""
|
|
214
|
+
q = urlencode(_drop_none({"before": before, "after": after, "limit": limit}))
|
|
215
|
+
return await self.get(f"/channels/{channel_id}/messages" + (f"?{q}" if q else "")) # type: ignore[no-any-return]
|
|
216
|
+
|
|
217
|
+
async def react(self, channel_id: str, message_id: str, emoji: str) -> JSON:
|
|
218
|
+
return await self.put(f"/channels/{channel_id}/messages/{message_id}/reactions/{quote(emoji, safe='')}")
|
|
219
|
+
|
|
220
|
+
async def unreact(self, channel_id: str, message_id: str, emoji: str) -> JSON:
|
|
221
|
+
return await self.delete(f"/channels/{channel_id}/messages/{message_id}/reactions/{quote(emoji, safe='')}")
|
|
222
|
+
|
|
223
|
+
async def typing(self, channel_id: str) -> JSON:
|
|
224
|
+
""""Digitando..." for a few seconds."""
|
|
225
|
+
return await self.post(f"/channels/{channel_id}/typing")
|
|
226
|
+
|
|
227
|
+
# ---------- people, guilds, channels ----------
|
|
228
|
+
|
|
229
|
+
async def me(self) -> JSON:
|
|
230
|
+
return await self.get("/users/@me")
|
|
231
|
+
|
|
232
|
+
async def profile(self, user_id: str) -> JSON:
|
|
233
|
+
return await self.get(f"/users/{user_id}/profile")
|
|
234
|
+
|
|
235
|
+
async def dm(self, user_id: str) -> JSON:
|
|
236
|
+
"""The DM channel with someone (opened if needed)."""
|
|
237
|
+
return await self.post("/users/@me/channels", {"recipient_id": user_id})
|
|
238
|
+
|
|
239
|
+
async def members(self, guild_id: str) -> list[JSON]:
|
|
240
|
+
return await self.get(f"/guilds/{guild_id}/members") # type: ignore[no-any-return]
|
|
241
|
+
|
|
242
|
+
async def kick(self, guild_id: str, user_id: str) -> JSON:
|
|
243
|
+
return await self.delete(f"/guilds/{guild_id}/members/{user_id}")
|
|
244
|
+
|
|
245
|
+
async def ban(self, guild_id: str, user_id: str) -> JSON:
|
|
246
|
+
return await self.put(f"/guilds/{guild_id}/bans/{user_id}")
|
|
247
|
+
|
|
248
|
+
async def unban(self, guild_id: str, user_id: str) -> JSON:
|
|
249
|
+
return await self.delete(f"/guilds/{guild_id}/bans/{user_id}")
|
|
250
|
+
|
|
251
|
+
async def add_role(self, guild_id: str, user_id: str, role_id: str) -> JSON:
|
|
252
|
+
return await self.put(f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}")
|
|
253
|
+
|
|
254
|
+
async def remove_role(self, guild_id: str, user_id: str, role_id: str) -> JSON:
|
|
255
|
+
return await self.delete(f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}")
|
|
256
|
+
|
|
257
|
+
async def create_role(
|
|
258
|
+
self,
|
|
259
|
+
guild_id: str,
|
|
260
|
+
name: str,
|
|
261
|
+
*,
|
|
262
|
+
color: int | None = None,
|
|
263
|
+
permissions: int | None = None,
|
|
264
|
+
hoist: bool | None = None,
|
|
265
|
+
) -> JSON:
|
|
266
|
+
body = _drop_none({"name": name, "color": color, "permissions": permissions, "hoist": hoist})
|
|
267
|
+
return await self.post(f"/guilds/{guild_id}/roles", body)
|
|
268
|
+
|
|
269
|
+
async def create_channel(
|
|
270
|
+
self,
|
|
271
|
+
guild_id: str,
|
|
272
|
+
name: str,
|
|
273
|
+
*,
|
|
274
|
+
type: int | None = None,
|
|
275
|
+
parent_id: str | None = None,
|
|
276
|
+
topic: str | None = None,
|
|
277
|
+
) -> JSON:
|
|
278
|
+
body = _drop_none({"name": name, "type": type, "parent_id": parent_id, "topic": topic})
|
|
279
|
+
return await self.post(f"/guilds/{guild_id}/channels", body)
|
|
280
|
+
|
|
281
|
+
async def edit_channel(self, channel_id: str, **changes: Any) -> JSON:
|
|
282
|
+
"""Changes ``name``, ``topic``, ``position``, ``parent_id`` or ``rate_limit_per_user``."""
|
|
283
|
+
return await self.patch(f"/channels/{channel_id}", changes)
|
|
284
|
+
|
|
285
|
+
async def delete_channel(self, channel_id: str) -> JSON:
|
|
286
|
+
return await self.delete(f"/channels/{channel_id}")
|
|
287
|
+
|
|
288
|
+
async def create_invite(self, guild_id: str, *, max_age_secs: int | None = None, max_uses: int | None = None) -> JSON:
|
|
289
|
+
body = _drop_none({"max_age_secs": max_age_secs, "max_uses": max_uses})
|
|
290
|
+
return await self.post(f"/guilds/{guild_id}/invites", body)
|
|
291
|
+
|
|
292
|
+
# ---------- commands ----------
|
|
293
|
+
|
|
294
|
+
async def set_commands(self, commands: list[Command]) -> list[JSON]:
|
|
295
|
+
"""Replaces all of the bot's "/" commands."""
|
|
296
|
+
return await self.put("/users/@me/commands", commands) # type: ignore[no-any-return]
|
|
297
|
+
|
|
298
|
+
async def commands(self) -> list[JSON]:
|
|
299
|
+
return await self.get("/users/@me/commands") # type: ignore[no-any-return]
|
|
300
|
+
|
|
301
|
+
async def interaction_callback(self, id: str, token: str, body: dict[str, Any]) -> JSON:
|
|
302
|
+
"""Answers an interaction (see :class:`zynko.Interaction`)."""
|
|
303
|
+
return await self.post(f"/interactions/{id}/{token}/callback", body)
|
|
304
|
+
|
|
305
|
+
# ---------- voice ----------
|
|
306
|
+
|
|
307
|
+
async def voice_token(self, channel_id: str) -> JSON:
|
|
308
|
+
"""A media token for a voice room (the voice connection uses it; see ``Client.join_voice``)."""
|
|
309
|
+
return await self.post(f"/channels/{channel_id}/voice")
|
zynko/voice.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""A bot in a voice room.
|
|
2
|
+
|
|
3
|
+
The Zynko server hands out a media token (``POST /channels/{id}/voice``) and the guild learns
|
|
4
|
+
where the bot is from gateway op 4; the audio itself travels through LiveKit, the same media
|
|
5
|
+
server the apps use, through the ``livekit`` package (``pip install "zynko[voice]"``)::
|
|
6
|
+
|
|
7
|
+
voice = await client.join_voice(channel_id)
|
|
8
|
+
voice.player.enqueue("https://exemplo.com/musica.mp3")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
from array import array
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
|
18
|
+
|
|
19
|
+
from ._events import Emitter
|
|
20
|
+
from .models import Channel, VoiceState
|
|
21
|
+
from .player import CHANNELS, FRAME_SAMPLES, SAMPLE_RATE, AudioPlayer
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from livekit import rtc
|
|
25
|
+
|
|
26
|
+
from .client import Client
|
|
27
|
+
|
|
28
|
+
__all__ = ["VoiceConnection", "load_rtc"]
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("zynko.voice")
|
|
31
|
+
|
|
32
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
33
|
+
|
|
34
|
+
_rtc: Any = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_rtc() -> Any:
|
|
38
|
+
"""The ``livekit.rtc`` module, or a clear error when it is not installed."""
|
|
39
|
+
global _rtc
|
|
40
|
+
if _rtc is None:
|
|
41
|
+
try:
|
|
42
|
+
from livekit import rtc
|
|
43
|
+
except ImportError:
|
|
44
|
+
raise ImportError('A voz precisa do pacote livekit: pip install "zynko[voice]"') from None
|
|
45
|
+
_rtc = rtc
|
|
46
|
+
return _rtc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
Status = Literal["connecting", "connected", "reconnecting", "closed"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class VoiceConnection:
|
|
53
|
+
"""The bot in one voice room.
|
|
54
|
+
|
|
55
|
+
Events: ``ready()`` (connected, again after a server move), ``speaking(user_ids)``,
|
|
56
|
+
``audio(user_id, samples, channels)`` with ``receive=True``, ``disconnect(reason)`` (out
|
|
57
|
+
of the room for good) and ``error(error)``.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
client: Client,
|
|
63
|
+
channel_id: str,
|
|
64
|
+
*,
|
|
65
|
+
receive: bool = False,
|
|
66
|
+
relay: bool = False,
|
|
67
|
+
deaf: bool = False,
|
|
68
|
+
ffmpeg: str | None = None,
|
|
69
|
+
ytdlp: bool | str = False,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.client = client
|
|
72
|
+
self.channel_id = channel_id
|
|
73
|
+
self.receive = receive
|
|
74
|
+
self.relay = relay
|
|
75
|
+
self.deaf = deaf
|
|
76
|
+
self.status: Status = "connecting"
|
|
77
|
+
#: The voice server the room is on (Brasil 1, Brasil 2...).
|
|
78
|
+
self.server: str | None = None
|
|
79
|
+
#: Can speak here: a room's roles may let the bot only listen.
|
|
80
|
+
self.can_speak = True
|
|
81
|
+
self.player: AudioPlayer[Any] = AudioPlayer(self, ffmpeg=ffmpeg, ytdlp=ytdlp)
|
|
82
|
+
self._room: rtc.Room | None = None
|
|
83
|
+
self._source: rtc.AudioSource | None = None
|
|
84
|
+
self._events = Emitter("zynko.voice")
|
|
85
|
+
self._leaving = False
|
|
86
|
+
self._in_room = False
|
|
87
|
+
self._moving: asyncio.Task[None] | None = None
|
|
88
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str:
|
|
91
|
+
return f"<VoiceConnection channel_id={self.channel_id} status={self.status} server={self.server!r}>"
|
|
92
|
+
|
|
93
|
+
# ---------- events ----------
|
|
94
|
+
|
|
95
|
+
@overload
|
|
96
|
+
def on(self, event: str) -> Callable[[F], F]: ...
|
|
97
|
+
@overload
|
|
98
|
+
def on(self, event: str, handler: F) -> F: ...
|
|
99
|
+
def on(self, event: str, handler: F | None = None) -> Any:
|
|
100
|
+
return self._events.on(event, handler) if handler is not None else self._events.on(event)
|
|
101
|
+
|
|
102
|
+
def off(self, event: str, handler: Callable[..., Any]) -> None:
|
|
103
|
+
self._events.off(event, handler)
|
|
104
|
+
|
|
105
|
+
async def wait_for(self, event: str, check: Callable[..., bool] | None = None, timeout: float | None = 30.0) -> Any:
|
|
106
|
+
return await self._events.wait_for(event, check, timeout)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def channel(self) -> Channel | None:
|
|
110
|
+
"""The room's channel."""
|
|
111
|
+
return self.client.channels.get(self.channel_id)
|
|
112
|
+
|
|
113
|
+
# ---------- connecting ----------
|
|
114
|
+
|
|
115
|
+
async def connect(self) -> None:
|
|
116
|
+
"""Connects to the room's media and tells the guild the bot is there."""
|
|
117
|
+
lk = load_rtc()
|
|
118
|
+
join = await self.client.rest.voice_token(self.channel_id)
|
|
119
|
+
self.server = join.get("server")
|
|
120
|
+
self.can_speak = join.get("can_speak") is not False
|
|
121
|
+
room = lk.Room()
|
|
122
|
+
self._room = room
|
|
123
|
+
|
|
124
|
+
def _disconnected(reason: Any) -> None:
|
|
125
|
+
if self._room is not room or self._leaving:
|
|
126
|
+
return
|
|
127
|
+
# The room closed under us: a server move is followed through CHANNEL_UPDATE;
|
|
128
|
+
# anything else (removed, room deleted) ends the connection.
|
|
129
|
+
if self._moving is not None:
|
|
130
|
+
return
|
|
131
|
+
try:
|
|
132
|
+
name = lk.DisconnectReason.Name(reason)
|
|
133
|
+
except (ValueError, TypeError):
|
|
134
|
+
name = str(reason)
|
|
135
|
+
self._spawn(self._end(f"desconectado ({name})"))
|
|
136
|
+
|
|
137
|
+
def _speakers(speakers: list[Any]) -> None:
|
|
138
|
+
self._events.emit("speaking", [p.identity for p in speakers])
|
|
139
|
+
|
|
140
|
+
def _subscribed(track: Any, _pub: Any, participant: Any) -> None:
|
|
141
|
+
if track.kind != lk.TrackKind.KIND_AUDIO or not self.receive:
|
|
142
|
+
return
|
|
143
|
+
self._spawn(self._hear(lk, track, participant.identity))
|
|
144
|
+
|
|
145
|
+
room.on("disconnected", _disconnected)
|
|
146
|
+
room.on("active_speakers_changed", _speakers)
|
|
147
|
+
room.on("track_subscribed", _subscribed)
|
|
148
|
+
|
|
149
|
+
rtc_config = None
|
|
150
|
+
if self.relay:
|
|
151
|
+
rtc_config = lk.RtcConfiguration(
|
|
152
|
+
ice_transport_type=lk.IceTransportType.TRANSPORT_RELAY,
|
|
153
|
+
continual_gathering_policy=lk.ContinualGatheringPolicy.GATHER_CONTINUALLY,
|
|
154
|
+
ice_servers=[],
|
|
155
|
+
)
|
|
156
|
+
options = lk.RoomOptions(auto_subscribe=self.receive, dynacast=False, rtc_config=rtc_config)
|
|
157
|
+
await room.connect(join["url"], join["token"], options)
|
|
158
|
+
if self._room is not room:
|
|
159
|
+
await room.disconnect()
|
|
160
|
+
return
|
|
161
|
+
if self.can_speak:
|
|
162
|
+
source = lk.AudioSource(SAMPLE_RATE, CHANNELS, queue_size_ms=200)
|
|
163
|
+
track = lk.LocalAudioTrack.create_audio_track("zynko", source)
|
|
164
|
+
opts = lk.TrackPublishOptions(source=lk.TrackSource.SOURCE_MICROPHONE, dtx=False, red=False)
|
|
165
|
+
opts.audio_encoding.max_bitrate = 128_000
|
|
166
|
+
await room.local_participant.publish_track(track, opts)
|
|
167
|
+
self._source = source
|
|
168
|
+
await self._announce()
|
|
169
|
+
self.status = "connected"
|
|
170
|
+
self._events.emit("ready")
|
|
171
|
+
|
|
172
|
+
async def leave(self) -> None:
|
|
173
|
+
"""Leaves the room."""
|
|
174
|
+
if self.status == "closed":
|
|
175
|
+
return
|
|
176
|
+
self._leaving = True
|
|
177
|
+
self.player.stop()
|
|
178
|
+
await self.client.send_op(4, {"channel_id": None, "self_mute": False, "self_deaf": False})
|
|
179
|
+
await self._end("saiu")
|
|
180
|
+
|
|
181
|
+
# ---------- FrameSink, for the player ----------
|
|
182
|
+
|
|
183
|
+
async def capture(self, frame: bytes) -> None:
|
|
184
|
+
lk = _rtc
|
|
185
|
+
if lk is None:
|
|
186
|
+
return
|
|
187
|
+
# Moving to another server: wait for the new connection, then go on where it was.
|
|
188
|
+
moving = self._moving
|
|
189
|
+
if moving is not None:
|
|
190
|
+
try:
|
|
191
|
+
await asyncio.shield(moving)
|
|
192
|
+
except Exception: # noqa: BLE001
|
|
193
|
+
pass
|
|
194
|
+
source = self._source
|
|
195
|
+
if source is None or self.status == "closed":
|
|
196
|
+
return
|
|
197
|
+
await source.capture_frame(lk.AudioFrame(frame, SAMPLE_RATE, CHANNELS, FRAME_SAMPLES))
|
|
198
|
+
|
|
199
|
+
def flush(self) -> None:
|
|
200
|
+
if self._source is not None:
|
|
201
|
+
self._source.clear_queue()
|
|
202
|
+
|
|
203
|
+
# ---------- from the client ----------
|
|
204
|
+
|
|
205
|
+
def _on_channel_update(self, c: Channel) -> None:
|
|
206
|
+
"""The room moved to another voice server (someone picked Brasil 1 or 2): follow it."""
|
|
207
|
+
if c.id != self.channel_id or self.status != "connected" or not c.voice_server or c.voice_server == self.server:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
async def move() -> None:
|
|
211
|
+
self.status = "reconnecting"
|
|
212
|
+
old, old_source = self._room, self._source
|
|
213
|
+
self._room = None
|
|
214
|
+
self._source = None
|
|
215
|
+
try:
|
|
216
|
+
if old is not None:
|
|
217
|
+
await old.disconnect()
|
|
218
|
+
if old_source is not None:
|
|
219
|
+
await old_source.aclose()
|
|
220
|
+
except Exception: # noqa: BLE001
|
|
221
|
+
log.debug("closing the old room failed", exc_info=True)
|
|
222
|
+
try:
|
|
223
|
+
await self.connect()
|
|
224
|
+
except Exception as e: # noqa: BLE001
|
|
225
|
+
await self._end("não conseguiu entrar no servidor novo")
|
|
226
|
+
self._events.emit("error", e)
|
|
227
|
+
finally:
|
|
228
|
+
self._moving = None
|
|
229
|
+
|
|
230
|
+
self._moving = asyncio.ensure_future(move())
|
|
231
|
+
|
|
232
|
+
def _on_own_state(self, v: VoiceState) -> None:
|
|
233
|
+
"""The server says where the bot is in voice: elsewhere, or nowhere, ends this connection."""
|
|
234
|
+
if self.status == "closed" or self._leaving:
|
|
235
|
+
return
|
|
236
|
+
if v.channel_id == self.channel_id:
|
|
237
|
+
self._in_room = True
|
|
238
|
+
return
|
|
239
|
+
# Moving from another room, its "left" state can arrive after this one began: only a
|
|
240
|
+
# state that comes once the server has the bot here means it was moved or removed.
|
|
241
|
+
if not self._in_room:
|
|
242
|
+
return
|
|
243
|
+
self._spawn(self._end("entrou em outra sala" if v.channel_id else "removido da sala"))
|
|
244
|
+
|
|
245
|
+
def _resync(self) -> None:
|
|
246
|
+
"""After the gateway reconnects: say again where the bot is."""
|
|
247
|
+
if self.status in ("connected", "reconnecting"):
|
|
248
|
+
self._spawn(self._announce())
|
|
249
|
+
|
|
250
|
+
async def _announce(self) -> None:
|
|
251
|
+
await self.client.send_op(
|
|
252
|
+
4, {"channel_id": self.channel_id, "self_mute": not self.can_speak, "self_deaf": self.deaf}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _spawn(self, coro: Any) -> None:
|
|
256
|
+
task = asyncio.ensure_future(coro)
|
|
257
|
+
self._tasks.add(task)
|
|
258
|
+
task.add_done_callback(self._tasks.discard)
|
|
259
|
+
|
|
260
|
+
async def _hear(self, lk: Any, track: Any, user_id: str) -> None:
|
|
261
|
+
stream = lk.AudioStream(track, sample_rate=SAMPLE_RATE, num_channels=1)
|
|
262
|
+
try:
|
|
263
|
+
async for event in stream:
|
|
264
|
+
if self.status == "closed":
|
|
265
|
+
break
|
|
266
|
+
frame = event.frame
|
|
267
|
+
samples = array("h")
|
|
268
|
+
samples.frombytes(bytes(frame.data.cast("B")))
|
|
269
|
+
self._events.emit("audio", user_id, samples, frame.num_channels)
|
|
270
|
+
except Exception: # noqa: BLE001 - the track went away
|
|
271
|
+
pass
|
|
272
|
+
finally:
|
|
273
|
+
try:
|
|
274
|
+
await stream.aclose()
|
|
275
|
+
except Exception: # noqa: BLE001
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
async def _end(self, reason: str) -> None:
|
|
279
|
+
if self.status == "closed":
|
|
280
|
+
return
|
|
281
|
+
self.status = "closed"
|
|
282
|
+
self.player.stop()
|
|
283
|
+
room, source = self._room, self._source
|
|
284
|
+
self._room = None
|
|
285
|
+
self._source = None
|
|
286
|
+
try:
|
|
287
|
+
if room is not None:
|
|
288
|
+
await room.disconnect()
|
|
289
|
+
except Exception: # noqa: BLE001
|
|
290
|
+
pass
|
|
291
|
+
try:
|
|
292
|
+
if source is not None:
|
|
293
|
+
await source.aclose()
|
|
294
|
+
except Exception: # noqa: BLE001
|
|
295
|
+
pass
|
|
296
|
+
if self.client.voice is self:
|
|
297
|
+
self.client.voice = None
|
|
298
|
+
self._events.emit("disconnect", reason)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zynko
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Biblioteca oficial para bots do Zynko: API, gateway e voz.
|
|
5
|
+
Project-URL: Documentation, https://docs.zynko.org/bots/python
|
|
6
|
+
Project-URL: Homepage, https://zynko.org
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: asyncio,bot,chat,voice,zynko
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: aiohttp<4,>=3.11
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
25
|
+
Provides-Extra: voice
|
|
26
|
+
Requires-Dist: livekit==1.2.1; extra == 'voice'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# zynko
|
|
30
|
+
|
|
31
|
+
A biblioteca oficial para bots do [Zynko](https://zynko.org) em Python: gateway com
|
|
32
|
+
reconexão, cache de servidores, canais e membros, a API inteira, comandos com "/", cartões e
|
|
33
|
+
botões, e voz (tocar e ouvir áudio nas salas). Feita com asyncio.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install zynko
|
|
37
|
+
pip install "zynko[voice]" # com voz
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import os
|
|
42
|
+
import zynko
|
|
43
|
+
|
|
44
|
+
client = zynko.Client(intents=zynko.Intents.GUILDS | zynko.Intents.MESSAGES)
|
|
45
|
+
|
|
46
|
+
@client.on("message_create")
|
|
47
|
+
async def ping(msg: zynko.Message):
|
|
48
|
+
if not msg.mine and msg.content == "!ping":
|
|
49
|
+
await msg.reply("pong")
|
|
50
|
+
|
|
51
|
+
client.run(os.environ["ZYNKO_TOKEN"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Voz:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
voice = await client.join_voice(channel_id)
|
|
58
|
+
voice.player.enqueue("https://exemplo.com/musica.mp3")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Python 3.10 ou mais novo. Para tocar arquivos e links, o ffmpeg precisa estar instalado.
|
|
62
|
+
Exemplos em [`examples/`](examples): `ping.py`, `dado.py` (comandos, cartão e botões) e
|
|
63
|
+
`musica.py`.
|
|
64
|
+
|
|
65
|
+
Documentação: https://docs.zynko.org/bots/python · Crie seu bot: https://dev.zynko.org
|
|
66
|
+
|
|
67
|
+
Licença MIT.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
zynko/__init__.py,sha256=u1eqWLAiodi9-Z4fDM14HmCnR_tzo5hwOi6vahtxw4g,1474
|
|
2
|
+
zynko/_events.py,sha256=jt3iF3UjmXcYsaZIK03XCzlq4ITtwJj2rlIu48nRQAI,4394
|
|
3
|
+
zynko/bits.py,sha256=f40UjC1Mr822XEbYFprDkXzetQavxtcTj5hjrqbpNuA,1697
|
|
4
|
+
zynko/client.py,sha256=yTYMS_4PuBHCRO5-O8tjekL4Vk_W1EGgW4030Xb4i9w,18812
|
|
5
|
+
zynko/gateway.py,sha256=gHR6Cb183QE8LJa_PlKpLoPgwV51J4wyPHebnuy5Dz0,6023
|
|
6
|
+
zynko/interaction.py,sha256=EWTAtURFAZGWDVJimuPnsXqkJTMDeem14w1xgJtSvts,4226
|
|
7
|
+
zynko/message.py,sha256=PaP1ljTfXWj1doe-lK7Q6idTCPGeGCWrG_n11PmbeX0,4664
|
|
8
|
+
zynko/models.py,sha256=irLu527L9Sl645Fw2TB5Cx3Y3TYVwJigqRlV99pDtwE,9957
|
|
9
|
+
zynko/permissions.py,sha256=ZnKFbL8T_e3N38DdR5MCRbqoDOJT-g57bnCzNxuaWZg,1861
|
|
10
|
+
zynko/player.py,sha256=SeJac7maXeoqVELtNln1xbYsh8ospSETTM3crBgeolw,14133
|
|
11
|
+
zynko/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
zynko/rest.py,sha256=q1f4myUCGABRLF6uBHI6743YhWuqO5tf8uZ1kXbHbyw,12246
|
|
13
|
+
zynko/voice.py,sha256=oMUqxUwhJQpnDKCEPJfnh2Kap5IPaxtbbbDhG2lsf5s,11090
|
|
14
|
+
zynko-0.1.0.dist-info/METADATA,sha256=ehNAxuYlxkHsqiKpusf_bIlnZWd5lslO2Z56tHIY0pU,2103
|
|
15
|
+
zynko-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
16
|
+
zynko-0.1.0.dist-info/RECORD,,
|