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:
+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