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:
parent
a2a1f356ba
commit
bf3ae2351d
10 changed files with 1025 additions and 0 deletions
25
CLAUDE.md
25
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 <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
|
||||
|
|
|
|||
19
lib/project-types/DiscordBotProject.php
Normal file
19
lib/project-types/DiscordBotProject.php
Normal 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');
|
||||
}
|
||||
}
|
||||
53
views/project/_tab_bot.php
Normal file
53
views/project/_tab_bot.php
Normal 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>
|
||||
214
views/project/_tab_botconfig.php
Normal file
214
views/project/_tab_botconfig.php
Normal 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_<NAME></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_<KEY></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>
|
||||
28
views/project/_tab_logs.php
Normal file
28
views/project/_tab_logs.php
Normal 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>
|
||||
|
|
@ -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
188
web/api/botconfig.php
Normal 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
67
web/api/botcontrol.php
Normal 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
31
web/api/botlogs.php
Normal 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]);
|
||||
|
|
@ -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();
|
||||
})();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue