// 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 { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { config } from '../config.js'; import { saveDevlog } from '../db.js'; import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled } from '../runtime-settings.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' }); } // devlog.py-Payload zerlegen: Projekt aus dem Titel („Devlog — X"), // Commit-Fußzeile („*N Commits heute — Details im [Repo](url)*") aus der Prosa const project = title.match(/^Devlog\s*[—–-]\s*(.+)$/i)?.[1]?.trim() ?? null; const blocks = description.trim().split(/\n{2,}/); let prose = description.trim(); let commitCount = null; let repoUrl = srcEmbed.url || null; const lastBlock = blocks[blocks.length - 1]?.trim() ?? ''; const footerMatch = lastBlock.match(/^\*(\d+)\s+Commits?[^*]*\*$/s); if (blocks.length > 1 && footerMatch) { commitCount = Number(footerMatch[1]); repoUrl = lastBlock.match(/\[Repo\]\((https?:[^\s)]+)\)/)?.[1] ?? repoUrl; prose = blocks.slice(0, -1).join('\n\n'); } const targetChannel = devlogChannelId(); if (!targetChannel) { return reply.code(500).send({ error: 'no devlog channel configured' }); } const channel = await client.channels.fetch(targetChannel); 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 = `${config.publicUrl}/devlogs`; const dateStr = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', }).format(new Date()); const attachments = files.map( (f, i) => new AttachmentBuilder(f.buffer, { name: `devlog_${i}.${f.name.split('.').pop().toLowerCase()}` }) ); const mainEmbed = new EmbedBuilder() .setColor(BRAND_YELLOW) .setTitle(`📔 Devlog — ${dateStr}`) .setURL(groupUrl) .setDescription(prose) .setFooter({ text: `D4RKST3R // DEVLOG${commitCount ? ` • ${commitCount} Commits` : ''}`, }) .setTimestamp(); if (project) { mainEmbed.setAuthor({ name: project, iconURL: client.user?.displayAvatarURL?.() ?? undefined, }); } const embeds = [mainEmbed]; attachments.forEach((att, i) => { if (i === 0) { embeds[0].setImage(`attachment://${att.name}`); } else { embeds.push( new EmbedBuilder().setURL(groupUrl).setImage(`attachment://${att.name}`) ); } }); // Link-Buttons unterm Embed: Archiv-Seite + Repo + Abo-Button für die Ping-Rolle const buttons = new ActionRowBuilder().addComponents( new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel('Devlog-Archiv').setEmoji('🗂️').setURL(groupUrl) ); if (repoUrl) { buttons.addComponents( new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel('Repo').setEmoji('⚙️').setURL(repoUrl) ); } // Ping-Rolle: Rolle erwähnen + Abo-Button anbieten (Setup-Seite) const pingRole = devlogPingRoleId(); if (pingRole) { buttons.addComponents( new ButtonBuilder() .setStyle(ButtonStyle.Secondary) .setLabel('Benachrichtigungen') .setEmoji('🔔') .setCustomId('devlog_ping_toggle') ); } const message = await channel.send({ embeds, files: attachments, components: [buttons], ...(pingRole ? { content: `<@&${pingRole}>`, allowedMentions: { roles: [pingRole] } } : {}), }); // Diskussions-Thread unterm Devlog (Fehler nicht fatal — z. B. fehlende Rechte) if (devlogThreadsEnabled() && typeof message.startThread === 'function') { try { const threadDate = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', }).format(new Date()); await message.startThread({ name: `💬 Devlog ${threadDate}`, autoArchiveDuration: 4320, // 3 Tage }); } catch (error) { request.log.warn(`Devlog-Thread fehlgeschlagen: ${error.message}`); } } // 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 }; }); }