This commit is contained in:
Spengreb 2026-05-04 19:34:31 -04:00 committed by GitHub
commit 65fddd1d7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2294 additions and 18 deletions

1
.gitignore vendored
View file

@ -19,5 +19,6 @@ www/js/cytube-google-drive.meta.js
www/js/player.js
tor-exit-list.json
*.patch
examples/demo-bot/.env
mysql/
peertube-hosts.json

431
docs/bot-api.md Normal file
View file

@ -0,0 +1,431 @@
# Bot API
Bots connect to a channel in two ways:
- **WebSocket** (socket.io) — real-time events: chat, user list, playlist changes, media updates
- **REST** (`/api/v1/...`) — commands: queue/delete/shuffle/clear playlist, manage emotes, read/write settings, kick/ban users
Chat can only be sent and received over the WebSocket. The REST API has no chat endpoint.
---
## Ranks
| Name | Value |
|-----------|-------|
| Moderator | 2 |
| Admin | 3 |
| Owner | 4 |
| Creator | 5 |
A bot's rank is capped at the rank of the user who issued its token. Every REST endpoint that modifies state has a minimum rank requirement listed in its description.
---
## Issuing a token
Tokens are issued in the channel settings modal on the **Bots** tab. You need at least moderator rank (2) to see this tab.
Fill in a name (120 alphanumeric, `-`, `_` characters), choose a rank, and click **Issue Token**. The token is shown **once** — copy it immediately. It looks like:
```
cbt_a3f8e2...64 hex characters...
```
To revoke a token, click **Revoke** next to it in the table. Any live WebSocket connections using that token are disconnected immediately.
---
## WebSocket connection
The bot authenticates by passing the token in the socket.io `auth` object:
```js
const io = require('socket.io-client');
const socket = io('http://your-server:1337', {
auth: { token: process.env.BOT_TOKEN }
});
```
> **Port note:** socket.io runs on the port defined by `io.port` in your server config (default 1337), which is separate from the HTTP port used for the web UI and REST API.
### Join sequence
After connecting you must wait for the `login` event before emitting `joinChannel`, otherwise the server will ignore it:
```js
socket.once('login', () => {
socket.emit('joinChannel', { name: 'your-channel-name' });
});
```
### Sending chat
```js
socket.emit('chatMsg', { msg: 'Hello from a bot!' });
// Action message:
socket.emit('chatMsg', { msg: '/me waves' });
```
### Events the bot receives
| Event | Payload | Description |
|------------------|----------------------------------------------|-------------------------------------|
| `login` | `{ success, name, guest }` | Server accepted the connection |
| `chatMsg` | `{ username, msg, meta, time }` | A chat message was sent |
| `userlist` | `[{ name, rank, meta }, ...]` | Full user list on join |
| `addUser` | `{ name, rank, meta }` | A user joined the channel |
| `userLeave` | `{ name }` | A user left the channel |
| `setUserMeta` | `{ name, meta }` | A user's meta (AFK, muted) changed |
| `changeMedia` | `{ id, type, title, seconds, ... }` | A new video started playing |
| `playlist` | `[{ uid, media, queueby, temp }, ...]` | Full playlist on join |
| `queue` | `{ item, after }` | An item was added to the playlist |
| `delete` | `{ uid }` | A playlist item was removed |
| `errorMsg` | `{ msg }` | An error from the server |
| `kick` | `{ reason }` | The bot was kicked |
| `announcement` | `{ title, text }` | Server-wide announcement |
`meta.is_bot` is set to `true` in `chatMsg` and user list payloads for bots, which the web UI renders as a `[bot]` badge.
---
## REST API
### Base URL
```
http://your-server:8080/api/v1
```
All channel-scoped endpoints are under `/channels/:channel/`.
### Authentication
Every REST request must include the bot token as a Bearer token:
```
Authorization: Bearer cbt_...
```
The token is validated against the channel in the URL — a token issued for `#general` will be rejected for `/channels/gaming/...`.
### Error responses
All errors return JSON:
```json
{ "error": "Human readable message" }
```
Common status codes:
| Code | Meaning |
|------|-------------------------------------------------------|
| 400 | Bad request (missing/invalid fields) |
| 401 | Missing or invalid token |
| 403 | Token not authorized for this channel, or rank too low |
| 404 | Resource not found |
| 503 | Channel is not currently active (no users in it) |
---
### Playlist
Playlist endpoints require the channel to be active (at least one user present).
#### `GET /channels/:channel/playlist`
Returns the current playlist and which item is playing.
No minimum rank.
**Response:**
```json
{
"items": [
{
"uid": 1,
"id": "dQw4w9WgXcQ",
"type": "yt",
"title": "Rick Astley - Never Gonna Give You Up",
"seconds": 212,
"duration": "3:32",
"thumb": { "url": "..." },
"meta": {}
}
],
"currentIndex": 0,
"locked": false
}
```
`uid` is the server-assigned unique ID for the playlist slot. Use it for delete/jump operations.
#### `POST /channels/:channel/playlist`
Queue a new item. Minimum rank: **2 (Mod)**.
Returns `202 Accepted` immediately because media lookup is asynchronous. If the bot's permissions don't allow the add (e.g. playlist is locked and rank is too low), a `400` is returned synchronously.
**Body:**
```json
{ "id": "dQw4w9WgXcQ", "type": "yt", "pos": "end" }
```
| Field | Required | Values | Default |
|-------|----------|---------------------|---------|
| `id` | yes | media ID string | |
| `type`| yes | see media types below | |
| `pos` | no | `"end"` or `"next"` | `"end"` |
**Media types:**
| Type | Source |
|------|----------------------|
| `yt` | YouTube |
| `sc` | SoundCloud |
| `tw` | Twitch stream |
| `tc` | Twitch clip |
| `rt` | Dailymotion |
| `vm` | Vimeo |
| `dm` | Dailymotion |
| `gd` | Google Drive |
| `fi` | Direct file URL |
| `cu` | Custom embed (HTML) |
#### `DELETE /channels/:channel/playlist/:uid`
Remove a playlist item by uid. Minimum rank: **3 (Admin)**.
#### `PUT /channels/:channel/playlist/playing`
Skip to a specific item. Minimum rank: **2 (Mod)**.
**Body:**
```json
{ "uid": 3 }
```
#### `POST /channels/:channel/playlist/shuffle`
Shuffle the playlist. Minimum rank: **3 (Admin)**.
#### `POST /channels/:channel/playlist/clear`
Clear the entire playlist. Minimum rank: **3 (Admin)**.
---
### Emotes
Emote endpoints read and write directly to the database and work even when the channel is offline. If the channel happens to be active, changes are broadcast live to connected users.
#### `GET /channels/:channel/emotes`
Returns the full emote list. No minimum rank.
**Response:**
```json
[
{ "name": "KEKW", "image": "https://cdn.example.com/kekw.png", "source": "..." }
]
```
#### `POST /channels/:channel/emotes`
Add a new emote. Minimum rank: **4 (Owner)**.
**Body:**
```json
{ "name": "KEKW", "image": "https://cdn.example.com/kekw.png" }
```
Returns `409 Conflict` if the name is already taken.
#### `PUT /channels/:channel/emotes/:name`
Update an emote's image or rename it. Minimum rank: **4 (Owner)**.
**Body** (all fields optional, but at least one must differ):
```json
{ "image": "https://cdn.example.com/kekw-v2.png", "newName": "KEKWait" }
```
Omit `newName` to keep the existing name. Returns `409` if the new name is already taken.
#### `DELETE /channels/:channel/emotes/:name`
Delete an emote by name. Minimum rank: **4 (Owner)**.
---
### Users / Moderation
These endpoints require the channel to be active.
#### `GET /channels/:channel/users`
List currently connected users. No minimum rank.
**Response:**
```json
[
{ "name": "Alice", "rank": 3, "afk": false, "is_bot": false },
{ "name": "MyBot", "rank": 2, "afk": false, "is_bot": true }
]
```
#### `POST /channels/:channel/users/:name/kick`
Kick a user. Minimum rank: **2 (Mod)**. Cannot kick users with equal or higher rank.
**Body:**
```json
{ "reason": "Spamming" }
```
`reason` is optional, defaults to `"Kicked by bot"`.
#### `POST /channels/:channel/users/:name/ban`
Ban a user by name. Minimum rank: **3 (Admin)**. Cannot ban users with equal or higher rank.
**Body:**
```json
{ "reason": "Ban evasion" }
```
`reason` is optional, defaults to `"Banned by bot"`.
#### `DELETE /channels/:channel/users/:name/ban`
Remove a ban. Minimum rank: **3 (Admin)**.
#### `PUT /channels/:channel/users/:name/rank`
Change a user's channel rank. Minimum rank: **4 (Owner)**. Cannot assign a rank equal to or higher than your own, and cannot target users with equal or higher rank.
**Body:**
```json
{ "rank": 2 }
```
---
### Settings
Settings endpoints require the channel to be active. Minimum rank: **4 (Owner)** for both read and write.
#### `GET /channels/:channel/settings`
Returns current channel options.
**Response:**
```json
{
"pagetitle": "My Channel",
"allow_voteskip": true,
"voteskip_ratio": 0.5,
"maxlength": 0,
"afk_timeout": 600,
"password": "",
"show_public": false,
...
}
```
**Available 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`
#### `PUT /channels/:channel/settings`
Update one or more settings. Unknown keys are silently ignored.
**Body:**
```json
{ "pagetitle": "Now Playing: Chill Beats", "allow_voteskip": false }
```
---
### Bot management
These endpoints use **session cookie auth** (the normal logged-in web session), not a bot token. They are intended for the channel settings UI.
#### `GET /channels/:channel/bots`
List all bots for the channel. Returns id, name, rank, creator, creation time, last connection time. Requires channel rank ≥ 2.
#### `POST /channels/:channel/bots`
Issue a new bot token. Requires channel rank ≥ 2. The returned `token` is shown **once** and not stored.
**Body:**
```json
{ "name": "MyBot", "rank": 2 }
```
**Response:**
```json
{ "id": 7, "name": "MyBot", "rank": 2, "token": "cbt_..." }
```
#### `DELETE /channels/:channel/bots/:id`
Revoke a bot token. Any live socket connections for that bot are disconnected immediately. Requires channel rank ≥ 2 and your rank must be ≥ the bot's rank.
---
## Demo bot
A working example with a TUI is in `examples/demo-bot/`.
```
cd examples/demo-bot
npm install
cp .env.example .env
# Edit .env: fill in BOT_TOKEN, CHANNEL, SERVER_URL, API_BASE
node bot.js
```
**.env fields:**
| Variable | Description | Default |
|--------------|---------------------------------------------------------|-----------------------------------|
| `BOT_TOKEN` | Bot token from the channel settings Bots tab | — |
| `CHANNEL` | Channel name (lowercase, no `#`) | — |
| `SERVER_URL` | socket.io URL (use the io.port, not the HTTP port) | `http://localhost:1337` |
| `API_BASE` | Base URL for REST requests | `http://localhost:8080/api/v1` |
**TUI keybindings:** Enter to send, PgUp/PgDn to scroll, Ctrl-C to quit.
**Bot commands** (prefix with `/` in the input box):
| Command | Description |
|--------------------------|----------------------------------------|
| `/help` | List commands |
| `/playlist` | Show playlist via REST |
| `/emotes` | List emotes via REST |
| `/settings` | Show channel settings via REST |
| `/users` | Show connected user list |
| `/add <type> <id>` | Queue media, e.g. `/add yt dQw4w9WgXcQ` |
| `/skip` | Skip to next item |
| `/clear` | Clear the playlist |
| `/kick <name> [reason]` | Kick a user |
| `/me <text>` | Send an action message |

