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