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

@ -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'; ?>