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

View file

@ -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 <service>`.
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

View file

@ -0,0 +1,19 @@
<?php
require_once __DIR__ . '/ProjectTypeBase.php';
class DiscordBotProject extends ProjectTypeBase {
public static function typeSlug(): string { return 'discord-bot'; }
public static function typeName(): string { return 'Discord Bot'; }
public static function typeIcon(): string { return 'bi-robot'; }
public static function description(): string { return 'Discord bot managed via systemd'; }
public static function tabs(): array {
return ['dashboard', 'bot', 'botconfig', 'logs', 'files', 'notes', 'settings'];
}
public static function detectFromPath(string $path): bool {
return file_exists($path . '/main.py')
&& is_dir($path . '/cogs')
&& file_exists($path . '/config.json');
}
}

View file

@ -0,0 +1,53 @@
<div id="botControlPanel" data-project-id="<?= $pid ?>">
<!-- Status card -->
<div class="card mb-4">
<div class="card-body d-flex align-items-center gap-3 flex-wrap">
<div class="d-flex align-items-center gap-2">
<span id="botStatusDot" class="rounded-circle d-inline-block"
style="width:12px;height:12px;background:#6c757d"></span>
<span id="botStatusText" class="fw-semibold">Checking…</span>
</div>
<small id="botSince" class="text-muted"></small>
<small id="botService" class="text-muted font-monospace ms-auto"></small>
</div>
</div>
<!-- Controls -->
<div class="d-flex gap-2 mb-4">
<button class="btn btn-success btn-sm" id="botStartBtn">
<i class="bi bi-play-fill me-1"></i>Start
</button>
<button class="btn btn-danger btn-sm" id="botStopBtn">
<i class="bi bi-stop-fill me-1"></i>Stop
</button>
<button class="btn btn-warning btn-sm" id="botRestartBtn">
<i class="bi bi-arrow-clockwise me-1"></i>Restart
</button>
<button class="btn btn-outline-secondary btn-sm ms-auto" id="botRefreshStatusBtn">
<i class="bi bi-arrow-repeat me-1"></i>Refresh
</button>
</div>
<!-- Output -->
<div id="botCmdOutputWrap" class="d-none">
<div class="card">
<div class="card-header small d-flex justify-content-between align-items-center">
Command output
<button class="btn btn-sm btn-outline-secondary py-0" id="botClearOutputBtn">Clear</button>
</div>
<pre id="botCmdOutput" class="m-0 p-3 text-success"
style="min-height:80px;max-height:300px;overflow-y:auto;font-size:.8rem;background:transparent"></pre>
</div>
</div>
<div class="mt-4">
<p class="text-muted small mb-1">
<i class="bi bi-info-circle me-1"></i>
Bot process managed via <code>systemctl</code>.
The <code>www-data</code> user requires passwordless sudo for these commands:
</p>
<pre class="small text-muted p-2 border rounded bg-body-tertiary" style="font-size:.75rem">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</pre>
</div>
</div>

View file

@ -0,0 +1,214 @@
<div id="botConfigPanel" data-project-id="<?= $pid ?>">
<!-- Twitch Streamers -->
<h6 class="mb-3"><i class="bi bi-twitch me-1 text-primary"></i>Twitch Streamers</h6>
<div id="streamerList" class="mb-2"><div class="text-muted small">Loading…</div></div>
<button class="btn btn-sm btn-outline-primary mb-4" id="addStreamerBtn">
<i class="bi bi-plus-lg me-1"></i>Add Streamer
</button>
<hr>
<!-- RSS Feeds -->
<h6 class="mb-3 mt-3"><i class="bi bi-rss me-1 text-warning"></i>RSS Feeds</h6>
<div id="rssList" class="mb-2"><div class="text-muted small">Loading…</div></div>
<button class="btn btn-sm btn-outline-primary mb-4" id="addRssBtn">
<i class="bi bi-plus-lg me-1"></i>Add Feed
</button>
<hr>
<!-- Mastodon Accounts -->
<h6 class="mb-1 mt-3"><i class="bi bi-mastodon me-1 text-primary"></i>Mastodon Accounts</h6>
<p class="text-muted small mb-3">
Add the token to <code>.env</code> as <code>MASTODON_TOKEN_&lt;NAME&gt;</code> (uppercase).
The UI manages the label and API base URL only.
</p>
<div id="mastodonList" class="mb-2"><div class="text-muted small">Loading…</div></div>
<button class="btn btn-sm btn-outline-primary mb-4" id="addMastodonBtn">
<i class="bi bi-plus-lg me-1"></i>Add Account
</button>
<hr>
<!-- LinkedIn Pages -->
<h6 class="mb-3 mt-3"><i class="bi bi-linkedin me-1 text-primary"></i>LinkedIn Pages</h6>
<div class="card mb-3">
<div class="card-body py-2 d-flex align-items-center gap-3">
<div>
<div class="small fw-semibold" id="liConnectionLabel">Checking…</div>
<div class="text-muted small" id="liTokenExpiry"></div>
</div>
<a href="#" class="btn btn-sm btn-outline-primary ms-auto disabled" id="liConnectBtn">
<i class="bi bi-box-arrow-in-right me-1"></i>Connect LinkedIn
<span class="badge bg-secondary ms-1">coming soon</span>
</a>
</div>
</div>
<div id="linkedinPagesList" class="mb-2"><div class="text-muted small">Loading…</div></div>
<button class="btn btn-sm btn-outline-primary mb-4" id="addLinkedinPageBtn">
<i class="bi bi-plus-lg me-1"></i>Add Page
</button>
</div>
<!-- Streamer modal -->
<div class="modal fade" id="streamerModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="streamerModalTitle">Add Streamer</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="streamerModalMode">
<div class="mb-2">
<label class="form-label small">Twitch login name</label>
<input type="text" id="streamerName" class="form-control form-control-sm font-monospace"
placeholder="streamer_login" autocomplete="off">
</div>
<div class="mb-2">
<label class="form-label small">Discord channel ID</label>
<input type="text" id="streamerChannelId" class="form-control form-control-sm font-monospace">
</div>
<div class="mb-2">
<label class="form-label small">Role ID <span class="text-muted">(optional)</span></label>
<input type="text" id="streamerRoleId" class="form-control form-control-sm font-monospace">
</div>
<div class="mb-2">
<label class="form-label small">Embed colour</label>
<input type="color" id="streamerColor" class="form-control form-control-color form-control-sm" value="#6441a5">
</div>
</div>
<div class="modal-footer py-2">
<div class="text-danger small d-none flex-grow-1" id="streamerModalError"></div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="streamerModalSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- RSS modal -->
<div class="modal fade" id="rssModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="rssModalTitle">Add RSS Feed</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="rssModalMode">
<div class="mb-2">
<label class="form-label small">Feed name (label)</label>
<input type="text" id="rssName" class="form-control form-control-sm font-monospace"
placeholder="myblog" autocomplete="off">
</div>
<div class="mb-2">
<label class="form-label small">RSS URL</label>
<input type="url" id="rssUrl" class="form-control form-control-sm font-monospace">
</div>
<div class="mb-2">
<label class="form-label small">Discord channel ID</label>
<input type="text" id="rssChannelId" class="form-control form-control-sm font-monospace">
</div>
<div class="mb-2">
<label class="form-label small">Role ID <span class="text-muted">(optional)</span></label>
<input type="text" id="rssRoleId" class="form-control form-control-sm font-monospace">
</div>
<div class="mb-2">
<label class="form-label small">Embed colour</label>
<input type="color" id="rssColor" class="form-control form-control-color form-control-sm" value="#ffd700">
</div>
<div class="mb-2">
<label class="form-label small">Mastodon account <span class="text-muted">(optional)</span></label>
<select id="rssMastodonAccount" class="form-select form-select-sm"></select>
</div>
<div class="mb-2">
<label class="form-label small">LinkedIn page <span class="text-muted">(optional)</span></label>
<select id="rssLinkedinPage" class="form-select form-select-sm"></select>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rssActive" checked>
<label class="form-check-label small" for="rssActive">Active</label>
</div>
</div>
<div class="modal-footer py-2">
<div class="text-danger small d-none flex-grow-1" id="rssModalError"></div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="rssModalSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Mastodon account modal -->
<div class="modal fade" id="mastodonModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="mastodonModalTitle">Add Mastodon Account</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="mastodonModalMode">
<div class="mb-2">
<label class="form-label small">Account key</label>
<input type="text" id="mastodonName" class="form-control form-control-sm font-monospace"
placeholder="main" autocomplete="off">
<div class="form-text small">Used as <code>MASTODON_TOKEN_&lt;KEY&gt;</code> in .env (uppercase).</div>
</div>
<div class="mb-2">
<label class="form-label small">Label</label>
<input type="text" id="mastodonLabel" class="form-control form-control-sm" placeholder="Main Account">
</div>
<div class="mb-2">
<label class="form-label small">API base URL</label>
<input type="url" id="mastodonBase" class="form-control form-control-sm font-monospace"
placeholder="https://mastodon.social">
</div>
</div>
<div class="modal-footer py-2">
<div class="text-danger small d-none flex-grow-1" id="mastodonModalError"></div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="mastodonModalSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- LinkedIn page modal -->
<div class="modal fade" id="linkedinPageModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="linkedinPageModalTitle">Add LinkedIn Page</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="linkedinPageModalMode">
<div class="mb-2">
<label class="form-label small">Page key</label>
<input type="text" id="linkedinPageName" class="form-control form-control-sm font-monospace"
placeholder="myorg" autocomplete="off">
</div>
<div class="mb-2">
<label class="form-label small">Label</label>
<input type="text" id="linkedinPageLabel" class="form-control form-control-sm" placeholder="My Organisation">
</div>
<div class="mb-2">
<label class="form-label small">Organization ID</label>
<input type="text" id="linkedinPageOrgId" class="form-control form-control-sm font-monospace"
placeholder="123456789">
</div>
</div>
<div class="modal-footer py-2">
<div class="text-danger small d-none flex-grow-1" id="linkedinPageModalError"></div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="linkedinPageModalSaveBtn">Save</button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,28 @@
<div id="botLogsPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
<h6 class="mb-0 text-muted">
<i class="bi bi-journal-text me-1"></i>Bot logs
</h6>
<div class="d-flex align-items-center gap-2 ms-auto">
<label class="form-label small mb-0">Lines:</label>
<select id="logsLineCount" class="form-select form-select-sm" style="width:auto">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="logsAutoRefresh">
<label class="form-check-label small" for="logsAutoRefresh">Auto-refresh</label>
</div>
<button class="btn btn-sm btn-outline-secondary" id="logsRefreshBtn">
<i class="bi bi-arrow-repeat"></i>
</button>
</div>
</div>
<pre id="logsOutput" class="p-3 border rounded bg-body-tertiary text-body-secondary"
style="font-size:.75rem;min-height:200px;max-height:70vh;overflow-y:auto;white-space:pre-wrap;word-break:break-all">Loading…</pre>
</div>

View file

@ -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 = [
</div>
</div>
<?php elseif ($tab === 'bot'): ?>
<?php include ROOT . '/views/project/_tab_bot.php'; ?>
<?php elseif ($tab === 'botconfig'): ?>
<?php include ROOT . '/views/project/_tab_botconfig.php'; ?>
<?php elseif ($tab === 'logs'): ?>
<?php include ROOT . '/views/project/_tab_logs.php'; ?>
<?php elseif ($tab === 'git'): ?>
<?php include ROOT . '/views/project/_tab_git.php'; ?>

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();
})();