From 07c3bf47d02feaa15419a0e21ce739e0b8952375 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Thu, 23 Jul 2026 11:29:01 +0200 Subject: [PATCH] Bug-Reports, Devlog-Threads und Watchdog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /bug (alle Member): erstellt Gitea-Issue inkl. Screenshot-Upload als Asset; bug_reports-Tabelle für den Rückkanal — Issue geschlossen (issues-Webhook) → DM an den Reporter - Auto-Thread '💬 Devlog ' unter jedem Devlog-Post (Setting, Default an) - Watchdog: prüft Setting watchdog_urls alle 2 min, DM an Admin nach 2 Fails in Folge + Entwarnung mit Downtime-Dauer - Setup-Seite: Bug-Repo, Threads-Toggle, Watchdog-URLs; Env GITEA_API_TOKEN/GITEA_URL Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 ++++ README.md | 25 +++++++++++ docker-compose.yml | 2 + frontend/src/pages/Settings.jsx | 44 +++++++++++++++++++ src/bot/client.js | 3 +- src/bot/commands/bug.js | 75 +++++++++++++++++++++++++++++++++ src/bot/watchdog.js | 67 +++++++++++++++++++++++++++++ src/config.js | 4 ++ src/db.js | 34 +++++++++++++++ src/gitea-api.js | 38 +++++++++++++++++ src/index.js | 2 + src/runtime-settings.js | 10 +++++ src/web/api.js | 9 +++- src/web/devlog-endpoint.js | 17 +++++++- src/web/server.js | 24 ++++++++++- 15 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 src/bot/commands/bug.js create mode 100644 src/bot/watchdog.js create mode 100644 src/gitea-api.js diff --git a/.env.example b/.env.example index ad6d5b7..4bee08b 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,15 @@ ADMIN_DISCORD_ID= # Lokal zum Testen: http://localhost:3080 (auch im Developer Portal eintragen!) PUBLIC_URL=https://bot.d4rkst3r.de +# --- Bug-Reports (/bug → Gitea-Issue) --- + +# Gitea → Einstellungen → Anwendungen → Token erzeugen (Scope: write:issue) +# Ohne Token ist /bug deaktiviert (Bot läuft trotzdem) +GITEA_API_TOKEN= + +# Optional: Gitea-Basis-URL (Default: https://git.d4rkst3r.de) +# GITEA_URL=https://git.d4rkst3r.de + # Optional: HTTP-Port des Webservers (Default: 3080) # HTTP_PORT=3080 diff --git a/README.md b/README.md index 7890181..0c63afb 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,31 @@ Unter **/settings** (Nav: „Setup", nur als Admin sichtbar) lassen sich zur Lau --- +## Bug-Reports, Threads & Watchdog + +**`/bug` (alle Member):** Titel + Beschreibung + optional Screenshot → der Bot +erstellt ein Gitea-Issue im konfigurierten Repo (Setup-Seite), lädt den +Screenshot als Issue-Attachment hoch und merkt sich den Reporter. +Wird das Issue in Gitea **geschlossen**, bekommt der Reporter automatisch +eine DM („Dein Bug ist behoben ✅"). + +Setup: +1. Gitea → Einstellungen → Anwendungen → **Token erzeugen** (Scope `write:issue`) + → als `GITEA_API_TOKEN` in die Env +2. Im Gitea-Webhook zusätzlich das Trigger-Event **„Issues"** aktivieren + (für die Rückkanal-DMs) + +**Auto-Threads:** Unter jedem Devlog-Post öffnet der Bot einen Diskussions-Thread +(„💬 Devlog 23.07.", Auto-Archiv nach 3 Tagen). Abschaltbar auf der Setup-Seite. +Der Bot braucht im Devlog-Kanal die Berechtigung **„Öffentliche Threads erstellen"**. + +**Watchdog:** Auf der Setup-Seite URLs eintragen (kommagetrennt, z. B. +`https://git.d4rkst3r.de, https://bot.d4rkst3r.de/health`) — der Bot prüft sie +alle 2 Minuten und schickt dir bei Ausfall (2 Fehlschläge in Folge) eine +**Discord-DM**, inkl. Entwarnung mit Downtime-Dauer. + +--- + ## Setup: Webinterface **Zugriffsmodell:** Devlog-Archiv ist öffentlich (wie der Discord-Kanal), diff --git a/docker-compose.yml b/docker-compose.yml index e7bf984..806ed00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: COMMIT_CHANNEL_ID: ${COMMIT_CHANNEL_ID} DEVLOG_CHANNEL_ID: ${DEVLOG_CHANNEL_ID} DEVLOG_POST_SECRET: ${DEVLOG_POST_SECRET} + GITEA_API_TOKEN: ${GITEA_API_TOKEN:-} + GITEA_URL: ${GITEA_URL:-https://git.d4rkst3r.de} GITEA_WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET} DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET} SESSION_SECRET: ${SESSION_SECRET} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 3bf58ad..0a6d6fa 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -178,11 +178,55 @@ export default function Settings({ me }) { Commits pro Tag, Devlogs & Top-Projekte in den Devlog-Kanal +
+ +
+ {/* Bug-Reports */} +
+

