diff --git a/README.md b/README.md
index fa214b6..7890181 100644
--- a/README.md
+++ b/README.md
@@ -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
**Zugriffsmodell:** Devlog-Archiv ist öffentlich (wie der Discord-Kanal),
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index bea3fdd..9ce2cb9 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate } from 'react-router-dom';
import { apiGet } from './api.js';
import Devlogs from './pages/Devlogs.jsx';
import Commits from './pages/Commits.jsx';
+import Settings from './pages/Settings.jsx';
function DiscordMark() {
return (
@@ -69,6 +70,7 @@ export default function App() {
Devlog
{me.admin && Commits }
+ {me.admin && Setup }
@@ -91,6 +93,7 @@ export default function App() {
} />
} />
} />
+
} />
} />
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 6e92076..d2c2e94 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -11,3 +11,15 @@ async function api(path, options = {}) {
export const apiGet = (path) => api(path);
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),
+ });
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
new file mode 100644
index 0000000..6d007c1
--- /dev/null
+++ b/frontend/src/pages/Settings.jsx
@@ -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 = (
+
+ SETUP
+
+
+
// Admin
+
Setup
+
Kanäle, Feed-Regeln und Status — Änderungen greifen sofort.
+
+
+ );
+
+ if (me.loading) return <>{header}
>;
+ if (!me.user || !me.admin) {
+ return (
+ <>
+ {header}
+
+ >
+ );
+ }
+ if (error) return <>{header}
Fehler beim Laden ({error}).
>;
+ if (!data || !form) return <>{header}
>;
+
+ const channelOptions = data.channels.map((c) => (
+
+ #{c.name} — {c.guild}
+
+ ));
+
+ return (
+ <>
+ {header}
+
+ {/* Status */}
+
+
// Status
+
+
{data.status.botTag ?? '—'} Bot
+
{fmtUptime(data.status.uptimeSeconds)} Uptime
+
{data.status.devlogs} Devlogs
+
{data.status.commits} Commits
+
{fmtBytes(data.status.dbSizeBytes)} Datenbank
+
+
+
+ {/* Kanäle */}
+
+
// Kanäle
+
+
+
Devlog-Kanal öffentlich — hier postet der Bot die Devlogs
+
+ setForm({ ...form, devlog_channel_id: e.target.value })}
+ >
+ — Kanal wählen —
+ {channelOptions}
+
+ sendTest('devlog')}>Test senden
+
+
+
+
+
Commit-Kanal privat — hier landen die Push-Embeds
+
+ setForm({ ...form, commit_channel_id: e.target.value })}
+ >
+ — Kanal wählen —
+ {channelOptions}
+
+ sendTest('commit')}>Test senden
+
+
+
+
+ {/* Commit-Feed */}
+
+
+
+ Speichern
+ {feedback && {feedback} }
+
+
+ >
+ );
+}
diff --git a/frontend/src/style.css b/frontend/src/style.css
index b0b488e..aa8e49e 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -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;
}
+/* ── 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 ────────────────────────────────────── */
.pager {
display: flex; align-items: center; justify-content: center;
diff --git a/src/bot/client.js b/src/bot/client.js
index 438cef6..e8f2fe7 100644
--- a/src/bot/client.js
+++ b/src/bot/client.js
@@ -1,6 +1,7 @@
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
import { Client, Collection, Events, GatewayIntentBits, Partials, REST, Routes } from 'discord.js';
import { config } from '../config.js';
+import { devlogChannelId } from '../runtime-settings.js';
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import * as ping from './commands/ping.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
client.on(Events.MessageCreate, async (message) => {
- if (message.channelId !== config.devlogChannelId) return;
+ if (message.channelId !== devlogChannelId()) return;
try {
if (await archiveDevlogMessage(message)) {
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)
client.on(Events.MessageDelete, async (message) => {
- if (message.channelId !== config.devlogChannelId) return;
+ if (message.channelId !== devlogChannelId()) return;
try {
if (await removeDevlog(message.id)) {
console.log(`[devlog] Archiv-Eintrag entfernt (Discord-Nachricht ${message.id} gelöscht)`);
diff --git a/src/bot/commands/devlog-backfill.js b/src/bot/commands/devlog-backfill.js
index 53adc67..3d3a1ec 100644
--- a/src/bot/commands/devlog-backfill.js
+++ b/src/bot/commands/devlog-backfill.js
@@ -1,6 +1,6 @@
// /devlog-backfill — komplette Kanal-Historie scannen und alte Devlogs nacharchivieren (Admin only)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js';
-import { config } from '../../config.js';
+import { devlogChannelId } from '../../runtime-settings.js';
import { archiveDevlogMessage } from '../devlog-archive.js';
export const data = new SlashCommandBuilder()
@@ -11,7 +11,12 @@ export const data = new SlashCommandBuilder()
export async function execute(interaction) {
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()) {
await interaction.editReply('❌ Devlog-Kanal nicht gefunden oder kein Textkanal.');
return;
diff --git a/src/bot/commit-feed.js b/src/bot/commit-feed.js
index 030effa..d8302c3 100644
--- a/src/bot/commit-feed.js
+++ b/src/bot/commit-feed.js
@@ -1,6 +1,6 @@
// Baut aus einem Gitea-Push ein hübsches Embed und postet es in den Commit-Kanal
import { EmbedBuilder } from 'discord.js';
-import { config } from '../config.js';
+import { commitChannelId } from '../runtime-settings.js';
const GITEA_GREEN = 0x609926;
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
*/
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()) {
- 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;
diff --git a/src/config.js b/src/config.js
index a3e5699..d37896f 100644
--- a/src/config.js
+++ b/src/config.js
@@ -19,15 +19,16 @@ export const config = {
discordGuildId: process.env.DISCORD_GUILD_ID || null,
// Devlog-Archiv (Feature 3)
- // Kanal, in den der Bot die Devlogs postet (und den er mitliest)
- devlogChannelId: required('DEVLOG_CHANNEL_ID'),
+ // Kanal-Defaults — können über die Settings-Seite im Webinterface
+ // überschrieben werden (DB gewinnt, Env ist Fallback)
+ devlogChannelId: process.env.DEVLOG_CHANNEL_ID || null,
// Secret im Pfad des Devlog-Endpoints: /webhooks/devlog/
// (steckt in der URL in tools/.devlog_webhook im EcoGame-Repo)
devlogPostSecret: required('DEVLOG_POST_SECRET'),
// Commit-Feed (Feature 2)
- // Kanal, in den Push-Embeds gepostet werden
- commitChannelId: required('COMMIT_CHANNEL_ID'),
+ // Kanal, in den Push-Embeds gepostet werden (Default, siehe oben)
+ commitChannelId: process.env.COMMIT_CHANNEL_ID || null,
// Shared Secret — muss identisch im Gitea-Webhook eingetragen sein
giteaWebhookSecret: required('GITEA_WEBHOOK_SECRET'),
diff --git a/src/db.js b/src/db.js
index a7461cf..29587ea 100644
--- a/src/db.js
+++ b/src/db.js
@@ -1,6 +1,6 @@
// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt
import Database from 'better-sqlite3';
-import { mkdirSync } from 'node:fs';
+import { mkdirSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
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 '[]'`);
}
+// 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(`
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)
@@ -103,3 +119,12 @@ const countCommitsStmt = db.prepare('SELECT COUNT(*) AS n FROM commits');
export function listCommits(limit, offset) {
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,
+ };
+}
diff --git a/src/runtime-settings.js b/src/runtime-settings.js
new file mode 100644
index 0000000..844e8b0
--- /dev/null
+++ b/src/runtime-settings.js
@@ -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()
+ );
+}
diff --git a/src/web/api.js b/src/web/api.js
index 52db044..930d56a 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -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' });
+ }
+ });
}
diff --git a/src/web/devlog-endpoint.js b/src/web/devlog-endpoint.js
index 80a8500..8cffd09 100644
--- a/src/web/devlog-endpoint.js
+++ b/src/web/devlog-endpoint.js
@@ -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' });
}
diff --git a/src/web/server.js b/src/web/server.js
index 746488a..7c291cb 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -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, {