Setup-Seite: Kanal-Auswahl, Feed-Regeln und Status im Webinterface

- Settings-Tabelle in SQLite; Env-Variablen nur noch Fallback, Änderungen greifen sofort
- /settings (Admin): Devlog-/Commit-Kanal als Dropdown aus allen sichtbaren Textkanälen,
  je mit Test-senden-Button
- Commit-Feed-Regeln: an/aus, Branch-Filter, ignorierte Repos (archiviert wird immer,
  gefiltert wird nur das Posten; ignorierte Repos komplett übersprungen)
- Status-Panel: Bot-Tag, Uptime, Devlog-/Commit-Zahlen, DB-Größe
- API: GET/PUT /api/settings, POST /api/settings/test/:target (alles Admin-only)
- COMMIT_CHANNEL_ID/DEVLOG_CHANNEL_ID in config optional

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 10:21:05 +02:00
co-authored by Claude Opus 4.8
parent 71d2bb07f1
commit ae5732a61b
14 changed files with 516 additions and 23 deletions
+18
View File
@@ -124,6 +124,24 @@ Bilder werden lokal gespeichert (`data/devlog_images/`), weil Discord-CDN-Links
--- ---
## Setup-Seite im Webinterface (Admin)
Unter **/settings** (Nav: „Setup", nur als Admin sichtbar) lassen sich zur Laufzeit
ändern — gespeichert in SQLite, **Env-Variablen sind nur noch der Fallback**,
Änderungen greifen sofort ohne Redeploy:
- **Devlog-Kanal** und **Commit-Kanal** als Dropdown (alle Textkanäle, die der
Bot sieht) — mit **Test senden**-Button pro Kanal
- **Commit-Feed an/aus** (aus = Commits werden weiter archiviert, nur nicht gepostet)
- **Branch-Filter** (kommagetrennt, leer = alle) — gefilterte Branches werden
archiviert, aber nicht gepostet
- **Ignorierte Repos** (kommagetrennt, z. B. `D4rkst3r/ecobot`) — komplett übersprungen
- **Status-Panel:** Bot-Account, Uptime, Anzahl Devlogs/Commits, DB-Größe
`COMMIT_CHANNEL_ID` und `DEVLOG_CHANNEL_ID` sind damit optional geworden.
---
## Setup: Webinterface ## Setup: Webinterface
**Zugriffsmodell:** Devlog-Archiv ist öffentlich (wie der Discord-Kanal), **Zugriffsmodell:** Devlog-Archiv ist öffentlich (wie der Discord-Kanal),
+3
View File
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate } from 'react-router-dom';
import { apiGet } from './api.js'; import { apiGet } from './api.js';
import Devlogs from './pages/Devlogs.jsx'; import Devlogs from './pages/Devlogs.jsx';
import Commits from './pages/Commits.jsx'; import Commits from './pages/Commits.jsx';
import Settings from './pages/Settings.jsx';
function DiscordMark() { function DiscordMark() {
return ( return (
@@ -69,6 +70,7 @@ export default function App() {
<nav className="nav-links"> <nav className="nav-links">
<NavLink to="/devlogs">Devlog</NavLink> <NavLink to="/devlogs">Devlog</NavLink>
{me.admin && <NavLink to="/commits">Commits</NavLink>} {me.admin && <NavLink to="/commits">Commits</NavLink>}
{me.admin && <NavLink to="/settings">Setup</NavLink>}
</nav> </nav>
<div className="auth"> <div className="auth">
@@ -91,6 +93,7 @@ export default function App() {
<Route path="/" element={<Navigate to="/devlogs" replace />} /> <Route path="/" element={<Navigate to="/devlogs" replace />} />
<Route path="/devlogs" element={<Devlogs me={me} />} /> <Route path="/devlogs" element={<Devlogs me={me} />} />
<Route path="/commits" element={<Commits me={me} />} /> <Route path="/commits" element={<Commits me={me} />} />
<Route path="/settings" element={<Settings me={me} />} />
<Route path="*" element={<Navigate to="/devlogs" replace />} /> <Route path="*" element={<Navigate to="/devlogs" replace />} />
</Routes> </Routes>
</main> </main>
+12
View File
@@ -11,3 +11,15 @@ async function api(path, options = {}) {
export const apiGet = (path) => api(path); export const apiGet = (path) => api(path);
export const apiDelete = (path) => api(path, { method: 'DELETE' }); export const apiDelete = (path) => api(path, { method: 'DELETE' });
export const apiPut = (path, body) =>
api(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
export const apiPost = (path, body = {}) =>
api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react';
import { apiGet, apiPut, apiPost } from '../api.js';
/** Sekunden → "2d 4h 13m" */
function fmtUptime(s) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
return [d && `${d}d`, (d || h) && `${h}h`, `${m}m`].filter(Boolean).join(' ');
}
function fmtBytes(b) {
if (b > 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
return `${(b / 1024).toFixed(0)} KB`;
}
export default function Settings({ me }) {
const [data, setData] = useState(null);
const [form, setForm] = useState(null);
const [error, setError] = useState(null);
const [feedback, setFeedback] = useState(null);
useEffect(() => {
if (!me.admin) return;
apiGet('/api/settings')
.then((d) => {
setData(d);
setForm(d.settings);
})
.catch((e) => setError(e.status));
}, [me.admin]);
function flash(msg) {
setFeedback(msg);
setTimeout(() => setFeedback(null), 3000);
}
async function save() {
try {
const res = await apiPut('/api/settings', form);
setForm(res.settings);
flash('✓ Gespeichert');
} catch {
flash('✗ Speichern fehlgeschlagen');
}
}
async function sendTest(target) {
try {
await apiPost(`/api/settings/test/${target}`);
flash('✓ Test-Nachricht gesendet');
} catch {
flash('✗ Test fehlgeschlagen — Kanal/Rechte prüfen');
}
}
const header = (
<section className="page-header">
<div className="page-bg-text">SETUP</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Admin</div>
<h1 className="page-title">Setup</h1>
<p className="page-subtitle">Kanäle, Feed-Regeln und Status Änderungen greifen sofort.</p>
</div>
</section>
);
if (me.loading) return <>{header}<section className="content"><p className="notice">Lade </p></section></>;
if (!me.user || !me.admin) {
return (
<>
{header}
<section className="content">
<div className="notice center">
<p>🔒 Nur für den Admin.</p>
{!me.user && <a className="btn" href="/auth/login">Login mit Discord</a>}
</div>
</section>
</>
);
}
if (error) return <>{header}<section className="content"><p className="notice">Fehler beim Laden ({error}).</p></section></>;
if (!data || !form) return <>{header}<section className="content"><p className="notice">Lade </p></section></>;
const channelOptions = data.channels.map((c) => (
<option key={c.id} value={c.id}>
#{c.name} {c.guild}
</option>
));
return (
<>
{header}
<section className="content">
{/* Status */}
<div className="settings-section">
<h2 className="settings-title">// Status</h2>
<div className="stats">
<div className="stat"><span className="stat-value">{data.status.botTag ?? '—'}</span><span className="stat-label">Bot</span></div>
<div className="stat"><span className="stat-value">{fmtUptime(data.status.uptimeSeconds)}</span><span className="stat-label">Uptime</span></div>
<div className="stat"><span className="stat-value">{data.status.devlogs}</span><span className="stat-label">Devlogs</span></div>
<div className="stat"><span className="stat-value">{data.status.commits}</span><span className="stat-label">Commits</span></div>
<div className="stat"><span className="stat-value">{fmtBytes(data.status.dbSizeBytes)}</span><span className="stat-label">Datenbank</span></div>
</div>
</div>
{/* Kanäle */}
<div className="settings-section">
<h2 className="settings-title">// Kanäle</h2>
<div className="field">
<label>Devlog-Kanal <span className="field-hint">öffentlich hier postet der Bot die Devlogs</span></label>
<div className="field-row">
<select
value={form.devlog_channel_id ?? ''}
onChange={(e) => setForm({ ...form, devlog_channel_id: e.target.value })}
>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
<button className="btn" onClick={() => sendTest('devlog')}>Test senden</button>
</div>
</div>
<div className="field">
<label>Commit-Kanal <span className="field-hint">privat hier landen die Push-Embeds</span></label>
<div className="field-row">
<select
value={form.commit_channel_id ?? ''}
onChange={(e) => setForm({ ...form, commit_channel_id: e.target.value })}
>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
<button className="btn" onClick={() => sendTest('commit')}>Test senden</button>
</div>
</div>
</div>
{/* Commit-Feed */}
<div className="settings-section">
<h2 className="settings-title">// Commit-Feed</h2>
<div className="field">
<label className="toggle">
<input
type="checkbox"
checked={form.commit_feed_enabled}
onChange={(e) => setForm({ ...form, commit_feed_enabled: e.target.checked })}
/>
<span>Push-Embeds posten</span>
<span className="field-hint">aus = Commits werden weiter archiviert, nur nicht gepostet</span>
</label>
</div>
<div className="field">
<label>Branch-Filter <span className="field-hint">kommagetrennt, leer = alle Branches posten</span></label>
<input
type="text"
placeholder="z. B. main, release"
value={form.commit_branch_filter}
onChange={(e) => setForm({ ...form, commit_branch_filter: e.target.value })}
/>
</div>
<div className="field">
<label>Ignorierte Repos <span className="field-hint">kommagetrennt, volle Namen werden komplett übersprungen</span></label>
<input
type="text"
placeholder="z. B. D4rkst3r/ecobot"
value={form.ignored_repos}
onChange={(e) => setForm({ ...form, ignored_repos: e.target.value })}
/>
</div>
</div>
<div className="settings-actions">
<button className="btn btn-save" onClick={save}>Speichern</button>
{feedback && <span className="feedback">{feedback}</span>}
</div>
</section>
</>
);
}
+80
View File
@@ -354,6 +354,86 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
font-size: .68rem; color: var(--muted2); white-space: nowrap; font-size: .68rem; color: var(--muted2); white-space: nowrap;
} }
/* ── SETTINGS ──────────────────────────────────────── */
.settings-section {
background: var(--bg2);
border: 1px solid rgba(255, 255, 255, .06);
padding: 1.4rem 1.6rem;
margin-bottom: 1.5rem;
}
.settings-title {
font-family: var(--mono);
font-size: .72rem; letter-spacing: .3em; text-transform: uppercase;
color: var(--neon2);
margin-bottom: 1.2rem;
}
.stats { display: flex; flex-wrap: wrap; gap: 2.5rem; }
.stat { display: flex; flex-direction: column; gap: .1rem; }
.stat-value {
font-family: var(--display);
font-size: 1.5rem; letter-spacing: .05em; color: var(--text);
}
.stat-label {
font-family: var(--mono);
font-size: .6rem; letter-spacing: .25em; text-transform: uppercase;
color: var(--muted2);
}
.field { margin-bottom: 1.1rem; }
.field:last-child { margin-bottom: 0; }
.field > label {
display: block;
font-family: var(--mono);
font-size: .68rem; letter-spacing: .15em; text-transform: uppercase;
color: var(--muted);
margin-bottom: .4rem;
}
.field-hint {
text-transform: none; letter-spacing: .03em;
color: var(--muted2); margin-left: .5rem;
}
.field-row { display: flex; gap: .6rem; }
.field-row select { flex: 1; }
.field select, .field input[type="text"] {
width: 100%;
background: #0d0d0d;
border: 1px solid rgba(255, 255, 255, .12);
color: var(--text);
font-family: var(--body);
font-size: .95rem;
padding: .5rem .7rem;
outline: none;
transition: border-color .2s;
}
.field select:focus, .field input[type="text"]:focus { border-color: var(--neon); }
.field select option { background: #0d0d0d; }
.toggle { display: flex; align-items: center; gap: .6rem; cursor: pointer; }
.toggle input {
appearance: none;
width: 34px; height: 18px; flex-shrink: 0;
background: #222; border: 1px solid rgba(255, 255, 255, .15);
position: relative; cursor: pointer;
transition: background .2s, border-color .2s;
}
.toggle input::after {
content: '';
position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px;
background: var(--muted);
transition: transform .2s, background .2s;
}
.toggle input:checked { background: rgba(245, 197, 24, .2); border-color: var(--neon); }
.toggle input:checked::after { transform: translateX(16px); background: var(--neon); }
.toggle > span:first-of-type { color: var(--text); font-size: .95rem; }
.settings-actions { display: flex; align-items: center; gap: 1rem; }
.btn-save { background: var(--neon); color: #0a0a0a; border-color: var(--neon); font-weight: 600; }
.btn-save:hover:not(:disabled) { background: var(--text); border-color: var(--text); }
.feedback { font-family: var(--mono); font-size: .75rem; letter-spacing: .1em; color: var(--success); }
/* ── PAGINATION ────────────────────────────────────── */ /* ── PAGINATION ────────────────────────────────────── */
.pager { .pager {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
+3 -2
View File
@@ -1,6 +1,7 @@
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten // Discord-Client: Commands laden, registrieren und Interactions verarbeiten
import { Client, Collection, Events, GatewayIntentBits, Partials, REST, Routes } from 'discord.js'; import { Client, Collection, Events, GatewayIntentBits, Partials, REST, Routes } from 'discord.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { devlogChannelId } from '../runtime-settings.js';
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js'; import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import * as ping from './commands/ping.js'; import * as ping from './commands/ping.js';
import * as devlogBackfill from './commands/devlog-backfill.js'; import * as devlogBackfill from './commands/devlog-backfill.js';
@@ -34,7 +35,7 @@ export async function startBot() {
// Live-Archivierung: neue Devlogs (Webhook-Posts im Devlog-Kanal) sofort sichern // Live-Archivierung: neue Devlogs (Webhook-Posts im Devlog-Kanal) sofort sichern
client.on(Events.MessageCreate, async (message) => { client.on(Events.MessageCreate, async (message) => {
if (message.channelId !== config.devlogChannelId) return; if (message.channelId !== devlogChannelId()) return;
try { try {
if (await archiveDevlogMessage(message)) { if (await archiveDevlogMessage(message)) {
console.log(`[devlog] Neues Devlog archiviert (${message.id})`); console.log(`[devlog] Neues Devlog archiviert (${message.id})`);
@@ -46,7 +47,7 @@ export async function startBot() {
// In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder) // In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder)
client.on(Events.MessageDelete, async (message) => { client.on(Events.MessageDelete, async (message) => {
if (message.channelId !== config.devlogChannelId) return; if (message.channelId !== devlogChannelId()) return;
try { try {
if (await removeDevlog(message.id)) { if (await removeDevlog(message.id)) {
console.log(`[devlog] Archiv-Eintrag entfernt (Discord-Nachricht ${message.id} gelöscht)`); console.log(`[devlog] Archiv-Eintrag entfernt (Discord-Nachricht ${message.id} gelöscht)`);
+7 -2
View File
@@ -1,6 +1,6 @@
// /devlog-backfill — komplette Kanal-Historie scannen und alte Devlogs nacharchivieren (Admin only) // /devlog-backfill — komplette Kanal-Historie scannen und alte Devlogs nacharchivieren (Admin only)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js';
import { config } from '../../config.js'; import { devlogChannelId } from '../../runtime-settings.js';
import { archiveDevlogMessage } from '../devlog-archive.js'; import { archiveDevlogMessage } from '../devlog-archive.js';
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
@@ -11,7 +11,12 @@ export const data = new SlashCommandBuilder()
export async function execute(interaction) { export async function execute(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const channel = await interaction.client.channels.fetch(config.devlogChannelId); const targetChannel = devlogChannelId();
if (!targetChannel) {
await interaction.editReply('❌ Kein Devlog-Kanal konfiguriert (Settings-Seite im Webinterface).');
return;
}
const channel = await interaction.client.channels.fetch(targetChannel);
if (!channel?.isTextBased()) { if (!channel?.isTextBased()) {
await interaction.editReply('❌ Devlog-Kanal nicht gefunden oder kein Textkanal.'); await interaction.editReply('❌ Devlog-Kanal nicht gefunden oder kein Textkanal.');
return; return;
+7 -3
View File
@@ -1,6 +1,6 @@
// Baut aus einem Gitea-Push ein hübsches Embed und postet es in den Commit-Kanal // Baut aus einem Gitea-Push ein hübsches Embed und postet es in den Commit-Kanal
import { EmbedBuilder } from 'discord.js'; import { EmbedBuilder } from 'discord.js';
import { config } from '../config.js'; import { commitChannelId } from '../runtime-settings.js';
const GITEA_GREEN = 0x609926; const GITEA_GREEN = 0x609926;
const MAX_COMMITS_SHOWN = 10; const MAX_COMMITS_SHOWN = 10;
@@ -19,9 +19,13 @@ function firstLine(message, maxLen = 72) {
* commits: Array<{sha: string, message: string, url: string, author_name: string}> }} push * commits: Array<{sha: string, message: string, url: string, author_name: string}> }} push
*/ */
export async function postPushEmbed(client, push) { export async function postPushEmbed(client, push) {
const channel = await client.channels.fetch(config.commitChannelId); const channelId = commitChannelId();
if (!channelId) {
throw new Error('Kein Commit-Kanal konfiguriert (Settings-Seite oder COMMIT_CHANNEL_ID)');
}
const channel = await client.channels.fetch(channelId);
if (!channel?.isTextBased()) { if (!channel?.isTextBased()) {
throw new Error(`Commit-Kanal ${config.commitChannelId} nicht gefunden oder kein Textkanal`); throw new Error(`Commit-Kanal ${channelId} nicht gefunden oder kein Textkanal`);
} }
const count = push.commits.length; const count = push.commits.length;
+5 -4
View File
@@ -19,15 +19,16 @@ export const config = {
discordGuildId: process.env.DISCORD_GUILD_ID || null, discordGuildId: process.env.DISCORD_GUILD_ID || null,
// Devlog-Archiv (Feature 3) // Devlog-Archiv (Feature 3)
// Kanal, in den der Bot die Devlogs postet (und den er mitliest) // Kanal-Defaults — können über die Settings-Seite im Webinterface
devlogChannelId: required('DEVLOG_CHANNEL_ID'), // überschrieben werden (DB gewinnt, Env ist Fallback)
devlogChannelId: process.env.DEVLOG_CHANNEL_ID || null,
// Secret im Pfad des Devlog-Endpoints: /webhooks/devlog/<secret> // Secret im Pfad des Devlog-Endpoints: /webhooks/devlog/<secret>
// (steckt in der URL in tools/.devlog_webhook im EcoGame-Repo) // (steckt in der URL in tools/.devlog_webhook im EcoGame-Repo)
devlogPostSecret: required('DEVLOG_POST_SECRET'), devlogPostSecret: required('DEVLOG_POST_SECRET'),
// Commit-Feed (Feature 2) // Commit-Feed (Feature 2)
// Kanal, in den Push-Embeds gepostet werden // Kanal, in den Push-Embeds gepostet werden (Default, siehe oben)
commitChannelId: required('COMMIT_CHANNEL_ID'), commitChannelId: process.env.COMMIT_CHANNEL_ID || null,
// Shared Secret — muss identisch im Gitea-Webhook eingetragen sein // Shared Secret — muss identisch im Gitea-Webhook eingetragen sein
giteaWebhookSecret: required('GITEA_WEBHOOK_SECRET'), giteaWebhookSecret: required('GITEA_WEBHOOK_SECRET'),
+26 -1
View File
@@ -1,6 +1,6 @@
// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt // SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs'; import { mkdirSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { config } from './config.js'; import { config } from './config.js';
@@ -41,6 +41,22 @@ if (!devlogCols.some((c) => c.name === 'images')) {
db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`); db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`);
} }
// Laufzeit-Einstellungen (Settings-Seite im Webinterface) — überschreiben Env-Defaults
db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
const getSettingStmt = db.prepare('SELECT value FROM settings WHERE key = ?');
const setSettingStmt = db.prepare(`
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`);
export function getSetting(key) {
return getSettingStmt.get(key)?.value ?? null;
}
export function setSetting(key, value) {
setSettingStmt.run(key, value);
}
const insertCommit = db.prepare(` const insertCommit = db.prepare(`
INSERT OR IGNORE INTO commits (sha, repo, branch, message, author_name, author_user, url, committed_at) INSERT OR IGNORE INTO commits (sha, repo, branch, message, author_name, author_user, url, committed_at)
VALUES (@sha, @repo, @branch, @message, @author_name, @author_user, @url, @committed_at) VALUES (@sha, @repo, @branch, @message, @author_name, @author_user, @url, @committed_at)
@@ -103,3 +119,12 @@ const countCommitsStmt = db.prepare('SELECT COUNT(*) AS n FROM commits');
export function listCommits(limit, offset) { export function listCommits(limit, offset) {
return { items: selectCommits.all(limit, offset), total: countCommitsStmt.get().n }; return { items: selectCommits.all(limit, offset), total: countCommitsStmt.get().n };
} }
/** Statistiken fürs Status-Panel der Settings-Seite */
export function archiveStats() {
return {
devlogs: countDevlogsStmt.get().n,
commits: countCommitsStmt.get().n,
dbSizeBytes: statSync(dbFile).size,
};
}
+38
View File
@@ -0,0 +1,38 @@
// Effektive Laufzeit-Konfiguration: DB-Setting (Webinterface) vor Env-Variable.
// Wird bei jedem Zugriff frisch aufgelöst — Änderungen greifen ohne Neustart.
import { getSetting } from './db.js';
import { config } from './config.js';
export function commitChannelId() {
return getSetting('commit_channel_id') || config.commitChannelId;
}
export function devlogChannelId() {
return getSetting('devlog_channel_id') || config.devlogChannelId;
}
/** Commit-Feed global an/aus (Default: an). Aus = weiter archivieren, nur nicht posten. */
export function commitFeedEnabled() {
return getSetting('commit_feed_enabled') !== '0';
}
/** Kommagetrennte Liste in Array übersetzen ('' → leer) */
function csv(value) {
return (value ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
/** Branch-Filter: leer = alle Branches werden gepostet */
export function branchAllowed(branch) {
const allowed = csv(getSetting('commit_branch_filter'));
return allowed.length === 0 || allowed.includes(branch);
}
/** Ignorierte Repos (z. B. "D4rkst3r/ecobot") — werden komplett übersprungen */
export function repoIgnored(repo) {
return csv(getSetting('ignored_repos')).some(
(r) => r.toLowerCase() === repo.toLowerCase()
);
}
+113 -9
View File
@@ -1,6 +1,8 @@
// REST-API fürs Webinterface: Devlogs öffentlich, Commits nur für den Admin // REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
import { listDevlogs, listCommits } from '../db.js'; import { EmbedBuilder } from 'discord.js';
import { listDevlogs, listCommits, archiveStats, getSetting, setSetting } from '../db.js';
import { removeDevlog } from '../bot/devlog-archive.js'; import { removeDevlog } from '../bot/devlog-archive.js';
import { commitChannelId, devlogChannelId } from '../runtime-settings.js';
import { getSessionUser, isAdmin } from './auth.js'; import { getSessionUser, isAdmin } from './auth.js';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -11,7 +13,15 @@ function paging(request) {
return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page }; return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page };
} }
export function registerApiRoutes(app) { /** Admin-Guard: null = okay, sonst wurde bereits eine Fehler-Antwort gesendet */
function requireAdmin(request, reply) {
const user = getSessionUser(request);
if (!user) return reply.code(401).send({ error: 'login required' });
if (!isAdmin(user)) return reply.code(403).send({ error: 'admin only' });
return null;
}
export function registerApiRoutes(app, client) {
// Wer bin ich? (fürs Frontend: Login-Status + Admin-Flag) // Wer bin ich? (fürs Frontend: Login-Status + Admin-Flag)
app.get('/api/me', async (request) => { app.get('/api/me', async (request) => {
const user = getSessionUser(request); const user = getSessionUser(request);
@@ -32,9 +42,7 @@ export function registerApiRoutes(app) {
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts) // Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
app.delete('/api/devlogs/:id', async (request, reply) => { app.delete('/api/devlogs/:id', async (request, reply) => {
const user = getSessionUser(request); if (requireAdmin(request, reply)) return;
if (!user) return reply.code(401).send({ error: 'login required' });
if (!isAdmin(user)) return reply.code(403).send({ error: 'admin only' });
const deleted = await removeDevlog(request.params.id); const deleted = await removeDevlog(request.params.id);
request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`); request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`);
@@ -43,12 +51,108 @@ export function registerApiRoutes(app) {
// Commit-Feed — nur für den Admin (spiegelt den privaten #-gitea-Kanal) // Commit-Feed — nur für den Admin (spiegelt den privaten #-gitea-Kanal)
app.get('/api/commits', async (request, reply) => { app.get('/api/commits', async (request, reply) => {
const user = getSessionUser(request); if (requireAdmin(request, reply)) return;
if (!user) return reply.code(401).send({ error: 'login required' });
if (!isAdmin(user)) return reply.code(403).send({ error: 'admin only' });
const { limit, offset, page } = paging(request); const { limit, offset, page } = paging(request);
const { items, total } = listCommits(limit, offset); const { items, total } = listCommits(limit, offset);
return { items, total, page, pageSize: PAGE_SIZE }; return { items, total, page, pageSize: PAGE_SIZE };
}); });
// --- Settings (Admin) ---
/** Alle Textkanäle, die der Bot sehen kann (für die Dropdowns) */
function listChannels() {
const channels = [];
for (const guild of client.guilds.cache.values()) {
for (const ch of guild.channels.cache.values()) {
if (ch.isTextBased?.() && ch.viewable !== false) {
channels.push({ id: ch.id, name: ch.name, guild: guild.name });
}
}
}
return channels.sort((a, b) => a.name.localeCompare(b.name));
}
function currentSettings() {
return {
commit_channel_id: commitChannelId(),
devlog_channel_id: devlogChannelId(),
commit_feed_enabled: getSetting('commit_feed_enabled') !== '0',
commit_branch_filter: getSetting('commit_branch_filter') ?? '',
ignored_repos: getSetting('ignored_repos') ?? '',
};
}
app.get('/api/settings', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const stats = archiveStats();
return {
channels: listChannels(),
settings: currentSettings(),
status: {
botTag: client.user?.tag ?? null,
uptimeSeconds: Math.floor(process.uptime()),
guilds: client.guilds.cache.size,
...stats,
},
};
});
app.put('/api/settings', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const body = request.body ?? {};
// Kanäle: müssen existierende Textkanäle sein
for (const key of ['commit_channel_id', 'devlog_channel_id']) {
if (body[key] !== undefined) {
const ch = client.channels.cache.get(String(body[key]));
if (!ch?.isTextBased?.()) {
return reply.code(400).send({ error: `${key}: Kanal nicht gefunden` });
}
setSetting(key, String(body[key]));
}
}
if (body.commit_feed_enabled !== undefined) {
setSetting('commit_feed_enabled', body.commit_feed_enabled ? '1' : '0');
}
for (const key of ['commit_branch_filter', 'ignored_repos']) {
if (body[key] !== undefined) {
setSetting(key, String(body[key]).trim());
}
}
request.log.info('Settings per Web-UI aktualisiert');
return { ok: true, settings: currentSettings() };
});
// Test-Embed in den konfigurierten Kanal senden (prüft die Kanal-Wahl ohne Push/Devlog)
app.post('/api/settings/test/:target', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const target = request.params.target;
const channelId = target === 'commit' ? commitChannelId() : target === 'devlog' ? devlogChannelId() : null;
if (!channelId) {
return reply.code(400).send({ error: 'Kein Kanal konfiguriert' });
}
try {
const channel = await client.channels.fetch(channelId);
await channel.send({
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setTitle('🔧 Test')
.setDescription(
`Test-Nachricht für den **${target === 'commit' ? 'Commit' : 'Devlog'}-Kanal** — von der Settings-Seite ausgelöst.`
)
.setFooter({ text: 'D4RKST3R // SETUP' }),
],
});
return { ok: true };
} catch (error) {
request.log.error({ err: error }, 'Test-Nachricht fehlgeschlagen');
return reply.code(502).send({ error: 'Senden fehlgeschlagen — Rechte im Kanal prüfen' });
}
});
} }
+6 -1
View File
@@ -10,6 +10,7 @@ import { join } from 'node:path';
import { AttachmentBuilder, EmbedBuilder } from 'discord.js'; import { AttachmentBuilder, EmbedBuilder } from 'discord.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { saveDevlog } from '../db.js'; import { saveDevlog } from '../db.js';
import { devlogChannelId } from '../runtime-settings.js';
import { imagesDir } from '../bot/devlog-archive.js'; import { imagesDir } from '../bot/devlog-archive.js';
const BRAND_YELLOW = 0xf5c518; const BRAND_YELLOW = 0xf5c518;
@@ -64,7 +65,11 @@ export function registerDevlogEndpoint(app, client) {
return reply.code(400).send({ error: 'empty devlog' }); return reply.code(400).send({ error: 'empty devlog' });
} }
const channel = await client.channels.fetch(config.devlogChannelId); const targetChannel = devlogChannelId();
if (!targetChannel) {
return reply.code(500).send({ error: 'no devlog channel configured' });
}
const channel = await client.channels.fetch(targetChannel);
if (!channel?.isTextBased()) { if (!channel?.isTextBased()) {
return reply.code(500).send({ error: 'devlog channel not found' }); return reply.code(500).send({ error: 'devlog channel not found' });
} }
+13 -1
View File
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { config } from '../config.js'; import { config } from '../config.js';
import { saveCommits } from '../db.js'; import { saveCommits } from '../db.js';
import { commitFeedEnabled, branchAllowed, repoIgnored } from '../runtime-settings.js';
import { postPushEmbed } from '../bot/commit-feed.js'; import { postPushEmbed } from '../bot/commit-feed.js';
import { registerAuthRoutes } from './auth.js'; import { registerAuthRoutes } from './auth.js';
import { registerApiRoutes } from './api.js'; import { registerApiRoutes } from './api.js';
@@ -59,7 +60,7 @@ export async function startWebServer(client) {
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 8 } }); await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 8 } });
registerAuthRoutes(app); registerAuthRoutes(app);
registerApiRoutes(app); registerApiRoutes(app, client);
registerDevlogEndpoint(app, client); registerDevlogEndpoint(app, client);
// Healthcheck (für Portainer/NPM) // Healthcheck (für Portainer/NPM)
@@ -118,6 +119,11 @@ export async function startWebServer(client) {
const repo = payload.repository?.full_name ?? 'unbekannt'; const repo = payload.repository?.full_name ?? 'unbekannt';
const branch = (payload.ref ?? '').replace('refs/heads/', ''); const branch = (payload.ref ?? '').replace('refs/heads/', '');
// Ignorierte Repos komplett überspringen (Settings-Seite)
if (repoIgnored(repo)) {
return { ok: true, ignored: `repo ${repo}` };
}
const rows = commits.map((c) => ({ const rows = commits.map((c) => ({
sha: c.id, sha: c.id,
repo, repo,
@@ -131,6 +137,12 @@ export async function startWebServer(client) {
const inserted = saveCommits(rows); const inserted = saveCommits(rows);
request.log.info(`Push auf ${repo}@${branch}: ${rows.length} Commit(s), ${inserted} neu gespeichert`); request.log.info(`Push auf ${repo}@${branch}: ${rows.length} Commit(s), ${inserted} neu gespeichert`);
// Posten nur wenn Feed aktiv und Branch erlaubt (archiviert wird immer)
if (!commitFeedEnabled() || !branchAllowed(branch)) {
request.log.info(`Embed übersprungen (Feed aus oder Branch ${branch} gefiltert)`);
return { ok: true, commits: rows.length, new: inserted, posted: false };
}
// Embed posten — Fehler hier sollen den Webhook nicht scheitern lassen (Gitea würde sonst retrien) // Embed posten — Fehler hier sollen den Webhook nicht scheitern lassen (Gitea würde sonst retrien)
try { try {
await postPushEmbed(client, { await postPushEmbed(client, {