View file

@ -0,0 +1,11 @@
# Issue a bot token in the channel settings modal (Bots tab), then paste it here.
BOT_TOKEN=cbt_your_token_here
# The channel this token was issued for.
CHANNEL=yourchannel
# Base URL of the CyTube server (no trailing slash).
SERVER_URL=http://localhost:1337
# REST API base (usually SERVER_URL + /api/v1).
API_BASE=http://localhost:8080/api/v1

594
examples/demo-bot/bot.js Normal file
View file

@ -0,0 +1,594 @@
#!/usr/bin/env node
/**
* CyTube Sync Demo Bot
*
* Shows the two halves of the bot API:
* WebSocket (socket.io-client) real-time events: chat, user list, playlist
* REST API (fetch) queries and commands: playlist, emotes, settings, moderation
*
* Setup:
* cp .env.example .env # fill in BOT_TOKEN and CHANNEL
* npm install
* node bot.js
*
* TUI keybindings:
* Enter send message or run command
* PgUp/PgDn scroll chat
* Ctrl-C quit
*
* Commands (prefix with /):
* /help list commands
* /playlist show current playlist via REST
* /emotes list emotes via REST
* /settings show channel settings via REST
* /users dump user list from in-memory state
* /add <type> <id> add media e.g. /add yt dQw4w9WgXcQ
* /skip skip to next playlist item
* /clear clear the playlist
* /kick <name> [reason] kick a user
* /me <text> send an action (/me) message
*/
'use strict';
require('dotenv').config();
const io = require('socket.io-client');
const fetch = require('node-fetch');
const blessed = require('blessed');
// ── Config ────────────────────────────────────────────────────────────────────
const {
BOT_TOKEN,
CHANNEL,
SERVER_URL = 'http://localhost:8080',
API_BASE = 'http://localhost:8080/api/v1',
} = process.env;
if (!BOT_TOKEN || !CHANNEL) {
console.error('BOT_TOKEN and CHANNEL must be set. Copy .env.example to .env and fill it in.');
process.exit(1);
}
if (!BOT_TOKEN.startsWith('cbt_')) {
console.error('BOT_TOKEN does not look right — it should start with cbt_');
process.exit(1);
}
// ── REST helpers ──────────────────────────────────────────────────────────────
async function apiRequest(method, path, body) {
const url = `${API_BASE}/channels/${CHANNEL}${path}`;
const opts = {
method,
headers: {
'Authorization': `Bearer ${BOT_TOKEN}`,
'Content-Type': 'application/json',
},
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
const json = await res.json().catch(() => null);
if (!res.ok) {
throw new Error((json && json.error) || `HTTP ${res.status}`);
}
return json;
}
const api = {
get: (path) => apiRequest('GET', path),
post: (path, body) => apiRequest('POST', path, body),
put: (path, body) => apiRequest('PUT', path, body),
delete: (path) => apiRequest('DELETE', path),
};
// ── Utilities ─────────────────────────────────────────────────────────────────
function stripHtml(html) {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
// Escape blessed tag syntax so server content can't inject markup.
function escBless(str) {
return String(str).replace(/\{/g, '\\{');
}
function hhmm(secs) {
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`;
}
function timestamp() {
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// ── In-memory state ───────────────────────────────────────────────────────────
const state = {
users: [], // userlist entries from server
nowPlaying: null, // current changeMedia payload
connected: false,
inChannel: false,
};
// ── TUI ───────────────────────────────────────────────────────────────────────
const screen = blessed.screen({
smartCSR: true,
title: `CyTube Bot :: #${CHANNEL}`,
fullUnicode: true,
forceUnicode: true,
});
// Top bar — connection status + now playing
const statusBar = blessed.box({
top: 0,
left: 0,
width: '100%',
height: 1,
tags: true,
style: { bg: 'blue', fg: 'white', bold: true },
content: ` CyTube Bot #${CHANNEL} Connecting...`,
});
// Main chat log (left, scrollable)
const chatLog = blessed.log({
top: 1,
left: 0,
width: '70%',
height: '100%-4',
border: { type: 'line' },
label: ' Chat ',
scrollable: true,
alwaysScroll: true,
scrollbar: { ch: ' ', style: { bg: 'cyan' } },
mouse: true,
tags: true,
wrap: true,
style: { border: { fg: 'cyan' }, label: { fg: 'cyan', bold: true } },
});
// User list sidebar (right)
const userList = blessed.list({
top: 1,
right: 0,
width: '30%',
height: '100%-4',
border: { type: 'line' },
label: ' Users ',
scrollable: true,
mouse: true,
tags: true,
style: {
border: { fg: 'cyan' },
label: { fg: 'cyan', bold: true },
item: { fg: 'white' },
selected: { fg: 'white' },
},
});
// Input box (bottom)
const inputBox = blessed.textbox({
bottom: 0,
left: 0,
width: '100%',
height: 3,
border: { type: 'line' },
label: ' Message — /help for commands — Ctrl-C to quit ',
inputOnFocus: true,
style: { border: { fg: 'green' }, label: { fg: 'green' } },
});
screen.append(statusBar);
screen.append(chatLog);
screen.append(userList);
screen.append(inputBox);
screen.key(['C-c'], () => process.exit(0));
// ── TUI helpers ───────────────────────────────────────────────────────────────
function setStatus(msg) {
statusBar.setContent(` CyTube Bot #${CHANNEL} ${msg}`);
screen.render();
}
function chat(line) {
chatLog.log(line);
screen.render();
}
function info(line) {
chat(`{cyan-fg}${line}{/cyan-fg}`);
}
function warn(line) {
chat(`{yellow-fg}${line}{/yellow-fg}`);
}
function fail(line) {
chat(`{red-fg}✗ ${line}{/red-fg}`);
}
function rebuildUserList() {
const items = state.users.map(u => {
const isBot = u.meta && u.meta.is_bot;
const afk = u.meta && u.meta.afk;
let rankStr;
if (u.rank >= 5) rankStr = '{red-fg}[creator]{/red-fg}';
else if (u.rank >= 4) rankStr = '{yellow-fg}[owner]{/yellow-fg}';
else if (u.rank >= 3) rankStr = '{magenta-fg}[admin]{/magenta-fg}';
else if (u.rank >= 2) rankStr = '{green-fg}[mod]{/green-fg}';
else rankStr = '';
const botTag = isBot ? ' {blue-fg}[bot]{/blue-fg}' : '';
const afkTag = afk ? ' {grey-fg}[afk]{/grey-fg}' : '';
return `${escBless(u.name)} ${rankStr}${botTag}${afkTag}`;
});
userList.setLabel(` Users (${items.length}) `);
userList.setItems(items);
screen.render();
}
// ── Socket.IO ─────────────────────────────────────────────────────────────────
const socket = io(SERVER_URL, {
// This is how the bot authenticates — token goes in socket.handshake.auth.token
auth: { token: BOT_TOKEN },
reconnection: true,
reconnectionDelay: 3000,
reconnectionAttempts: Infinity,
});
socket.on('connect', () => {
state.connected = true;
setStatus('Authenticating...');
});
socket.on('disconnect', (reason) => {
state.connected = false;
state.inChannel = false;
state.users = [];
rebuildUserList();
warn(`Disconnected: ${reason}`);
setStatus(`Disconnected — reconnecting...`);
});
socket.on('connect_error', (err) => {
fail(`Connection error: ${err.message}`);
});
// Server confirms authentication and sends the bot's display name + rank.
// We wait for this before joining the channel to avoid any ordering issues.
socket.on('login', (data) => {
if (data.success) {
info(`Authenticated as: ${escBless(data.name)}`);
socket.emit('joinChannel', { name: CHANNEL });
} else {
fail(`Authentication failed: ${escBless(data.error || 'unknown')}`);
}
});
socket.on('errorMsg', (data) => {
fail(escBless(data.msg || 'Server error'));
});
socket.on('kick', (data) => {
fail(`Kicked: ${escBless(data.reason || '')}`);
setStatus('Kicked from channel');
});
// ── Channel events ────────────────────────────────────────────────────────────
// Channel options (title, password, etc.)
socket.on('channelOpts', (opts) => {
const title = opts.pagetitle || CHANNEL;
const playing = state.nowPlaying ? `${escBless(state.nowPlaying.title)}` : '';
setStatus(`{bold}${escBless(title)}{/bold}${playing}`);
});
// ── User list events ──────────────────────────────────────────────────────────
// Full user list, sent on join and refresh
socket.on('userlist', (users) => {
state.users = users;
state.inChannel = true;
rebuildUserList();
info(`Joined #${CHANNEL}${users.length} user(s) present`);
});
// New user joined
socket.on('addUser', (user) => {
state.users = state.users.filter(u => u.name !== user.name);
state.users.push(user);
rebuildUserList();
chat(`{green-fg}→ ${escBless(user.name)} joined{/green-fg}`);
});
// User left
socket.on('userLeave', (data) => {
state.users = state.users.filter(u => u.name !== data.name);
rebuildUserList();
chat(`{red-fg}← ${escBless(data.name)} left{/red-fg}`);
});
// Rank changed for a user
socket.on('setUserRank', (data) => {
const user = state.users.find(u => u.name === data.name);
if (user) user.rank = data.rank;
rebuildUserList();
});
// AFK / mute state changed
socket.on('setUserMeta', (data) => {
const user = state.users.find(u => u.name === data.name);
if (user) Object.assign(user.meta, data.meta);
rebuildUserList();
});
// ── Chat events ───────────────────────────────────────────────────────────────
socket.on('chatMsg', (data) => {
const time = timestamp();
const name = escBless(data.username || '?');
const msg = escBless(stripHtml(data.msg || ''));
const isAction = data.meta && data.meta.addClass === 'action';
// is_bot is set by the server in the message meta; fall back to checking
// the local user list in case the message arrives before the userlist does.
const senderInList = state.users.find(u => u.name === data.username);
const isBot = (data.meta && data.meta.is_bot) ||
(senderInList && senderInList.meta && senderInList.meta.is_bot);
const botTag = isBot ? ' {blue-fg}[bot]{/blue-fg}' : '';
if (isAction) {
chat(`{grey-fg}[${time}]{/grey-fg}${botTag} {italic}* ${name} ${msg}{/italic}`);
} else {
chat(`{grey-fg}[${time}]{/grey-fg} {bold}${name}{/bold}${botTag}: ${msg}`);
}
});
// Private messages
socket.on('pm', (data) => {
const name = escBless(data.username || '?');
const msg = escBless(stripHtml(data.msg || ''));
chat(`{magenta-fg}[PM ← ${name}]{/magenta-fg} ${msg}`);
});
// Chat cleared by a moderator
socket.on('clearchat', () => {
chatLog.setContent('');
info('Chat was cleared by a moderator.');
screen.render();
});
// ── Playlist events ───────────────────────────────────────────────────────────
// Full playlist on join
socket.on('playlist', (items) => {
if (items.length > 0) {
info(`Playlist loaded: ${items.length} item(s)`);
}
});
// New item added to playlist by someone
socket.on('queue', (data) => {
const item = data.item;
if (item && item.media) {
info(`Queued: [${item.media.type}] ${escBless(item.media.title)}`);
}
});
// Currently playing changed
socket.on('changeMedia', (media) => {
state.nowPlaying = media;
const dur = media.seconds ? ` (${hhmm(media.seconds)})` : '';
const title = escBless(media.title || media.id);
setStatus(`${title}${dur}`);
info(`Now playing: {bold}${title}{/bold}${dur}`);
});
// ── Emote events ──────────────────────────────────────────────────────────────
socket.on('updateEmote', (emote) => {
info(`Emote updated: :${escBless(emote.name)}:`);
});
socket.on('removeEmote', (data) => {
info(`Emote removed: :${escBless(data.name)}:`);
});
// ── Commands ──────────────────────────────────────────────────────────────────
const COMMANDS = {
help() {
info('─── Commands ───────────────────────────────────────────────────');
info(' /playlist fetch and show the playlist');
info(' /emotes list emote names');
info(' /settings show channel settings');
info(' /users dump in-memory user list');
info(' /add <type> <id> add media e.g. /add yt dQw4w9WgXcQ');
info(' /skip skip to next playlist item');
info(' /clear clear the playlist');
info(' /kick <name> [reason] kick a user from the channel');
info(' /me <text> send an action message');
info(' <anything else> send as a chat message');
info('───────────────────────────────────────────────────────────────');
},
async playlist() {
try {
const data = await api.get('/playlist');
info(`─── Playlist (${data.items.length} item${data.items.length !== 1 ? 's' : ''}) ──`);
if (data.items.length === 0) {
info(' (empty)');
} else {
data.items.forEach((item, i) => {
const dur = item.seconds ? hhmm(item.seconds) : '?:??';
const marker = i === data.currentIndex ? '{yellow-fg}▶{/yellow-fg}' : ' ';
info(` ${marker} [${item.type}] ${escBless(item.title)} (${dur}) uid:${item.uid}`);
});
}
if (data.locked) warn(' Playlist is locked');
} catch (e) {
fail(`playlist: ${e.message}`);
}
},
async emotes() {
try {
const emotes = await api.get('/emotes');
info(`─── Emotes (${emotes.length}) ──`);
if (emotes.length === 0) {
info(' (none)');
} else {
// Print names in rows of 8
for (let i = 0; i < emotes.length; i += 8) {
info(' ' + emotes.slice(i, i + 8).map(e => `:${escBless(e.name)}:`).join(' '));
}
}
} catch (e) {
fail(`emotes: ${e.message}`);
}
},
async settings() {
try {
const s = await api.get('/settings');
info('─── Channel Settings ──');
for (const [k, v] of Object.entries(s)) {
if (v !== null && v !== '' && v !== false) {
info(` ${k}: ${escBless(String(v))}`);
}
}
} catch (e) {
fail(`settings: ${e.message}`);
}
},
users() {
info(`─── Users (${state.users.length}) ──`);
state.users.forEach(u => {
const rank = u.rank >= 5 ? 'creator' : u.rank >= 4 ? 'owner' : u.rank >= 3 ? 'admin' : u.rank >= 2 ? 'mod' : 'user';
const isBot = u.meta && u.meta.is_bot ? ' [bot]' : '';
info(` ${escBless(u.name)} (${rank} rank:${u.rank})${isBot}`);
});
},
async add(args) {
const parts = args.trim().split(/\s+/);
if (parts.length < 2) {
fail('Usage: /add <type> <id> e.g. /add yt dQw4w9WgXcQ');
return;
}
const [type, id] = parts;
try {
await api.post('/playlist', { type, id, pos: 'end' });
info(`Added [${type}] ${escBless(id)} to playlist`);
} catch (e) {
fail(`add: ${e.message}`);
}
},
async skip() {
try {
const data = await api.get('/playlist');
if (data.items.length === 0) { warn('Playlist is empty'); return; }
const idx = data.currentIndex;
if (idx < 0 || idx >= data.items.length - 1) {
warn('No next item in playlist');
return;
}
const next = data.items[idx + 1];
await api.put('/playlist/playing', { uid: next.uid });
info(`Skipped to: ${escBless(next.title)}`);
} catch (e) {
fail(`skip: ${e.message}`);
}
},
async clear() {
try {
await api.post('/playlist/clear');
info('Playlist cleared');
} catch (e) {
fail(`clear: ${e.message}`);
}
},
async kick(args) {
const parts = args.trim().split(/\s+/);
const name = parts[0];
const reason = parts.slice(1).join(' ') || 'Kicked by bot';
if (!name) { fail('Usage: /kick <name> [reason]'); return; }
try {
await api.post(`/users/${encodeURIComponent(name)}/kick`, { reason });
info(`Kicked ${escBless(name)}`);
} catch (e) {
fail(`kick: ${e.message}`);
}
},
me(args) {
const text = args.trim();
if (!text) { fail('Usage: /me <text>'); return; }
socket.emit('chatMsg', { msg: `/me ${text}` });
},
};
async function handleInput(line) {
line = line.trim();
if (!line) return;
if (line.startsWith('/')) {
const space = line.indexOf(' ');
const name = (space === -1 ? line.slice(1) : line.slice(1, space)).toLowerCase();
const args = space === -1 ? '' : line.slice(space + 1);
if (COMMANDS[name]) {
try {
await COMMANDS[name](args);
} catch (e) {
fail(`Command error: ${e.message}`);
}
} else {
fail(`Unknown command: /${name} (type /help for a list)`);
}
} else {
// Regular chat message — sent over the WebSocket, not REST.
// The server will echo it back as a chatMsg event.
socket.emit('chatMsg', { msg: line });
}
}
// ── Input box wiring ──────────────────────────────────────────────────────────
inputBox.on('submit', async (value) => {
inputBox.clearValue();
inputBox.focus();
screen.render();
if (value && value.trim()) {
await handleInput(value);
}
});
// Pressing Enter submits, but blessed's textbox needs this nudge
inputBox.key('enter', () => inputBox.submit());
// ── Boot ──────────────────────────────────────────────────────────────────────
info(`Connecting to ${SERVER_URL} as bot in #${CHANNEL}...`);
info('Type /help for available commands.');
inputBox.focus();
screen.render();

283
examples/demo-bot/package-lock.json generated Normal file
View file

@ -0,0 +1,283 @@
{
"name": "cytube-demo-bot",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "cytube-demo-bot",
"version": "1.0.0",
"dependencies": {
"blessed": "^0.1.81",
"dotenv": "^16.0.0",
"node-fetch": "^2.7.0",
"socket.io-client": "^4.7.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/blessed": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
"integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==",
"bin": {
"blessed": "bin/tput.js"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
}
},
"dependencies": {
"@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"blessed": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
"integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ=="
},
"debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"requires": {
"ms": "^2.1.3"
}
},
"dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="
},
"engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="
},
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"requires": {
"whatwg-url": "^5.0.0"
}
},
"socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
}
},
"socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
}
},
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"requires": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"requires": {}
},
"xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="
}
}
}

