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.
- riscord_bot-0.2.1/LICENSE +2 -0
- riscord_bot-0.2.1/MANIFEST.in +4 -0
- riscord_bot-0.2.1/PKG-INFO +68 -0
- riscord_bot-0.2.1/README.md +51 -0
- riscord_bot-0.2.1/examples/controls.py +25 -0
- riscord_bot-0.2.1/examples/hello.py +18 -0
- riscord_bot-0.2.1/examples/messages.py +30 -0
- riscord_bot-0.2.1/examples/moderation.py +20 -0
- riscord_bot-0.2.1/examples/music.py +23 -0
- riscord_bot-0.2.1/pyproject.toml +26 -0
- riscord_bot-0.2.1/setup.cfg +4 -0
- riscord_bot-0.2.1/src/riscord/__init__.py +6 -0
- riscord_bot-0.2.1/src/riscord/client.py +279 -0
- riscord_bot-0.2.1/src/riscord/models.py +68 -0
- riscord_bot-0.2.1/src/riscord/py.typed +0 -0
- riscord_bot-0.2.1/src/riscord/voice.py +234 -0
- riscord_bot-0.2.1/src/riscord_bot.egg-info/PKG-INFO +68 -0
- riscord_bot-0.2.1/src/riscord_bot.egg-info/SOURCES.txt +19 -0
- riscord_bot-0.2.1/src/riscord_bot.egg-info/dependency_links.txt +1 -0
- riscord_bot-0.2.1/src/riscord_bot.egg-info/requires.txt +2 -0
- riscord_bot-0.2.1/src/riscord_bot.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
riscord
|