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 };