View file

@ -0,0 +1,15 @@
{
"name": "cytube-demo-bot",
"version": "1.0.0",
"description": "Demo bot for the CyTube Sync bot API — TUI chat client + REST queries",
"main": "bot.js",
"scripts": {
"start": "node bot.js"
},
"dependencies": {
"blessed": "^0.1.81",
"dotenv": "^16.0.0",
"node-fetch": "^2.7.0",
"socket.io-client": "^4.7.0"
}
}

View file

@ -0,0 +1,26 @@
from veretube_bot import Bot, Rank
bot = Bot(
token="TOKEN_HERE",
channel="CHANNEL_NAME_HERE",
socket_url="http://localhost:1337",
api_url="http://localhost:8080/api/v1"
)
@bot.on("chatMsg")
def on_chat(data):
if data["msg"] == "!playlist":
playlist = bot.api.get_playlist()
bot.send_message(f"{len(playlist['items'])} items in queue")
elif data["msg"].startswith("!kick "):
name = data["msg"].split(" ", 1)[1]
bot.kick(name, "Kicked by command")
elif data["msg"] == "!emotes":
emotes = bot.get_emotes()
print(emotes)
@bot.on("changeMedia")
def on_media(data):
bot.send_message(f"Now playing: {data['title']}")
bot.run()

