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:
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
<nav className="nav-links">
|
||||
<NavLink to="/devlogs">Devlog</NavLink>
|
||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
||||
</nav>
|
||||
|
||||
<div className="auth">
|
||||
@@ -91,6 +93,7 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||
<Route path="/commits" element={<Commits me={me} />} />
|
||||
<Route path="/settings" element={<Settings me={me} />} />
|
||||
<Route path="*" element={<Navigate to="/devlogs" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+3
-2
@@ -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)`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
-4
@@ -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/<secret>
|
||||
// (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'),
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user