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:
@@ -9,3 +9,19 @@ DISCORD_CLIENT_ID=
|
|||||||
# Rechtsklick auf deinen Discord-Server → "Server-ID kopieren" (Entwicklermodus nötig)
|
# 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
|
# Optional: wenn gesetzt, sind Slash-Commands sofort verfügbar statt nach bis zu 1h
|
||||||
DISCORD_GUILD_ID=
|
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
@@ -1,5 +1,6 @@
|
|||||||
# EcoBot — Discord-Bot + (später) Webinterface
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ Discord-Bot + Webinterface für die EcoGame-Community.
|
|||||||
|
|
||||||
**Features (Roadmap):**
|
**Features (Roadmap):**
|
||||||
1. ✅ Bot online + `/ping` Slash-Command
|
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
|
3. ⬜ Devlog-Archiv: tägliche Devlogs archivieren
|
||||||
4. ⬜ Webinterface: Discord-Login, Devlog- & Commit-Seiten
|
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
|
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/
|
ecobot/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── index.js # Einstiegspunkt
|
│ ├── index.js # Einstiegspunkt (Bot + Webserver)
|
||||||
│ ├── config.js # Env-Konfiguration mit Validierung
|
│ ├── config.js # Env-Konfiguration mit Validierung
|
||||||
│ └── bot/
|
│ ├── db.js # SQLite (better-sqlite3), Schema + Queries
|
||||||
│ ├── client.js # Discord-Client, Command-Registry, Interactions
|
│ ├── bot/
|
||||||
│ └── commands/
|
│ │ ├── client.js # Discord-Client, Command-Registry, Interactions
|
||||||
│ └── ping.js # /ping — Lebenszeichen
|
│ │ ├── 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)
|
├── .env.example # Vorlage für Secrets (nach .env kopieren)
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
|
|||||||
+4
-3
@@ -6,9 +6,10 @@ services:
|
|||||||
container_name: ecobot
|
container_name: ecobot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
# Ab Feature 2: HTTP-Endpoint für Gitea-Webhooks + Webinterface
|
# HTTP-Endpoint für Gitea-Webhooks + Webinterface
|
||||||
# ports:
|
# NPM leitet bot.d4rkst3r.de → host.docker.internal:3080
|
||||||
# - "3080:3080"
|
ports:
|
||||||
|
- "3080:3080"
|
||||||
volumes:
|
volumes:
|
||||||
# SQLite & Co. überleben Container-Neustarts
|
# SQLite & Co. überleben Container-Neustarts
|
||||||
- ecobot_data:/app/data
|
- ecobot_data:/app/data
|
||||||
|
|||||||
Generated
+1069
-1
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -14,7 +14,9 @@
|
|||||||
"dev": "node --watch src/index.js"
|
"dev": "node --watch src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
"discord.js": "^14.16.3",
|
"discord.js": "^14.16.3",
|
||||||
"dotenv": "^16.4.7"
|
"dotenv": "^16.4.7",
|
||||||
|
"fastify": "^5.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
}
|
||||||
@@ -17,4 +17,16 @@ export const config = {
|
|||||||
discordClientId: required('DISCORD_CLIENT_ID'),
|
discordClientId: required('DISCORD_CLIENT_ID'),
|
||||||
// Optional: Guild-ID für sofortige Slash-Command-Registrierung (global dauert bis zu 1h)
|
// Optional: Guild-ID für sofortige Slash-Command-Registrierung (global dauert bis zu 1h)
|
||||||
discordGuildId: process.env.DISCORD_GUILD_ID || null,
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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 { startBot } from './bot/client.js';
|
||||||
|
import { startWebServer } from './web/server.js';
|
||||||
|
|
||||||
process.on('unhandledRejection', (error) => {
|
process.on('unhandledRejection', (error) => {
|
||||||
console.error('[main] Unhandled Rejection:', error);
|
console.error('[main] Unhandled Rejection:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await startBot();
|
const client = await startBot();
|
||||||
|
await startWebServer(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[main] Bot-Start fehlgeschlagen:', error);
|
console.error('[main] Start fehlgeschlagen:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user