View file

@ -0,0 +1,13 @@
bidict==0.23.1
certifi==2026.4.22
charset-normalizer==3.4.7
h11==0.16.0
idna==3.13
python-engineio==4.13.1
python-socketio==5.16.1
requests==2.33.1
simple-websocket==1.1.0
urllib3==2.6.3
veretube-bot==0.1.0
websocket-client==1.9.0
wsproto==1.3.2

View file

@ -0,0 +1,29 @@
const registry = new Map(); // botId -> Set<socket>
function register(botId, socket) {
if (!registry.has(botId)) {
registry.set(botId, new Set());
}
registry.get(botId).add(socket);
socket.once('disconnect', () => {
const sockets = registry.get(botId);
if (sockets) {
sockets.delete(socket);
if (sockets.size === 0) {
registry.delete(botId);
}
}
});
}
function disconnect(botId) {
const sockets = registry.get(botId);
if (sockets) {
for (const socket of sockets) {
socket.disconnect(true);
}
registry.delete(botId);
}
}
module.exports = { register, disconnect };

View file

@ -510,13 +510,16 @@ Channel.prototype.maybeResendUserlist = function maybeResendUserlist(user, newRa
};
Channel.prototype.packUserData = function (user) {
var isBot = Boolean(user.socket.context.user && user.socket.context.user.isBot);
var base = {
name: user.getName(),
rank: user.account.effectiveRank,
profile: user.account.profile,
meta: {
afk: user.is(Flags.U_AFK),
muted: user.is(Flags.U_MUTED) && !user.is(Flags.U_SMUTED)
muted: user.is(Flags.U_MUTED) && !user.is(Flags.U_SMUTED),
is_bot: isBot
}
};
@ -529,7 +532,8 @@ Channel.prototype.packUserData = function (user) {
muted: user.is(Flags.U_MUTED),
smuted: user.is(Flags.U_SMUTED),
aliases: user.account.aliases,
ip: user.displayip
ip: user.displayip,
is_bot: isBot
}
};
@ -542,7 +546,8 @@ Channel.prototype.packUserData = function (user) {
muted: user.is(Flags.U_MUTED),
smuted: user.is(Flags.U_SMUTED),
aliases: user.account.aliases,
ip: user.realip
ip: user.realip,
is_bot: isBot
}
};

