From bf3ae2351da0578d3a9517de47751e9e67b3e96c Mon Sep 17 00:00:00 2001 From: Bashy Date: Tue, 26 May 2026 14:39:17 +0300 Subject: [PATCH] Add Discord Bot project type with process control, config editor, and log viewer - DiscordBotProject: new project type (discord-bot), auto-detects from main.py + cogs/ - botcontrol API: start/stop/restart/status via sudo systemctl - botconfig API: atomic read/write of config.json; CRUD for streamers, RSS feeds, Mastodon accounts, and LinkedIn pages; tokens masked in GET responses - botlogs API: tail log file (path via project_settings.bot_log_file) - Tab partials: _tab_bot (process control), _tab_botconfig (structured editor), _tab_logs (log viewer with auto-refresh) - view.php: register bot/botconfig/logs tabs with labels, icons, and groups - app.js: bot control panel, config editor with account/page dropdowns in RSS modal, Mastodon accounts CRUD, LinkedIn pages CRUD; audit labels/icons for all new actions - CLAUDE.md: document Discord Bot integration, sudoers requirement, API actions Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 25 ++ lib/project-types/DiscordBotProject.php | 19 ++ views/project/_tab_bot.php | 53 ++++ views/project/_tab_botconfig.php | 214 +++++++++++++ views/project/_tab_logs.php | 28 ++ views/project/view.php | 14 + web/api/botconfig.php | 188 ++++++++++++ web/api/botcontrol.php | 67 ++++ web/api/botlogs.php | 31 ++ web/assets/js/app.js | 386 ++++++++++++++++++++++++ 10 files changed, 1025 insertions(+) create mode 100644 lib/project-types/DiscordBotProject.php create mode 100644 views/project/_tab_bot.php create mode 100644 views/project/_tab_botconfig.php create mode 100644 views/project/_tab_logs.php create mode 100644 web/api/botconfig.php create mode 100644 web/api/botcontrol.php create mode 100644 web/api/botlogs.php diff --git a/CLAUDE.md b/CLAUDE.md index 0409ded..48a0ceb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,8 @@ default — every type that doesn't override gets those four universal tabs. Universal (in default `ProjectTypeBase::tabs()`): `dashboard`, `analytics`, `files`, `notes`, `settings`. +Discord Bot-only (in `DiscordBotProject::tabs()`): `bot`, `botconfig`, `logs`. + Hexo-only (in `HexoProject::tabs()`): `posts`, `config`, `run`, `themes`, `plugins`, `git`. Storage-only: `media`. @@ -154,6 +156,27 @@ Apache vhost locally: Add `127.0.0.1 hackmancms.local` to `/etc/hosts`. +## Discord Bot integration (DiscordBotProject) + +Project type `discord-bot` manages a bashyBot instance. Auto-detected from a path containing `main.py` + `cogs/` + `config.json`. + +**Tabs:** `bot` (process control), `botconfig` (structured config.json editor), `logs` (log tail). + +**Bot control** (`web/api/botcontrol.php`) runs `sudo systemctl start|stop|restart|is-active|show `. +The service name is stored in `project_settings` as `bot_service_name` (default: `bashybot`). +`www-data` requires passwordless sudo for these commands: + +``` +www-data ALL=(ALL) NOPASSWD: /usr/bin/systemctl start bashybot, /usr/bin/systemctl stop bashybot, /usr/bin/systemctl restart bashybot, /usr/bin/systemctl is-active bashybot, /usr/bin/systemctl show bashybot +``` + +**Config editor** (`web/api/botconfig.php`) reads/writes the project's `config.json` atomically (`.tmp` → rename). +Supports: `add_streamer`, `save_streamer`, `remove_streamer`, `add_rss`, `save_rss`, `remove_rss`, `save_linkedin`. +Never wipes existing tokens with an empty string submission. + +**Log viewer** (`web/api/botlogs.php`) tails the log file. +Log path stored in `project_settings` as `bot_log_file` (default: `logs/bashybot.log`, relative to project path). + ## Scheduled builds `bin/run-schedules.php` is a cron-driven dispatcher: it iterates `scheduled_builds`, @@ -201,6 +224,8 @@ Currently logged action strings: | `web/api/templates.php`| `template_create`, `template_update`, `template_delete` (detail=name) | | `web/api/snippets.php` | `snippet_create`, `snippet_update`, `snippet_delete` (detail=name) | | `bin/run-schedules.php`| `scheduled_build` (detail=`cmd_id status=...`) | +| `web/api/botcontrol.php` | `bot_start`, `bot_stop`, `bot_restart` (detail=service name) | +| `web/api/botconfig.php` | `botconfig_streamer_add`, `botconfig_streamer_save`, `botconfig_streamer_remove` (detail=name), `botconfig_rss_add`, `botconfig_rss_save`, `botconfig_rss_remove` (detail=name), `botconfig_linkedin_save` | The activity feed renderer (`#activityFeed` in `app.js`) maps these to labels + icons in `ACTION_LABELS` / `ACTION_ICONS`. Unmapped actions still render — they just show the diff --git a/lib/project-types/DiscordBotProject.php b/lib/project-types/DiscordBotProject.php new file mode 100644 index 0000000..3a45ad5 --- /dev/null +++ b/lib/project-types/DiscordBotProject.php @@ -0,0 +1,19 @@ +"> + + +
+
+
+ + Checking… +
+ + +
+
+ + +
+ + + + +
+ + +
+
+
+ Command output + +
+

