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:
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user