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 <noreply@anthropic.com>
This commit is contained in:
Bashy 2026-05-26 14:39:17 +03:00
parent a2a1f356ba
commit bf3ae2351d
10 changed files with 1025 additions and 0 deletions

188
web/api/botconfig.php Normal file
View file

@ -0,0 +1,188 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = $method === 'POST' ? (json_decode(file_get_contents('php://input'), true) ?? []) : [];
$pid = (int)(($method === 'GET' ? $_GET : $input)['project_id'] ?? 0);
$action = ($method === 'GET' ? ($_GET['action'] ?? 'get') : ($input['action'] ?? ''));
$stmt = $db->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']);

67
web/api/botcontrol.php Normal file
View file

@ -0,0 +1,67 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)(($method === 'GET' ? $_GET : (json_decode(file_get_contents('php://input'), true) ?? []))['project_id'] ?? 0);
$stmt = $db->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)),
]);

31
web/api/botlogs.php Normal file
View file

@ -0,0 +1,31 @@
<?php
header('Content-Type: application/json');
$pid = (int)($_GET['project_id'] ?? 0);
$stmt = $db->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]);

View file

@ -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 = '<div class="text-muted small">No streamers configured.</div>'; return; }
list.innerHTML = names.map(name => {
const s = twitch[name];
const hex = rgbToHex(s.color || [255, 255, 255]);
return `<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
<span class="rounded-circle border flex-shrink-0" style="width:16px;height:16px;background:${hex}"></span>
<span class="fw-semibold small">${esc(name)}</span>
<small class="text-muted ms-1">ch: <code>${esc(String(s.channel_id ?? ''))}</code></small>
${s.role_id ? `<small class="text-muted">role: <code>${esc(String(s.role_id))}</code></small>` : ''}
<div class="ms-auto d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-streamer-btn" data-name="${esc(name)}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-streamer-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
</div>
</div>`;
}).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 = '<div class="text-muted small">No RSS feeds configured.</div>'; 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
? '<span class="badge bg-success-subtle text-success border ms-1">active</span>'
: '<span class="badge bg-secondary-subtle text-muted border ms-1">paused</span>';
const acct = f.mastodon_account ? `<small class="text-muted"><i class="bi bi-mastodon"></i> ${esc(f.mastodon_account)}</small>` : '';
const liPage = f.linkedin_page ? `<small class="text-muted"><i class="bi bi-linkedin"></i> ${esc(f.linkedin_page)}</small>` : '';
return `<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1 flex-wrap">
<span class="rounded-circle border flex-shrink-0" style="width:16px;height:16px;background:${hex}"></span>
<span class="fw-semibold small">${esc(name)}</span>${activeBadge}
${acct}${liPage}
<small class="text-muted text-truncate" style="max-width:180px" title="${esc(f.rss_url || '')}">${esc(f.rss_url || '')}</small>
<div class="ms-auto d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-rss-btn" data-name="${esc(name)}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-rss-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
</div>
</div>`;
}).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 = '<option value="">— none —</option>' +
options.map(([v, l]) => `<option value="${esc(v)}" ${v === selected ? 'selected' : ''}>${esc(l)}</option>`).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 = '<div class="text-muted small">No accounts configured.</div>'; return; }
list.innerHTML = names.map(name => {
const a = mastodon[name];
return `<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
<span class="fw-semibold small">${esc(a.label || name)}</span>
<code class="small text-muted">MASTODON_TOKEN_${esc(name.toUpperCase())}</code>
<small class="text-muted text-truncate" style="max-width:160px">${esc(a.api_base_url || '')}</small>
<div class="ms-auto d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-mastodon-btn" data-name="${esc(name)}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-mastodon-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
</div>
</div>`;
}).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 = '<div class="text-muted small">No pages configured.</div>'; return; }
list.innerHTML = names.map(name => {
const p = pages[name];
return `<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
<span class="fw-semibold small">${esc(p.label || name)}</span>
<code class="small text-muted">${esc(p.organization_id || '')}</code>
<div class="ms-auto d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-lipage-btn" data-name="${esc(name)}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-lipage-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
</div>
</div>`;
}).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();
})();