Bug-Reports, Devlog-Threads und Watchdog

- /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 <Datum>' 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 11:29:01 +02:00
co-authored by Claude Opus 4.8
parent 4fb3a6d02c
commit 07c3bf47d0
15 changed files with 359 additions and 4 deletions
+9
View File
@@ -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
+25
View File
@@ -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),
+2
View File
@@ -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}
+44
View File
@@ -178,11 +178,55 @@ export default function Settings({ me }) {
<span className="field-hint">Commits pro Tag, Devlogs & Top-Projekte in den Devlog-Kanal</span>
</label>
</div>
<div className="field">
<label className="toggle">
<input
type="checkbox"
checked={form.devlog_threads_enabled}
onChange={(e) => setForm({ ...form, devlog_threads_enabled: e.target.checked })}
/>
<span>Diskussions-Thread unter jedem Devlog</span>
<span className="field-hint">Bot öffnet automatisch 💬 Devlog &lt;Datum&gt;"</span>
</label>
</div>
<div className="field">
<button className="btn" onClick={() => sendTest('recap')}>Rückblick jetzt testen</button>
</div>
</div>
{/* Bug-Reports */}
<div className="settings-section">
<h2 className="settings-title">// Bug-Reports (/bug)</h2>
{!data.status.giteaTokenConfigured && (
<p className="notice" style={{ padding: '0 0 .8rem' }}>
⚠️ Kein <code>GITEA_API_TOKEN</code> gesetzt — /bug ist deaktiviert.
</p>
)}
<div className="field">
<label>Ziel-Repo <span className="field-hint">hier landen die Issues aus /bug</span></label>
<input
type="text"
placeholder="D4rkst3r/EcoGame"
value={form.bug_report_repo}
onChange={(e) => setForm({ ...form, bug_report_repo: e.target.value })}
/>
</div>
</div>
{/* Watchdog */}
<div className="settings-section">
<h2 className="settings-title">// Watchdog</h2>
<div className="field">
<label>Überwachte URLs <span className="field-hint">kommagetrennt; bei Ausfall gibt's eine DM — leer = aus</span></label>
<input
type="text"
placeholder="https://git.d4rkst3r.de, https://bot.d4rkst3r.de/health"
value={form.watchdog_urls}
onChange={(e) => setForm({ ...form, watchdog_urls: e.target.value })}
/>
</div>
</div>
{/* Commit-Feed */}
<div className="settings-section">
<h2 className="settings-title">// Commit-Feed</h2>
+2 -1
View File
@@ -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({
+75
View File
@@ -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.');
}
}
+67
View File
@@ -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);
}
+4
View File
@@ -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,
};
+34
View File
@@ -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 = ?');
+38
View File
@@ -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();
}
+2
View File
@@ -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);
+10
View File
@@ -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;
+8 -1
View File
@@ -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());
}
+16 -1
View File
@@ -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()) {
+23 -1
View File
@@ -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 };