veretube-bot 0.1.0__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.
- veretube_bot-0.1.0/LICENSE +21 -0
- veretube_bot-0.1.0/PKG-INFO +249 -0
- veretube_bot-0.1.0/README.md +221 -0
- veretube_bot-0.1.0/pyproject.toml +40 -0
- veretube_bot-0.1.0/setup.cfg +4 -0
- veretube_bot-0.1.0/veretube_bot/__init__.py +5 -0
- veretube_bot-0.1.0/veretube_bot/_api.py +121 -0
- veretube_bot-0.1.0/veretube_bot/bot.py +344 -0
- veretube_bot-0.1.0/veretube_bot/exceptions.py +4 -0
- veretube_bot-0.1.0/veretube_bot/rank.py +5 -0
- veretube_bot-0.1.0/veretube_bot.egg-info/PKG-INFO +249 -0
- veretube_bot-0.1.0/veretube_bot.egg-info/SOURCES.txt +13 -0
- veretube_bot-0.1.0/veretube_bot.egg-info/dependency_links.txt +1 -0
- veretube_bot-0.1.0/veretube_bot.egg-info/requires.txt +7 -0
- veretube_bot-0.1.0/veretube_bot.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 veretube contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: veretube-bot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python bot library for veretube sync channels
|
|
5
|
+
Author-email: veretube <faceeatingtumor@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://veretium.com
|
|
8
|
+
Project-URL: Source, https://veretium.com/w0zard/veretube_bot_lib
|
|
9
|
+
Keywords: veretube,cytube,bot,chat,socketio
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Communications :: Chat
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: python-socketio[client]>=5.0
|
|
22
|
+
Requires-Dist: requests>=2.28
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
25
|
+
Requires-Dist: build; extra == "dev"
|
|
26
|
+
Requires-Dist: twine; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# veretube-bot
|
|
30
|
+
|
|
31
|
+
Python library for writing bots on sync 4.0 aka veretube channels.
|
|
32
|
+
|
|
33
|
+
Bots connect over **socket.io** for real-time events (chat, user list, playlist changes) and use a **REST API** for commands (queue media, kick/ban, manage emotes, read/write settings). Both surfaces are covered by this library.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install veretube-bot
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Getting a token
|
|
42
|
+
|
|
43
|
+
Tokens are issued in the channel settings modal on the **Bots** tab. You need at least moderator rank to see it. Fill in a name and rank, click **Issue Token**, and copy the token immediately — it starts with `cbt_` and is only shown once.
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from veretube_bot import Bot
|
|
49
|
+
|
|
50
|
+
bot = Bot(
|
|
51
|
+
token="cbt_...",
|
|
52
|
+
channel="mychannel",
|
|
53
|
+
socket_url="http://your-server:1337", # socket.io port (default 1337)
|
|
54
|
+
api_url="http://your-server:8080/api/v1", # HTTP port (default 8080)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@bot.on("chatMsg")
|
|
58
|
+
def on_chat(data):
|
|
59
|
+
if data["msg"] == "!hello":
|
|
60
|
+
bot.send_message("Hello!", to=data["username"])
|
|
61
|
+
|
|
62
|
+
if data["msg"] == "!np":
|
|
63
|
+
if bot.now_playing:
|
|
64
|
+
bot.send_message(f"Now playing: {bot.now_playing['title']}")
|
|
65
|
+
|
|
66
|
+
@bot.on("changeMedia")
|
|
67
|
+
def on_media(data):
|
|
68
|
+
print(f"Now playing: {data['title']}")
|
|
69
|
+
|
|
70
|
+
bot.run() # connect and block until disconnected
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Connection
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
Bot(
|
|
77
|
+
token, # str — bot token starting with 'cbt_'
|
|
78
|
+
channel, # str — channel name (lowercase, no '#')
|
|
79
|
+
socket_url, # str — socket.io server URL (uses io.port, not the HTTP port)
|
|
80
|
+
api_url, # str — REST API base URL, e.g. http://host:8080/api/v1
|
|
81
|
+
reconnection=False, # set True to reconnect automatically on drop
|
|
82
|
+
reconnection_delay=3, # seconds between reconnect attempts
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Method | Description |
|
|
87
|
+
|--------|-------------|
|
|
88
|
+
| `bot.run()` | Connect and block until disconnected |
|
|
89
|
+
| `bot.connect()` | Open the connection (returns immediately) |
|
|
90
|
+
| `bot.wait()` | Block until disconnected (call after `connect()`) |
|
|
91
|
+
| `bot.disconnect()` | Close the connection |
|
|
92
|
+
|
|
93
|
+
By default `reconnection=False` — the expectation is that an external process supervisor (systemd, supervisord, etc.) handles restarts. Pass `reconnection=True` for bots that should self-recover.
|
|
94
|
+
|
|
95
|
+
## Event handling
|
|
96
|
+
|
|
97
|
+
Register handlers with `@bot.on(event_name)`. Multiple handlers for the same event are all called. Exceptions in a handler are logged and skipped so one bad handler doesn't affect the others.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
@bot.on("chatMsg")
|
|
101
|
+
def on_chat(data):
|
|
102
|
+
print(data["username"], data["msg"])
|
|
103
|
+
|
|
104
|
+
@bot.on("userLeave")
|
|
105
|
+
def on_leave(data):
|
|
106
|
+
print(f"{data['name']} left")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Socket events
|
|
110
|
+
|
|
111
|
+
| Event | Payload | Description |
|
|
112
|
+
|-------|---------|-------------|
|
|
113
|
+
| `connect` | `None` | Socket connected |
|
|
114
|
+
| `disconnect` | reason string or `None` | Socket disconnected |
|
|
115
|
+
| `connect_error` | error object | Connection failed |
|
|
116
|
+
| `login` | `{ success, name, guest }` | Server accepted the token |
|
|
117
|
+
| `chatMsg` | `{ username, msg, meta, time }` | Chat message sent |
|
|
118
|
+
| `pm` | `{ username, msg, meta }` | Private message received |
|
|
119
|
+
| `userlist` | `[{ name, rank, meta }, ...]` | Full user list on join |
|
|
120
|
+
| `addUser` | `{ name, rank, meta }` | User joined |
|
|
121
|
+
| `userLeave` | `{ name }` | User left |
|
|
122
|
+
| `setUserMeta` | `{ name, meta }` | User AFK/muted state changed |
|
|
123
|
+
| `setUserRank` | `{ name, rank }` | User rank changed |
|
|
124
|
+
| `changeMedia` | `{ id, type, title, seconds, ... }` | New video started |
|
|
125
|
+
| `playlist` | `[{ uid, media, queueby, temp }, ...]` | Full playlist on join |
|
|
126
|
+
| `queue` | `{ item, after }` | Item added to playlist |
|
|
127
|
+
| `delete` | `{ uid }` | Playlist item removed |
|
|
128
|
+
| `channelOpts` | settings dict | Channel options updated |
|
|
129
|
+
| `clearchat` | `None` | Chat cleared by a moderator |
|
|
130
|
+
| `errorMsg` | `{ msg }` | Error from the server |
|
|
131
|
+
| `kick` | `{ reason }` | Bot was kicked |
|
|
132
|
+
| `announcement` | `{ title, text }` | Server-wide announcement |
|
|
133
|
+
| `updateEmote` | `{ name, image }` | Emote added or changed |
|
|
134
|
+
| `removeEmote` | `{ name }` | Emote removed |
|
|
135
|
+
|
|
136
|
+
`meta.is_bot` is `True` in `chatMsg` and userlist payloads for bots.
|
|
137
|
+
|
|
138
|
+
## In-memory state
|
|
139
|
+
|
|
140
|
+
These are updated automatically from socket events before your handlers are called:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
bot.users # list of { name, rank, meta } — current user list
|
|
144
|
+
bot.now_playing # { id, type, title, seconds, ... } or None
|
|
145
|
+
bot.playlist # list of playlist items
|
|
146
|
+
bot.channel_opts # channel options dict
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Look up a specific user:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
user = bot.get_user("Alice") # returns dict or None
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Chat
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
bot.send_message("Hello!")
|
|
159
|
+
bot.send_message("Hey!", to="Alice") # sends "Alice: Hey!"
|
|
160
|
+
bot.send_action("waves") # sends "/me waves"
|
|
161
|
+
bot.send_pm("Alice", "Hello privately")
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Playlist
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
bot.queue("dQw4w9WgXcQ", "yt") # add to end (rank >= MOD)
|
|
168
|
+
bot.queue("dQw4w9WgXcQ", "yt", "next") # add as next up
|
|
169
|
+
bot.skip() # skip to next item (rank >= MOD)
|
|
170
|
+
bot.skip_to(uid) # jump to specific uid (rank >= MOD)
|
|
171
|
+
bot.delete_item(uid) # remove by uid (rank >= ADMIN)
|
|
172
|
+
bot.shuffle_playlist() # shuffle (rank >= ADMIN)
|
|
173
|
+
bot.clear_playlist() # clear all (rank >= ADMIN)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Media types:** `yt` YouTube, `sc` SoundCloud, `tw` Twitch stream, `tc` Twitch clip, `vm` Vimeo, `dm` Dailymotion, `fi` direct file URL, `cu` custom embed.
|
|
177
|
+
|
|
178
|
+
## Moderation
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
bot.kick("BadUser") # rank >= MOD
|
|
182
|
+
bot.kick("BadUser", reason="Spamming")
|
|
183
|
+
bot.ban("BadUser") # rank >= ADMIN
|
|
184
|
+
bot.ban("BadUser", reason="Evading")
|
|
185
|
+
bot.unban("BadUser") # rank >= ADMIN
|
|
186
|
+
bot.set_rank("Alice", Rank.MOD) # rank >= OWNER
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Emotes
|
|
190
|
+
|
|
191
|
+
Emote endpoints read/write the database directly and work even when the channel is offline.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
emotes = bot.get_emotes() # list of { name, image, source }
|
|
195
|
+
bot.add_emote("KEKW", "https://...") # rank >= OWNER
|
|
196
|
+
bot.update_emote("KEKW", image="https://...")
|
|
197
|
+
bot.update_emote("KEKW", new_name="KEKWait")
|
|
198
|
+
bot.delete_emote("KEKW") # rank >= OWNER
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Settings
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
settings = bot.get_settings() # rank >= OWNER, channel must be active
|
|
205
|
+
bot.update_settings(pagetitle="Now Playing: Chill Beats")
|
|
206
|
+
bot.update_settings(allow_voteskip=False, afk_timeout=300)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Available setting keys: `allow_voteskip`, `allow_dupes`, `voteskip_ratio`, `maxlength`, `playlist_max_duration_per_user`, `afk_timeout`, `enable_link_regex`, `chat_antiflood`, `chat_antiflood_burst`, `chat_antiflood_sustained`, `new_user_chat_delay`, `new_user_chat_link_delay`, `pagetitle`, `password`, `externalcss`, `externaljs`, `show_public`, `torbanned`, `block_anonymous_users`, `allow_ascii_control`, `playlist_max_per_user`.
|
|
210
|
+
|
|
211
|
+
## Direct REST access
|
|
212
|
+
|
|
213
|
+
Every REST endpoint is also accessible on `bot.api` if you need something not covered by the shortcuts:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
playlist = bot.api.get_playlist() # { items, currentIndex, locked }
|
|
217
|
+
bot.api.skip_to(uid)
|
|
218
|
+
bot.api.set_user_rank("Alice", 2)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Error handling
|
|
222
|
+
|
|
223
|
+
REST calls raise `BotAPIError` on failure:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from veretube_bot import Bot, BotAPIError
|
|
227
|
+
|
|
228
|
+
@bot.on("chatMsg")
|
|
229
|
+
def on_chat(data):
|
|
230
|
+
if data["msg"].startswith("!add "):
|
|
231
|
+
_, type, id = data["msg"].split(None, 2)
|
|
232
|
+
try:
|
|
233
|
+
bot.queue(id, type)
|
|
234
|
+
except BotAPIError as e:
|
|
235
|
+
bot.send_message(f"Error: {e}") # e.status_code has the HTTP code
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Rank constants
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from veretube_bot import Rank
|
|
242
|
+
|
|
243
|
+
Rank.MOD # 2
|
|
244
|
+
Rank.ADMIN # 3
|
|
245
|
+
Rank.OWNER # 4
|
|
246
|
+
Rank.CREATOR # 5
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
A bot's effective rank is capped at the rank of the user who issued its token.
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# veretube-bot
|
|
2
|
+
|
|
3
|
+
Python library for writing bots on sync 4.0 aka veretube channels.
|
|
4
|
+
|
|
5
|
+
Bots connect over **socket.io** for real-time events (chat, user list, playlist changes) and use a **REST API** for commands (queue media, kick/ban, manage emotes, read/write settings). Both surfaces are covered by this library.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install veretube-bot
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Getting a token
|
|
14
|
+
|
|
15
|
+
Tokens are issued in the channel settings modal on the **Bots** tab. You need at least moderator rank to see it. Fill in a name and rank, click **Issue Token**, and copy the token immediately — it starts with `cbt_` and is only shown once.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from veretube_bot import Bot
|
|
21
|
+
|
|
22
|
+
bot = Bot(
|
|
23
|
+
token="cbt_...",
|
|
24
|
+
channel="mychannel",
|
|
25
|
+
socket_url="http://your-server:1337", # socket.io port (default 1337)
|
|
26
|
+
api_url="http://your-server:8080/api/v1", # HTTP port (default 8080)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@bot.on("chatMsg")
|
|
30
|
+
def on_chat(data):
|
|
31
|
+
if data["msg"] == "!hello":
|
|
32
|
+
bot.send_message("Hello!", to=data["username"])
|
|
33
|
+
|
|
34
|
+
if data["msg"] == "!np":
|
|
35
|
+
if bot.now_playing:
|
|
36
|
+
bot.send_message(f"Now playing: {bot.now_playing['title']}")
|
|
37
|
+
|
|
38
|
+
@bot.on("changeMedia")
|
|
39
|
+
def on_media(data):
|
|
40
|
+
print(f"Now playing: {data['title']}")
|
|
41
|
+
|
|
42
|
+
bot.run() # connect and block until disconnected
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Connection
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
Bot(
|
|
49
|
+
token, # str — bot token starting with 'cbt_'
|
|
50
|
+
channel, # str — channel name (lowercase, no '#')
|
|
51
|
+
socket_url, # str — socket.io server URL (uses io.port, not the HTTP port)
|
|
52
|
+
api_url, # str — REST API base URL, e.g. http://host:8080/api/v1
|
|
53
|
+
reconnection=False, # set True to reconnect automatically on drop
|
|
54
|
+
reconnection_delay=3, # seconds between reconnect attempts
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Method | Description |
|
|
59
|
+
|--------|-------------|
|
|
60
|
+
| `bot.run()` | Connect and block until disconnected |
|
|
61
|
+
| `bot.connect()` | Open the connection (returns immediately) |
|
|
62
|
+
| `bot.wait()` | Block until disconnected (call after `connect()`) |
|
|
63
|
+
| `bot.disconnect()` | Close the connection |
|
|
64
|
+
|
|
65
|
+
By default `reconnection=False` — the expectation is that an external process supervisor (systemd, supervisord, etc.) handles restarts. Pass `reconnection=True` for bots that should self-recover.
|
|
66
|
+
|
|
67
|
+
## Event handling
|
|
68
|
+
|
|
69
|
+
Register handlers with `@bot.on(event_name)`. Multiple handlers for the same event are all called. Exceptions in a handler are logged and skipped so one bad handler doesn't affect the others.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
@bot.on("chatMsg")
|
|
73
|
+
def on_chat(data):
|
|
74
|
+
print(data["username"], data["msg"])
|
|
75
|
+
|
|
76
|
+
@bot.on("userLeave")
|
|
77
|
+
def on_leave(data):
|
|
78
|
+
print(f"{data['name']} left")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Socket events
|
|
82
|
+
|
|
83
|
+
| Event | Payload | Description |
|
|
84
|
+
|-------|---------|-------------|
|
|
85
|
+
| `connect` | `None` | Socket connected |
|
|
86
|
+
| `disconnect` | reason string or `None` | Socket disconnected |
|
|
87
|
+
| `connect_error` | error object | Connection failed |
|
|
88
|
+
| `login` | `{ success, name, guest }` | Server accepted the token |
|
|
89
|
+
| `chatMsg` | `{ username, msg, meta, time }` | Chat message sent |
|
|
90
|
+
| `pm` | `{ username, msg, meta }` | Private message received |
|
|
91
|
+
| `userlist` | `[{ name, rank, meta }, ...]` | Full user list on join |
|
|
92
|
+
| `addUser` | `{ name, rank, meta }` | User joined |
|
|
93
|
+
| `userLeave` | `{ name }` | User left |
|
|
94
|
+
| `setUserMeta` | `{ name, meta }` | User AFK/muted state changed |
|
|
95
|
+
| `setUserRank` | `{ name, rank }` | User rank changed |
|
|
96
|
+
| `changeMedia` | `{ id, type, title, seconds, ... }` | New video started |
|
|
97
|
+
| `playlist` | `[{ uid, media, queueby, temp }, ...]` | Full playlist on join |
|
|
98
|
+
| `queue` | `{ item, after }` | Item added to playlist |
|
|
99
|
+
| `delete` | `{ uid }` | Playlist item removed |
|
|
100
|
+
| `channelOpts` | settings dict | Channel options updated |
|
|
101
|
+
| `clearchat` | `None` | Chat cleared by a moderator |
|
|
102
|
+
| `errorMsg` | `{ msg }` | Error from the server |
|
|
103
|
+
| `kick` | `{ reason }` | Bot was kicked |
|
|
104
|
+
| `announcement` | `{ title, text }` | Server-wide announcement |
|
|
105
|
+
| `updateEmote` | `{ name, image }` | Emote added or changed |
|
|
106
|
+
| `removeEmote` | `{ name }` | Emote removed |
|
|
107
|
+
|
|
108
|
+
`meta.is_bot` is `True` in `chatMsg` and userlist payloads for bots.
|
|
109
|
+
|
|
110
|
+
## In-memory state
|
|
111
|
+
|
|
112
|
+
These are updated automatically from socket events before your handlers are called:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
bot.users # list of { name, rank, meta } — current user list
|
|
116
|
+
bot.now_playing # { id, type, title, seconds, ... } or None
|
|
117
|
+
bot.playlist # list of playlist items
|
|
118
|
+
bot.channel_opts # channel options dict
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Look up a specific user:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
user = bot.get_user("Alice") # returns dict or None
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Chat
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
bot.send_message("Hello!")
|
|
131
|
+
bot.send_message("Hey!", to="Alice") # sends "Alice: Hey!"
|
|
132
|
+
bot.send_action("waves") # sends "/me waves"
|
|
133
|
+
bot.send_pm("Alice", "Hello privately")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Playlist
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
bot.queue("dQw4w9WgXcQ", "yt") # add to end (rank >= MOD)
|
|
140
|
+
bot.queue("dQw4w9WgXcQ", "yt", "next") # add as next up
|
|
141
|
+
bot.skip() # skip to next item (rank >= MOD)
|
|
142
|
+
bot.skip_to(uid) # jump to specific uid (rank >= MOD)
|
|
143
|
+
bot.delete_item(uid) # remove by uid (rank >= ADMIN)
|
|
144
|
+
bot.shuffle_playlist() # shuffle (rank >= ADMIN)
|
|
145
|
+
bot.clear_playlist() # clear all (rank >= ADMIN)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Media types:** `yt` YouTube, `sc` SoundCloud, `tw` Twitch stream, `tc` Twitch clip, `vm` Vimeo, `dm` Dailymotion, `fi` direct file URL, `cu` custom embed.
|
|
149
|
+
|
|
150
|
+
## Moderation
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
bot.kick("BadUser") # rank >= MOD
|
|
154
|
+
bot.kick("BadUser", reason="Spamming")
|
|
155
|
+
bot.ban("BadUser") # rank >= ADMIN
|
|
156
|
+
bot.ban("BadUser", reason="Evading")
|
|
157
|
+
bot.unban("BadUser") # rank >= ADMIN
|
|
158
|
+
bot.set_rank("Alice", Rank.MOD) # rank >= OWNER
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Emotes
|
|
162
|
+
|
|
163
|
+
Emote endpoints read/write the database directly and work even when the channel is offline.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
emotes = bot.get_emotes() # list of { name, image, source }
|
|
167
|
+
bot.add_emote("KEKW", "https://...") # rank >= OWNER
|
|
168
|
+
bot.update_emote("KEKW", image="https://...")
|
|
169
|
+
bot.update_emote("KEKW", new_name="KEKWait")
|
|
170
|
+
bot.delete_emote("KEKW") # rank >= OWNER
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Settings
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
settings = bot.get_settings() # rank >= OWNER, channel must be active
|
|
177
|
+
bot.update_settings(pagetitle="Now Playing: Chill Beats")
|
|
178
|
+
bot.update_settings(allow_voteskip=False, afk_timeout=300)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Available setting keys: `allow_voteskip`, `allow_dupes`, `voteskip_ratio`, `maxlength`, `playlist_max_duration_per_user`, `afk_timeout`, `enable_link_regex`, `chat_antiflood`, `chat_antiflood_burst`, `chat_antiflood_sustained`, `new_user_chat_delay`, `new_user_chat_link_delay`, `pagetitle`, `password`, `externalcss`, `externaljs`, `show_public`, `torbanned`, `block_anonymous_users`, `allow_ascii_control`, `playlist_max_per_user`.
|
|
182
|
+
|
|
183
|
+
## Direct REST access
|
|
184
|
+
|
|
185
|
+
Every REST endpoint is also accessible on `bot.api` if you need something not covered by the shortcuts:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
playlist = bot.api.get_playlist() # { items, currentIndex, locked }
|
|
189
|
+
bot.api.skip_to(uid)
|
|
190
|
+
bot.api.set_user_rank("Alice", 2)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Error handling
|
|
194
|
+
|
|
195
|
+
REST calls raise `BotAPIError` on failure:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from veretube_bot import Bot, BotAPIError
|
|
199
|
+
|
|
200
|
+
@bot.on("chatMsg")
|
|
201
|
+
def on_chat(data):
|
|
202
|
+
if data["msg"].startswith("!add "):
|
|
203
|
+
_, type, id = data["msg"].split(None, 2)
|
|
204
|
+
try:
|
|
205
|
+
bot.queue(id, type)
|
|
206
|
+
except BotAPIError as e:
|
|
207
|
+
bot.send_message(f"Error: {e}") # e.status_code has the HTTP code
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Rank constants
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from veretube_bot import Rank
|
|
214
|
+
|
|
215
|
+
Rank.MOD # 2
|
|
216
|
+
Rank.ADMIN # 3
|
|
217
|
+
Rank.OWNER # 4
|
|
218
|
+
Rank.CREATOR # 5
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A bot's effective rank is capped at the rank of the user who issued its token.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "veretube-bot"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python bot library for veretube sync channels"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "veretube", email = "faceeatingtumor@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["veretube", "cytube", "bot", "chat", "socketio"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Topic :: Communications :: Chat",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"python-socketio[client]>=5.0",
|
|
28
|
+
"requests>=2.28",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest", "build", "twine"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://veretium.com"
|
|
36
|
+
Source = "https://veretium.com/w0zard/veretube_bot_lib"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["."]
|
|
40
|
+
include = ["veretube_bot*"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from .exceptions import BotAPIError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BotAPI:
|
|
9
|
+
def __init__(self, api_url: str, channel: str, token: str):
|
|
10
|
+
self._base = f"{api_url.rstrip('/')}/channels/{channel}"
|
|
11
|
+
self._session = requests.Session()
|
|
12
|
+
self._session.headers.update({
|
|
13
|
+
"Authorization": f"Bearer {token}",
|
|
14
|
+
"Content-Type": "application/json",
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
def _request(self, method: str, path: str, **kwargs):
|
|
18
|
+
url = f"{self._base}{path}"
|
|
19
|
+
resp = self._session.request(method, url, **kwargs)
|
|
20
|
+
if not resp.ok:
|
|
21
|
+
try:
|
|
22
|
+
error = resp.json().get("error", f"HTTP {resp.status_code}")
|
|
23
|
+
except Exception:
|
|
24
|
+
error = f"HTTP {resp.status_code}"
|
|
25
|
+
raise BotAPIError(error, status_code=resp.status_code)
|
|
26
|
+
if not resp.content:
|
|
27
|
+
return None
|
|
28
|
+
return resp.json()
|
|
29
|
+
|
|
30
|
+
# ── Playlist ──────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
def get_playlist(self) -> dict:
|
|
33
|
+
"""Returns { items, currentIndex, locked }. Requires channel active."""
|
|
34
|
+
return self._request("GET", "/playlist")
|
|
35
|
+
|
|
36
|
+
def add_to_playlist(self, id: str, type: str, pos: str = "end") -> None:
|
|
37
|
+
"""Queue media. pos is 'end' or 'next'. Requires rank >= MOD."""
|
|
38
|
+
self._request("POST", "/playlist", json={"id": id, "type": type, "pos": pos})
|
|
39
|
+
|
|
40
|
+
def delete_playlist_item(self, uid: int) -> None:
|
|
41
|
+
"""Remove a playlist item by uid. Requires rank >= ADMIN."""
|
|
42
|
+
self._request("DELETE", f"/playlist/{uid}")
|
|
43
|
+
|
|
44
|
+
def skip_to(self, uid: int) -> None:
|
|
45
|
+
"""Jump to a specific playlist item by uid. Requires rank >= MOD."""
|
|
46
|
+
self._request("PUT", "/playlist/playing", json={"uid": uid})
|
|
47
|
+
|
|
48
|
+
def shuffle_playlist(self) -> None:
|
|
49
|
+
"""Shuffle the playlist. Requires rank >= ADMIN."""
|
|
50
|
+
self._request("POST", "/playlist/shuffle")
|
|
51
|
+
|
|
52
|
+
def clear_playlist(self) -> None:
|
|
53
|
+
"""Clear the entire playlist. Requires rank >= ADMIN."""
|
|
54
|
+
self._request("POST", "/playlist/clear")
|
|
55
|
+
|
|
56
|
+
# ── Emotes ────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def get_emotes(self) -> list:
|
|
59
|
+
"""Returns list of { name, image, source }. Works offline."""
|
|
60
|
+
return self._request("GET", "/emotes")
|
|
61
|
+
|
|
62
|
+
def add_emote(self, name: str, image: str) -> None:
|
|
63
|
+
"""Add a new emote. Requires rank >= OWNER. Raises BotAPIError(409) if name taken."""
|
|
64
|
+
self._request("POST", "/emotes", json={"name": name, "image": image})
|
|
65
|
+
|
|
66
|
+
def update_emote(self, name: str, image: str | None = None, new_name: str | None = None) -> None:
|
|
67
|
+
"""Update an emote's image or rename it. Requires rank >= OWNER."""
|
|
68
|
+
body: dict = {}
|
|
69
|
+
if image is not None:
|
|
70
|
+
body["image"] = image
|
|
71
|
+
if new_name is not None:
|
|
72
|
+
body["newName"] = new_name
|
|
73
|
+
self._request("PUT", f"/emotes/{urllib.parse.quote(name)}", json=body)
|
|
74
|
+
|
|
75
|
+
def delete_emote(self, name: str) -> None:
|
|
76
|
+
"""Delete an emote by name. Requires rank >= OWNER."""
|
|
77
|
+
self._request("DELETE", f"/emotes/{urllib.parse.quote(name)}")
|
|
78
|
+
|
|
79
|
+
# ── Users / Moderation ────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def get_users(self) -> list:
|
|
82
|
+
"""List connected users. Returns list of { name, rank, afk, is_bot }."""
|
|
83
|
+
return self._request("GET", "/users")
|
|
84
|
+
|
|
85
|
+
def kick_user(self, name: str, reason: str = "Kicked by bot") -> None:
|
|
86
|
+
"""Kick a user. Requires rank >= MOD."""
|
|
87
|
+
self._request(
|
|
88
|
+
"POST",
|
|
89
|
+
f"/users/{urllib.parse.quote(name)}/kick",
|
|
90
|
+
json={"reason": reason},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def ban_user(self, name: str, reason: str = "Banned by bot") -> None:
|
|
94
|
+
"""Ban a user. Requires rank >= ADMIN."""
|
|
95
|
+
self._request(
|
|
96
|
+
"POST",
|
|
97
|
+
f"/users/{urllib.parse.quote(name)}/ban",
|
|
98
|
+
json={"reason": reason},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def unban_user(self, name: str) -> None:
|
|
102
|
+
"""Remove a ban. Requires rank >= ADMIN."""
|
|
103
|
+
self._request("DELETE", f"/users/{urllib.parse.quote(name)}/ban")
|
|
104
|
+
|
|
105
|
+
def set_user_rank(self, name: str, rank: int) -> None:
|
|
106
|
+
"""Change a user's channel rank. Requires rank >= OWNER."""
|
|
107
|
+
self._request(
|
|
108
|
+
"PUT",
|
|
109
|
+
f"/users/{urllib.parse.quote(name)}/rank",
|
|
110
|
+
json={"rank": rank},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# ── Settings ──────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def get_settings(self) -> dict:
|
|
116
|
+
"""Get channel settings. Requires rank >= OWNER and channel active."""
|
|
117
|
+
return self._request("GET", "/settings")
|
|
118
|
+
|
|
119
|
+
def update_settings(self, **kwargs) -> None:
|
|
120
|
+
"""Update one or more channel settings. Requires rank >= OWNER and channel active."""
|
|
121
|
+
self._request("PUT", "/settings", json=kwargs)
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
import socketio
|
|
6
|
+
|
|
7
|
+
from ._api import BotAPI
|
|
8
|
+
from .exceptions import BotAPIError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Events that the server broadcasts and that user code can subscribe to.
|
|
13
|
+
_PASSTHROUGH_EVENTS = (
|
|
14
|
+
"chatMsg",
|
|
15
|
+
"pm",
|
|
16
|
+
"errorMsg",
|
|
17
|
+
"kick",
|
|
18
|
+
"announcement",
|
|
19
|
+
"clearchat",
|
|
20
|
+
"updateEmote",
|
|
21
|
+
"removeEmote",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Bot:
|
|
26
|
+
"""
|
|
27
|
+
Base class for a veretube sync bot.
|
|
28
|
+
|
|
29
|
+
Connects via socket.io (token auth) and exposes the full REST API as
|
|
30
|
+
convenience methods. Internal state (users, playlist, now_playing,
|
|
31
|
+
channel_opts) is kept up to date automatically from socket events.
|
|
32
|
+
|
|
33
|
+
Usage::
|
|
34
|
+
|
|
35
|
+
bot = Bot(
|
|
36
|
+
token="cbt_...",
|
|
37
|
+
channel="mychannel",
|
|
38
|
+
socket_url="http://localhost:1337",
|
|
39
|
+
api_url="http://localhost:8080/api/v1",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@bot.on("chatMsg")
|
|
43
|
+
def on_chat(data):
|
|
44
|
+
if data["msg"] == "!hello":
|
|
45
|
+
bot.send_message("Hello!")
|
|
46
|
+
|
|
47
|
+
bot.run() # blocks until disconnected
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
token: str,
|
|
53
|
+
channel: str,
|
|
54
|
+
socket_url: str,
|
|
55
|
+
api_url: str,
|
|
56
|
+
reconnection: bool = False,
|
|
57
|
+
reconnection_delay: int = 3,
|
|
58
|
+
):
|
|
59
|
+
if not token.startswith("cbt_"):
|
|
60
|
+
raise ValueError("token must start with 'cbt_'")
|
|
61
|
+
|
|
62
|
+
self.token = token
|
|
63
|
+
self.channel = channel
|
|
64
|
+
self.socket_url = socket_url
|
|
65
|
+
|
|
66
|
+
self.api = BotAPI(api_url, channel, token)
|
|
67
|
+
|
|
68
|
+
# In-memory channel state, kept up to date by socket events
|
|
69
|
+
self.users: list[dict] = []
|
|
70
|
+
self.now_playing: dict | None = None
|
|
71
|
+
self.playlist: list[dict] = []
|
|
72
|
+
self.channel_opts: dict = {}
|
|
73
|
+
|
|
74
|
+
self._handlers: dict[str, list[Callable]] = defaultdict(list)
|
|
75
|
+
|
|
76
|
+
self._sio = socketio.Client(
|
|
77
|
+
reconnection=reconnection,
|
|
78
|
+
reconnection_delay=reconnection_delay,
|
|
79
|
+
logger=False,
|
|
80
|
+
engineio_logger=False,
|
|
81
|
+
)
|
|
82
|
+
self._wire_sio()
|
|
83
|
+
|
|
84
|
+
# ── Internal event wiring ─────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def _wire_sio(self):
|
|
87
|
+
sio = self._sio
|
|
88
|
+
|
|
89
|
+
@sio.on("connect")
|
|
90
|
+
def _connect():
|
|
91
|
+
logger.info("connected to %s", self.socket_url)
|
|
92
|
+
self._fire("connect", None)
|
|
93
|
+
|
|
94
|
+
@sio.on("disconnect")
|
|
95
|
+
def _disconnect(*args):
|
|
96
|
+
reason = args[0] if args else None
|
|
97
|
+
self.users = []
|
|
98
|
+
self.playlist = []
|
|
99
|
+
logger.info("disconnected: %s", reason)
|
|
100
|
+
self._fire("disconnect", reason)
|
|
101
|
+
|
|
102
|
+
@sio.on("connect_error")
|
|
103
|
+
def _connect_error(err):
|
|
104
|
+
logger.error("connection error: %s", err)
|
|
105
|
+
self._fire("connect_error", err)
|
|
106
|
+
|
|
107
|
+
@sio.on("login")
|
|
108
|
+
def _login(data):
|
|
109
|
+
if data.get("success"):
|
|
110
|
+
logger.info("authenticated as %s", data.get("name"))
|
|
111
|
+
sio.emit("joinChannel", {"name": self.channel})
|
|
112
|
+
else:
|
|
113
|
+
logger.error("authentication failed: %s", data.get("error", "unknown"))
|
|
114
|
+
self._fire("login", data)
|
|
115
|
+
|
|
116
|
+
@sio.on("userlist")
|
|
117
|
+
def _userlist(users):
|
|
118
|
+
self.users = list(users)
|
|
119
|
+
logger.debug("userlist: %d users", len(users))
|
|
120
|
+
self._fire("userlist", users)
|
|
121
|
+
|
|
122
|
+
@sio.on("addUser")
|
|
123
|
+
def _add_user(user):
|
|
124
|
+
self.users = [u for u in self.users if u["name"] != user["name"]]
|
|
125
|
+
self.users.append(user)
|
|
126
|
+
self._fire("addUser", user)
|
|
127
|
+
|
|
128
|
+
@sio.on("userLeave")
|
|
129
|
+
def _user_leave(data):
|
|
130
|
+
self.users = [u for u in self.users if u["name"] != data["name"]]
|
|
131
|
+
self._fire("userLeave", data)
|
|
132
|
+
|
|
133
|
+
@sio.on("setUserMeta")
|
|
134
|
+
def _user_meta(data):
|
|
135
|
+
for user in self.users:
|
|
136
|
+
if user["name"] == data["name"]:
|
|
137
|
+
user.setdefault("meta", {}).update(data.get("meta", {}))
|
|
138
|
+
break
|
|
139
|
+
self._fire("setUserMeta", data)
|
|
140
|
+
|
|
141
|
+
@sio.on("setUserRank")
|
|
142
|
+
def _user_rank(data):
|
|
143
|
+
for user in self.users:
|
|
144
|
+
if user["name"] == data["name"]:
|
|
145
|
+
user["rank"] = data["rank"]
|
|
146
|
+
break
|
|
147
|
+
self._fire("setUserRank", data)
|
|
148
|
+
|
|
149
|
+
@sio.on("changeMedia")
|
|
150
|
+
def _change_media(data):
|
|
151
|
+
self.now_playing = data
|
|
152
|
+
logger.debug("now playing: %s", data.get("title"))
|
|
153
|
+
self._fire("changeMedia", data)
|
|
154
|
+
|
|
155
|
+
@sio.on("playlist")
|
|
156
|
+
def _playlist(items):
|
|
157
|
+
self.playlist = list(items)
|
|
158
|
+
self._fire("playlist", items)
|
|
159
|
+
|
|
160
|
+
@sio.on("queue")
|
|
161
|
+
def _queue(data):
|
|
162
|
+
item = data.get("item")
|
|
163
|
+
if item:
|
|
164
|
+
self.playlist.append(item)
|
|
165
|
+
self._fire("queue", data)
|
|
166
|
+
|
|
167
|
+
@sio.on("delete")
|
|
168
|
+
def _delete(data):
|
|
169
|
+
uid = data.get("uid")
|
|
170
|
+
self.playlist = [i for i in self.playlist if i.get("uid") != uid]
|
|
171
|
+
self._fire("delete", data)
|
|
172
|
+
|
|
173
|
+
@sio.on("channelOpts")
|
|
174
|
+
def _channel_opts(opts):
|
|
175
|
+
self.channel_opts = opts
|
|
176
|
+
self._fire("channelOpts", opts)
|
|
177
|
+
|
|
178
|
+
# Events with no state to update — just fan out to user handlers.
|
|
179
|
+
for _event in _PASSTHROUGH_EVENTS:
|
|
180
|
+
def _make(ev: str):
|
|
181
|
+
@sio.on(ev)
|
|
182
|
+
def _handler(data=None):
|
|
183
|
+
self._fire(ev, data)
|
|
184
|
+
_make(_event)
|
|
185
|
+
|
|
186
|
+
def _fire(self, event: str, data):
|
|
187
|
+
for handler in self._handlers[event]:
|
|
188
|
+
try:
|
|
189
|
+
handler(data)
|
|
190
|
+
except Exception:
|
|
191
|
+
logger.exception("unhandled exception in %s handler", event)
|
|
192
|
+
|
|
193
|
+
# ── Event registration ────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
def on(self, event: str) -> Callable:
|
|
196
|
+
"""
|
|
197
|
+
Register a handler for a socket event.
|
|
198
|
+
|
|
199
|
+
Can be used as a decorator::
|
|
200
|
+
|
|
201
|
+
@bot.on("chatMsg")
|
|
202
|
+
def handle_chat(data):
|
|
203
|
+
print(data["msg"])
|
|
204
|
+
|
|
205
|
+
Or called directly::
|
|
206
|
+
|
|
207
|
+
bot.on("chatMsg")(my_handler)
|
|
208
|
+
|
|
209
|
+
Multiple handlers for the same event are all called in registration order.
|
|
210
|
+
Exceptions in handlers are caught and logged so one bad handler doesn't
|
|
211
|
+
kill the others.
|
|
212
|
+
"""
|
|
213
|
+
def decorator(fn: Callable) -> Callable:
|
|
214
|
+
self._handlers[event].append(fn)
|
|
215
|
+
return fn
|
|
216
|
+
return decorator
|
|
217
|
+
|
|
218
|
+
# ── Connection ────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def connect(self):
|
|
221
|
+
"""Open the socket.io connection. Returns immediately after connecting."""
|
|
222
|
+
self._sio.connect(self.socket_url, auth={"token": self.token})
|
|
223
|
+
|
|
224
|
+
def wait(self):
|
|
225
|
+
"""Block until the connection closes. Call after connect()."""
|
|
226
|
+
self._sio.wait()
|
|
227
|
+
|
|
228
|
+
def run(self):
|
|
229
|
+
"""Connect and block until disconnected. Equivalent to connect() + wait()."""
|
|
230
|
+
self.connect()
|
|
231
|
+
self._sio.wait()
|
|
232
|
+
|
|
233
|
+
def disconnect(self):
|
|
234
|
+
"""Close the connection."""
|
|
235
|
+
self._sio.disconnect()
|
|
236
|
+
|
|
237
|
+
# ── Chat ──────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
def send_message(self, msg: str, to: str | None = None):
|
|
240
|
+
"""Send a chat message, optionally prefixed with 'to: '."""
|
|
241
|
+
if to:
|
|
242
|
+
msg = f"{to}: {msg}"
|
|
243
|
+
self._sio.emit("chatMsg", {"msg": msg, "meta": {}})
|
|
244
|
+
|
|
245
|
+
def send_action(self, text: str):
|
|
246
|
+
"""Send a /me action message."""
|
|
247
|
+
self._sio.emit("chatMsg", {"msg": f"/me {text}", "meta": {}})
|
|
248
|
+
|
|
249
|
+
def send_pm(self, to: str, msg: str):
|
|
250
|
+
"""Send a private message."""
|
|
251
|
+
self._sio.emit("pm", {"to": to, "msg": msg, "meta": {}})
|
|
252
|
+
|
|
253
|
+
# ── Playlist ──────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
def queue(self, id: str, type: str, pos: str = "end"):
|
|
256
|
+
"""
|
|
257
|
+
Queue media. type is the source: 'yt', 'sc', 'tw', 'fi', etc.
|
|
258
|
+
pos is 'end' or 'next'. Requires rank >= MOD.
|
|
259
|
+
"""
|
|
260
|
+
self.api.add_to_playlist(id, type, pos)
|
|
261
|
+
|
|
262
|
+
def delete_item(self, uid: int):
|
|
263
|
+
"""Remove a playlist item by uid. Requires rank >= ADMIN."""
|
|
264
|
+
self.api.delete_playlist_item(uid)
|
|
265
|
+
|
|
266
|
+
def skip_to(self, uid: int):
|
|
267
|
+
"""Jump to a specific playlist item by uid. Requires rank >= MOD."""
|
|
268
|
+
self.api.skip_to(uid)
|
|
269
|
+
|
|
270
|
+
def skip(self):
|
|
271
|
+
"""
|
|
272
|
+
Skip to the next playlist item. Requires rank >= MOD.
|
|
273
|
+
Fetches the current playlist state via REST to find the next uid.
|
|
274
|
+
"""
|
|
275
|
+
data = self.api.get_playlist()
|
|
276
|
+
items = data.get("items", [])
|
|
277
|
+
idx = data.get("currentIndex", -1)
|
|
278
|
+
if 0 <= idx < len(items) - 1:
|
|
279
|
+
self.api.skip_to(items[idx + 1]["uid"])
|
|
280
|
+
|
|
281
|
+
def shuffle_playlist(self):
|
|
282
|
+
"""Shuffle the playlist. Requires rank >= ADMIN."""
|
|
283
|
+
self.api.shuffle_playlist()
|
|
284
|
+
|
|
285
|
+
def clear_playlist(self):
|
|
286
|
+
"""Clear the entire playlist. Requires rank >= ADMIN."""
|
|
287
|
+
self.api.clear_playlist()
|
|
288
|
+
|
|
289
|
+
# ── Emotes ────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
def get_emotes(self) -> list:
|
|
292
|
+
"""Return the full emote list. Works even when channel is offline."""
|
|
293
|
+
return self.api.get_emotes()
|
|
294
|
+
|
|
295
|
+
def add_emote(self, name: str, image: str):
|
|
296
|
+
"""Add an emote. Requires rank >= OWNER."""
|
|
297
|
+
self.api.add_emote(name, image)
|
|
298
|
+
|
|
299
|
+
def update_emote(self, name: str, image: str | None = None, new_name: str | None = None):
|
|
300
|
+
"""Update an emote's image or rename it. Requires rank >= OWNER."""
|
|
301
|
+
self.api.update_emote(name, image=image, new_name=new_name)
|
|
302
|
+
|
|
303
|
+
def delete_emote(self, name: str):
|
|
304
|
+
"""Delete an emote. Requires rank >= OWNER."""
|
|
305
|
+
self.api.delete_emote(name)
|
|
306
|
+
|
|
307
|
+
# ── Moderation ────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
def kick(self, name: str, reason: str = "Kicked by bot"):
|
|
310
|
+
"""Kick a user. Requires rank >= MOD."""
|
|
311
|
+
self.api.kick_user(name, reason)
|
|
312
|
+
|
|
313
|
+
def ban(self, name: str, reason: str = "Banned by bot"):
|
|
314
|
+
"""Ban a user. Requires rank >= ADMIN."""
|
|
315
|
+
self.api.ban_user(name, reason)
|
|
316
|
+
|
|
317
|
+
def unban(self, name: str):
|
|
318
|
+
"""Remove a ban. Requires rank >= ADMIN."""
|
|
319
|
+
self.api.unban_user(name)
|
|
320
|
+
|
|
321
|
+
def set_rank(self, name: str, rank: int):
|
|
322
|
+
"""Change a user's channel rank. Requires rank >= OWNER."""
|
|
323
|
+
self.api.set_user_rank(name, rank)
|
|
324
|
+
|
|
325
|
+
# ── Settings ──────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
def get_settings(self) -> dict:
|
|
328
|
+
"""Get channel settings. Requires rank >= OWNER and channel active."""
|
|
329
|
+
return self.api.get_settings()
|
|
330
|
+
|
|
331
|
+
def update_settings(self, **kwargs):
|
|
332
|
+
"""
|
|
333
|
+
Update channel settings. Pass any setting key as a keyword argument.
|
|
334
|
+
Requires rank >= OWNER and channel active. Example::
|
|
335
|
+
|
|
336
|
+
bot.update_settings(pagetitle="Now Playing: Chill", allow_voteskip=False)
|
|
337
|
+
"""
|
|
338
|
+
self.api.update_settings(**kwargs)
|
|
339
|
+
|
|
340
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
def get_user(self, name: str) -> dict | None:
|
|
343
|
+
"""Look up a user in the current in-memory userlist by name."""
|
|
344
|
+
return next((u for u in self.users if u["name"] == name), None)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: veretube-bot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python bot library for veretube sync channels
|
|
5
|
+
Author-email: veretube <faceeatingtumor@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://veretium.com
|
|
8
|
+
Project-URL: Source, https://veretium.com/w0zard/veretube_bot_lib
|
|
9
|
+
Keywords: veretube,cytube,bot,chat,socketio
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Communications :: Chat
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: python-socketio[client]>=5.0
|
|
22
|
+
Requires-Dist: requests>=2.28
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
25
|
+
Requires-Dist: build; extra == "dev"
|
|
26
|
+
Requires-Dist: twine; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# veretube-bot
|
|
30
|
+
|
|
31
|
+
Python library for writing bots on sync 4.0 aka veretube channels.
|
|
32
|
+
|
|
33
|
+
Bots connect over **socket.io** for real-time events (chat, user list, playlist changes) and use a **REST API** for commands (queue media, kick/ban, manage emotes, read/write settings). Both surfaces are covered by this library.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install veretube-bot
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Getting a token
|
|
42
|
+
|
|
43
|
+
Tokens are issued in the channel settings modal on the **Bots** tab. You need at least moderator rank to see it. Fill in a name and rank, click **Issue Token**, and copy the token immediately — it starts with `cbt_` and is only shown once.
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from veretube_bot import Bot
|
|
49
|
+
|
|
50
|
+
bot = Bot(
|
|
51
|
+
token="cbt_...",
|
|
52
|
+
channel="mychannel",
|
|
53
|
+
socket_url="http://your-server:1337", # socket.io port (default 1337)
|
|
54
|
+
api_url="http://your-server:8080/api/v1", # HTTP port (default 8080)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@bot.on("chatMsg")
|
|
58
|
+
def on_chat(data):
|
|
59
|
+
if data["msg"] == "!hello":
|
|
60
|
+
bot.send_message("Hello!", to=data["username"])
|
|
61
|
+
|
|
62
|
+
if data["msg"] == "!np":
|
|
63
|
+
if bot.now_playing:
|
|
64
|
+
bot.send_message(f"Now playing: {bot.now_playing['title']}")
|
|
65
|
+
|
|
66
|
+
@bot.on("changeMedia")
|
|
67
|
+
def on_media(data):
|
|
68
|
+
print(f"Now playing: {data['title']}")
|
|
69
|
+
|
|
70
|
+
bot.run() # connect and block until disconnected
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Connection
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
Bot(
|
|
77
|
+
token, # str — bot token starting with 'cbt_'
|
|
78
|
+
channel, # str — channel name (lowercase, no '#')
|
|
79
|
+
socket_url, # str — socket.io server URL (uses io.port, not the HTTP port)
|
|
80
|
+
api_url, # str — REST API base URL, e.g. http://host:8080/api/v1
|
|
81
|
+
reconnection=False, # set True to reconnect automatically on drop
|
|
82
|
+
reconnection_delay=3, # seconds between reconnect attempts
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Method | Description |
|
|
87
|
+
|--------|-------------|
|
|
88
|
+
| `bot.run()` | Connect and block until disconnected |
|
|
89
|
+
| `bot.connect()` | Open the connection (returns immediately) |
|
|
90
|
+
| `bot.wait()` | Block until disconnected (call after `connect()`) |
|
|
91
|
+
| `bot.disconnect()` | Close the connection |
|
|
92
|
+
|
|
93
|
+
By default `reconnection=False` — the expectation is that an external process supervisor (systemd, supervisord, etc.) handles restarts. Pass `reconnection=True` for bots that should self-recover.
|
|
94
|
+
|
|
95
|
+
## Event handling
|
|
96
|
+
|
|
97
|
+
Register handlers with `@bot.on(event_name)`. Multiple handlers for the same event are all called. Exceptions in a handler are logged and skipped so one bad handler doesn't affect the others.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
@bot.on("chatMsg")
|
|
101
|
+
def on_chat(data):
|
|
102
|
+
print(data["username"], data["msg"])
|
|
103
|
+
|
|
104
|
+
@bot.on("userLeave")
|
|
105
|
+
def on_leave(data):
|
|
106
|
+
print(f"{data['name']} left")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Socket events
|
|
110
|
+
|
|
111
|
+
| Event | Payload | Description |
|
|
112
|
+
|-------|---------|-------------|
|
|
113
|
+
| `connect` | `None` | Socket connected |
|
|
114
|
+
| `disconnect` | reason string or `None` | Socket disconnected |
|
|
115
|
+
| `connect_error` | error object | Connection failed |
|
|
116
|
+
| `login` | `{ success, name, guest }` | Server accepted the token |
|
|
117
|
+
| `chatMsg` | `{ username, msg, meta, time }` | Chat message sent |
|
|
118
|
+
| `pm` | `{ username, msg, meta }` | Private message received |
|
|
119
|
+
| `userlist` | `[{ name, rank, meta }, ...]` | Full user list on join |
|
|
120
|
+
| `addUser` | `{ name, rank, meta }` | User joined |
|
|
121
|
+
| `userLeave` | `{ name }` | User left |
|
|
122
|
+
| `setUserMeta` | `{ name, meta }` | User AFK/muted state changed |
|
|
123
|
+
| `setUserRank` | `{ name, rank }` | User rank changed |
|
|
124
|
+
| `changeMedia` | `{ id, type, title, seconds, ... }` | New video started |
|
|
125
|
+
| `playlist` | `[{ uid, media, queueby, temp }, ...]` | Full playlist on join |
|
|
126
|
+
| `queue` | `{ item, after }` | Item added to playlist |
|
|
127
|
+
| `delete` | `{ uid }` | Playlist item removed |
|
|
128
|
+
| `channelOpts` | settings dict | Channel options updated |
|
|
129
|
+
| `clearchat` | `None` | Chat cleared by a moderator |
|
|
130
|
+
| `errorMsg` | `{ msg }` | Error from the server |
|
|
131
|
+
| `kick` | `{ reason }` | Bot was kicked |
|
|
132
|
+
| `announcement` | `{ title, text }` | Server-wide announcement |
|
|
133
|
+
| `updateEmote` | `{ name, image }` | Emote added or changed |
|
|
134
|
+
| `removeEmote` | `{ name }` | Emote removed |
|
|
135
|
+
|
|
136
|
+
`meta.is_bot` is `True` in `chatMsg` and userlist payloads for bots.
|
|
137
|
+
|
|
138
|
+
## In-memory state
|
|
139
|
+
|
|
140
|
+
These are updated automatically from socket events before your handlers are called:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
bot.users # list of { name, rank, meta } — current user list
|
|
144
|
+
bot.now_playing # { id, type, title, seconds, ... } or None
|
|
145
|
+
bot.playlist # list of playlist items
|
|
146
|
+
bot.channel_opts # channel options dict
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Look up a specific user:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
user = bot.get_user("Alice") # returns dict or None
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Chat
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
bot.send_message("Hello!")
|
|
159
|
+
bot.send_message("Hey!", to="Alice") # sends "Alice: Hey!"
|
|
160
|
+
bot.send_action("waves") # sends "/me waves"
|
|
161
|
+
bot.send_pm("Alice", "Hello privately")
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Playlist
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
bot.queue("dQw4w9WgXcQ", "yt") # add to end (rank >= MOD)
|
|
168
|
+
bot.queue("dQw4w9WgXcQ", "yt", "next") # add as next up
|
|
169
|
+
bot.skip() # skip to next item (rank >= MOD)
|
|
170
|
+
bot.skip_to(uid) # jump to specific uid (rank >= MOD)
|
|
171
|
+
bot.delete_item(uid) # remove by uid (rank >= ADMIN)
|
|
172
|
+
bot.shuffle_playlist() # shuffle (rank >= ADMIN)
|
|
173
|
+
bot.clear_playlist() # clear all (rank >= ADMIN)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Media types:** `yt` YouTube, `sc` SoundCloud, `tw` Twitch stream, `tc` Twitch clip, `vm` Vimeo, `dm` Dailymotion, `fi` direct file URL, `cu` custom embed.
|
|
177
|
+
|
|
178
|
+
## Moderation
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
bot.kick("BadUser") # rank >= MOD
|
|
182
|
+
bot.kick("BadUser", reason="Spamming")
|
|
183
|
+
bot.ban("BadUser") # rank >= ADMIN
|
|
184
|
+
bot.ban("BadUser", reason="Evading")
|
|
185
|
+
bot.unban("BadUser") # rank >= ADMIN
|
|
186
|
+
bot.set_rank("Alice", Rank.MOD) # rank >= OWNER
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Emotes
|
|
190
|
+
|
|
191
|
+
Emote endpoints read/write the database directly and work even when the channel is offline.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
emotes = bot.get_emotes() # list of { name, image, source }
|
|
195
|
+
bot.add_emote("KEKW", "https://...") # rank >= OWNER
|
|
196
|
+
bot.update_emote("KEKW", image="https://...")
|
|
197
|
+
bot.update_emote("KEKW", new_name="KEKWait")
|
|
198
|
+
bot.delete_emote("KEKW") # rank >= OWNER
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Settings
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
settings = bot.get_settings() # rank >= OWNER, channel must be active
|
|
205
|
+
bot.update_settings(pagetitle="Now Playing: Chill Beats")
|
|
206
|
+
bot.update_settings(allow_voteskip=False, afk_timeout=300)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Available setting keys: `allow_voteskip`, `allow_dupes`, `voteskip_ratio`, `maxlength`, `playlist_max_duration_per_user`, `afk_timeout`, `enable_link_regex`, `chat_antiflood`, `chat_antiflood_burst`, `chat_antiflood_sustained`, `new_user_chat_delay`, `new_user_chat_link_delay`, `pagetitle`, `password`, `externalcss`, `externaljs`, `show_public`, `torbanned`, `block_anonymous_users`, `allow_ascii_control`, `playlist_max_per_user`.
|
|
210
|
+
|
|
211
|
+
## Direct REST access
|
|
212
|
+
|
|
213
|
+
Every REST endpoint is also accessible on `bot.api` if you need something not covered by the shortcuts:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
playlist = bot.api.get_playlist() # { items, currentIndex, locked }
|
|
217
|
+
bot.api.skip_to(uid)
|
|
218
|
+
bot.api.set_user_rank("Alice", 2)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Error handling
|
|
222
|
+
|
|
223
|
+
REST calls raise `BotAPIError` on failure:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from veretube_bot import Bot, BotAPIError
|
|
227
|
+
|
|
228
|
+
@bot.on("chatMsg")
|
|
229
|
+
def on_chat(data):
|
|
230
|
+
if data["msg"].startswith("!add "):
|
|
231
|
+
_, type, id = data["msg"].split(None, 2)
|
|
232
|
+
try:
|
|
233
|
+
bot.queue(id, type)
|
|
234
|
+
except BotAPIError as e:
|
|
235
|
+
bot.send_message(f"Error: {e}") # e.status_code has the HTTP code
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Rank constants
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from veretube_bot import Rank
|
|
242
|
+
|
|
243
|
+
Rank.MOD # 2
|
|
244
|
+
Rank.ADMIN # 3
|
|
245
|
+
Rank.OWNER # 4
|
|
246
|
+
Rank.CREATOR # 5
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
A bot's effective rank is capped at the rank of the user who issued its token.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
veretube_bot/__init__.py
|
|
5
|
+
veretube_bot/_api.py
|
|
6
|
+
veretube_bot/bot.py
|
|
7
|
+
veretube_bot/exceptions.py
|
|
8
|
+
veretube_bot/rank.py
|
|
9
|
+
veretube_bot.egg-info/PKG-INFO
|
|
10
|
+
veretube_bot.egg-info/SOURCES.txt
|
|
11
|
+
veretube_bot.egg-info/dependency_links.txt
|
|
12
|
+
veretube_bot.egg-info/requires.txt
|
|
13
|
+
veretube_bot.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
veretube_bot
|