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
+113 -9
View File
@@ -1,6 +1,8 @@
// REST-API fürs Webinterface: Devlogs öffentlich, Commits nur für den Admin
import { listDevlogs, listCommits } from '../db.js';
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
import { EmbedBuilder } from 'discord.js';
import { listDevlogs, listCommits, archiveStats, getSetting, setSetting } from '../db.js';
import { removeDevlog } from '../bot/devlog-archive.js';
import { commitChannelId, devlogChannelId } from '../runtime-settings.js';
import { getSessionUser, isAdmin } from './auth.js';
const PAGE_SIZE = 20;
@@ -11,7 +13,15 @@ function paging(request) {
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)
app.get('/api/me', async (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)
app.delete('/api/devlogs/:id', async (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' });
if (requireAdmin(request, reply)) return;
const deleted = await removeDevlog(request.params.id);
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)
app.get('/api/commits', async (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' });
if (requireAdmin(request, reply)) return;
const { limit, offset, page } = paging(request);
const { items, total } = listCommits(limit, offset);
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 { config } from '../config.js';
import { saveDevlog } from '../db.js';
import { devlogChannelId } from '../runtime-settings.js';
import { imagesDir } from '../bot/devlog-archive.js';
const BRAND_YELLOW = 0xf5c518;
@@ -64,7 +65,11 @@ export function registerDevlogEndpoint(app, client) {
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()) {
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 { config } from '../config.js';
import { saveCommits } from '../db.js';
import { commitFeedEnabled, branchAllowed, repoIgnored } from '../runtime-settings.js';
import { postPushEmbed } from '../bot/commit-feed.js';
import { registerAuthRoutes } from './auth.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 } });
registerAuthRoutes(app);
registerApiRoutes(app);
registerApiRoutes(app, client);
registerDevlogEndpoint(app, client);
// Healthcheck (für Portainer/NPM)
@@ -118,6 +119,11 @@ export async function startWebServer(client) {
const repo = payload.repository?.full_name ?? 'unbekannt';
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) => ({
sha: c.id,
repo,
@@ -131,6 +137,12 @@ export async function startWebServer(client) {
const inserted = saveCommits(rows);
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)
try {
await postPushEmbed(client, {