- /bug (alle Member): erstellt Gitea-Issue inkl. Screenshot-Upload als Asset; bug_reports-Tabelle für den Rückkanal — Issue geschlossen (issues-Webhook) → DM an den Reporter - Auto-Thread '💬 Devlog <Datum>' unter jedem Devlog-Post (Setting, Default an) - Watchdog: prüft Setting watchdog_urls alle 2 min, DM an Admin nach 2 Fails in Folge + Entwarnung mit Downtime-Dauer - Setup-Seite: Bug-Repo, Threads-Toggle, Watchdog-URLs; Env GITEA_API_TOKEN/GITEA_URL Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
8.0 KiB
JavaScript
192 lines
8.0 KiB
JavaScript
// 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 { 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 };
|
|
});
|
|
}
|