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/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,,