riscord-bot 0.2.1__tar.gz

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.
@@ -0,0 +1,2 @@
1
+ Riscord SDK
2
+ All rights reserved.
@@ -0,0 +1,4 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include examples *.py
4
+ include src/riscord/py.typed
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: riscord-bot
3
+ Version: 0.2.1
4
+ Summary: Official async Riscord bot client
5
+ License: All rights reserved.
6
+ Project-URL: Documentation, https://riscord.ir/developers/docs
7
+ Project-URL: Homepage, https://riscord.ir
8
+ Classifier: License :: Other/Proprietary License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Framework :: AsyncIO
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: python-socketio[asyncio_client]<6,>=5.11
15
+ Requires-Dist: aiohttp<4,>=3.10
16
+ Dynamic: license-file
17
+
18
+ # Riscord bot SDK — Python
19
+
20
+ Requires Python 3.11 or newer. Install with `python -m pip install riscord-bot`. Versioned source archives remain available from [Developer Docs](https://riscord.ir/developers/docs); install a downloaded archive with `python -m pip install ./riscord-bot-python-0.2.1.tar.gz`.
21
+
22
+ Create and install a bot through `https://riscord.ir/developers`, request View Channel and Send Messages, generate its token and set the `RISCORD_BOT_TOKEN` environment variable. Never commit the token.
23
+
24
+ ```python
25
+ import asyncio, os
26
+ from riscord import Client
27
+
28
+ bot = Client(os.environ['RISCORD_BOT_TOKEN'])
29
+
30
+ @bot.command('hello')
31
+ async def hello(interaction):
32
+ await interaction.reply('Hello!', private=True)
33
+
34
+ async def main():
35
+ await bot.register_commands([{'name':'hello','description':'Say hello','contexts':['server','dm']}])
36
+ try:
37
+ await bot.run()
38
+ finally:
39
+ await bot.close()
40
+
41
+ asyncio.run(main())
42
+ ```
43
+
44
+ Defer slow work with `await interaction.defer(private=True)` before replying; later replies retain that audience. `show_modal({customId,title,fields})` opens a form. Use `@bot.on('interaction')` for buttons, selects and submitted forms and inspect `interaction.kind` and `interaction.data`.
45
+
46
+ `send(channel_id,content,components=...)`, `open_dm(user_id)`, `send_dm(conversation_id,content)`, `moderate(server_id,user_id,action,...)`, `history`, `edit` and `delete` expose the corresponding API operations. For message options, `client_id` is a Python keyword argument; component/command JSON follows the documented camelCase wire schema. IDs remain strings.
47
+
48
+ Only registered text commands, mentions and the bot's DMs are delivered without broader Message Content permission. SDKs ignore automated messages, reconnect with bounded replay and honor retry delays. Deduplication is bounded and memory-only; persist operation IDs for external effects that must survive restarts.
49
+
50
+ For voice playback, request View Channel, Connect and Speak when installing the bot, and allow them in the target server voice channel. Install a separate FFmpeg executable with the `libopus` encoder and SRTP support and add it to `PATH`; the SDK does not download an encoder. Chat and interactions do not need FFmpeg.
51
+
52
+ ```python
53
+ await bot.connect()
54
+ voice = await bot.join_voice('VOICE_CHANNEL_ID')
55
+ try:
56
+ await voice.play('./music.ogg')
57
+ await voice.play('https://example.com/direct-audio.ogg')
58
+ finally:
59
+ await voice.leave()
60
+ ```
61
+
62
+ `join_voice(channel_id)` requires a connected bot gateway and returns a `VoiceConnection`. `play(source, ffmpeg='ffmpeg')` accepts a trusted local audio file or direct HTTP(S) audio URL and waits until playback finishes. A new `play` replaces the previous track; call it in an `asyncio` task if your handler must keep accepting other work. `stop()` ends playback while keeping the bot in the channel; `leave()` releases the connection. Cancelling the playback task also stops its encoder. `playing`, `closed`, `channel_id` and `server_id` expose connection state.
63
+
64
+ Voice is send-only and self-deafened; the SDK never receives or records channel audio. Each playback gets fresh Opus/SRTP media parameters and stays within the server's audio bitrate limit. The SDK renews the lease every ten seconds and stops playback on a lost lease, gateway disconnect or `bot.close()`. Reconnecting the gateway does not rejoin voice automatically. One client owns one voice channel at a time, and stale playback cleanup cannot stop its replacement.
65
+
66
+ Use sources you control. FFmpeg fetches URLs from the bot's machine, so do not pass arbitrary member-provided paths or URLs to `play`. Website pages that require a media extractor are not direct audio URLs. Encoder errors omit raw process output because it can contain private source URLs or transport keys. Set `RISCORD_VOICE_CHANNEL_ID` and `RISCORD_AUDIO_SOURCE` alongside the bot token, then run `examples/music.py` to play a configured source and leave when it finishes.
67
+
68
+ The public [Developer Docs](https://riscord.ir/developers/docs) cover the API contract, limits and migration guidance.
@@ -0,0 +1,51 @@
1
+ # Riscord bot SDK — Python
2
+
3
+ Requires Python 3.11 or newer. Install with `python -m pip install riscord-bot`. Versioned source archives remain available from [Developer Docs](https://riscord.ir/developers/docs); install a downloaded archive with `python -m pip install ./riscord-bot-python-0.2.1.tar.gz`.
4
+
5
+ Create and install a bot through `https://riscord.ir/developers`, request View Channel and Send Messages, generate its token and set the `RISCORD_BOT_TOKEN` environment variable. Never commit the token.
6
+
7
+ ```python
8
+ import asyncio, os
9
+ from riscord import Client
10
+
11
+ bot = Client(os.environ['RISCORD_BOT_TOKEN'])
12
+
13
+ @bot.command('hello')
14
+ async def hello(interaction):
15
+ await interaction.reply('Hello!', private=True)
16
+
17
+ async def main():
18
+ await bot.register_commands([{'name':'hello','description':'Say hello','contexts':['server','dm']}])
19
+ try:
20
+ await bot.run()
21
+ finally:
22
+ await bot.close()
23
+
24
+ asyncio.run(main())
25
+ ```
26
+
27
+ Defer slow work with `await interaction.defer(private=True)` before replying; later replies retain that audience. `show_modal({customId,title,fields})` opens a form. Use `@bot.on('interaction')` for buttons, selects and submitted forms and inspect `interaction.kind` and `interaction.data`.
28
+
29
+ `send(channel_id,content,components=...)`, `open_dm(user_id)`, `send_dm(conversation_id,content)`, `moderate(server_id,user_id,action,...)`, `history`, `edit` and `delete` expose the corresponding API operations. For message options, `client_id` is a Python keyword argument; component/command JSON follows the documented camelCase wire schema. IDs remain strings.
30
+
31
+ Only registered text commands, mentions and the bot's DMs are delivered without broader Message Content permission. SDKs ignore automated messages, reconnect with bounded replay and honor retry delays. Deduplication is bounded and memory-only; persist operation IDs for external effects that must survive restarts.
32
+
33
+ For voice playback, request View Channel, Connect and Speak when installing the bot, and allow them in the target server voice channel. Install a separate FFmpeg executable with the `libopus` encoder and SRTP support and add it to `PATH`; the SDK does not download an encoder. Chat and interactions do not need FFmpeg.
34
+
35
+ ```python
36
+ await bot.connect()
37
+ voice = await bot.join_voice('VOICE_CHANNEL_ID')
38
+ try:
39
+ await voice.play('./music.ogg')
40
+ await voice.play('https://example.com/direct-audio.ogg')
41
+ finally:
42
+ await voice.leave()
43
+ ```
44
+
45
+ `join_voice(channel_id)` requires a connected bot gateway and returns a `VoiceConnection`. `play(source, ffmpeg='ffmpeg')` accepts a trusted local audio file or direct HTTP(S) audio URL and waits until playback finishes. A new `play` replaces the previous track; call it in an `asyncio` task if your handler must keep accepting other work. `stop()` ends playback while keeping the bot in the channel; `leave()` releases the connection. Cancelling the playback task also stops its encoder. `playing`, `closed`, `channel_id` and `server_id` expose connection state.
46
+
47
+ Voice is send-only and self-deafened; the SDK never receives or records channel audio. Each playback gets fresh Opus/SRTP media parameters and stays within the server's audio bitrate limit. The SDK renews the lease every ten seconds and stops playback on a lost lease, gateway disconnect or `bot.close()`. Reconnecting the gateway does not rejoin voice automatically. One client owns one voice channel at a time, and stale playback cleanup cannot stop its replacement.
48
+
49
+ Use sources you control. FFmpeg fetches URLs from the bot's machine, so do not pass arbitrary member-provided paths or URLs to `play`. Website pages that require a media extractor are not direct audio URLs. Encoder errors omit raw process output because it can contain private source URLs or transport keys. Set `RISCORD_VOICE_CHANNEL_ID` and `RISCORD_AUDIO_SOURCE` alongside the bot token, then run `examples/music.py` to play a configured source and leave when it finishes.
50
+
51
+ The public [Developer Docs](https://riscord.ir/developers/docs) cover the API contract, limits and migration guidance.
@@ -0,0 +1,25 @@
1
+ import asyncio
2
+ import os
3
+ from riscord import Client
4
+
5
+ bot = Client(os.environ["RISCORD_BOT_TOKEN"])
6
+
7
+ @bot.command("feedback")
8
+ async def feedback(i):
9
+ await i.reply("Got a suggestion?", client_id=f"feedback-{i.id}", components=[[{"type": "button", "label": "Write feedback", "customId": "feedback", "userId": i.user_id}]])
10
+
11
+ @bot.on("interaction")
12
+ async def controls(i):
13
+ if i.kind == "component" and i.data.get("customId") == "feedback":
14
+ await i.show_modal({"customId": "feedback-form", "title": "Your feedback", "fields": [{"name": "message", "label": "What could be better?", "type": "paragraph", "required": True, "maxLength": 1000}]})
15
+ elif i.kind == "form" and i.data.get("customId") == "feedback-form":
16
+ await i.reply("Thanks for sharing your feedback.", private=True, client_id=f"thanks-{i.id}")
17
+
18
+ async def main():
19
+ await bot.register_commands([{"name": "feedback", "description": "Leave feedback", "contexts": ["server", "dm"]}])
20
+ try:
21
+ await bot.run()
22
+ finally:
23
+ await bot.close()
24
+
25
+ asyncio.run(main())
@@ -0,0 +1,18 @@
1
+ import asyncio
2
+ import os
3
+ from riscord import Client
4
+
5
+ client = Client(os.environ["RISCORD_BOT_TOKEN"])
6
+
7
+ @client.command("hello")
8
+ async def hello(interaction):
9
+ await interaction.reply("Hello! This reply is just for you.", private=True)
10
+
11
+ async def main():
12
+ await client.register_commands([{"name": "hello", "description": "Say hello", "contexts": ["server", "dm"]}])
13
+ try:
14
+ await client.run()
15
+ finally:
16
+ await client.close()
17
+
18
+ asyncio.run(main())
@@ -0,0 +1,30 @@
1
+ import asyncio
2
+ import os
3
+ from riscord import Client
4
+
5
+ bot = Client(os.environ["RISCORD_BOT_TOKEN"])
6
+
7
+ @bot.command("hello")
8
+ async def hello(i):
9
+ await i.reply("Hello!", private=True)
10
+
11
+ @bot.on("message.create")
12
+ async def message(data):
13
+ if data.get("channelId") and (data.get("content") or "").strip() == "!hi":
14
+ await bot.send(data["channelId"], "Hello!", client_id=f"hello-{data['id']}")
15
+
16
+ @bot.command("sendme")
17
+ async def sendme(i):
18
+ await i.defer(private=True)
19
+ conversation = await bot.open_dm(i.user_id)
20
+ await bot.send_dm(conversation["id"], "You asked me to send this.", client_id=f"requested-{i.id}")
21
+ await i.reply("Sent. Check your direct messages.", client_id=f"sent-{i.id}")
22
+
23
+ async def main():
24
+ await bot.register_commands([{"name": "hello", "description": "Say hello", "aliases": ["hi"]}, {"name": "sendme", "description": "Send me a direct message"}])
25
+ try:
26
+ await bot.run()
27
+ finally:
28
+ await bot.close()
29
+
30
+ asyncio.run(main())
@@ -0,0 +1,20 @@
1
+ import asyncio
2
+ import os
3
+ from riscord import Client
4
+
5
+ bot = Client(os.environ["RISCORD_BOT_TOKEN"])
6
+
7
+ @bot.command("quiet")
8
+ async def quiet(i):
9
+ await i.defer(private=True)
10
+ await bot.moderate(i.server_id, i.data["values"]["member"], "timeout", durationMinutes=10)
11
+ await i.reply("The member has been timed out.", client_id=f"quiet-{i.id}")
12
+
13
+ async def main():
14
+ await bot.register_commands([{"name": "quiet", "description": "Time out a lower member for ten minutes", "permissions": ["MODERATE_MEMBERS"], "options": [{"name": "member", "description": "Member to time out", "type": "user", "required": True}]}])
15
+ try:
16
+ await bot.run()
17
+ finally:
18
+ await bot.close()
19
+
20
+ asyncio.run(main())
@@ -0,0 +1,23 @@
1
+ import asyncio
2
+ import os
3
+ from riscord import Client
4
+
5
+ bot = Client(os.environ["RISCORD_BOT_TOKEN"])
6
+ # Keep the source and voice channel under your control; do not accept arbitrary paths or URLs from chat.
7
+ channel_id = os.environ["RISCORD_VOICE_CHANNEL_ID"]
8
+ source = os.environ["RISCORD_AUDIO_SOURCE"]
9
+
10
+
11
+ async def main():
12
+ try:
13
+ await bot.connect()
14
+ voice = await bot.join_voice(channel_id)
15
+ try:
16
+ await voice.play(source)
17
+ finally:
18
+ await voice.leave()
19
+ finally:
20
+ await bot.close()
21
+
22
+
23
+ asyncio.run(main())
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "riscord-bot"
7
+ version = "0.2.1"
8
+ description = "Official async Riscord bot client"
9
+ readme = "README.md"
10
+ license = { text = "All rights reserved." }
11
+ classifiers = ["License :: Other/Proprietary License", "Programming Language :: Python :: 3", "Framework :: AsyncIO"]
12
+ requires-python = ">=3.11"
13
+ dependencies = ["python-socketio[asyncio_client]>=5.11,<6", "aiohttp>=3.10,<4"]
14
+
15
+ [project.urls]
16
+ Documentation = "https://riscord.ir/developers/docs"
17
+ Homepage = "https://riscord.ir"
18
+
19
+ [tool.setuptools]
20
+ include-package-data = false
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.setuptools.package-data]
26
+ riscord = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ from .client import Client, Interaction, RiscordError
2
+ from .voice import VoiceConnection
3
+
4
+ from .models import Command, Option, Components, Modal, Message
5
+
6
+ __all__ = ["Client", "Interaction", "RiscordError", "VoiceConnection", "Command", "Option", "Components", "Modal", "Message"]
@@ -0,0 +1,279 @@
1
+ import asyncio
2
+ import inspect
3
+ import random
4
+ import uuid
5
+ from collections import OrderedDict
6
+ from typing import TYPE_CHECKING
7
+ from urllib.parse import urlsplit
8
+ from .models import Command, Modal
9
+
10
+ if TYPE_CHECKING:
11
+ from .voice import VoiceConnection
12
+
13
+ import aiohttp
14
+ import socketio
15
+
16
+
17
+ class RiscordError(Exception):
18
+ def __init__(self, message: str, status: int = 0):
19
+ super().__init__(message)
20
+ self.status = status
21
+
22
+
23
+ class Interaction:
24
+ def __init__(self, client: "Client", data: dict):
25
+ self.client = client
26
+ self.id: str = data["id"]
27
+ self.kind: str = data["kind"]
28
+ self.user_id: str = data["userId"]
29
+ self.channel_id: str | None = data.get("channelId")
30
+ self.conversation_id: str | None = data.get("conversationId")
31
+ self.server_id: str | None = data.get("serverId")
32
+ self.data: dict = data["data"]
33
+ self._private = bool(data.get("private", False))
34
+ self._reply_index = 0
35
+
36
+ async def reply(self, content: str, *, private: bool | None = None, client_id: str | None = None, **options) -> dict:
37
+ audience = self._private if private is None else private
38
+ key = client_id or f"{self.id}-reply-{self._reply_index}"
39
+ self._reply_index += 1
40
+ result = await self.client.request(f"/interactions/{self.id}/respond", "POST", {"type": "message", "content": content, **options, "private": audience, "clientId": key})
41
+ self._private = audience
42
+ return result
43
+
44
+ async def defer(self, *, private: bool | None = None) -> dict:
45
+ audience = self._private if private is None else private
46
+ result = await self.client.request(f"/interactions/{self.id}/respond", "POST", {"type": "defer", "private": audience})
47
+ self._private = audience
48
+ return result
49
+
50
+ async def show_modal(self, modal: Modal) -> dict:
51
+ return await self.client.request(f"/interactions/{self.id}/respond", "POST", {"type": "modal", "modal": modal, "private": True})
52
+
53
+
54
+ class Client:
55
+ def __init__(self, token: str, *, base_url: str = "https://riscord.ir", on_error=None):
56
+ if not token or not token.startswith("rb_"):
57
+ raise RiscordError("A valid bot token is required")
58
+ parts = urlsplit(base_url)
59
+ if parts.scheme not in ("http", "https") or not parts.netloc:
60
+ raise RiscordError("Invalid Riscord base URL")
61
+ self.base_url = f"{parts.scheme}://{parts.netloc}"
62
+ self._token = token
63
+ self.on_error = on_error or (lambda error: print(str(error)))
64
+ self.user_id = None
65
+ self._cursor = "0"
66
+ self._seen = OrderedDict()
67
+ self._handlers = {}
68
+ self._commands = {}
69
+ self._http = None
70
+ self._socket = socketio.AsyncClient(reconnection=True, reconnection_delay=1, reconnection_delay_max=30, randomization_factor=0.5)
71
+ self._lock = asyncio.Lock()
72
+ self._queued = set()
73
+ self._epoch = 0
74
+ self._closed = False
75
+ self._voice = None
76
+ self._voice_lock = asyncio.Lock()
77
+ self._voice_epoch = 0
78
+ self._socket.on("ready", self._ready, namespace="/bots")
79
+ self._socket.on("dispatch", self._dispatch, namespace="/bots")
80
+ self._socket.on("connect_error", self._connect_error, namespace="/bots")
81
+ self._socket.on("disconnect", self._disconnect, namespace="/bots")
82
+
83
+ def on(self, event: str):
84
+ def register(handler):
85
+ self._handlers.setdefault(event, []).append(handler)
86
+ return handler
87
+ return register
88
+
89
+ def command(self, name: str):
90
+ def register(handler):
91
+ self._commands[name] = handler
92
+ return handler
93
+ return register
94
+
95
+ async def _call(self, handler, data):
96
+ result = handler(data)
97
+ if inspect.isawaitable(result):
98
+ await result
99
+
100
+ async def _emit(self, event, data):
101
+ for handler in self._handlers.get(event, []):
102
+ await self._call(handler, data)
103
+
104
+ async def _connect_error(self, _data):
105
+ await self._call(self.on_error, RiscordError("Could not connect to the bot gateway"))
106
+
107
+ async def _disconnect(self, _reason=None):
108
+ self._voice_epoch += 1
109
+ voice = self._voice
110
+ if voice:
111
+ try:
112
+ await voice.leave()
113
+ except Exception:
114
+ await self._call(self.on_error, RiscordError("Voice connection closed after the gateway disconnected"))
115
+
116
+ async def _ready(self, data):
117
+ self.user_id = data["userId"]
118
+ if data.get("resyncRequired"):
119
+ self._cursor = "0"
120
+ await self._emit("ready", data)
121
+
122
+ async def _dispatch(self, envelope):
123
+ event_id = envelope.get("id")
124
+ if not isinstance(event_id, str) or event_id in self._queued or len(self._queued) >= 100:
125
+ return
126
+ self._queued.add(event_id)
127
+ epoch = self._epoch
128
+ try:
129
+ async with self._lock:
130
+ if epoch != self._epoch:
131
+ return
132
+ if event_id not in self._seen:
133
+ event, data = envelope["event"], envelope["data"]
134
+ if event == "interaction.create":
135
+ interaction = Interaction(self, data)
136
+ handler = self._commands.get(interaction.data.get("name")) if interaction.kind == "command" else None
137
+ if handler:
138
+ await self._call(handler, interaction)
139
+ await self._emit("interaction", interaction)
140
+ elif event != "checkpoint":
141
+ automated = data.get("senderKind") in ("bot", "webhook") or data.get("authorId") == self.user_id or data.get("senderId") == self.user_id
142
+ if event != "message.create" or not automated:
143
+ await self._emit(event, data)
144
+ self._seen[event_id] = None
145
+ if len(self._seen) > 5000:
146
+ self._seen.popitem(last=False)
147
+ self._cursor = event_id
148
+ await self._socket.emit("ack", {"id": event_id}, namespace="/bots")
149
+ except Exception as error:
150
+ if epoch == self._epoch:
151
+ await self._call(self.on_error, error)
152
+ self._seen[event_id] = None
153
+ if len(self._seen) > 5000:
154
+ self._seen.popitem(last=False)
155
+ self._cursor = event_id
156
+ if self._socket.connected:
157
+ await self._socket.emit("ack", {"id": event_id, "failed": True}, namespace="/bots")
158
+ finally:
159
+ self._queued.discard(event_id)
160
+
161
+ async def request(self, path: str, method: str = "GET", body=None):
162
+ if not path.startswith("/") or "://" in path:
163
+ raise RiscordError("Invalid API path")
164
+ if self._http is None or self._http.closed:
165
+ self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=20))
166
+ retryable = method in ("GET", "PUT") or isinstance(body, dict) and bool(body.get("clientId"))
167
+ for attempt in range(4):
168
+ try:
169
+ async with self._http.request(method, self.base_url + "/api/bot/v1" + path, **({"data": body} if isinstance(body, aiohttp.FormData) else {"json": body}), headers={"Authorization": "Bot " + self._token}, allow_redirects=False) as response:
170
+ if response.status in (429, 503) and retryable and attempt < 3:
171
+ try:
172
+ delay = float(response.headers.get("Retry-After", "1"))
173
+ except ValueError:
174
+ delay = 1
175
+ await asyncio.sleep(min(60, max(1, delay)))
176
+ continue
177
+ try:
178
+ data = await response.json()
179
+ except (aiohttp.ContentTypeError, ValueError):
180
+ raise RiscordError(f"Riscord returned {response.status}", response.status) from None
181
+ if response.status >= 300:
182
+ raise RiscordError(data.get("error", f"Riscord returned {response.status}"), response.status)
183
+ return data
184
+ except (aiohttp.ClientError, asyncio.TimeoutError):
185
+ if retryable and attempt < 3:
186
+ await asyncio.sleep(0.5 * 2 ** attempt)
187
+ continue
188
+ raise RiscordError("Could not reach Riscord") from None
189
+
190
+ async def me(self):
191
+ return await self.request("/me")
192
+
193
+ async def channels(self, server_id: str):
194
+ return await self.request(f"/servers/{server_id}/channels")
195
+
196
+ async def members(self, server_id: str, cursor: str | None = None):
197
+ suffix = f"?cursor={cursor}" if cursor else ""
198
+ return await self.request(f"/servers/{server_id}/members{suffix}")
199
+
200
+ async def set_role(self, server_id: str, user_id: str, role_id: str, add: bool = True):
201
+ return await self.request(f"/servers/{server_id}/members/{user_id}/roles", "POST", {"roleId": role_id, "add": add})
202
+
203
+ async def upload(self, data: bytes, filename: str, content_type: str):
204
+ form = aiohttp.FormData()
205
+ form.add_field("file", data, filename=filename, content_type=content_type)
206
+ return await self.request("/upload", "POST", form)
207
+
208
+ async def register_commands(self, commands: list[Command]):
209
+ return await self.request("/commands", "PUT", commands)
210
+
211
+ async def send(self, channel_id: str, content: str, *, client_id: str | None = None, **options):
212
+ return await self.request(f"/channels/{channel_id}/messages", "POST", {"content": content, **options, "clientId": client_id or str(uuid.uuid4())})
213
+
214
+ async def edit(self, channel_id: str, message_id: str, content: str, components=None):
215
+ return await self.request(f"/channels/{channel_id}/messages/{message_id}", "PATCH", {"content": content, "components": components or []})
216
+
217
+ async def delete(self, channel_id: str, message_id: str):
218
+ return await self.request(f"/channels/{channel_id}/messages/{message_id}", "DELETE")
219
+
220
+ async def history(self, channel_id: str):
221
+ return await self.request(f"/channels/{channel_id}/messages")
222
+
223
+ async def open_dm(self, user_id: str):
224
+ return await self.request("/conversations", "POST", {"userId": user_id})
225
+
226
+ async def send_dm(self, conversation_id: str, content: str, *, client_id: str | None = None, **options):
227
+ return await self.request(f"/conversations/{conversation_id}/messages", "POST", {"content": content, **options, "clientId": client_id or str(uuid.uuid4())})
228
+
229
+ async def moderate(self, server_id: str, user_id: str, action: str, **options):
230
+ return await self.request(f"/servers/{server_id}/members/{user_id}/{action}", "POST", options)
231
+
232
+ async def join_voice(self, channel_id: str) -> "VoiceConnection":
233
+ from .voice import VoiceConnection, _identifier
234
+
235
+ if not isinstance(channel_id, str) or not channel_id.isascii() or not channel_id.isdecimal():
236
+ raise RiscordError("Voice channel ID must be a string of digits")
237
+ async with self._voice_lock:
238
+ if self._closed or not self._socket.connected:
239
+ raise RiscordError("Connect to the bot gateway before joining voice")
240
+ epoch = self._voice_epoch
241
+ if self._voice:
242
+ await self._voice.leave()
243
+ data = await self.request("/voice/join", "POST", {"channelId": channel_id})
244
+ try:
245
+ if self._closed or not self._socket.connected or epoch != self._voice_epoch:
246
+ raise RiscordError("Bot gateway disconnected while joining voice")
247
+ voice = VoiceConnection(self, data)
248
+ except BaseException:
249
+ if isinstance(data, dict) and _identifier(data.get("connectionId")):
250
+ try:
251
+ await self.request("/voice/leave", "POST", {"connectionId": data["connectionId"]})
252
+ except Exception:
253
+ pass
254
+ raise
255
+ self._voice = voice
256
+ return voice
257
+
258
+ async def connect(self):
259
+ self._closed = False
260
+ if not self._socket.connected:
261
+ await self._socket.connect(self.base_url, auth=lambda: {"token": self._token, "cursor": self._cursor}, namespaces=["/bots"])
262
+
263
+ async def run(self):
264
+ await self.connect()
265
+ await self._socket.wait()
266
+
267
+ async def close(self):
268
+ self._closed = True
269
+ self._epoch += 1
270
+ self._voice_epoch += 1
271
+ try:
272
+ if self._voice:
273
+ await self._voice.leave()
274
+ finally:
275
+ try:
276
+ await self._socket.disconnect()
277
+ finally:
278
+ if self._http:
279
+ await self._http.close()
@@ -0,0 +1,68 @@
1
+ from typing import Literal, NotRequired, TypedDict
2
+
3
+ ID = str
4
+ Permission = Literal["VIEW_CHANNEL", "SEND_MESSAGES", "EMBED_LINKS", "ATTACH_FILES", "ADD_REACTIONS", "MANAGE_MESSAGES", "KICK_MEMBERS", "BAN_MEMBERS", "MODERATE_MEMBERS", "MANAGE_ROLES", "MESSAGE_CONTENT", "SEND_DIRECT_MESSAGES", "CONNECT", "SPEAK"]
5
+
6
+ class Option(TypedDict):
7
+ name: str
8
+ description: str
9
+ type: Literal["string", "integer", "boolean", "user", "channel"]
10
+ required: NotRequired[bool]
11
+
12
+ class Command(TypedDict):
13
+ name: str
14
+ description: str
15
+ options: NotRequired[list[Option]]
16
+ contexts: NotRequired[list[Literal["server", "dm"]]]
17
+ permissions: NotRequired[list[Permission]]
18
+ aliases: NotRequired[list[str]]
19
+ serverId: NotRequired[ID]
20
+
21
+ class Button(TypedDict):
22
+ type: Literal["button"]
23
+ customId: str
24
+ label: str
25
+ disabled: NotRequired[bool]
26
+ userId: NotRequired[ID]
27
+
28
+ class LinkButton(TypedDict):
29
+ type: Literal["link"]
30
+ label: str
31
+ url: str
32
+
33
+ class SelectOption(TypedDict):
34
+ label: str
35
+ value: str
36
+
37
+ class Select(TypedDict):
38
+ type: Literal["select"]
39
+ customId: str
40
+ label: str
41
+ options: list[SelectOption]
42
+ disabled: NotRequired[bool]
43
+ userId: NotRequired[ID]
44
+
45
+ Components = list[list[Button | LinkButton | Select]]
46
+
47
+ class FormField(TypedDict):
48
+ name: str
49
+ label: str
50
+ type: Literal["text", "paragraph"]
51
+ required: NotRequired[bool]
52
+ minLength: NotRequired[int]
53
+ maxLength: NotRequired[int]
54
+
55
+ class Modal(TypedDict):
56
+ customId: str
57
+ title: str
58
+ fields: list[FormField]
59
+
60
+ class Message(TypedDict, total=False):
61
+ id: ID
62
+ channelId: ID
63
+ conversationId: ID
64
+ senderId: ID
65
+ authorId: ID
66
+ content: str | None
67
+ senderKind: Literal["user", "bot", "webhook"]
68
+ components: Components
File without changes
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import binascii
6
+ import ipaddress
7
+ import os
8
+ from dataclasses import dataclass
9
+ from urllib.parse import urlsplit
10
+
11
+ from .client import RiscordError
12
+
13
+
14
+ def _identifier(value) -> bool:
15
+ return isinstance(value, str) and 0 < len(value) <= 128
16
+
17
+
18
+ def _source(source: str | os.PathLike) -> str:
19
+ try:
20
+ source = os.fspath(source)
21
+ except TypeError:
22
+ raise RiscordError("Audio source must be a local file or an HTTP(S) URL") from None
23
+ if not isinstance(source, str) or not source or len(source) > 8192 or any(ord(c) < 32 for c in source):
24
+ raise RiscordError("Audio source must be a local file or an HTTP(S) URL")
25
+ try:
26
+ parts = urlsplit(source)
27
+ if parts.scheme in ("http", "https"):
28
+ if not parts.hostname:
29
+ raise ValueError
30
+ return source
31
+ except ValueError:
32
+ raise RiscordError("Invalid audio URL") from None
33
+ if not os.path.isfile(source):
34
+ raise RiscordError("Audio source must be a local file or an HTTP(S) URL")
35
+ return os.path.abspath(source)
36
+
37
+
38
+ def _ffmpeg_arguments(source: str, media: dict) -> list[str]:
39
+ try:
40
+ address = ipaddress.ip_address(media["ip"])
41
+ port, ssrc, bitrate = media["port"], media["ssrc"], media["audioBitrate"]
42
+ if type(port) is not int or not 1 <= port <= 65535 or type(ssrc) is not int or not 1 <= ssrc <= 0x7fffffff:
43
+ raise ValueError
44
+ if media["payloadType"] != 111 or media["clockRate"] != 48000 or media["channels"] != 2:
45
+ raise ValueError
46
+ if type(bitrate) is not int or not 6000 <= bitrate <= 510000:
47
+ raise ValueError
48
+ if media["srtpSuite"] != "AES_CM_128_HMAC_SHA1_80" or not isinstance(media["srtpKeyBase64"], str) or len(media["srtpKeyBase64"]) != 40 or len(base64.b64decode(media["srtpKeyBase64"], validate=True)) != 30:
49
+ raise ValueError
50
+ except (KeyError, TypeError, ValueError, binascii.Error):
51
+ raise RiscordError("Riscord returned invalid voice media parameters") from None
52
+ host = f"[{address}]" if address.version == 6 else str(address)
53
+ return [
54
+ "-nostdin", "-hide_banner", "-loglevel", "error", "-re",
55
+ "-protocol_whitelist", "http,https,tcp,tls,crypto" if urlsplit(source).scheme in ("http", "https") else "file",
56
+ "-i", source,
57
+ "-map", "0:a:0", "-vn", "-c:a", "libopus", "-ar", "48000", "-ac", "2",
58
+ "-b:a", str(min(96000, bitrate)), "-application", "audio", "-frame_duration", "20",
59
+ "-f", "rtp", "-payload_type", "111", "-ssrc", str(ssrc), "-rtpflags", "skip_rtcp",
60
+ "-srtp_out_suite", media["srtpSuite"], "-srtp_out_params", media["srtpKeyBase64"],
61
+ f"srtp://{host}:{port}?rtcpport={port}&pkt_size=1200",
62
+ ]
63
+
64
+
65
+ async def _terminate(process):
66
+ if process.returncode is not None:
67
+ return
68
+ try:
69
+ process.terminate()
70
+ except ProcessLookupError:
71
+ pass
72
+ try:
73
+ await asyncio.wait_for(process.wait(), 3)
74
+ except asyncio.TimeoutError:
75
+ try:
76
+ process.kill()
77
+ except ProcessLookupError:
78
+ pass
79
+ try:
80
+ await asyncio.wait_for(process.wait(), 3)
81
+ except asyncio.TimeoutError:
82
+ raise RiscordError("FFmpeg did not exit after being killed") from None
83
+
84
+
85
+ @dataclass(repr=False)
86
+ class _Playback:
87
+ process: asyncio.subprocess.Process
88
+ media_id: str
89
+ stopped: bool = False
90
+
91
+
92
+ class VoiceConnection:
93
+ """A send-only, self-deafened server voice connection owned by this gateway."""
94
+
95
+ def __init__(self, client, data: dict):
96
+ if not isinstance(data, dict) or not all(_identifier(data.get(key)) for key in ("connectionId", "channelId", "serverId")):
97
+ raise RiscordError("Riscord returned an invalid voice connection")
98
+ interval, timeout = data.get("heartbeatIntervalMs"), data.get("leaseTimeoutMs")
99
+ if type(interval) is not int or type(timeout) is not int or not 1000 <= interval <= 10000 or timeout < interval * 2:
100
+ raise RiscordError("Riscord returned an invalid voice lease")
101
+ self.connection_id: str = data["connectionId"]
102
+ self.channel_id: str = data["channelId"]
103
+ self.server_id: str = data["serverId"]
104
+ self._client = client
105
+ self._closed = False
106
+ self._playback = None
107
+ self._media_lock = asyncio.Lock()
108
+ self._leave_lock = asyncio.Lock()
109
+ self._heartbeat_task = asyncio.create_task(self._heartbeat(interval / 1000))
110
+
111
+ @property
112
+ def closed(self) -> bool:
113
+ return self._closed
114
+
115
+ @property
116
+ def playing(self) -> bool:
117
+ return self._playback is not None and self._playback.process.returncode is None
118
+
119
+ async def _heartbeat(self, interval):
120
+ try:
121
+ while not self._closed:
122
+ await asyncio.sleep(interval)
123
+ await self._client.request("/voice/heartbeat", "POST", {"connectionId": self.connection_id})
124
+ except asyncio.CancelledError:
125
+ raise
126
+ except Exception:
127
+ try:
128
+ await self.leave()
129
+ except Exception:
130
+ pass
131
+ await self._client._call(self._client.on_error, RiscordError("Voice connection lost; join the channel again"))
132
+
133
+ async def _release_media(self, media_id):
134
+ await self._client.request("/voice/media/stop", "POST", {"connectionId": self.connection_id, "mediaId": media_id})
135
+
136
+ async def _stop_locked(self):
137
+ playback = self._playback
138
+ if playback is not None:
139
+ self._playback = None
140
+ playback.stopped = True
141
+ try:
142
+ await _terminate(playback.process)
143
+ finally:
144
+ await self._release_media(playback.media_id)
145
+
146
+ async def stop(self):
147
+ """Stop playback and its producer, keeping the channel connection alive."""
148
+ async with self._media_lock:
149
+ await self._stop_locked()
150
+
151
+ async def play(self, source: str | os.PathLike, *, ffmpeg: str = "ffmpeg"):
152
+ """Play a local file or HTTP(S) URL until completion, replacement or stop."""
153
+ source = _source(source)
154
+ if not isinstance(ffmpeg, str) or not ffmpeg or any(ord(c) < 32 for c in ffmpeg):
155
+ raise RiscordError("Invalid FFmpeg executable")
156
+ async with self._media_lock:
157
+ if self._closed:
158
+ raise RiscordError("Voice connection is closed; join the channel again")
159
+ await self._stop_locked()
160
+ prepare = asyncio.create_task(self._client.request("/voice/media", "POST", {"connectionId": self.connection_id}))
161
+ try:
162
+ media = await asyncio.shield(prepare)
163
+ except asyncio.CancelledError:
164
+ try:
165
+ media = await prepare
166
+ if isinstance(media, dict) and _identifier(media.get("mediaId")):
167
+ await self._release_media(media["mediaId"])
168
+ except Exception:
169
+ pass
170
+ raise
171
+ media_id = media.get("mediaId") if isinstance(media, dict) else None
172
+ if not _identifier(media_id):
173
+ raise RiscordError("Riscord returned an invalid voice media ID")
174
+ try:
175
+ if self._closed:
176
+ raise RiscordError("Voice connection is closed; join the channel again")
177
+ arguments = _ffmpeg_arguments(source, media)
178
+ # Keep encoder output private: FFmpeg errors can include source URLs and SRTP keys.
179
+ environment = {key: value for key, value in os.environ.items() if key.upper() != "FFREPORT"}
180
+ spawn = asyncio.create_task(asyncio.create_subprocess_exec(ffmpeg, *arguments, env=environment, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL))
181
+ try:
182
+ process = await asyncio.shield(spawn)
183
+ except asyncio.CancelledError:
184
+ process = await spawn
185
+ await _terminate(process)
186
+ raise
187
+ if self._closed:
188
+ await _terminate(process)
189
+ raise RiscordError("Voice connection is closed; join the channel again")
190
+ self._playback = playback = _Playback(process, media_id)
191
+ except BaseException as error:
192
+ try:
193
+ await self._release_media(media_id)
194
+ except Exception:
195
+ pass
196
+ if isinstance(error, FileNotFoundError):
197
+ raise RiscordError("FFmpeg was not found. Install FFmpeg with libopus and add it to PATH, or pass ffmpeg= to play()") from None
198
+ if isinstance(error, OSError):
199
+ raise RiscordError("Could not start FFmpeg") from None
200
+ raise
201
+ try:
202
+ result = await process.wait()
203
+ except asyncio.CancelledError:
204
+ async with self._media_lock:
205
+ if self._playback is playback:
206
+ await self._stop_locked()
207
+ raise
208
+ async with self._media_lock:
209
+ if self._playback is playback:
210
+ self._playback = None
211
+ await self._release_media(media_id)
212
+ if result and not playback.stopped:
213
+ raise RiscordError("FFmpeg could not play this audio. Check the source and FFmpeg's libopus/SRTP support")
214
+
215
+ async def leave(self):
216
+ """Release playback, heartbeat and this connection's server voice lease."""
217
+ async with self._leave_lock:
218
+ if self._closed:
219
+ return
220
+ self._closed = True
221
+ try:
222
+ if self._heartbeat_task is not asyncio.current_task():
223
+ self._heartbeat_task.cancel()
224
+ try:
225
+ await self._heartbeat_task
226
+ except asyncio.CancelledError:
227
+ pass
228
+ try:
229
+ await self.stop()
230
+ finally:
231
+ await self._client.request("/voice/leave", "POST", {"connectionId": self.connection_id})
232
+ finally:
233
+ if self._client._voice is self:
234
+ self._client._voice = None
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: riscord-bot
3
+ Version: 0.2.1
4
+ Summary: Official async Riscord bot client
5
+ License: All rights reserved.
6
+ Project-URL: Documentation, https://riscord.ir/developers/docs
7
+ Project-URL: Homepage, https://riscord.ir
8
+ Classifier: License :: Other/Proprietary License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Framework :: AsyncIO
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: python-socketio[asyncio_client]<6,>=5.11
15
+ Requires-Dist: aiohttp<4,>=3.10
16
+ Dynamic: license-file
17
+
18
+ # Riscord bot SDK — Python
19
+
20
+ Requires Python 3.11 or newer. Install with `python -m pip install riscord-bot`. Versioned source archives remain available from [Developer Docs](https://riscord.ir/developers/docs); install a downloaded archive with `python -m pip install ./riscord-bot-python-0.2.1.tar.gz`.
21
+
22
+ Create and install a bot through `https://riscord.ir/developers`, request View Channel and Send Messages, generate its token and set the `RISCORD_BOT_TOKEN` environment variable. Never commit the token.
23
+
24
+ ```python
25
+ import asyncio, os
26
+ from riscord import Client
27
+
28
+ bot = Client(os.environ['RISCORD_BOT_TOKEN'])
29
+
30
+ @bot.command('hello')
31
+ async def hello(interaction):
32
+ await interaction.reply('Hello!', private=True)
33
+
34
+ async def main():
35
+ await bot.register_commands([{'name':'hello','description':'Say hello','contexts':['server','dm']}])
36
+ try:
37
+ await bot.run()
38
+ finally:
39
+ await bot.close()
40
+
41
+ asyncio.run(main())
42
+ ```
43
+
44
+ Defer slow work with `await interaction.defer(private=True)` before replying; later replies retain that audience. `show_modal({customId,title,fields})` opens a form. Use `@bot.on('interaction')` for buttons, selects and submitted forms and inspect `interaction.kind` and `interaction.data`.
45
+
46
+ `send(channel_id,content,components=...)`, `open_dm(user_id)`, `send_dm(conversation_id,content)`, `moderate(server_id,user_id,action,...)`, `history`, `edit` and `delete` expose the corresponding API operations. For message options, `client_id` is a Python keyword argument; component/command JSON follows the documented camelCase wire schema. IDs remain strings.
47
+
48
+ Only registered text commands, mentions and the bot's DMs are delivered without broader Message Content permission. SDKs ignore automated messages, reconnect with bounded replay and honor retry delays. Deduplication is bounded and memory-only; persist operation IDs for external effects that must survive restarts.
49
+
50
+ For voice playback, request View Channel, Connect and Speak when installing the bot, and allow them in the target server voice channel. Install a separate FFmpeg executable with the `libopus` encoder and SRTP support and add it to `PATH`; the SDK does not download an encoder. Chat and interactions do not need FFmpeg.
51
+
52
+ ```python
53
+ await bot.connect()
54
+ voice = await bot.join_voice('VOICE_CHANNEL_ID')
55
+ try:
56
+ await voice.play('./music.ogg')
57
+ await voice.play('https://example.com/direct-audio.ogg')
58
+ finally:
59
+ await voice.leave()
60
+ ```
61
+
62
+ `join_voice(channel_id)` requires a connected bot gateway and returns a `VoiceConnection`. `play(source, ffmpeg='ffmpeg')` accepts a trusted local audio file or direct HTTP(S) audio URL and waits until playback finishes. A new `play` replaces the previous track; call it in an `asyncio` task if your handler must keep accepting other work. `stop()` ends playback while keeping the bot in the channel; `leave()` releases the connection. Cancelling the playback task also stops its encoder. `playing`, `closed`, `channel_id` and `server_id` expose connection state.
63
+
64
+ Voice is send-only and self-deafened; the SDK never receives or records channel audio. Each playback gets fresh Opus/SRTP media parameters and stays within the server's audio bitrate limit. The SDK renews the lease every ten seconds and stops playback on a lost lease, gateway disconnect or `bot.close()`. Reconnecting the gateway does not rejoin voice automatically. One client owns one voice channel at a time, and stale playback cleanup cannot stop its replacement.
65
+
66
+ Use sources you control. FFmpeg fetches URLs from the bot's machine, so do not pass arbitrary member-provided paths or URLs to `play`. Website pages that require a media extractor are not direct audio URLs. Encoder errors omit raw process output because it can contain private source URLs or transport keys. Set `RISCORD_VOICE_CHANNEL_ID` and `RISCORD_AUDIO_SOURCE` alongside the bot token, then run `examples/music.py` to play a configured source and leave when it finishes.
67
+
68
+ The public [Developer Docs](https://riscord.ir/developers/docs) cover the API contract, limits and migration guidance.
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ examples/controls.py
6
+ examples/hello.py
7
+ examples/messages.py
8
+ examples/moderation.py
9
+ examples/music.py
10
+ src/riscord/__init__.py
11
+ src/riscord/client.py
12
+ src/riscord/models.py
13
+ src/riscord/py.typed
14
+ src/riscord/voice.py
15
+ src/riscord_bot.egg-info/PKG-INFO
16
+ src/riscord_bot.egg-info/SOURCES.txt
17
+ src/riscord_bot.egg-info/dependency_links.txt
18
+ src/riscord_bot.egg-info/requires.txt
19
+ src/riscord_bot.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ python-socketio[asyncio_client]<6,>=5.11
2
+ aiohttp<4,>=3.10