View file

@ -303,6 +303,9 @@ ChatModule.prototype.processChatMsg = function (user, data) {
}
var msgobj = this.formatMessage(user.getName(), data);
if (user.socket.context.user && user.socket.context.user.isBot) {
msgobj.meta.is_bot = true;
}
var antiflood = MIN_ANTIFLOOD;
if (this.channel.modules.options &&
this.channel.modules.options.get("chat_antiflood") &&

55
src/database/bots.js Normal file
View file

@ -0,0 +1,55 @@
const db = require('../database');
function knex() {
return db.getDB().knex;
}
async function createBot({ channelId, name, tokenHash, rank, createdBy }) {
const [id] = await knex()('channel_bots').insert({
channel_id: channelId,
name,
token_hash: tokenHash,
rank,
created_by: createdBy,
created_at: Date.now(),
active: true,
last_connected: null
});
return id;
}
async function getBotByTokenHash(tokenHash) {
const rows = await knex()('channel_bots')
.join('channels', 'channel_bots.channel_id', 'channels.id')
.where({ token_hash: tokenHash, active: true })
.select('channel_bots.*', 'channels.name as channel_name');
return rows[0] || null;
}
async function getBotById(id, channelId) {
const rows = await knex()('channel_bots')
.where({ id, channel_id: channelId })
.select();
return rows[0] || null;
}
async function listBots(channelId) {
return knex()('channel_bots')
.where({ channel_id: channelId })
.orderBy('created_at', 'desc')
.select('id', 'name', 'rank', 'created_by', 'created_at', 'active', 'last_connected');
}
async function revokeBot(id, channelId) {
await knex()('channel_bots')
.where({ id, channel_id: channelId })
.update({ active: false });
}
async function updateLastConnected(id) {
await knex()('channel_bots')
.where({ id })
.update({ last_connected: Date.now() });
}
module.exports = { createBot, getBotByTokenHash, getBotById, listBots, revokeBot, updateLastConnected };

View file

@ -157,6 +157,24 @@ export async function initTables() {
t.index('updated_at');
});
await ensureTable('channel_bots', t => {
t.charset('utf8');
t.increments('id').notNullable().primary();
t.integer('channel_id')
.unsigned()
.notNullable()
.references('id').inTable('channels')
.onDelete('cascade');
t.string('name', 20).notNullable();
t.string('token_hash', 64).notNullable().unique();
t.integer('rank').notNullable();
t.string('created_by', 20).notNullable();
t.bigInteger('created_at').notNullable();
t.boolean('active').notNullable().defaultTo(true);
t.bigInteger('last_connected').nullable();
t.index('channel_id');
});
await ensureTable('banned_channels', t => {
t.charset('utf8mb4');
t.string('channel_name', 30)

View file

@ -11,6 +11,9 @@ import { verifyIPSessionCookie } from '../web/middleware/ipsessioncookie';
import Promise from 'bluebird';
const verifySession = Promise.promisify(session.verifySession);
const getAliases = Promise.promisify(db.getAliases);
import crypto from 'crypto';
const botDB = require('../database/bots');
const botSocketRegistry = require('../bot-socket-registry');
import { CachingGlobalBanlist } from './globalban';
import proxyaddr from 'proxy-addr';
import { Counter, Gauge } from 'prom-client';
@ -169,19 +172,51 @@ class IOServer {
}
// Match login cookie against the DB, look up aliases
// Also handles bot token auth via socket.handshake.auth.token
authUserMiddleware(socket, next) {
socket.context.aliases = [];
const promises = [];
const auth = socket.handshake.signedCookies.auth;
if (auth) {
promises.push(verifySession(auth).then(user => {
socket.context.user = Object.assign({}, user);
}).catch(_error => {
authFailureCount.inc(1);
LOGGER.warn('Unable to verify session for %s - ignoring auth',
socket.context.ipAddress);
}));
const botToken = socket.handshake.auth && socket.handshake.auth.token;
if (botToken && typeof botToken === 'string' && botToken.startsWith('cbt_')) {
const tokenHash = crypto.createHash('sha256').update(botToken).digest('hex');
promises.push(
botDB.getBotByTokenHash(tokenHash).then(bot => {
if (bot) {
socket.context.user = {
name: bot.name,
global_rank: bot.rank,
time: Date.now(),
profile: { image: '', text: '' },
isBot: true,
botId: bot.id,
botChannelName: bot.channel_name
};
botSocketRegistry.register(bot.id, socket);
botDB.updateLastConnected(bot.id).catch(err => {
LOGGER.warn('Failed to update bot last_connected: %s', err);
});
} else {
LOGGER.warn('Bot token auth failed for %s - invalid or revoked',
socket.context.ipAddress);
}
}).catch(err => {
LOGGER.warn('Bot token lookup error for %s: %s',
socket.context.ipAddress, err);
})
);
} else {
const auth = socket.handshake.signedCookies.auth;
if (auth) {
promises.push(verifySession(auth).then(user => {
socket.context.user = Object.assign({}, user);
}).catch(_error => {
authFailureCount.inc(1);
LOGGER.warn('Unable to verify session for %s - ignoring auth',
socket.context.ipAddress);
}));
}
}
promises.push(getAliases(socket.context.ipAddress).then(aliases => {
@ -217,7 +252,7 @@ class IOServer {
});
const user = new User(socket, socket.context.ipAddress, socket.context.user);
if (socket.context.user) {
if (socket.context.user && !socket.context.user.isBot) {
db.recordVisit(socket.context.ipAddress, user.getName());
}

View file

@ -75,6 +75,13 @@ User.prototype.handleJoinChannel = function handleJoinChannel(data) {
return;
}
if (this.socket.context.user && this.socket.context.user.isBot) {
if (this.socket.context.user.botChannelName !== data.name.toLowerCase()) {
this.kick("Bot token is not valid for this channel");
return;
}
}
data.name = data.name.toLowerCase();
this.waitFlag(Flags.U_READY, () => {

View file

@ -0,0 +1,92 @@
const express = require('express');
const webserver = require('../../webserver');
const botDB = require('../../../database/bots');
const botSocketRegistry = require('../../../bot-socket-registry');
const { getChannelRow, getUserEffectiveRank, hashToken, generateToken } = require('./middleware');
const router = express.Router({ mergeParams: true });
router.get('/', async (req, res) => {
const user = await webserver.authorize(req);
if (!user) return res.status(401).json({ error: 'Unauthorized' });
let channelRow;
try {
channelRow = await getChannelRow(req.params.channel);
} catch (_err) {
return res.status(404).json({ error: 'Channel not found' });
}
const rank = await getUserEffectiveRank(user, channelRow);
if (rank < 2) return res.status(403).json({ error: 'Insufficient rank' });
const bots = await botDB.listBots(channelRow.id);
res.json(bots);
});
router.post('/', async (req, res) => {
const user = await webserver.authorize(req);
if (!user) return res.status(401).json({ error: 'Unauthorized' });
let channelRow;
try {
channelRow = await getChannelRow(req.params.channel);
} catch (_err) {
return res.status(404).json({ error: 'Channel not found' });
}
const issuerRank = await getUserEffectiveRank(user, channelRow);
if (issuerRank < 2) return res.status(403).json({ error: 'Insufficient rank' });
const { name, rank } = req.body;
if (!name || typeof name !== 'string' || !/^[a-zA-Z0-9_-]{1,20}$/.test(name)) {
return res.status(400).json({ error: 'Bot name must be 1-20 alphanumeric/dash/underscore characters' });
}
const desiredRank = parseInt(rank, 10);
if (isNaN(desiredRank) || desiredRank < 1 || desiredRank > issuerRank) {
return res.status(400).json({ error: `Rank must be between 1 and ${issuerRank}` });
}
const token = generateToken();
const tokenHash = hashToken(token);
const botId = await botDB.createBot({
channelId: channelRow.id,
name,
tokenHash,
rank: desiredRank,
createdBy: user.name
});
res.status(201).json({ id: botId, name, rank: desiredRank, token });
});
router.delete('/:id', async (req, res) => {
const user = await webserver.authorize(req);
if (!user) return res.status(401).json({ error: 'Unauthorized' });
let channelRow;
try {
channelRow = await getChannelRow(req.params.channel);
} catch (_err) {
return res.status(404).json({ error: 'Channel not found' });
}
const issuerRank = await getUserEffectiveRank(user, channelRow);
if (issuerRank < 2) return res.status(403).json({ error: 'Insufficient rank' });
const botId = parseInt(req.params.id, 10);
if (isNaN(botId)) return res.status(400).json({ error: 'Invalid bot id' });
const bot = await botDB.getBotById(botId, channelRow.id);
if (!bot) return res.status(404).json({ error: 'Bot not found' });
if (bot.rank > issuerRank) return res.status(403).json({ error: 'Cannot revoke a bot with higher rank than your own' });
await botDB.revokeBot(botId, channelRow.id);
botSocketRegistry.disconnect(botId);
res.json({ success: true });
});
module.exports = router;

View file

@ -0,0 +1,133 @@
const express = require('express');
const db = require('../../../database');
const { botAuth, requireRank, getLoadedChannel } = require('./middleware');
const router = express.Router({ mergeParams: true });
const XSS = require('../../../xss');
function validateEmote(f) {
if (typeof f.name !== 'string' || typeof f.image !== 'string') return null;
f.image = f.image.substring(0, 1000);
f.image = XSS.sanitizeText(f.image);
var s = XSS.looseSanitizeText(f.name).replace(/([\\.?+*$^|()[\]{}])/g, '\\$1');
s = '(^|\\s)' + s + '(?!\\S)';
f.source = s;
if (!f.image || !f.name) return null;
try { new RegExp(f.source, 'gi'); } catch (e) { return null; }
return f;
}
async function getChannelEmotes(channelId) {
return new Promise((resolve, reject) => {
db.query(
'SELECT `value` FROM channel_data WHERE channel_id = ? AND `key` = ?',
[channelId, 'emotes'],
(err, rows) => {
if (err) return reject(new Error(err));
if (!rows || rows.length === 0) return resolve([]);
try { resolve(JSON.parse(rows[0].value)); }
catch (e) { resolve([]); }
}
);
});
}
async function saveChannelEmotes(channelId, emotes) {
const value = JSON.stringify(emotes);
return new Promise((resolve, reject) => {
db.query(
'INSERT INTO channel_data (channel_id, `key`, `value`) VALUES (?, ?, ?) ' +
'ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)',
[channelId, 'emotes', value],
(err) => {
if (err) reject(new Error(err));
else resolve();
}
);
});
}
router.get('/', botAuth, async (req, res) => {
const emotes = await getChannelEmotes(req.bot.channel_id);
res.json(emotes);
});
router.post('/', botAuth, requireRank(4), async (req, res) => {
const { name, image } = req.body;
const validated = validateEmote({ name, image });
if (!validated) return res.status(400).json({ error: 'Invalid emote name or image' });
const emotes = await getChannelEmotes(req.bot.channel_id);
if (emotes.some(e => e.name === validated.name)) {
return res.status(409).json({ error: 'Emote already exists' });
}
emotes.push(validated);
await saveChannelEmotes(req.bot.channel_id, emotes);
const chan = getLoadedChannel(req.params.channel);
if (chan && chan.modules.emotes) {
chan.modules.emotes.emotes.updateEmote(validated);
chan.modules.emotes.dirty = true;
chan.broadcastAll('updateEmote', validated);
}
res.status(201).json(validated);
});
router.put('/:name', botAuth, requireRank(4), async (req, res) => {
const emoteName = req.params.name;
const { image, newName } = req.body;
const emotes = await getChannelEmotes(req.bot.channel_id);
const idx = emotes.findIndex(e => e.name === emoteName);
if (idx === -1) return res.status(404).json({ error: 'Emote not found' });
const updatedRaw = { name: newName || emoteName, image: image || emotes[idx].image };
const validated = validateEmote(updatedRaw);
if (!validated) return res.status(400).json({ error: 'Invalid emote data' });
if (validated.name !== emoteName && emotes.some(e => e.name === validated.name)) {
return res.status(409).json({ error: 'An emote with that name already exists' });
}
const old = emotes[idx];
emotes[idx] = validated;
await saveChannelEmotes(req.bot.channel_id, emotes);
const chan = getLoadedChannel(req.params.channel);
if (chan && chan.modules.emotes) {
if (validated.name !== emoteName) {
chan.modules.emotes.emotes.renameEmote({ ...validated, old: emoteName });
chan.broadcastAll('renameEmote', { ...validated, old: emoteName });
} else {
chan.modules.emotes.emotes.updateEmote(validated);
chan.broadcastAll('updateEmote', validated);
}
chan.modules.emotes.dirty = true;
}
res.json(validated);
});
router.delete('/:name', botAuth, requireRank(4), async (req, res) => {
const emoteName = req.params.name;
const emotes = await getChannelEmotes(req.bot.channel_id);
const idx = emotes.findIndex(e => e.name === emoteName);
if (idx === -1) return res.status(404).json({ error: 'Emote not found' });
emotes.splice(idx, 1);
await saveChannelEmotes(req.bot.channel_id, emotes);
const chan = getLoadedChannel(req.params.channel);
if (chan && chan.modules.emotes) {
chan.modules.emotes.emotes.removeEmote({ name: emoteName });
chan.modules.emotes.dirty = true;
chan.broadcastAll('removeEmote', { name: emoteName });
}
res.json({ success: true });
});
module.exports = router;

View file

@ -0,0 +1,11 @@
const express = require('express');
const router = express.Router();
router.use('/channels/:channel/bots', require('./bots'));
router.use('/channels/:channel/emotes', require('./emotes'));
router.use('/channels/:channel/playlist', require('./playlist'));
router.use('/channels/:channel/settings', require('./settings'));
router.use('/channels/:channel', require('./moderation'));
module.exports = router;

View file

@ -0,0 +1,88 @@
const crypto = require('crypto');
const botDB = require('../../../database/bots');
const db = require('../../../database');
const Server = require('../../../server');
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
function generateToken() {
return 'cbt_' + crypto.randomBytes(32).toString('hex');
}
async function botAuth(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing Authorization header' });
}
const token = authHeader.slice(7).trim();
if (!token.startsWith('cbt_')) {
return res.status(401).json({ error: 'Invalid token format' });
}
const tokenHash = hashToken(token);
let bot;
try {
bot = await botDB.getBotByTokenHash(tokenHash);
} catch (err) {
return res.status(500).json({ error: 'Internal error' });
}
if (!bot) {
return res.status(401).json({ error: 'Invalid or revoked token' });
}
if (bot.channel_name.toLowerCase() !== req.params.channel.toLowerCase()) {
return res.status(403).json({ error: 'Token not authorized for this channel' });
}
req.bot = bot;
next();
}
function requireRank(minRank) {
return (req, res, next) => {
if (req.bot.rank < minRank) {
return res.status(403).json({ error: `Requires rank ${minRank}, bot has rank ${req.bot.rank}` });
}
next();
};
}
function getLoadedChannel(channelName) {
const server = Server.getServer();
if (!server.isChannelLoaded(channelName)) {
return null;
}
return server.getChannel(channelName);
}
async function getChannelRow(channelName) {
return new Promise((resolve, reject) => {
db.channels.lookup(channelName, (err, row) => {
if (err) reject(new Error(err));
else resolve(row);
});
});
}
async function getUserEffectiveRank(user, channelRow) {
return new Promise((resolve, reject) => {
db.channels.getRank(channelRow.name, user.name.toLowerCase(), (err, channelRank) => {
if (err) reject(new Error(err));
else resolve(Math.max(user.global_rank, channelRank));
});
});
}
module.exports = {
botAuth,
requireRank,
getLoadedChannel,
getChannelRow,
getUserEffectiveRank,
hashToken,
generateToken
};

View file

@ -0,0 +1,103 @@
const express = require('express');
const db = require('../../../database');
const { botAuth, requireRank, getLoadedChannel } = require('./middleware');
const router = express.Router({ mergeParams: true });
router.get('/users', botAuth, (req, res) => {
const chan = getLoadedChannel(req.params.channel);
if (!chan) return res.status(503).json({ error: 'Channel is not currently active' });
const users = chan.users.map(u => ({
name: u.getName(),
rank: u.account.effectiveRank,
afk: u.is(0x4),
is_bot: Boolean(u.socket.context.user && u.socket.context.user.isBot)
}));
res.json(users);
});
router.post('/users/:name/kick', botAuth, requireRank(2), (req, res) => {
const chan = getLoadedChannel(req.params.channel);
if (!chan) return res.status(503).json({ error: 'Channel is not currently active' });
const targetName = req.params.name.toLowerCase();
const reason = (req.body && req.body.reason) ? String(req.body.reason) : 'Kicked by bot';
const target = chan.users.find(u => u.getLowerName() === targetName);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.account.effectiveRank >= req.bot.rank) {
return res.status(403).json({ error: 'Cannot kick a user with equal or higher rank' });
}
target.kick(reason);
res.json({ success: true });
});
router.post('/users/:name/ban', botAuth, requireRank(3), async (req, res) => {
const chan = getLoadedChannel(req.params.channel);
if (!chan) return res.status(503).json({ error: 'Channel is not currently active' });
const targetName = req.params.name.toLowerCase();
const reason = (req.body && req.body.reason) ? String(req.body.reason) : 'Banned by bot';
const target = chan.users.find(u => u.getLowerName() === targetName);
if (target && target.account.effectiveRank >= req.bot.rank) {
return res.status(403).json({ error: 'Cannot ban a user with equal or higher rank' });
}
chan.modules.kickban.ban(targetName, '', reason, req.bot.name, (err) => {
if (err) return res.status(400).json({ error: err });
res.json({ success: true });
});
});
router.delete('/users/:name/ban', botAuth, requireRank(3), (req, res) => {
const chan = getLoadedChannel(req.params.channel);
if (!chan) return res.status(503).json({ error: 'Channel is not currently active' });
const targetName = req.params.name.toLowerCase();
db.channels.listBans(chan.name, (err, bans) => {
if (err) return res.status(500).json({ error: 'Database error' });
const entry = bans.find(b => b.name.toLowerCase() === targetName);
if (!entry) return res.status(404).json({ error: 'Ban not found' });
db.channels.unbanId(chan.name, entry.id, (err2) => {
if (err2) return res.status(500).json({ error: 'Database error' });
chan.broadcastAll('banlist', bans.filter(b => b.id !== entry.id));
res.json({ success: true });
});
});
});
router.put('/users/:name/rank', botAuth, requireRank(4), async (req, res) => {
const chan = getLoadedChannel(req.params.channel);
if (!chan) return res.status(503).json({ error: 'Channel is not currently active' });
const targetName = req.params.name.toLowerCase();
const newRank = parseInt(req.body && req.body.rank, 10);
if (isNaN(newRank) || newRank < 1) {
return res.status(400).json({ error: 'Invalid rank' });
}
if (newRank >= req.bot.rank) {
return res.status(403).json({ error: 'Cannot assign rank equal to or higher than your own' });
}
const target = chan.users.find(u => u.getLowerName() === targetName);
if (!target) return res.status(404).json({ error: 'User not found in channel' });
if (target.account.effectiveRank >= req.bot.rank) {
return res.status(403).json({ error: 'Cannot change rank of a user with equal or higher rank' });
}
db.channels.setRank(chan.name, targetName, newRank, (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
target.setChannelRank(newRank);
chan.broadcastAll('setUserRank', { name: target.getName(), rank: target.account.effectiveRank });
res.json({ success: true });
});
});
module.exports = router;

View file

@ -0,0 +1,140 @@
const express = require('express');
const { botAuth, requireRank, getLoadedChannel } = require('./middleware');
const util = require('../../../utilities');
const router = express.Router({ mergeParams: true });
function liveChannel(req, res) {
const chan = getLoadedChannel(req.params.channel);
if (!chan) {
res.status(503).json({ error: 'Channel is not currently active' });
return null;
}
return chan;
}
function makeBotProxy(bot) {
const rank = bot.rank;
const syncErrors = [];
return {
effectiveRank: rank,
account: { effectiveRank: rank },
getName: () => bot.name,
getLowerName: () => bot.name.toLowerCase(),
is: () => true,
isAnonymous: () => false,
queueLimiter: util.newRateLimiter(),
socket: {
emit: (event, data) => {
if (event === 'queueFail') {
syncErrors.push((data && data.msg) || 'Queue failed');
}
}
},
_syncErrors: syncErrors
};
}
router.get('/', botAuth, (req, res) => {
const chan = liveChannel(req, res);
if (!chan) return;
const pl = chan.modules.playlist;
const arr = pl.items.toArray(true);
const currentUid = pl.current ? pl.current.uid : null;
const currentIndex = currentUid !== null
? arr.findIndex(i => i.uid === currentUid)
: -1;
const items = arr.map(item => ({
uid: item.uid,
id: item.media.id,
type: item.media.type,
title: item.media.title,
seconds: item.media.seconds,
duration: item.media.duration,
thumb: item.media.thumb,
meta: item.media.meta
}));
res.json({
items,
currentIndex,
locked: !chan.modules.permissions.openPlaylist
});
});
// Returns 202 Accepted because queueStandard is async (semaphore + media lookup).
// Sync permission failures are captured and returned as 400.
router.post('/', botAuth, requireRank(2), (req, res) => {
const chan = liveChannel(req, res);
if (!chan) return;
const { id, type, pos } = req.body;
if (!id || !type) return res.status(400).json({ error: 'id and type are required' });
const proxy = makeBotProxy(req.bot);
chan.modules.playlist.handleQueue(proxy, {
id,
type,
pos: pos === 'next' ? 'next' : 'end'
});
if (proxy._syncErrors.length > 0) {
return res.status(400).json({ error: proxy._syncErrors[0] });
}
res.status(202).json({ success: true });
});
router.delete('/:uid', botAuth, requireRank(3), (req, res) => {
const chan = liveChannel(req, res);
if (!chan) return;
const uid = parseInt(req.params.uid, 10);
if (isNaN(uid)) return res.status(400).json({ error: 'Invalid uid' });
const item = chan.modules.playlist.items.find(uid);
if (!item) return res.status(404).json({ error: 'Playlist item not found' });
const proxy = makeBotProxy(req.bot);
chan.modules.playlist.handleDelete(proxy, uid);
res.json({ success: true });
});
router.put('/playing', botAuth, requireRank(2), (req, res) => {
const chan = liveChannel(req, res);
if (!chan) return;
const { uid } = req.body;
if (uid === undefined) return res.status(400).json({ error: 'uid is required' });
const parsed = parseInt(uid, 10);
const item = chan.modules.playlist.items.find(parsed);
if (!item) return res.status(404).json({ error: 'Playlist item not found' });
const proxy = makeBotProxy(req.bot);
chan.modules.playlist.handleJumpTo(proxy, parsed);
res.json({ success: true });
});
router.post('/shuffle', botAuth, requireRank(3), (req, res) => {
const chan = liveChannel(req, res);
if (!chan) return;
const proxy = makeBotProxy(req.bot);
chan.modules.playlist.handleShuffle(proxy);
res.json({ success: true });
});
router.post('/clear', botAuth, requireRank(3), (req, res) => {
const chan = liveChannel(req, res);
if (!chan) return;
const proxy = makeBotProxy(req.bot);
chan.modules.playlist.handleClear(proxy);
res.json({ success: true });
});
module.exports = router;

View file

@ -0,0 +1,53 @@
const express = require('express');
const { botAuth, requireRank, getLoadedChannel } = require('./middleware');
const router = express.Router({ mergeParams: true });
const ALLOWED_SETTINGS = new Set([
'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'
]);
router.get('/', botAuth, requireRank(4), (req, res) => {
const chan = getLoadedChannel(req.params.channel);
if (!chan) return res.status(503).json({ error: 'Channel is not currently active' });
const opts = chan.modules.options;
if (!opts) return res.status(503).json({ error: 'Options module not available' });
const out = {};
for (const key of ALLOWED_SETTINGS) {
out[key] = opts.get(key);
}
res.json(out);
});
router.put('/', botAuth, requireRank(4), (req, res) => {
const chan = getLoadedChannel(req.params.channel);
if (!chan) return res.status(503).json({ error: 'Channel is not currently active' });
const opts = chan.modules.options;
if (!opts) return res.status(503).json({ error: 'Options module not available' });
const updates = {};
for (const [key, value] of Object.entries(req.body)) {
if (ALLOWED_SETTINGS.has(key)) {
updates[key] = value;
}
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'No valid settings provided' });
}
opts.setOptions(updates);
chan.broadcastAll('setOptions', opts.getOptions());
res.json({ success: true });
});
module.exports = router;

View file

@ -216,6 +216,7 @@ module.exports = {
);
require('./acp').init(app, ioConfig);
app.use('/api/v1', require('./routes/api/index'));
require('../google2vtt').attach(app);
require('./routes/google_drive_userscript')(app);
require('./routes/iframe')(app);

View file

@ -222,6 +222,7 @@ html(lang="en")
li: a(href="#cs-permedit", data-toggle="tab", tabindex="-1") Permissions
li: a(href="#cs-chanranks", data-toggle="tab", tabindex="-1", onclick="javascript:socket.emit('requestChannelRanks')") Moderators
li: a(href="#cs-banlist", data-toggle="tab", tabindex="-1", onclick="javascript:socket.emit('requestBanlist')") Ban list
li: a(href="#cs-bots", data-toggle="tab", tabindex="-1", onclick="javascript:CSTBots.load()") Bots
li: a(href="#cs-chanlog", data-toggle="tab", onclick="javascript:socket.emit('readChanLog')") Log
.modal-body
.tab-content
@ -236,6 +237,7 @@ html(lang="en")
+chanranks()
+chatfilters()
+emotes()
+bots()
+chanlog()
+permeditor()
.modal-footer

View file

@ -224,5 +224,34 @@ mixin chanlog
pre#cs-chanlog-text
button.btn.btn-default#cs-chanlog-refresh Refresh
mixin bots
#cs-bots.tab-pane
h4 Bot Tokens
p Issue API tokens for bots that can control this channel via the REST API and WebSocket. Tokens are shown only once — store them securely.
form.form-inline.cs-bots-form(action="javascript:void(0)", role="form")
.form-group
input#cs-bots-name.form-control(type="text", placeholder="Bot name", maxlength="20")
.form-group
select#cs-bots-rank.form-control
option(value="2") Moderator (rank 2)
option(value="3") Admin (rank 3)
option(value="4") Owner (rank 4)
.form-group
button#cs-bots-issue.btn.btn-primary Issue Token
.cs-bots-token-result.alert.alert-success(style="display:none; margin-top:10px")
strong Token (copy now — will not be shown again):
br
code#cs-bots-token-value
button.btn.btn-xs.btn-default.cs-bots-copy(type="button", style="margin-left:8px") Copy
table.table.table-striped.table-condensed(style="margin-top:12px")
thead
tr
th Revoke
th Name
th Rank
th Created by
th Last connected
tbody#cs-bots-list
mixin permeditor
#cs-permedit.tab-pane

View file

@ -301,6 +301,15 @@ li.ui-sortable-helper, li.ui-sortable-placeholder + li.queue_entry {
color: #888888!important;
}
.bot-tag {
font-size: 0.75em;
color: #5bc0de;
font-weight: normal;
font-style: normal;
vertical-align: middle;
margin-left: 1px;
}
.action {
font-style: italic;
color: #888888;

View file

@ -1095,3 +1095,81 @@ $("#resize-video-smaller").on('click', function () {
console.error(error);
}
});
var CSTBots = (function () {
function apiBase() {
return '/api/v1/channels/' + CHANNEL.name;
}
function load() {
$.getJSON(apiBase() + '/bots', function (bots) {
var tbody = $('#cs-bots-list').empty();
bots.forEach(function (bot) {
var lastConn = bot.last_connected
? new Date(bot.last_connected).toLocaleString()
: 'Never';
var rankLabel = bot.rank >= 5 ? 'Creator' : bot.rank >= 4 ? 'Owner' : bot.rank >= 3 ? 'Admin' : 'Mod';
var row = $('<tr>');
if (bot.active) {
row.append($('<td>').append(
$('<button class="btn btn-xs btn-danger">').text('Revoke')
.on('click', function () { revoke(bot.id); })
));
} else {
row.append($('<td>').append($('<span class="text-muted">').text('Revoked')));
}
row.append($('<td>').text(bot.name));
row.append($('<td>').text(rankLabel + ' (' + bot.rank + ')'));
row.append($('<td>').text(bot.created_by));
row.append($('<td>').text(lastConn));
tbody.append(row);
});
}).fail(function () {
$('#cs-bots-list').html('<tr><td colspan="5" class="text-danger">Failed to load bots</td></tr>');
});
}
function revoke(id) {
if (!confirm('Revoke this bot token? Any connected bot will be disconnected immediately.')) return;
$.ajax({
url: apiBase() + '/bots/' + id,
method: 'DELETE'
}).done(function () {
load();
}).fail(function (xhr) {
alert('Failed to revoke: ' + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
});
}
$('#cs-bots-issue').on('click', function () {
var name = $('#cs-bots-name').val().trim();
var rank = parseInt($('#cs-bots-rank').val(), 10);
if (!name) { alert('Bot name is required'); return; }
$.ajax({
url: apiBase() + '/bots',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ name: name, rank: rank })
}).done(function (data) {
$('#cs-bots-token-value').text(data.token);
$('.cs-bots-token-result').show();
$('#cs-bots-name').val('');
load();
}).fail(function (xhr) {
alert('Failed to create bot: ' + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
});
});
$('.cs-bots-copy').on('click', function () {
var text = $('#cs-bots-token-value').text();
navigator.clipboard.writeText(text).catch(function () {
var el = document.getElementById('cs-bots-token-value');
var range = document.createRange();
range.selectNodeContents(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
});
});
return { load: load };
})();

View file

@ -86,9 +86,9 @@ function findUserlistItem(name) {
if(isNaN(parseInt(i))) {
continue;
}
var child = children[i];
if($(child.children[1]).text().toLowerCase() == name)
return $(child);
var child = $(children[i]);
if((child.data("name") || "").toLowerCase() == name)
return child;
}
return null;
}
@ -126,6 +126,11 @@ function formatUserlistItem(div) {
div.removeClass("userlist_smuted");
}
name.find(".bot-tag").remove();
if (meta.is_bot) {
$("<span/>").addClass("bot-tag").text(" [bot]").appendTo(name);
}
var profile = null;
/*
* 2015-10-19
@ -1536,7 +1541,13 @@ function formatChatMessage(data, last) {
if (!skip) {
name.appendTo(div);
}
$("<strong/>").addClass("username").text(data.username + ": ").appendTo(name);
var usernameEl = $("<strong/>").addClass("username").appendTo(name);
usernameEl.append(document.createTextNode(data.username));
if (data.meta && data.meta.is_bot) {
usernameEl.append(document.createTextNode(" "));
$("<span/>").addClass("bot-tag").text("[bot]").appendTo(usernameEl);
}
usernameEl.append(document.createTextNode(": "));
if (data.meta.modflair) {
name.addClass(getNameColor(data.meta.modflair));
}