+    
+
+ +
+

+ + Bot process managed via systemctl. + The www-data user requires passwordless sudo for these commands: +

+
www-data ALL=(ALL) NOPASSWD: /usr/bin/systemctl start bashybot, /usr/bin/systemctl stop bashybot, /usr/bin/systemctl restart bashybot, /usr/bin/systemctl is-active bashybot, /usr/bin/systemctl show bashybot
+
+ + diff --git a/views/project/_tab_botconfig.php b/views/project/_tab_botconfig.php new file mode 100644 index 0000000..4ff7482 --- /dev/null +++ b/views/project/_tab_botconfig.php @@ -0,0 +1,214 @@ +
+ + +
Twitch Streamers
+
Loading…
+ + +
+ + +
RSS Feeds
+
Loading…
+ + +
+ + +
Mastodon Accounts
+

+ Add the token to .env as MASTODON_TOKEN_<NAME> (uppercase). + The UI manages the label and API base URL only. +

+
Loading…
+ + +
+ + +
LinkedIn Pages
+ +
+
+
+
Checking…
+
+
+ + Connect LinkedIn + coming soon + +
+
+ +
Loading…
+ + +
+ + + + + + + + + + + + diff --git a/views/project/_tab_logs.php b/views/project/_tab_logs.php new file mode 100644 index 0000000..e678293 --- /dev/null +++ b/views/project/_tab_logs.php @@ -0,0 +1,28 @@ +
+ +
+
+ Bot logs +
+
+ + +
+ + +
+ +
+
+ +
Loading…
+ +
diff --git a/views/project/view.php b/views/project/view.php index 32d21d2..97e6e50 100644 --- a/views/project/view.php +++ b/views/project/view.php @@ -39,6 +39,8 @@ $tabLabels = [ 'run' => 'Run', 'themes' => 'Themes', 'plugins' => 'Plugins', 'git' => 'Git', 'notes' => 'Notes', 'settings' => 'Settings', + 'bot' => 'Bot', 'botconfig' => 'Config', + 'logs' => 'Logs', ]; $tabIcons = [ 'dashboard' => 'bi-grid-1x2', 'analytics' => 'bi-graph-up', @@ -47,9 +49,12 @@ $tabIcons = [ 'run' => 'bi-terminal', 'themes' => 'bi-palette', 'plugins' => 'bi-puzzle', 'git' => 'bi-git', 'notes' => 'bi-sticky', 'settings' => 'bi-gear', + 'bot' => 'bi-robot', 'botconfig' => 'bi-sliders2', + 'logs' => 'bi-journal-text', ]; $tabGroups = [ ['dashboard', 'analytics'], + ['bot', 'botconfig', 'logs'], ['posts', 'config', 'files', 'media'], ['run', 'themes', 'plugins', 'git'], ['notes', 'settings'], @@ -256,6 +261,15 @@ $tabGroups = [ + + + + + + + + + diff --git a/web/api/botconfig.php b/web/api/botconfig.php new file mode 100644 index 0000000..f097b38 --- /dev/null +++ b/web/api/botconfig.php @@ -0,0 +1,188 @@ +prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1'); +$stmt->execute([$pid]); +$project = $stmt->fetch(); +if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; } + +$base = realpath($project['path']); +$config_path = $base . '/config.json'; + +if (!$base || !is_dir($base)) { + echo json_encode(['error' => 'Project path not accessible']); exit; +} + +function read_config(string $path): ?array { + if (!file_exists($path)) return null; + $data = json_decode(file_get_contents($path), true); + return is_array($data) ? $data : null; +} + +function write_config(string $path, array $data): bool { + $tmp = $path . '.tmp'; + $ok = file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false; + if ($ok) $ok = rename($tmp, $path); + return $ok; +} + +// ── GET ─────────────────────────────────────────────────────────────────────── +if ($method === 'GET') { + $config = read_config($config_path); + if ($config === null) { echo json_encode(['error' => 'config.json not found or invalid']); exit; } + // Strip secrets from response — tokens are write-only + $safe = $config; + if (isset($safe['linkedin']['access_token'])) $safe['linkedin']['access_token'] = $safe['linkedin']['access_token'] ? '***' : null; + if (isset($safe['linkedin']['refresh_token'])) $safe['linkedin']['refresh_token'] = $safe['linkedin']['refresh_token'] ? '***' : null; + echo json_encode(['config' => $safe]); + exit; +} + +if ($method !== 'POST') { http_response_code(405); echo json_encode(['error' => 'POST required']); exit; } + +$config = read_config($config_path); +if ($config === null) { echo json_encode(['error' => 'config.json not found or invalid']); exit; } + +// ── ADD / SAVE / REMOVE STREAMER ────────────────────────────────────────────── +if ($action === 'add_streamer' || $action === 'save_streamer') { + $name = trim($input['name'] ?? ''); + $data = $input['data'] ?? []; + if (!$name) { echo json_encode(['error' => 'Streamer name required']); exit; } + if ($action === 'add_streamer' && isset($config['twitch'][$name])) { + echo json_encode(['error' => 'Streamer already exists']); exit; + } + if (!isset($config['twitch'])) $config['twitch'] = []; + $existing = $config['twitch'][$name] ?? ['twitch_id' => null, 'is_live' => false]; + $config['twitch'][$name] = array_merge($existing, [ + 'color' => array_map('intval', $data['color'] ?? [255, 255, 255]), + 'channel_id' => isset($data['channel_id']) ? (int)$data['channel_id'] : null, + 'role_id' => isset($data['role_id']) && $data['role_id'] !== '' ? (int)$data['role_id'] : null, + ]); + write_config($config_path, $config); + Audit::log($db, 'botconfig_streamer_' . ($action === 'add_streamer' ? 'add' : 'save'), $pid, $name); + echo json_encode(['ok' => true]); + exit; +} + +if ($action === 'remove_streamer') { + $name = trim($input['name'] ?? ''); + if (!$name || !isset($config['twitch'][$name])) { + echo json_encode(['error' => 'Streamer not found']); exit; + } + unset($config['twitch'][$name]); + write_config($config_path, $config); + Audit::log($db, 'botconfig_streamer_remove', $pid, $name); + echo json_encode(['ok' => true]); + exit; +} + +// ── ADD / SAVE / REMOVE RSS ─────────────────────────────────────────────────── +if ($action === 'add_rss' || $action === 'save_rss') { + $name = trim($input['name'] ?? ''); + $data = $input['data'] ?? []; + if (!$name) { echo json_encode(['error' => 'Feed name required']); exit; } + if ($action === 'add_rss' && isset($config['rss'][$name])) { + echo json_encode(['error' => 'Feed already exists']); exit; + } + if (!isset($config['rss'])) $config['rss'] = []; + $existing = $config['rss'][$name] ?? ['last_id' => '']; + $config['rss'][$name] = array_merge($existing, [ + 'active' => !empty($data['active']), + 'rss_url' => trim($data['rss_url'] ?? ''), + 'color' => array_map('intval', $data['color'] ?? [255, 255, 255]), + 'channel_id' => isset($data['channel_id']) ? (int)$data['channel_id'] : null, + 'role_id' => isset($data['role_id']) && $data['role_id'] !== '' ? (int)$data['role_id'] : null, + 'mastodon_account' => $data['mastodon_account'] !== '' ? ($data['mastodon_account'] ?? null) : null, + 'linkedin_page' => $data['linkedin_page'] !== '' ? ($data['linkedin_page'] ?? null) : null, + ]); + write_config($config_path, $config); + Audit::log($db, 'botconfig_rss_' . ($action === 'add_rss' ? 'add' : 'save'), $pid, $name); + echo json_encode(['ok' => true]); + exit; +} + +if ($action === 'remove_rss') { + $name = trim($input['name'] ?? ''); + if (!$name || !isset($config['rss'][$name])) { + echo json_encode(['error' => 'Feed not found']); exit; + } + unset($config['rss'][$name]); + write_config($config_path, $config); + Audit::log($db, 'botconfig_rss_remove', $pid, $name); + echo json_encode(['ok' => true]); + exit; +} + +// ── ADD / SAVE / REMOVE MASTODON ACCOUNT ───────────────────────────────────── +if ($action === 'add_mastodon' || $action === 'save_mastodon') { + $name = trim($input['name'] ?? ''); + $data = $input['data'] ?? []; + if (!$name) { echo json_encode(['error' => 'Account name required']); exit; } + if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) { + echo json_encode(['error' => 'Account name must be alphanumeric/underscore (used as env var suffix)']); exit; + } + if ($action === 'add_mastodon' && isset($config['mastodon'][$name])) { + echo json_encode(['error' => 'Account already exists']); exit; + } + if (!isset($config['mastodon'])) $config['mastodon'] = []; + $config['mastodon'][$name] = [ + 'label' => trim($data['label'] ?? $name), + 'api_base_url' => trim($data['api_base_url'] ?? ''), + ]; + write_config($config_path, $config); + Audit::log($db, 'botconfig_mastodon_' . ($action === 'add_mastodon' ? 'add' : 'save'), $pid, $name); + echo json_encode(['ok' => true]); + exit; +} + +if ($action === 'remove_mastodon') { + $name = trim($input['name'] ?? ''); + if (!$name || !isset($config['mastodon'][$name])) { + echo json_encode(['error' => 'Account not found']); exit; + } + unset($config['mastodon'][$name]); + write_config($config_path, $config); + Audit::log($db, 'botconfig_mastodon_remove', $pid, $name); + echo json_encode(['ok' => true]); + exit; +} + +// ── ADD / SAVE / REMOVE LINKEDIN PAGE ──────────────────────────────────────── +if ($action === 'add_linkedin_page' || $action === 'save_linkedin_page') { + $name = trim($input['name'] ?? ''); + $data = $input['data'] ?? []; + if (!$name) { echo json_encode(['error' => 'Page name required']); exit; } + if ($action === 'add_linkedin_page' && isset($config['linkedin']['pages'][$name])) { + echo json_encode(['error' => 'Page already exists']); exit; + } + if (!isset($config['linkedin'])) $config['linkedin'] = []; + if (!isset($config['linkedin']['pages'])) $config['linkedin']['pages'] = []; + $config['linkedin']['pages'][$name] = [ + 'label' => trim($data['label'] ?? $name), + 'organization_id' => trim($data['organization_id'] ?? ''), + ]; + write_config($config_path, $config); + Audit::log($db, 'botconfig_linkedin_page_' . ($action === 'add_linkedin_page' ? 'add' : 'save'), $pid, $name); + echo json_encode(['ok' => true]); + exit; +} + +if ($action === 'remove_linkedin_page') { + $name = trim($input['name'] ?? ''); + if (!$name || !isset($config['linkedin']['pages'][$name])) { + echo json_encode(['error' => 'Page not found']); exit; + } + unset($config['linkedin']['pages'][$name]); + write_config($config_path, $config); + Audit::log($db, 'botconfig_linkedin_page_remove', $pid, $name); + echo json_encode(['ok' => true]); + exit; +} + +http_response_code(400); +echo json_encode(['error' => 'Unknown action']); diff --git a/web/api/botcontrol.php b/web/api/botcontrol.php new file mode 100644 index 0000000..ac4c138 --- /dev/null +++ b/web/api/botcontrol.php @@ -0,0 +1,67 @@ +prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1'); +$stmt->execute([$project_id]); +$project = $stmt->fetch(); +if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; } + +// Service name from project settings (default: bashybot) +$psStmt = $db->prepare('SELECT value FROM project_settings WHERE project_id = ? AND key = ?'); +$psStmt->execute([$project_id, 'bot_service_name']); +$row = $psStmt->fetch(); +$service = $row ? $row['value'] : 'bashybot'; +$service = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', $service); // sanitize + +if ($method === 'GET') { + $action = $_GET['action'] ?? 'status'; + if ($action !== 'status') { http_response_code(400); echo json_encode(['error' => 'Unknown action']); exit; } + + exec('sudo systemctl is-active ' . escapeshellarg($service) . ' 2>&1', $activeOut, $activeCode); + $isActive = trim(implode('', $activeOut)) === 'active'; + + exec('sudo systemctl show ' . escapeshellarg($service) . ' --property=ActiveEnterTimestamp --value 2>&1', $tsOut); + $since = trim(implode('', $tsOut)); + + echo json_encode([ + 'active' => $isActive, + 'status' => trim(implode('', $activeOut)), + 'since' => $since, + 'service' => $service, + ]); + exit; +} + +if ($method !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; } + +$input = json_decode(file_get_contents('php://input'), true) ?? []; +$action = $input['action'] ?? ''; + +$allowed = ['start', 'stop', 'restart']; +if (!in_array($action, $allowed, true)) { + http_response_code(400); + echo json_encode(['error' => 'Unknown action']); + exit; +} + +$output = []; +$exit_code = 0; +exec('sudo systemctl ' . $action . ' ' . escapeshellarg($service) . ' 2>&1', $output, $exit_code); + +Audit::log($db, 'bot_' . $action, $project_id, $service); + +// Return updated status after action +exec('sudo systemctl is-active ' . escapeshellarg($service) . ' 2>&1', $activeOut); +$isActive = trim(implode('', $activeOut)) === 'active'; + +echo json_encode([ + 'ok' => $exit_code === 0, + 'action' => $action, + 'output' => implode("\n", $output), + 'active' => $isActive, + 'status' => trim(implode('', $activeOut)), +]); diff --git a/web/api/botlogs.php b/web/api/botlogs.php new file mode 100644 index 0000000..b85b2b1 --- /dev/null +++ b/web/api/botlogs.php @@ -0,0 +1,31 @@ +prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1'); +$stmt->execute([$pid]); +$project = $stmt->fetch(); +if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; } + +// Log file path from project settings (default: logs/bashybot.log) +$psStmt = $db->prepare('SELECT value FROM project_settings WHERE project_id = ? AND key = ?'); +$psStmt->execute([$pid, 'bot_log_file']); +$row = $psStmt->fetch(); +$rel = $row ? $row['value'] : 'logs/bashybot.log'; + +$base = realpath($project['path']); +$logPath = realpath($base . '/' . ltrim($rel, '/')); + +if (!$logPath || strpos($logPath, $base) !== 0 || !is_file($logPath)) { + echo json_encode(['lines' => [], 'error' => 'Log file not found: ' . htmlspecialchars($rel)]); + exit; +} + +$lines = (int)($_GET['lines'] ?? 100); +$lines = max(10, min(500, $lines)); + +$output = []; +exec('tail -n ' . $lines . ' ' . escapeshellarg($logPath) . ' 2>&1', $output); + +echo json_encode(['lines' => $output]); diff --git a/web/assets/js/app.js b/web/assets/js/app.js index 89180a0..2ee4574 100644 --- a/web/assets/js/app.js +++ b/web/assets/js/app.js @@ -2355,6 +2355,15 @@ if (addScanPathForm) { theme_git_pull: 'theme pull', theme_git_push: 'theme push', theme_git_fetch: 'theme fetch', plugin_install: 'plugin install', plugin_uninstall: 'plugin uninstall', scheduled_build: 'scheduled build', + bot_start: 'bot started', bot_stop: 'bot stopped', bot_restart: 'bot restarted', + botconfig_streamer_add: 'streamer added', botconfig_streamer_save: 'streamer updated', + botconfig_streamer_remove: 'streamer removed', + botconfig_rss_add: 'feed added', botconfig_rss_save: 'feed updated', + botconfig_rss_remove: 'feed removed', + botconfig_mastodon_add: 'mastodon added', botconfig_mastodon_save: 'mastodon updated', + botconfig_mastodon_remove: 'mastodon removed', + botconfig_linkedin_page_add: 'linkedin page added', botconfig_linkedin_page_save: 'linkedin page updated', + botconfig_linkedin_page_remove: 'linkedin page removed', file_write: 'file edit', file_delete: 'file delete', file_upload: 'upload', post_create: 'post created', post_delete: 'post deleted', post_publish: 'post published', post_duplicate: 'post duplicated', @@ -2384,6 +2393,12 @@ if (addScanPathForm) { theme_git_fetch: 'bi-arrow-repeat', plugin_install: 'bi-puzzle', plugin_uninstall: 'bi-puzzle', scheduled_build: 'bi-clock', + bot_start: 'bi-play-circle', bot_stop: 'bi-stop-circle', bot_restart: 'bi-arrow-clockwise', + botconfig_streamer_add: 'bi-twitch', botconfig_streamer_save: 'bi-twitch', + botconfig_streamer_remove: 'bi-twitch', + botconfig_rss_add: 'bi-rss', botconfig_rss_save: 'bi-rss', botconfig_rss_remove: 'bi-rss', + botconfig_mastodon_add: 'bi-mastodon', botconfig_mastodon_save: 'bi-mastodon', botconfig_mastodon_remove: 'bi-mastodon', + botconfig_linkedin_page_add: 'bi-linkedin', botconfig_linkedin_page_save: 'bi-linkedin', botconfig_linkedin_page_remove: 'bi-linkedin', file_write: 'bi-pencil', file_delete: 'bi-trash', file_upload: 'bi-cloud-upload', post_create: 'bi-file-earmark-plus', post_delete: 'bi-file-earmark-x', post_publish: 'bi-send', post_duplicate: 'bi-files', @@ -3103,3 +3118,374 @@ if (addScanPathForm) { window.addEventListener('DOMContentLoaded', () => { setTimeout(() => { try { _restoreTabState(); } catch(e) {} }, 50); }); + +// ── Bot: control panel ──────────────────────────────────────────────────────── +(function () { + const panel = document.getElementById('botControlPanel'); + if (!panel) return; + const pid = panel.dataset.projectId; + + const dot = document.getElementById('botStatusDot'); + const txt = document.getElementById('botStatusText'); + const since = document.getElementById('botSince'); + const svc = document.getElementById('botService'); + const outputWrap = document.getElementById('botCmdOutputWrap'); + const output = document.getElementById('botCmdOutput'); + + function setStatus(data) { + const active = data.active; + dot.style.background = active ? '#198754' : '#dc3545'; + txt.textContent = active ? 'Running' : (data.status || 'Stopped'); + since.textContent = data.since ? 'since ' + data.since : ''; + svc.textContent = data.service ? data.service + '.service' : ''; + } + + async function loadStatus() { + try { + const r = await fetch('/api/botcontrol?action=status&project_id=' + pid); + const d = await r.json(); + if (d.error) { txt.textContent = d.error; return; } + setStatus(d); + } catch (e) { txt.textContent = 'Error loading status'; } + } + + async function runAction(action) { + outputWrap.classList.remove('d-none'); + output.textContent = action + '…'; + try { + const r = await fetch('/api/botcontrol', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project_id: +pid, action }), + }); + const d = await r.json(); + output.textContent = d.output || (d.ok ? 'Done.' : 'Failed.'); + setStatus(d); + } catch (e) { output.textContent = 'Request failed: ' + e.message; } + } + + document.getElementById('botStartBtn').addEventListener('click', () => runAction('start')); + document.getElementById('botStopBtn').addEventListener('click', () => runAction('stop')); + document.getElementById('botRestartBtn').addEventListener('click', () => runAction('restart')); + document.getElementById('botRefreshStatusBtn').addEventListener('click', loadStatus); + document.getElementById('botClearOutputBtn').addEventListener('click', () => { + output.textContent = ''; + outputWrap.classList.add('d-none'); + }); + + loadStatus(); +})(); + +// ── Bot: config editor ──────────────────────────────────────────────────────── +(function () { + const panel = document.getElementById('botConfigPanel'); + if (!panel) return; + const pid = panel.dataset.projectId; + + function rgbToHex([r, g, b]) { + return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); + } + function hexToRgb(hex) { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + + let _config = null; + + async function loadConfig() { + const r = await fetch('/api/botconfig?action=get&project_id=' + pid); + const d = await r.json(); + if (d.error) { showError(d.error); return; } + _config = d.config; + renderStreamers(_config.twitch || {}); + renderFeeds(_config.rss || {}); + renderMastodon(_config.mastodon || {}); + renderLinkedinPages(_config.linkedin?.pages || {}); + renderLinkedinConnection(_config.linkedin || {}); + } + + // ── Streamers ──────────────────────────────────────────────────────────────── + function renderStreamers(twitch) { + const list = document.getElementById('streamerList'); + const names = Object.keys(twitch); + if (!names.length) { list.innerHTML = '
No streamers configured.
'; return; } + list.innerHTML = names.map(name => { + const s = twitch[name]; + const hex = rgbToHex(s.color || [255, 255, 255]); + return `
+ + ${esc(name)} + ch: ${esc(String(s.channel_id ?? '–'))} + ${s.role_id ? `role: ${esc(String(s.role_id))}` : ''} +
+ + +
+
`; + }).join(''); + list.querySelectorAll('.edit-streamer-btn').forEach(b => b.addEventListener('click', () => openStreamerModal('edit', b.dataset.name))); + list.querySelectorAll('.del-streamer-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_streamer', b.dataset.name, `Remove streamer "${b.dataset.name}"?`, 'Streamer removed'))); + } + + function openStreamerModal(mode, name = '') { + const s = (mode === 'edit' && _config?.twitch?.[name]) || {}; + document.getElementById('streamerModalTitle').textContent = mode === 'add' ? 'Add Streamer' : 'Edit Streamer'; + document.getElementById('streamerModalMode').value = mode; + document.getElementById('streamerName').value = name; + document.getElementById('streamerName').disabled = mode === 'edit'; + document.getElementById('streamerChannelId').value = s.channel_id ?? ''; + document.getElementById('streamerRoleId').value = s.role_id ?? ''; + document.getElementById('streamerColor').value = rgbToHex(s.color || [100, 65, 165]); + document.getElementById('streamerModalError').classList.add('d-none'); + bootstrap.Modal.getOrCreateInstance(document.getElementById('streamerModal')).show(); + } + + document.getElementById('addStreamerBtn').addEventListener('click', () => openStreamerModal('add')); + document.getElementById('streamerModalSaveBtn').addEventListener('click', async () => { + const mode = document.getElementById('streamerModalMode').value; + const name = document.getElementById('streamerName').value.trim(); + const errEl = document.getElementById('streamerModalError'); + errEl.classList.add('d-none'); + if (!name) { errEl.textContent = 'Name required'; errEl.classList.remove('d-none'); return; } + const ok = await apiPost({ action: mode === 'add' ? 'add_streamer' : 'save_streamer', name, data: { + channel_id: document.getElementById('streamerChannelId').value.trim(), + role_id: document.getElementById('streamerRoleId').value.trim(), + color: hexToRgb(document.getElementById('streamerColor').value), + }}, errEl); + if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('streamerModal')).hide(); showSuccess('Streamer saved'); loadConfig(); } + }); + + // ── RSS feeds ──────────────────────────────────────────────────────────────── + function renderFeeds(rss) { + const list = document.getElementById('rssList'); + const names = Object.keys(rss); + if (!names.length) { list.innerHTML = '
No RSS feeds configured.
'; return; } + list.innerHTML = names.map(name => { + const f = rss[name]; + const hex = rgbToHex(Array.isArray(f.color) ? f.color : [255, 215, 0]); + const activeBadge = f.active + ? 'active' + : 'paused'; + const acct = f.mastodon_account ? ` ${esc(f.mastodon_account)}` : ''; + const liPage = f.linkedin_page ? ` ${esc(f.linkedin_page)}` : ''; + return `
+ + ${esc(name)}${activeBadge} + ${acct}${liPage} + ${esc(f.rss_url || '–')} +
+ + +
+
`; + }).join(''); + list.querySelectorAll('.edit-rss-btn').forEach(b => b.addEventListener('click', () => openRssModal('edit', b.dataset.name))); + list.querySelectorAll('.del-rss-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_rss', b.dataset.name, `Remove RSS feed "${b.dataset.name}"?`, 'Feed removed'))); + } + + function populateSelect(id, options, selected) { + const sel = document.getElementById(id); + sel.innerHTML = '' + + options.map(([v, l]) => ``).join(''); + } + + function openRssModal(mode, name = '') { + const f = (mode === 'edit' && _config?.rss?.[name]) || {}; + document.getElementById('rssModalTitle').textContent = mode === 'add' ? 'Add RSS Feed' : 'Edit RSS Feed'; + document.getElementById('rssModalMode').value = mode; + document.getElementById('rssName').value = name; + document.getElementById('rssName').disabled = mode === 'edit'; + document.getElementById('rssUrl').value = f.rss_url ?? ''; + document.getElementById('rssChannelId').value = f.channel_id ?? ''; + document.getElementById('rssRoleId').value = f.role_id ?? ''; + document.getElementById('rssColor').value = rgbToHex(Array.isArray(f.color) ? f.color : [255, 215, 0]); + document.getElementById('rssActive').checked = f.active !== false; + populateSelect('rssMastodonAccount', + Object.entries(_config?.mastodon || {}).map(([k, v]) => [k, v.label || k]), + f.mastodon_account || ''); + populateSelect('rssLinkedinPage', + Object.entries(_config?.linkedin?.pages || {}).map(([k, v]) => [k, v.label || k]), + f.linkedin_page || ''); + document.getElementById('rssModalError').classList.add('d-none'); + bootstrap.Modal.getOrCreateInstance(document.getElementById('rssModal')).show(); + } + + document.getElementById('addRssBtn').addEventListener('click', () => openRssModal('add')); + document.getElementById('rssModalSaveBtn').addEventListener('click', async () => { + const mode = document.getElementById('rssModalMode').value; + const name = document.getElementById('rssName').value.trim(); + const errEl = document.getElementById('rssModalError'); + errEl.classList.add('d-none'); + if (!name) { errEl.textContent = 'Name required'; errEl.classList.remove('d-none'); return; } + const ok = await apiPost({ action: mode === 'add' ? 'add_rss' : 'save_rss', name, data: { + rss_url: document.getElementById('rssUrl').value.trim(), + channel_id: document.getElementById('rssChannelId').value.trim(), + role_id: document.getElementById('rssRoleId').value.trim(), + color: hexToRgb(document.getElementById('rssColor').value), + active: document.getElementById('rssActive').checked, + mastodon_account: document.getElementById('rssMastodonAccount').value, + linkedin_page: document.getElementById('rssLinkedinPage').value, + }}, errEl); + if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('rssModal')).hide(); showSuccess('Feed saved'); loadConfig(); } + }); + + // ── Mastodon accounts ───────────────────────────────────────────────────────── + function renderMastodon(mastodon) { + const list = document.getElementById('mastodonList'); + const names = Object.keys(mastodon); + if (!names.length) { list.innerHTML = '
No accounts configured.
'; return; } + list.innerHTML = names.map(name => { + const a = mastodon[name]; + return `
+ ${esc(a.label || name)} + MASTODON_TOKEN_${esc(name.toUpperCase())} + ${esc(a.api_base_url || '')} +
+ + +
+
`; + }).join(''); + list.querySelectorAll('.edit-mastodon-btn').forEach(b => b.addEventListener('click', () => openMastodonModal('edit', b.dataset.name))); + list.querySelectorAll('.del-mastodon-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_mastodon', b.dataset.name, `Remove account "${b.dataset.name}"?`, 'Account removed'))); + } + + function openMastodonModal(mode, name = '') { + const a = (mode === 'edit' && _config?.mastodon?.[name]) || {}; + document.getElementById('mastodonModalTitle').textContent = mode === 'add' ? 'Add Mastodon Account' : 'Edit Mastodon Account'; + document.getElementById('mastodonModalMode').value = mode; + document.getElementById('mastodonName').value = name; + document.getElementById('mastodonName').disabled = mode === 'edit'; + document.getElementById('mastodonLabel').value = a.label ?? ''; + document.getElementById('mastodonBase').value = a.api_base_url ?? ''; + document.getElementById('mastodonModalError').classList.add('d-none'); + bootstrap.Modal.getOrCreateInstance(document.getElementById('mastodonModal')).show(); + } + + document.getElementById('addMastodonBtn').addEventListener('click', () => openMastodonModal('add')); + document.getElementById('mastodonModalSaveBtn').addEventListener('click', async () => { + const mode = document.getElementById('mastodonModalMode').value; + const name = document.getElementById('mastodonName').value.trim(); + const errEl = document.getElementById('mastodonModalError'); + errEl.classList.add('d-none'); + if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; } + const ok = await apiPost({ action: mode === 'add' ? 'add_mastodon' : 'save_mastodon', name, data: { + label: document.getElementById('mastodonLabel').value.trim(), + api_base_url: document.getElementById('mastodonBase').value.trim(), + }}, errEl); + if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('mastodonModal')).hide(); showSuccess('Account saved'); loadConfig(); } + }); + + // ── LinkedIn pages ──────────────────────────────────────────────────────────── + function renderLinkedinConnection(li) { + const label = document.getElementById('liConnectionLabel'); + const expiry = document.getElementById('liTokenExpiry'); + const connected = li.access_token === '***'; + label.textContent = connected ? 'Connected to LinkedIn' : 'Not connected'; + label.className = 'small fw-semibold ' + (connected ? 'text-success' : 'text-muted'); + expiry.textContent = li.token_expiry ? 'Expires: ' + li.token_expiry : ''; + } + + function renderLinkedinPages(pages) { + const list = document.getElementById('linkedinPagesList'); + const names = Object.keys(pages); + if (!names.length) { list.innerHTML = '
No pages configured.
'; return; } + list.innerHTML = names.map(name => { + const p = pages[name]; + return `
+ ${esc(p.label || name)} + ${esc(p.organization_id || '–')} +
+ + +
+
`; + }).join(''); + list.querySelectorAll('.edit-lipage-btn').forEach(b => b.addEventListener('click', () => openLinkedinPageModal('edit', b.dataset.name))); + list.querySelectorAll('.del-lipage-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_linkedin_page', b.dataset.name, `Remove page "${b.dataset.name}"?`, 'Page removed'))); + } + + function openLinkedinPageModal(mode, name = '') { + const p = (mode === 'edit' && _config?.linkedin?.pages?.[name]) || {}; + document.getElementById('linkedinPageModalTitle').textContent = mode === 'add' ? 'Add LinkedIn Page' : 'Edit LinkedIn Page'; + document.getElementById('linkedinPageModalMode').value = mode; + document.getElementById('linkedinPageName').value = name; + document.getElementById('linkedinPageName').disabled = mode === 'edit'; + document.getElementById('linkedinPageLabel').value = p.label ?? ''; + document.getElementById('linkedinPageOrgId').value = p.organization_id ?? ''; + document.getElementById('linkedinPageModalError').classList.add('d-none'); + bootstrap.Modal.getOrCreateInstance(document.getElementById('linkedinPageModal')).show(); + } + + document.getElementById('addLinkedinPageBtn').addEventListener('click', () => openLinkedinPageModal('add')); + document.getElementById('linkedinPageModalSaveBtn').addEventListener('click', async () => { + const mode = document.getElementById('linkedinPageModalMode').value; + const name = document.getElementById('linkedinPageName').value.trim(); + const errEl = document.getElementById('linkedinPageModalError'); + errEl.classList.add('d-none'); + if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; } + const ok = await apiPost({ action: mode === 'add' ? 'add_linkedin_page' : 'save_linkedin_page', name, data: { + label: document.getElementById('linkedinPageLabel').value.trim(), + organization_id: document.getElementById('linkedinPageOrgId').value.trim(), + }}, errEl); + if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('linkedinPageModal')).hide(); showSuccess('Page saved'); loadConfig(); } + }); + + // ── Shared helpers ──────────────────────────────────────────────────────────── + async function apiPost(body, errEl) { + const r = await fetch('/api/botconfig', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project_id: +pid, ...body }), + }); + const d = await r.json(); + if (d.error && errEl) { errEl.textContent = d.error; errEl.classList.remove('d-none'); return false; } + if (d.error) { showError(d.error); return false; } + return true; + } + + function deleteItem(action, name, confirmMsg, successMsg) { + confirmAction(confirmMsg, async () => { + const ok = await apiPost({ action, name }, null); + if (ok) { showSuccess(successMsg); loadConfig(); } + }); + } + + loadConfig(); +})(); + +// ── Bot: log viewer ─────────────────────────────────────────────────────────── +(function () { + const panel = document.getElementById('botLogsPanel'); + if (!panel) return; + const pid = panel.dataset.projectId; + const pre = document.getElementById('logsOutput'); + let _timer = null; + + async function loadLogs() { + const lines = document.getElementById('logsLineCount').value; + try { + const r = await fetch(`/api/botlogs?project_id=${pid}&lines=${lines}`); + const d = await r.json(); + if (d.error) { pre.textContent = d.error; return; } + pre.textContent = d.lines.join('\n') || '(no log entries)'; + pre.scrollTop = pre.scrollHeight; + } catch (e) { pre.textContent = 'Error: ' + e.message; } + } + + function startAutoRefresh() { + stopAutoRefresh(); + _timer = setInterval(loadLogs, 5000); + } + function stopAutoRefresh() { + if (_timer) { clearInterval(_timer); _timer = null; } + } + + document.getElementById('logsRefreshBtn').addEventListener('click', loadLogs); + document.getElementById('logsLineCount').addEventListener('change', loadLogs); + document.getElementById('logsAutoRefresh').addEventListener('change', e => { + e.target.checked ? startAutoRefresh() : stopAutoRefresh(); + }); + + loadLogs(); +})(); +