Devlog-Endpoint: Bot postet Devlogs selbst — gebrandet, mit Bilder-Grid
- POST /webhooks/devlog/<secret> — Discord-Webhook-kompatibel (JSON + Multipart), devlog.py braucht nur die neue URL in tools/.devlog_webhook - Bot postet Embed in Brand-Gelb mit 'D4RKST3R // DEVLOG'-Footer, bis zu 4 Bilder als Grid (Embed-Gruppierung über gemeinsame URL) - Direkt-Archivierung inkl. Bilder (kein Umweg über den Live-Listener) - Neue Env-Var DEVLOG_POST_SECRET, README-Abschnitt neu geschrieben Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+7
-1
@@ -12,9 +12,15 @@ DISCORD_GUILD_ID=
|
|||||||
|
|
||||||
# --- Devlog-Archiv (Feature 3) ---
|
# --- 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=
|
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/<dieser Wert>
|
||||||
|
DEVLOG_POST_SECRET=
|
||||||
|
|
||||||
# --- Commit-Feed (Feature 2) ---
|
# --- Commit-Feed (Feature 2) ---
|
||||||
|
|
||||||
# Rechtsklick auf den Commit-Kanal → "Kanal-ID kopieren"
|
# Rechtsklick auf den Commit-Kanal → "Kanal-ID kopieren"
|
||||||
|
|||||||
@@ -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
|
Der Bot ist das Tagebuch: `tools/devlog.py` (EcoGame-Repo) schickt sein Devlog an
|
||||||
in den Devlog-Kanal — der Bot liest dort mit und archiviert jede Webhook-Nachricht
|
den **Bot-Endpoint** (Discord-Webhook-kompatibel), der Bot postet ein gebrandetes
|
||||||
in SQLite (User-Chatter im Kanal wird ignoriert).
|
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
|
1. `.env` / Portainer-Stack:
|
||||||
[Developer Portal](https://discord.com/developers/applications) unter **Bot**
|
- `DEVLOG_CHANNEL_ID` = Kanal-ID des Devlog-Kanals
|
||||||
aktiviert sein (steht oben schon im Setup — kurz prüfen)
|
- `DEVLOG_POST_SECRET` = generieren:
|
||||||
2. `.env` / Portainer-Stack: `DEVLOG_CHANNEL_ID` = Kanal-ID des Devlog-Kanals
|
`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
||||||
3. Bot neu starten → ab jetzt wird jedes neue Devlog automatisch archiviert
|
2. Im EcoGame-Repo in `tools/.devlog_webhook` statt der Discord-URL eintragen:
|
||||||
4. Einmalig in Discord: **`/devlog-backfill`** (Admin only) — scannt die komplette
|
```
|
||||||
Kanal-Historie und archiviert alle bisherigen Devlogs nach
|
https://bot.d4rkst3r.de/webhooks/devlog/<DEVLOG_POST_SECRET>
|
||||||
|
```
|
||||||
|
`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
|
**Zusätzlich** liest der Bot den Kanal live mit und archiviert auch fremde
|
||||||
können sich nicht doppeln, der Command ist beliebig oft wiederholbar.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ services:
|
|||||||
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-}
|
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-}
|
||||||
COMMIT_CHANNEL_ID: ${COMMIT_CHANNEL_ID}
|
COMMIT_CHANNEL_ID: ${COMMIT_CHANNEL_ID}
|
||||||
DEVLOG_CHANNEL_ID: ${DEVLOG_CHANNEL_ID}
|
DEVLOG_CHANNEL_ID: ${DEVLOG_CHANNEL_ID}
|
||||||
|
DEVLOG_POST_SECRET: ${DEVLOG_POST_SECRET}
|
||||||
GITEA_WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET}
|
GITEA_WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET}
|
||||||
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET}
|
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET}
|
||||||
SESSION_SECRET: ${SESSION_SECRET}
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
|||||||
Generated
+46
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.1.2",
|
"@fastify/cookie": "^11.1.2",
|
||||||
|
"@fastify/multipart": "^10.1.0",
|
||||||
"@fastify/static": "^10.1.2",
|
"@fastify/static": "^10.1.2",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"discord.js": "^14.16.3",
|
"discord.js": "^14.16.3",
|
||||||
@@ -187,6 +188,12 @@
|
|||||||
"fast-uri": "^3.0.0"
|
"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": {
|
"node_modules/@fastify/cookie": {
|
||||||
"version": "11.1.2",
|
"version": "11.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz",
|
||||||
@@ -220,6 +227,22 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
@@ -290,6 +313,29 @@
|
|||||||
"dequal": "^2.0.3"
|
"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": {
|
"node_modules/@fastify/proxy-addr": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.1.2",
|
"@fastify/cookie": "^11.1.2",
|
||||||
|
"@fastify/multipart": "^10.1.0",
|
||||||
"@fastify/static": "^10.1.2",
|
"@fastify/static": "^10.1.2",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"discord.js": "^14.16.3",
|
"discord.js": "^14.16.3",
|
||||||
|
|||||||
+4
-1
@@ -19,8 +19,11 @@ export const config = {
|
|||||||
discordGuildId: process.env.DISCORD_GUILD_ID || null,
|
discordGuildId: process.env.DISCORD_GUILD_ID || null,
|
||||||
|
|
||||||
// Devlog-Archiv (Feature 3)
|
// 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'),
|
devlogChannelId: required('DEVLOG_CHANNEL_ID'),
|
||||||
|
// Secret im Pfad des Devlog-Endpoints: /webhooks/devlog/<secret>
|
||||||
|
// (steckt in der URL in tools/.devlog_webhook im EcoGame-Repo)
|
||||||
|
devlogPostSecret: required('DEVLOG_POST_SECRET'),
|
||||||
|
|
||||||
// Commit-Feed (Feature 2)
|
// Commit-Feed (Feature 2)
|
||||||
// Kanal, in den Push-Embeds gepostet werden
|
// Kanal, in den Push-Embeds gepostet werden
|
||||||
|
|||||||
@@ -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/<DEVLOG_POST_SECRET> 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Fastify-Webserver: Gitea-Webhook, Discord-OAuth2, REST-API + React-Frontend
|
// Fastify-Webserver: Gitea-Webhook, Discord-OAuth2, REST-API + React-Frontend
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import fastifyMultipart from '@fastify/multipart';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
@@ -11,6 +12,7 @@ import { saveCommits } from '../db.js';
|
|||||||
import { postPushEmbed } from '../bot/commit-feed.js';
|
import { postPushEmbed } from '../bot/commit-feed.js';
|
||||||
import { registerAuthRoutes } from './auth.js';
|
import { registerAuthRoutes } from './auth.js';
|
||||||
import { registerApiRoutes } from './api.js';
|
import { registerApiRoutes } from './api.js';
|
||||||
|
import { registerDevlogEndpoint } from './devlog-endpoint.js';
|
||||||
import { imagesDir } from '../bot/devlog-archive.js';
|
import { imagesDir } from '../bot/devlog-archive.js';
|
||||||
|
|
||||||
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
|
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
|
||||||
@@ -53,9 +55,12 @@ export async function startWebServer(client) {
|
|||||||
|
|
||||||
// Signierte Cookies (Session + OAuth-State)
|
// Signierte Cookies (Session + OAuth-State)
|
||||||
await app.register(fastifyCookie, { secret: config.sessionSecret });
|
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);
|
registerAuthRoutes(app);
|
||||||
registerApiRoutes(app);
|
registerApiRoutes(app);
|
||||||
|
registerDevlogEndpoint(app, client);
|
||||||
|
|
||||||
// Healthcheck (für Portainer/NPM)
|
// Healthcheck (für Portainer/NPM)
|
||||||
app.get('/health', async () => ({ status: 'ok' }));
|
app.get('/health', async () => ({ status: 'ok' }));
|
||||||
|
|||||||
Reference in New Issue
Block a user