Willkommens-Karten, Geburtstage, Devlog-Permalinks mit OG-Vorschau, Auto-Publish
- Willkommens-Karten: gerendertes PNG (SVG → sharp) mit Avatar im Neon-Ring, Willkommens-Schriftzug und Member-Nummer; Fallback aufs bisherige Embed wenn das Rendering scheitert; Dockerfile installiert fonts-dejavu-core für die Text-Darstellung - Geburtstags-System: /geburtstag setzen|entfernen (birthdays-Tabelle), tägliche Runde ab 09:00 Europe/Berlin (Doppel-Post-Schutz über last_birthday_run), Gratulations-Embed + Tages-Rolle (wird am nächsten Morgen wieder abgeräumt); Kanal + Rolle im Community-Tab - Devlog-Permalinks: /devlogs/:id als eigene Seite (GET /api/devlogs/:id), Link-Symbol an jeder Karte, Link-kopieren-Button; der Server injiziert Open-Graph-Tags ins SPA-HTML — Devlog-Links zeigen Titel, Anriss und Bild, alle anderen Seiten bekommen Default-Tags (auch die Startseite) - Auto-Publish: maybeCrosspost() veröffentlicht Devlog-, Release-, Composer- und geplante Posts automatisch in Ankündigungs-Kanälen - brandEmbed: leere Avatar-URL crasht nicht mehr die Footer-Validierung Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+19
-2
@@ -1,7 +1,7 @@
|
||||
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import {
|
||||
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
|
||||
listDevlogs, searchDevlogs, getDevlog, listCommits, listReleases, archiveStats,
|
||||
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
||||
listGallery, listPlaytesters, topWishes, commitHeatmap,
|
||||
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
|
||||
@@ -89,6 +89,18 @@ export function registerApiRoutes(app, client) {
|
||||
return { items: mapped, total, page, pageSize: PAGE_SIZE };
|
||||
});
|
||||
|
||||
// Einzelnes Devlog (Permalink-Seite /devlogs/:id)
|
||||
app.get('/api/devlogs/:id', async (request, reply) => {
|
||||
const item = getDevlog(String(request.params.id));
|
||||
if (!item) return reply.code(404).send({ error: 'Devlog nicht gefunden' });
|
||||
return {
|
||||
item: {
|
||||
...item,
|
||||
images: JSON.parse(item.images || '[]').map((f) => `/devlog-assets/${f}`),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Changelog — öffentlich (Releases aller Repos)
|
||||
app.get('/api/releases', async (request) => {
|
||||
const { limit, offset, page } = paging(request);
|
||||
@@ -339,6 +351,8 @@ ${rssItems}
|
||||
server_alert_channel_id: getSetting('server_alert_channel_id') ?? '',
|
||||
member_gate_enabled: getSetting('member_gate_enabled') !== '0',
|
||||
discord_invite_url: getSetting('discord_invite_url') ?? '',
|
||||
birthday_channel_id: getSetting('birthday_channel_id') ?? '',
|
||||
birthday_role_id: getSetting('birthday_role_id') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -385,6 +399,7 @@ ${rssItems}
|
||||
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
|
||||
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_channel_id',
|
||||
'events_announce_channel_id', 'social_announce_channel_id', 'server_alert_channel_id',
|
||||
'birthday_channel_id',
|
||||
];
|
||||
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
|
||||
if (body[key] === undefined) continue;
|
||||
@@ -400,7 +415,7 @@ ${rssItems}
|
||||
setSetting(key, value);
|
||||
}
|
||||
// Rollen: müssen existieren ('' = Feature aus)
|
||||
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id']) {
|
||||
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id', 'birthday_role_id']) {
|
||||
if (body[key] === undefined) continue;
|
||||
const value = String(body[key]);
|
||||
if (value !== '') {
|
||||
@@ -1005,6 +1020,8 @@ ${rssItems}
|
||||
}
|
||||
|
||||
const message = await channel.send(payload);
|
||||
const { maybeCrosspost } = await import('../embeds.js');
|
||||
await maybeCrosspost(message);
|
||||
request.log.info(`Composer: Nachricht ${message.id} gesendet`);
|
||||
logAudit(getSessionUser(request), 'composer gesendet', `#${channel.name ?? channelId}`);
|
||||
return { ok: true, edited: false, message_id: message.id };
|
||||
|
||||
@@ -12,6 +12,7 @@ import { config } from '../config.js';
|
||||
import { saveDevlog } from '../db.js';
|
||||
import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
|
||||
import { imagesDir } from '../bot/devlog-archive.js';
|
||||
import { maybeCrosspost } from '../embeds.js';
|
||||
|
||||
const MAX_IMAGES = 4;
|
||||
|
||||
@@ -153,6 +154,7 @@ export function registerDevlogEndpoint(app, client) {
|
||||
? { content: `<@&${pingRole}>`, allowedMentions: { roles: [pingRole] } }
|
||||
: {}),
|
||||
});
|
||||
await maybeCrosspost(message);
|
||||
|
||||
// Diskussions-Thread unterm Devlog (Fehler nicht fatal — z. B. fehlende Rechte)
|
||||
if (devlogThreadsEnabled() && typeof message.startThread === 'function') {
|
||||
|
||||
@@ -65,7 +65,9 @@ export async function scheduledPostsTick(client) {
|
||||
try {
|
||||
const channel = await client.channels.fetch(post.channel_id).catch(() => null);
|
||||
if (channel?.isTextBased()) {
|
||||
await channel.send(buildScheduledPayload(post));
|
||||
const message = await channel.send(buildScheduledPayload(post));
|
||||
const { maybeCrosspost } = await import('../embeds.js');
|
||||
await maybeCrosspost(message);
|
||||
console.log(`[scheduled] Post ${post.id} → #${channel.name ?? post.channel_id}`);
|
||||
} else {
|
||||
console.warn(`[scheduled] Post ${post.id}: Kanal ${post.channel_id} nicht erreichbar`);
|
||||
|
||||
+62
-6
@@ -4,12 +4,12 @@ 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';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import { saveCommits, saveRelease, takeBugReport } from '../db.js';
|
||||
import { commitFeedEnabled, branchAllowed, repoIgnored } from '../runtime-settings.js';
|
||||
import { saveCommits, saveRelease, takeBugReport, getDevlog } from '../db.js';
|
||||
import { commitFeedEnabled, branchAllowed, repoIgnored, publicUrl, brandName } from '../runtime-settings.js';
|
||||
import { postPushEmbed } from '../bot/commit-feed.js';
|
||||
import { postReleaseEmbed } from '../bot/release-feed.js';
|
||||
import { registerAuthRoutes } from './auth.js';
|
||||
@@ -90,14 +90,70 @@ export async function startWebServer(client) {
|
||||
});
|
||||
|
||||
if (existsSync(frontendDist)) {
|
||||
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing)
|
||||
await app.register(fastifyStatic, { root: frontendDist });
|
||||
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing).
|
||||
// Dabei werden Open-Graph-Tags injiziert, damit geteilte Links (Discord,
|
||||
// WhatsApp, …) eine hübsche Vorschau zeigen — Devlog-Links sogar mit Bild.
|
||||
const indexHtml = readFileSync(join(frontendDist, 'index.html'), 'utf8');
|
||||
const esc = (s) => String(s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('"', '"');
|
||||
|
||||
function ogTagsFor(url) {
|
||||
const base = publicUrl();
|
||||
let title = `${brandName()} // Community-Hub`;
|
||||
let description = 'Devlogs, Roadmap, Server-Status und alles aus der Community — direkt aus dem Discord.';
|
||||
let image = null;
|
||||
|
||||
const pageTitles = {
|
||||
'/devlogs': ['Devlog-Archiv', 'Entwicklungs-Updates, automatisch archiviert.'],
|
||||
'/roadmap': ['Roadmap', 'Meilensteine und Community-Wünsche zum Abstimmen.'],
|
||||
'/server': ['Server-Status', 'Alle Game-Server live — Spielerzahlen und Verlauf.'],
|
||||
'/galerie': ['Galerie', 'Screenshots aus der Community.'],
|
||||
'/level': ['Level', 'Die XP-Bestenliste des Discords.'],
|
||||
'/events': ['Events', 'Playtests, Streams und was sonst ansteht.'],
|
||||
'/changelog': ['Changelog', 'Alle Releases mit Notes.'],
|
||||
};
|
||||
const path = url.split('?')[0];
|
||||
|
||||
const devlogId = path.match(/^\/devlogs\/(\d{15,21})$/)?.[1];
|
||||
const devlog = devlogId ? getDevlog(devlogId) : null;
|
||||
if (devlog) {
|
||||
const prose = devlog.content.replace(/^[-*>]\s+/gm, '').replace(/[#*`_]/g, '').replace(/\s+/g, ' ').trim();
|
||||
title = `${brandName()} // Devlog — ${new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }).format(new Date(devlog.posted_at))}`;
|
||||
description = prose.slice(0, 200) + (prose.length > 200 ? ' …' : '');
|
||||
const firstImage = JSON.parse(devlog.images || '[]')[0];
|
||||
if (firstImage) image = `${base}/devlog-assets/${firstImage}`;
|
||||
} else if (pageTitles[path]) {
|
||||
title = `${brandName()} // ${pageTitles[path][0]}`;
|
||||
description = pageTitles[path][1];
|
||||
}
|
||||
|
||||
return [
|
||||
`<meta property="og:site_name" content="${esc(brandName())}">`,
|
||||
`<meta property="og:title" content="${esc(title)}">`,
|
||||
`<meta property="og:description" content="${esc(description)}">`,
|
||||
`<meta property="og:url" content="${esc(base + path)}">`,
|
||||
`<meta property="og:type" content="website">`,
|
||||
`<meta name="theme-color" content="#f5c518">`,
|
||||
`<meta name="description" content="${esc(description)}">`,
|
||||
image ? `<meta property="og:image" content="${esc(image)}">` : '',
|
||||
image ? `<meta name="twitter:card" content="summary_large_image">` : '<meta name="twitter:card" content="summary">',
|
||||
].filter(Boolean).join('\n ');
|
||||
}
|
||||
|
||||
const sendInjected = (request, reply) => {
|
||||
const html = indexHtml.replace('</head>', ` ${ogTagsFor(request.url)}\n</head>`);
|
||||
return reply.type('text/html; charset=utf-8').send(html);
|
||||
};
|
||||
|
||||
// index: false → "/" wird nicht statisch bedient, sondern von der eigenen
|
||||
// Route darunter (sonst gäbe es keine OG-Tags auf der Startseite)
|
||||
await app.register(fastifyStatic, { root: frontendDist, index: false });
|
||||
app.get('/', sendInjected);
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
const isApiPath = ['/api', '/auth', '/webhooks'].some((p) =>
|
||||
request.url.startsWith(p)
|
||||
);
|
||||
if (request.method === 'GET' && !isApiPath) {
|
||||
return reply.sendFile('index.html');
|
||||
return sendInjected(request, reply);
|
||||
}
|
||||
return reply.code(404).send({ error: 'not found' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user