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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .bot import Bot
2
+ from .exceptions import BotAPIError
3
+ from .rank import Rank
4
+
5
+ __all__ = ["Bot", "BotAPIError", "Rank"]
@@ -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,4 @@
1
+ class BotAPIError(Exception):
2
+ def __init__(self, message: str, status_code: int | None = None):
3
+ super().__init__(message)
4
+ self.status_code = status_code
@@ -0,0 +1,5 @@
1
+ class Rank:
2
+ MOD = 2
3
+ ADMIN = 3
4
+ OWNER = 4
5
+ CREATOR = 5
@@ -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,7 @@
1
+ python-socketio[client]>=5.0
2
+ requests>=2.28
3
+
4
+ [dev]
5
+ pytest
6
+ build
7
+ twine
@@ -0,0 +1 @@
1
+ veretube_bot