// Bug-Reports (/bug)

+ {!data.status.giteaTokenConfigured && ( +

+ ⚠️ Kein GITEA_API_TOKEN gesetzt — /bug ist deaktiviert. +

+ )} +
+ + setForm({ ...form, bug_report_repo: e.target.value })} + /> +
+
+ + {/* Watchdog */} +
+

// Watchdog

+
+ + setForm({ ...form, watchdog_urls: e.target.value })} + /> +
+
+ {/* Commit-Feed */}

// Commit-Feed

diff --git a/src/bot/client.js b/src/bot/client.js index bd2aae8..03e4d46 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -5,9 +5,10 @@ import { devlogChannelId, devlogPingRoleId } 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'; +import * as bug from './commands/bug.js'; // Alle Commands hier eintragen — jedes Modul exportiert { data, execute } -const commandModules = [ping, devlogBackfill]; +const commandModules = [ping, devlogBackfill, bug]; export async function startBot() { const client = new Client({ diff --git a/src/bot/commands/bug.js b/src/bot/commands/bug.js new file mode 100644 index 0000000..1585435 --- /dev/null +++ b/src/bot/commands/bug.js @@ -0,0 +1,75 @@ +// /bug — Bug-Report aus Discord: erstellt ein Gitea-Issue (inkl. Screenshot-Upload) +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { config } from '../../config.js'; +import { bugReportRepo } from '../../runtime-settings.js'; +import { createIssue, uploadIssueAsset } from '../../gitea-api.js'; +import { saveBugReport } from '../../db.js'; + +export const data = new SlashCommandBuilder() + .setName('bug') + .setDescription('Bug melden — landet direkt beim Entwickler') + .addStringOption((o) => + o.setName('titel').setDescription('Kurze Zusammenfassung').setRequired(true).setMaxLength(120) + ) + .addStringOption((o) => + o.setName('beschreibung') + .setDescription('Was ist passiert? Was hast du erwartet?') + .setRequired(true) + .setMaxLength(1500) + ) + .addAttachmentOption((o) => + o.setName('screenshot').setDescription('Optional: Screenshot des Problems') + ); + +export async function execute(interaction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + if (!config.giteaApiToken) { + await interaction.editReply('❌ Bug-Reports sind gerade nicht konfiguriert (kein Gitea-Token).'); + return; + } + + const repo = bugReportRepo(); + const titel = interaction.options.getString('titel'); + const beschreibung = interaction.options.getString('beschreibung'); + const screenshot = interaction.options.getAttachment('screenshot'); + + const body = `${beschreibung}\n\n---\n🎮 Gemeldet via Discord von **${interaction.user.tag}**`; + + try { + const issue = await createIssue(repo, `[Bug] ${titel}`, body); + + // Screenshot als Issue-Attachment hochladen (Discord-CDN-Links laufen ab) + if (screenshot?.contentType?.startsWith('image/')) { + try { + const res = await fetch(screenshot.url); + if (res.ok) { + await uploadIssueAsset( + repo, + issue.number, + screenshot.name ?? 'screenshot.png', + Buffer.from(await res.arrayBuffer()) + ); + } + } catch (error) { + console.error('[bug] Screenshot-Upload fehlgeschlagen:', error.message); + } + } + + // Für den Rückkanal merken: Issue geschlossen → DM an den Reporter + saveBugReport({ + repo, + issue_number: issue.number, + discord_user_id: interaction.user.id, + title: titel, + }); + + await interaction.editReply( + `✅ Danke! Dein Bug ist erfasst als [#${issue.number}](${issue.html_url}) — ` + + `du bekommst eine DM, sobald er behoben ist.` + ); + } catch (error) { + console.error('[bug] Issue-Erstellung fehlgeschlagen:', error); + await interaction.editReply('❌ Konnte das Issue nicht anlegen — bitte später nochmal versuchen.'); + } +} diff --git a/src/bot/watchdog.js b/src/bot/watchdog.js new file mode 100644 index 0000000..37943a2 --- /dev/null +++ b/src/bot/watchdog.js @@ -0,0 +1,67 @@ +// Watchdog: prüft konfigurierte URLs und schickt dem Admin eine DM bei Ausfall. +// URLs kommen aus dem Setting watchdog_urls (Setup-Seite), leer = deaktiviert. +import { getSetting } from '../db.js'; +import { config } from '../config.js'; + +const CHECK_INTERVAL_MS = 2 * 60 * 1000; +const FAILS_BEFORE_ALERT = 2; // 2 Fehlschläge in Folge → Alarm (gegen Flattern) +const TIMEOUT_MS = 10_000; + +// URL → { fails, down, since } +const state = new Map(); + +function watchedUrls() { + return (getSetting('watchdog_urls') ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => /^https?:\/\//.test(s)); +} + +async function dmAdmin(client, content) { + const user = await client.users.fetch(config.adminDiscordId); + await user.send(content); +} + +/** Ein Prüfdurchlauf — exportiert für Tests und den Intervall-Timer */ +export async function watchdogTick(client, { failsBeforeAlert = FAILS_BEFORE_ALERT } = {}) { + for (const url of watchedUrls()) { + const s = state.get(url) ?? { fails: 0, down: false, since: null }; + + let ok = false; + let detail = ''; + try { + const res = await fetch(url, { + method: 'GET', + redirect: 'follow', + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { 'User-Agent': 'ecobot-watchdog/1.0' }, + }); + ok = res.status < 500; + detail = `HTTP ${res.status}`; + } catch (error) { + detail = error.cause?.code ?? error.name ?? 'Fehler'; + } + + if (ok) { + if (s.down) { + const minutes = Math.max(1, Math.round((Date.now() - s.since) / 60000)); + await dmAdmin(client, `✅ **${url}** ist wieder erreichbar (Downtime: ~${minutes} min).`).catch(() => {}); + console.log(`[watchdog] ${url} wieder ok`); + } + state.set(url, { fails: 0, down: false, since: null }); + } else { + const fails = s.fails + 1; + if (!s.down && fails >= failsBeforeAlert) { + await dmAdmin(client, `🚨 **${url}** ist DOWN (${detail}) — seit ${failsBeforeAlert} Checks nicht erreichbar!`).catch(() => {}); + console.warn(`[watchdog] ${url} down (${detail})`); + state.set(url, { fails, down: true, since: Date.now() }); + } else { + state.set(url, { ...s, fails }); + } + } + } +} + +export function startWatchdog(client) { + setInterval(() => watchdogTick(client), CHECK_INTERVAL_MS); +} diff --git a/src/config.js b/src/config.js index d37896f..6952121 100644 --- a/src/config.js +++ b/src/config.js @@ -47,4 +47,8 @@ export const config = { // SQLite-Datei (im Container: /app/data → ecobot_data-Volume) dbPath: process.env.DB_PATH || './data/ecobot.db', + + // Gitea-API (für /bug → Issues). Ohne Token ist /bug deaktiviert. + giteaUrl: (process.env.GITEA_URL || 'https://git.d4rkst3r.de').replace(/\/$/, ''), + giteaApiToken: process.env.GITEA_API_TOKEN || null, }; diff --git a/src/db.js b/src/db.js index c4c430d..03583ae 100644 --- a/src/db.js +++ b/src/db.js @@ -79,6 +79,40 @@ db.exec(` } } +// Bug-Reports aus Discord (/bug) — für den Rückkanal (Issue geschlossen → DM) +db.exec(` + CREATE TABLE IF NOT EXISTS bug_reports ( + repo TEXT NOT NULL, + issue_number INTEGER NOT NULL, + discord_user_id TEXT NOT NULL, + title TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (repo, issue_number) + ); +`); + +const insertBugReport = db.prepare(` + INSERT OR REPLACE INTO bug_reports (repo, issue_number, discord_user_id, title) + VALUES (@repo, @issue_number, @discord_user_id, @title) +`); +const getBugReportStmt = db.prepare( + 'SELECT * FROM bug_reports WHERE repo = ? AND issue_number = ?' +); +const deleteBugReportStmt = db.prepare( + 'DELETE FROM bug_reports WHERE repo = ? AND issue_number = ?' +); + +export function saveBugReport(report) { + insertBugReport.run(report); +} + +/** Report holen und direkt entfernen (einmalige Benachrichtigung) */ +export function takeBugReport(repo, issueNumber) { + const row = getBugReportStmt.get(repo, issueNumber); + if (row) deleteBugReportStmt.run(repo, issueNumber); + return row ?? null; +} + // 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 = ?'); diff --git a/src/gitea-api.js b/src/gitea-api.js new file mode 100644 index 0000000..81cfb36 --- /dev/null +++ b/src/gitea-api.js @@ -0,0 +1,38 @@ +// Minimaler Gitea-API-Client (Token-Auth) — für /bug → Issues +import { config } from './config.js'; + +function headers(extra = {}) { + return { + Authorization: `token ${config.giteaApiToken}`, + Accept: 'application/json', + ...extra, + }; +} + +/** Issue anlegen; gibt { number, html_url } zurück */ +export async function createIssue(repo, title, body) { + const res = await fetch(`${config.giteaUrl}/api/v1/repos/${repo}/issues`, { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ title, body }), + }); + if (!res.ok) { + throw new Error(`Gitea createIssue ${res.status}: ${(await res.text()).slice(0, 200)}`); + } + const issue = await res.json(); + return { number: issue.number, html_url: issue.html_url }; +} + +/** Datei (z. B. Screenshot) als Issue-Attachment hochladen */ +export async function uploadIssueAsset(repo, issueNumber, filename, buffer) { + const form = new FormData(); + form.append('attachment', new Blob([buffer]), filename); + const res = await fetch( + `${config.giteaUrl}/api/v1/repos/${repo}/issues/${issueNumber}/assets?name=${encodeURIComponent(filename)}`, + { method: 'POST', headers: headers(), body: form } + ); + if (!res.ok) { + throw new Error(`Gitea uploadIssueAsset ${res.status}`); + } + return res.json(); +} diff --git a/src/index.js b/src/index.js index 77d3f0c..98306cd 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ import { startBot } from './bot/client.js'; import { startWebServer } from './web/server.js'; import { scheduleWeeklyRecap } from './bot/weekly-recap.js'; +import { startWatchdog } from './bot/watchdog.js'; process.on('unhandledRejection', (error) => { console.error('[main] Unhandled Rejection:', error); @@ -11,6 +12,7 @@ try { const client = await startBot(); await startWebServer(client); scheduleWeeklyRecap(client); + startWatchdog(client); } catch (error) { console.error('[main] Start fehlgeschlagen:', error); process.exit(1); diff --git a/src/runtime-settings.js b/src/runtime-settings.js index 0e0ceac..71bd2b3 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -16,6 +16,16 @@ export function releaseChannelId() { return getSetting('release_channel_id') || null; } +/** Diskussions-Thread unter jedem Devlog-Post (Default: an) */ +export function devlogThreadsEnabled() { + return getSetting('devlog_threads_enabled') !== '0'; +} + +/** Ziel-Repo für /bug-Reports (Default: EcoGame) */ +export function bugReportRepo() { + return getSetting('bug_report_repo') || 'D4rkst3r/EcoGame'; +} + /** Rolle, die bei Devlog-Posts gepingt wird — leer = kein Ping */ export function devlogPingRoleId() { return getSetting('devlog_ping_role_id') || null; diff --git a/src/web/api.js b/src/web/api.js index e55e704..39b3c23 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -139,6 +139,9 @@ ${rssItems} commit_branch_filter: getSetting('commit_branch_filter') ?? '', ignored_repos: getSetting('ignored_repos') ?? '', weekly_recap_enabled: getSetting('weekly_recap_enabled') !== '0', + devlog_threads_enabled: getSetting('devlog_threads_enabled') !== '0', + bug_report_repo: getSetting('bug_report_repo') ?? 'D4rkst3r/EcoGame', + watchdog_urls: getSetting('watchdog_urls') ?? '', }; } @@ -154,6 +157,7 @@ ${rssItems} botTag: client.user?.tag ?? null, uptimeSeconds: Math.floor(process.uptime()), guilds: client.guilds.cache.size, + giteaTokenConfigured: Boolean(config.giteaApiToken), ...stats, }, }; @@ -197,7 +201,10 @@ ${rssItems} if (body.weekly_recap_enabled !== undefined) { setSetting('weekly_recap_enabled', body.weekly_recap_enabled ? '1' : '0'); } - for (const key of ['commit_branch_filter', 'ignored_repos']) { + if (body.devlog_threads_enabled !== undefined) { + setSetting('devlog_threads_enabled', body.devlog_threads_enabled ? '1' : '0'); + } + for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls']) { if (body[key] !== undefined) { setSetting(key, String(body[key]).trim()); } diff --git a/src/web/devlog-endpoint.js b/src/web/devlog-endpoint.js index 8dc4ee4..eea816f 100644 --- a/src/web/devlog-endpoint.js +++ b/src/web/devlog-endpoint.js @@ -10,7 +10,7 @@ import { join } from 'node:path'; import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { config } from '../config.js'; import { saveDevlog } from '../db.js'; -import { devlogChannelId, devlogPingRoleId } from '../runtime-settings.js'; +import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled } from '../runtime-settings.js'; import { imagesDir } from '../bot/devlog-archive.js'; const BRAND_YELLOW = 0xf5c518; @@ -154,6 +154,21 @@ export function registerDevlogEndpoint(app, client) { : {}), }); + // Diskussions-Thread unterm Devlog (Fehler nicht fatal — z. B. fehlende Rechte) + if (devlogThreadsEnabled() && typeof message.startThread === 'function') { + try { + const threadDate = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', month: '2-digit', + }).format(new Date()); + await message.startThread({ + name: `💬 Devlog ${threadDate}`, + autoArchiveDuration: 4320, // 3 Tage + }); + } catch (error) { + request.log.warn(`Devlog-Thread fehlgeschlagen: ${error.message}`); + } + } + // Direkt archivieren (der Live-Listener ignoriert eigene Bot-Posts bewusst) const imageFiles = []; for (const [i, f] of files.entries()) { diff --git a/src/web/server.js b/src/web/server.js index 0d95f04..df2c0dc 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -8,7 +8,7 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { config } from '../config.js'; -import { saveCommits, saveRelease } from '../db.js'; +import { saveCommits, saveRelease, takeBugReport } from '../db.js'; import { commitFeedEnabled, branchAllowed, repoIgnored } from '../runtime-settings.js'; import { postPushEmbed } from '../bot/commit-feed.js'; import { postReleaseEmbed } from '../bot/release-feed.js'; @@ -140,6 +140,28 @@ export async function startWebServer(client) { return { ok: true, release: release.tag, posted }; } + // Issue geschlossen → Rückkanal-DM an den Discord-Reporter (/bug) + if (event === 'issues') { + const payload = request.body; + if (payload.action === 'closed' && payload.issue?.number) { + const repo = payload.repository?.full_name ?? ''; + const report = takeBugReport(repo, payload.issue.number); + if (report) { + try { + const user = await client.users.fetch(report.discord_user_id); + await user.send( + `✅ Dein gemeldeter Bug **„${report.title}"** wurde behoben!\n` + + `Issue [#${payload.issue.number}](${payload.issue.html_url ?? ''}) ist geschlossen — danke für den Report! 🙌` + ); + request.log.info(`Bug-Report-DM an ${report.discord_user_id} (Issue #${payload.issue.number})`); + } catch (error) { + request.log.warn(`Bug-Report-DM fehlgeschlagen: ${error.message}`); + } + } + } + return { ok: true, event: 'issues' }; + } + // Andere Events (Tag, Issue, …) freundlich ignorieren if (event !== 'push') { return { ok: true, ignored: event };