diff --git a/.env.example b/.env.example index af9bbf7..ad6d5b7 100644 --- a/.env.example +++ b/.env.example @@ -12,9 +12,15 @@ DISCORD_GUILD_ID= # --- Devlog-Archiv (Feature 3) --- -# Rechtsklick auf den Devlog-Kanal (dort postet tools/devlog.py) → "Kanal-ID kopieren" +# Rechtsklick auf den Devlog-Kanal (dort postet der Bot die Devlogs) → "Kanal-ID kopieren" DEVLOG_CHANNEL_ID= +# Secret für den Devlog-Endpoint — generieren mit: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# Im EcoGame-Repo in tools/.devlog_webhook eintragen: +# https://bot.d4rkst3r.de/webhooks/devlog/ +DEVLOG_POST_SECRET= + # --- Commit-Feed (Feature 2) --- # Rechtsklick auf den Commit-Kanal → "Kanal-ID kopieren" diff --git a/README.md b/README.md index 53bf120..fa214b6 100644 --- a/README.md +++ b/README.md @@ -97,22 +97,30 @@ derselben Commits erzeugt keine doppelten DB-Einträge. --- -## Setup: Devlog-Archiv +## Setup: Devlog (Bot postet + archiviert) -`tools/devlog.py` (EcoGame-Repo) postet weiterhin unverändert per Discord-Webhook -in den Devlog-Kanal — der Bot liest dort mit und archiviert jede Webhook-Nachricht -in SQLite (User-Chatter im Kanal wird ignoriert). +Der Bot ist das Tagebuch: `tools/devlog.py` (EcoGame-Repo) schickt sein Devlog an +den **Bot-Endpoint** (Discord-Webhook-kompatibel), der Bot postet ein gebrandetes +Embed (Gelb, „D4RKST3R // DEVLOG"-Footer, bis zu 4 Bilder als Grid) in den +Devlog-Kanal und archiviert Text + Bilder in SQLite. -1. **Message Content Intent** muss im - [Developer Portal](https://discord.com/developers/applications) unter **Bot** - aktiviert sein (steht oben schon im Setup — kurz prüfen) -2. `.env` / Portainer-Stack: `DEVLOG_CHANNEL_ID` = Kanal-ID des Devlog-Kanals -3. Bot neu starten → ab jetzt wird jedes neue Devlog automatisch archiviert -4. Einmalig in Discord: **`/devlog-backfill`** (Admin only) — scannt die komplette - Kanal-Historie und archiviert alle bisherigen Devlogs nach +1. `.env` / Portainer-Stack: + - `DEVLOG_CHANNEL_ID` = Kanal-ID des Devlog-Kanals + - `DEVLOG_POST_SECRET` = generieren: + `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` +2. Im EcoGame-Repo in `tools/.devlog_webhook` statt der Discord-URL eintragen: + ``` + https://bot.d4rkst3r.de/webhooks/devlog/ + ``` + `devlog.py` selbst bleibt unverändert — der Endpoint versteht das + Discord-Webhook-Format (JSON + Multipart mit Bildern). +3. Den alten Discord-Webhook im Devlog-Kanal löschen (wird nicht mehr gebraucht) -Dedupe läuft über die Discord-Message-ID — Backfill und Live-Listener -können sich nicht doppeln, der Command ist beliebig oft wiederholbar. +**Zusätzlich** liest der Bot den Kanal live mit und archiviert auch fremde +Webhook-/Bot-Posts (dafür: **Message Content Intent** im Developer Portal aktiv). +**`/devlog-backfill`** (Admin) archiviert die Kanal-Historie nach. +Dedupe läuft über die Discord-Message-ID — alles beliebig wiederholbar. +Bilder werden lokal gespeichert (`data/devlog_images/`), weil Discord-CDN-Links ablaufen. --- diff --git a/docker-compose.yml b/docker-compose.yml index d7bc2c8..f7ed9a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-} COMMIT_CHANNEL_ID: ${COMMIT_CHANNEL_ID} DEVLOG_CHANNEL_ID: ${DEVLOG_CHANNEL_ID} + DEVLOG_POST_SECRET: ${DEVLOG_POST_SECRET} GITEA_WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET} DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET} SESSION_SECRET: ${SESSION_SECRET} diff --git a/package-lock.json b/package-lock.json index 1c1a2b7..fee2e6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@fastify/cookie": "^11.1.2", + "@fastify/multipart": "^10.1.0", "@fastify/static": "^10.1.2", "better-sqlite3": "^12.11.1", "discord.js": "^14.16.3", @@ -187,6 +188,12 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@fastify/cookie": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", @@ -220,6 +227,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -290,6 +313,29 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.1.0.tgz", + "integrity": "sha512-b2r6CovmLQvLFJ5HJDtxVigZjcO9TwkRGslhiNQxsGJwilm5B3eqvXPOVLudC44zXMXJITpQ/iEATIE7zoXqcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^6.0.0", + "secure-json-parse": "^4.0.0" + } + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", diff --git a/package.json b/package.json index f7d14b3..f2de73b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@fastify/cookie": "^11.1.2", + "@fastify/multipart": "^10.1.0", "@fastify/static": "^10.1.2", "better-sqlite3": "^12.11.1", "discord.js": "^14.16.3", diff --git a/src/config.js b/src/config.js index 6209f2a..a3e5699 100644 --- a/src/config.js +++ b/src/config.js @@ -19,8 +19,11 @@ export const config = { discordGuildId: process.env.DISCORD_GUILD_ID || null, // Devlog-Archiv (Feature 3) - // Kanal, in den tools/devlog.py (EcoGame-Repo) per Discord-Webhook postet + // Kanal, in den der Bot die Devlogs postet (und den er mitliest) devlogChannelId: required('DEVLOG_CHANNEL_ID'), + // Secret im Pfad des Devlog-Endpoints: /webhooks/devlog/ + // (steckt in der URL in tools/.devlog_webhook im EcoGame-Repo) + devlogPostSecret: required('DEVLOG_POST_SECRET'), // Commit-Feed (Feature 2) // Kanal, in den Push-Embeds gepostet werden diff --git a/src/web/devlog-endpoint.js b/src/web/devlog-endpoint.js new file mode 100644 index 0000000..80a8500 --- /dev/null +++ b/src/web/devlog-endpoint.js @@ -0,0 +1,117 @@ +// Devlog-Endpoint: nimmt Discord-Webhook-kompatible Payloads von tools/devlog.py +// entgegen (JSON oder Multipart mit Bildern), postet ein gebrandetes Embed als +// Bot in den Devlog-Kanal und archiviert Text + Bilder direkt in SQLite. +// +// devlog.py bleibt unverändert — in tools/.devlog_webhook steht einfach +// https://bot.d4rkst3r.de/webhooks/devlog/ statt der Discord-URL. +import crypto from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { AttachmentBuilder, EmbedBuilder } from 'discord.js'; +import { config } from '../config.js'; +import { saveDevlog } from '../db.js'; +import { imagesDir } from '../bot/devlog-archive.js'; + +const BRAND_YELLOW = 0xf5c518; +const MAX_IMAGES = 4; + +/** Secret timing-safe vergleichen */ +function secretOk(given) { + const a = Buffer.from(String(given ?? '')); + const b = Buffer.from(config.devlogPostSecret); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +/** Multipart-Request einlesen → { payload, files: [{name, buffer}] } */ +async function readMultipart(request) { + let payload = {}; + const files = []; + for await (const part of request.parts()) { + if (part.type === 'file') { + if (files.length < MAX_IMAGES && /\.(png|jpe?g|gif|webp)$/i.test(part.filename ?? '')) { + files.push({ name: part.filename, buffer: await part.toBuffer() }); + } else { + await part.toBuffer(); // Stream leeren, sonst hängt der Request + } + } else if (part.fieldname === 'payload_json') { + // @fastify/multipart parst Felder mit Content-Type application/json bereits selbst + payload = typeof part.value === 'string' ? JSON.parse(part.value) : part.value; + } + } + return { payload, files }; +} + +export function registerDevlogEndpoint(app, client) { + app.post('/webhooks/devlog/:secret', async (request, reply) => { + if (!secretOk(request.params.secret)) { + request.log.warn('Devlog-Post mit ungültigem Secret abgelehnt'); + return reply.code(401).send({ error: 'invalid secret' }); + } + + // Payload lesen: Multipart (mit Bildern) oder plain JSON + let payload; + let files = []; + if (request.isMultipart()) { + ({ payload, files } = await readMultipart(request)); + } else { + payload = request.body ?? {}; + } + + const srcEmbed = payload.embeds?.[0] ?? {}; + const title = srcEmbed.title ?? 'Devlog'; + const description = srcEmbed.description ?? payload.content ?? ''; + if (!description.trim()) { + return reply.code(400).send({ error: 'empty devlog' }); + } + + const channel = await client.channels.fetch(config.devlogChannelId); + if (!channel?.isTextBased()) { + return reply.code(500).send({ error: 'devlog channel not found' }); + } + + // Gebrandetes Embed; bis zu 4 Bilder als Grid (Discord gruppiert Embeds mit gleicher URL) + const groupUrl = srcEmbed.url || `${config.publicUrl}/devlogs`; + const attachments = files.map( + (f, i) => new AttachmentBuilder(f.buffer, { name: `devlog_${i}.${f.name.split('.').pop().toLowerCase()}` }) + ); + const embeds = [ + new EmbedBuilder() + .setColor(BRAND_YELLOW) + .setTitle(title) + .setURL(groupUrl) + .setDescription(description) + .setFooter({ text: 'D4RKST3R // DEVLOG' }) + .setTimestamp(), + ]; + attachments.forEach((att, i) => { + if (i === 0) { + embeds[0].setImage(`attachment://${att.name}`); + } else { + embeds.push( + new EmbedBuilder().setURL(groupUrl).setImage(`attachment://${att.name}`) + ); + } + }); + + const message = await channel.send({ embeds, files: attachments }); + + // Direkt archivieren (der Live-Listener ignoriert eigene Bot-Posts bewusst) + const imageFiles = []; + for (const [i, f] of files.entries()) { + const file = `${message.id}_${i}.${f.name.split('.').pop().toLowerCase()}`; + await writeFile(join(imagesDir, file), f.buffer); + imageFiles.push(file); + } + saveDevlog({ + message_id: message.id, + channel_id: message.channelId, + content: `${title}\n\n${description}`, + author_name: 'D4RKST3R // DEVLOG', + posted_at: message.createdAt.toISOString(), + images: JSON.stringify(imageFiles), + }); + + request.log.info(`Devlog gepostet + archiviert (${message.id}, ${imageFiles.length} Bilder)`); + return { ok: true, message_id: message.id, images: imageFiles.length }; + }); +} diff --git a/src/web/server.js b/src/web/server.js index 67c8583..746488a 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -1,6 +1,7 @@ // Fastify-Webserver: Gitea-Webhook, Discord-OAuth2, REST-API + React-Frontend import Fastify from 'fastify'; import fastifyCookie from '@fastify/cookie'; +import fastifyMultipart from '@fastify/multipart'; import fastifyStatic from '@fastify/static'; import crypto from 'node:crypto'; import { existsSync } from 'node:fs'; @@ -11,6 +12,7 @@ import { saveCommits } from '../db.js'; import { postPushEmbed } from '../bot/commit-feed.js'; import { registerAuthRoutes } from './auth.js'; import { registerApiRoutes } from './api.js'; +import { registerDevlogEndpoint } from './devlog-endpoint.js'; import { imagesDir } from '../bot/devlog-archive.js'; // Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden, @@ -53,9 +55,12 @@ export async function startWebServer(client) { // Signierte Cookies (Session + OAuth-State) await app.register(fastifyCookie, { secret: config.sessionSecret }); + // Multipart für den Devlog-Endpoint (Bilder von devlog.py, max 10 MB pro Datei) + await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 8 } }); registerAuthRoutes(app); registerApiRoutes(app); + registerDevlogEndpoint(app, client); // Healthcheck (für Portainer/NPM) app.get('/health', async () => ({ status: 'ok' }));