Feature 2: Commit-Feed — Gitea-Webhook, Discord-Embeds, SQLite-Archiv

- Fastify-Webserver mit /health und /webhooks/gitea (HMAC-SHA256-Signaturprüfung, timing-safe)
- Push-Commits werden in SQLite gespeichert (Dedupe per SHA) und als Embed gepostet
- Dockerfile auf node:22-slim (glibc-Prebuilds für better-sqlite3), Port 3080 published
- README: Anleitung für Cloudflare-DNS, Nginx Proxy Manager (bot.d4rkst3r.de) und Gitea-Webhook

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:27:33 +02:00
co-authored by Claude Opus 4.8
parent 2b3fa3eb69
commit 46d6a3f927
11 changed files with 1357 additions and 17 deletions
+16
View File
@@ -9,3 +9,19 @@ DISCORD_CLIENT_ID=
# Rechtsklick auf deinen Discord-Server → "Server-ID kopieren" (Entwicklermodus nötig)
# Optional: wenn gesetzt, sind Slash-Commands sofort verfügbar statt nach bis zu 1h
DISCORD_GUILD_ID=
# --- Commit-Feed (Feature 2) ---
# Rechtsklick auf den Commit-Kanal → "Kanal-ID kopieren"
COMMIT_CHANNEL_ID=
# Shared Secret für die Webhook-Signatur — generieren mit:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Denselben Wert im Gitea-Webhook als "Secret" eintragen!
GITEA_WEBHOOK_SECRET=
# Optional: HTTP-Port des Webservers (Default: 3080)
# HTTP_PORT=3080
# Optional: Pfad zur SQLite-Datei (Default: ./data/ecobot.db)
# DB_PATH=./data/ecobot.db
+2 -1
View File
@@ -1,5 +1,6 @@
# EcoBot — Discord-Bot + (später) Webinterface
FROM node:22-alpine
# slim statt alpine: better-sqlite3 liefert glibc-Prebuilds → kein Compiler im Image nötig
FROM node:22-slim
WORKDIR /app
+60 -8
View File
@@ -4,11 +4,11 @@ Discord-Bot + Webinterface für die EcoGame-Community.
**Features (Roadmap):**
1. ✅ Bot online + `/ping` Slash-Command
2. Commit-Feed: Gitea-Push-Webhooks → Discord-Embeds + SQLite-Archiv
2. Commit-Feed: Gitea-Push-Webhooks → Discord-Embeds + SQLite-Archiv
3. ⬜ Devlog-Archiv: tägliche Devlogs archivieren
4. ⬜ Webinterface: Discord-Login, Devlog- & Commit-Seiten
**Stack:** Node.js 20+, discord.js v14, Fastify (ab Feature 2), React (ab Feature 4), SQLite, Docker
**Stack:** Node.js 20+, discord.js v14, Fastify, React (ab Feature 4), SQLite (better-sqlite3), Docker
---
@@ -45,7 +45,55 @@ Discord-Bot + Webinterface für die EcoGame-Community.
```
cp .env.example .env
```
Dann die drei Werte eintragen. Die `.env` ist in `.gitignore` und landet nie im Repo.
Dann die Werte eintragen. Die `.env` ist in `.gitignore` und landet nie im Repo.
---
## Setup: Commit-Feed (Gitea → Discord)
### 1. Neue .env-Werte
| Variable | Woher |
|---|---|
| `COMMIT_CHANNEL_ID` | Rechtsklick auf den Ziel-Kanal in Discord → **Kanal-ID kopieren** |
| `GITEA_WEBHOOK_SECRET` | Generieren: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` |
### 2. Öffentlicher Host im Nginx Proxy Manager (`bot.d4rkst3r.de`)
**Cloudflare (DNS):**
1. Cloudflare-Dashboard → Zone `d4rkst3r.de`**DNS**
2. Neuen Record anlegen: Typ **CNAME**, Name `bot`, Ziel wie bei den bestehenden
Hosts (z. B. `d4rkst3r.de`), Proxy-Status **Proxied** (orange Wolke)
**Nginx Proxy Manager:**
1. NPM öffnen → **Hosts****Proxy Hosts****Add Proxy Host**
2. Tab **Details:**
- Domain Names: `bot.d4rkst3r.de`
- Scheme: `http`
- Forward Hostname / IP: `host.docker.internal`
(NPM läuft im Container, der Bot published Port 3080 auf dem Windows-Host)
- Forward Port: `3080`
- ✅ Block Common Exploits
3. Tab **SSL:**
- SSL Certificate: **Request a new SSL Certificate** (Let's Encrypt)
— oder das vorhandene Wildcard-Zertifikat auswählen
- ✅ Force SSL
4. **Save**
Test: `https://bot.d4rkst3r.de/health` im Browser → `{"status":"ok"}`
### 3. Webhook in Gitea eintragen
1. Gitea → EcoGame-Repo → **Einstellungen****Webhooks****Webhook hinzufügen****Gitea**
2. Ziel-URL: `https://bot.d4rkst3r.de/webhooks/gitea`
3. HTTP-Methode: `POST`, POST Content Type: `application/json`
4. **Geheimnis:** exakt der Wert aus `GITEA_WEBHOOK_SECRET`
5. Trigger: **Push-Events**, Branch-Filter leer (= alle)
6. Speichern → auf den Webhook klicken → **Testzustellung** senden
→ im Discord-Kanal erscheint ein Embed 🎉
> Den alten direkten Discord-Webhook im Repo danach löschen, sonst gibt's Doppel-Posts.
Bereits gepostete Commits werden per SHA dedupliziert — ein erneuter Push
derselben Commits erzeugt keine doppelten DB-Einträge.
---
@@ -98,12 +146,16 @@ docker logs -f ecobot
```
ecobot/
├── src/
│ ├── index.js # Einstiegspunkt
│ ├── index.js # Einstiegspunkt (Bot + Webserver)
│ ├── config.js # Env-Konfiguration mit Validierung
── bot/
├── client.js # Discord-Client, Command-Registry, Interactions
── commands/
└── ping.js # /ping — Lebenszeichen
── db.js # SQLite (better-sqlite3), Schema + Queries
├── bot/
── client.js # Discord-Client, Command-Registry, Interactions
│ ├── commit-feed.js # Push → Discord-Embed
│ │ └── commands/
│ │ └── ping.js # /ping — Lebenszeichen
│ └── web/
│ └── server.js # Fastify: /health, /webhooks/gitea (HMAC-geprüft)
├── .env.example # Vorlage für Secrets (nach .env kopieren)
├── Dockerfile
├── docker-compose.yml
+4 -3
View File
@@ -6,9 +6,10 @@ services:
container_name: ecobot
restart: unless-stopped
env_file: .env
# Ab Feature 2: HTTP-Endpoint für Gitea-Webhooks + Webinterface
# ports:
# - "3080:3080"
# HTTP-Endpoint für Gitea-Webhooks + Webinterface
# NPM leitet bot.d4rkst3r.de → host.docker.internal:3080
ports:
- "3080:3080"
volumes:
# SQLite & Co. überleben Container-Neustarts
- ecobot_data:/app/data
+1069 -1
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -14,7 +14,9 @@
"dev": "node --watch src/index.js"
},
"dependencies": {
"better-sqlite3": "^12.11.1",
"discord.js": "^14.16.3",
"dotenv": "^16.4.7"
"dotenv": "^16.4.7",
"fastify": "^5.10.0"
}
}
+45
View File
@@ -0,0 +1,45 @@
// 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';
const GITEA_GREEN = 0x609926;
const MAX_COMMITS_SHOWN = 10;
/** Erste Zeile der Commit-Message, auf maxLen gekürzt */
function firstLine(message, maxLen = 72) {
const line = message.split('\n')[0].trim();
return line.length > maxLen ? `${line.slice(0, maxLen - 1)}` : line;
}
/**
* Push-Embed in den Commit-Kanal posten.
* @param {import('discord.js').Client} client
* @param {{ repo: string, repoUrl: string, branch: string, compareUrl: string|null,
* pusherName: string, pusherAvatar: string|null,
* 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);
if (!channel?.isTextBased()) {
throw new Error(`Commit-Kanal ${config.commitChannelId} nicht gefunden oder kein Textkanal`);
}
const count = push.commits.length;
const lines = push.commits.slice(0, MAX_COMMITS_SHOWN).map(
(c) => `[\`${c.sha.slice(0, 7)}\`](${c.url}) ${firstLine(c.message)}${c.author_name}`
);
if (count > MAX_COMMITS_SHOWN) {
lines.push(`… und ${count - MAX_COMMITS_SHOWN} weitere`);
}
const embed = new EmbedBuilder()
.setColor(GITEA_GREEN)
.setAuthor({ name: push.pusherName, iconURL: push.pusherAvatar ?? undefined })
.setTitle(`📦 ${count} ${count === 1 ? 'neuer Commit' : 'neue Commits'} in ${push.repo}`)
.setURL(push.compareUrl || push.repoUrl)
.setDescription(lines.join('\n'))
.setFooter({ text: `Branch: ${push.branch}` })
.setTimestamp();
await channel.send({ embeds: [embed] });
}
+12
View File
@@ -17,4 +17,16 @@ export const config = {
discordClientId: required('DISCORD_CLIENT_ID'),
// Optional: Guild-ID für sofortige Slash-Command-Registrierung (global dauert bis zu 1h)
discordGuildId: process.env.DISCORD_GUILD_ID || null,
// Commit-Feed (Feature 2)
// Kanal, in den Push-Embeds gepostet werden
commitChannelId: required('COMMIT_CHANNEL_ID'),
// Shared Secret — muss identisch im Gitea-Webhook eingetragen sein
giteaWebhookSecret: required('GITEA_WEBHOOK_SECRET'),
// HTTP-Server (Webhooks, später Webinterface)
httpPort: Number(process.env.HTTP_PORT) || 3080,
// SQLite-Datei (im Container: /app/data → ecobot_data-Volume)
dbPath: process.env.DB_PATH || './data/ecobot.db',
};
+40
View File
@@ -0,0 +1,40 @@
// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { config } from './config.js';
const dbFile = resolve(config.dbPath);
mkdirSync(dirname(dbFile), { recursive: true });
export const db = new Database(dbFile);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS commits (
sha TEXT PRIMARY KEY,
repo TEXT NOT NULL,
branch TEXT NOT NULL,
message TEXT NOT NULL,
author_name TEXT,
author_user TEXT,
url TEXT,
committed_at TEXT NOT NULL,
received_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_commits_repo_time ON commits (repo, committed_at DESC);
`);
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)
`);
/** Commits eines Pushes speichern — bereits bekannte SHAs werden ignoriert. Gibt Anzahl neuer Zeilen zurück. */
export const saveCommits = db.transaction((commits) => {
let inserted = 0;
for (const commit of commits) {
inserted += insertCommit.run(commit).changes;
}
return inserted;
});
+5 -3
View File
@@ -1,13 +1,15 @@
// Einstiegspunkt — startet den Discord-Bot (Webserver folgt in Feature 2)
// Einstiegspunkt — startet Discord-Bot und Webserver (Webhooks)
import { startBot } from './bot/client.js';
import { startWebServer } from './web/server.js';
process.on('unhandledRejection', (error) => {
console.error('[main] Unhandled Rejection:', error);
});
try {
await startBot();
const client = await startBot();
await startWebServer(client);
} catch (error) {
console.error('[main] Bot-Start fehlgeschlagen:', error);
console.error('[main] Start fehlgeschlagen:', error);
process.exit(1);
}
+101
View File
@@ -0,0 +1,101 @@
// Fastify-Webserver: Gitea-Webhook-Endpoint (später auch REST-API + Webinterface)
import Fastify from 'fastify';
import crypto from 'node:crypto';
import { config } from '../config.js';
import { saveCommits } from '../db.js';
import { postPushEmbed } from '../bot/commit-feed.js';
/** Gitea-Signatur prüfen: HMAC-SHA256 (hex) über den rohen Request-Body */
function verifySignature(rawBody, signatureHex) {
if (!signatureHex) return false;
const expected = crypto
.createHmac('sha256', config.giteaWebhookSecret)
.update(rawBody)
.digest();
let received;
try {
received = Buffer.from(signatureHex, 'hex');
} catch {
return false;
}
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
/**
* Webserver starten.
* @param {import('discord.js').Client} client — laufender Discord-Client
*/
export async function startWebServer(client) {
const app = Fastify({ logger: true });
// JSON als Buffer parsen, damit wir den Raw-Body für die Signatur-Prüfung behalten
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
req.rawBody = body;
try {
done(null, JSON.parse(body.toString('utf8')));
} catch (error) {
error.statusCode = 400;
done(error);
}
});
// Healthcheck (für Portainer/NPM)
app.get('/health', async () => ({ status: 'ok' }));
// Gitea-Push-Webhook → Embed posten + Commits archivieren
app.post('/webhooks/gitea', async (request, reply) => {
if (!verifySignature(request.rawBody, request.headers['x-gitea-signature'])) {
request.log.warn('Webhook mit ungültiger Signatur abgelehnt');
return reply.code(401).send({ error: 'invalid signature' });
}
// Andere Events (Tag, Issue, …) freundlich ignorieren
const event = request.headers['x-gitea-event'];
if (event !== 'push') {
return { ok: true, ignored: event };
}
const payload = request.body;
const commits = payload.commits ?? [];
// Branch-Delete & Co. senden Pushes ohne Commits
if (commits.length === 0) {
return { ok: true, ignored: 'empty push' };
}
const repo = payload.repository?.full_name ?? 'unbekannt';
const branch = (payload.ref ?? '').replace('refs/heads/', '');
const rows = commits.map((c) => ({
sha: c.id,
repo,
branch,
message: c.message ?? '',
author_name: c.author?.name ?? 'unbekannt',
author_user: c.author?.username ?? null,
url: c.url ?? null,
committed_at: c.timestamp ?? new Date().toISOString(),
}));
const inserted = saveCommits(rows);
request.log.info(`Push auf ${repo}@${branch}: ${rows.length} Commit(s), ${inserted} neu gespeichert`);
// Embed posten — Fehler hier sollen den Webhook nicht scheitern lassen (Gitea würde sonst retrien)
try {
await postPushEmbed(client, {
repo,
repoUrl: payload.repository?.html_url ?? '',
branch,
compareUrl: payload.compare_url || null,
pusherName: payload.pusher?.username ?? payload.pusher?.login ?? 'unbekannt',
pusherAvatar: payload.sender?.avatar_url ?? null,
commits: rows,
});
} catch (error) {
request.log.error({ err: error }, 'Discord-Embed konnte nicht gepostet werden');
}
return { ok: true, commits: rows.length, new: inserted };
});
await app.listen({ port: config.httpPort, host: '0.0.0.0' });
return app;
}