#!/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 add media e.g. /add yt dQw4w9WgXcQ * /skip skip to next playlist item * /clear clear the playlist * /kick [reason] kick a user * /me 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(//gi, '\n') .replace(/<[^>]+>/g, '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/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 add media e.g. /add yt dQw4w9WgXcQ'); info(' /skip skip to next playlist item'); info(' /clear clear the playlist'); info(' /kick [reason] kick a user from the channel'); info(' /me send an action message'); info(' 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 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 [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 '); 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();