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:
@@ -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'),
|
||||
// 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',
|
||||
};
|
||||
|
||||
@@ -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 { 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);
|
||||
}
|
||||
|
||||
@@ -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