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:
2026-07-30 22:43:15 +02:00
co-authored by Claude Fable 5
parent ef0fb066f7
commit 676fc45bb1
23 changed files with 1080 additions and 21 deletions
+69
View File
@@ -0,0 +1,69 @@
// Geburtstags-Runde: einmal täglich ab 09:00 (Europe/Berlin) gratulieren
// und die Tages-Rolle umhängen (gestern Geburtstag → Rolle wieder weg).
import { getSetting, setSetting, birthdaysToday } from '../db.js';
import { birthdayChannelId, birthdayRoleId, discordGuildId, brandColor2 } from '../runtime-settings.js';
import { brandEmbed } from '../embeds.js';
const CHECK_INTERVAL_MS = 15 * 60 * 1000;
/** Aktuelles Datum/Stunde in Europe/Berlin */
function berlinNow() {
const parts = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false,
}).formatToParts(new Date());
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
return { day: get('day'), month: get('month'), hour: get('hour'), dateKey: `${get('year')}-${get('month')}-${get('day')}` };
}
export async function birthdayTick(client) {
const { day, month, hour, dateKey } = berlinNow();
if (hour < 9) return; // erst ab 09:00
if (getSetting('last_birthday_run') === dateKey) return; // heute schon gelaufen
setSetting('last_birthday_run', dateKey);
const guildId = discordGuildId() ?? [...client.guilds.cache.keys()][0];
const guild = guildId ? await client.guilds.fetch(guildId).catch(() => null) : null;
const kids = birthdaysToday(day, month);
// Tages-Rolle: erst bei allen abräumen, dann den heutigen geben
const roleId = birthdayRoleId();
if (guild && roleId) {
const role = await guild.roles.fetch(roleId).catch(() => null);
if (role) {
for (const member of role.members.values()) {
await member.roles.remove(roleId).catch(() => {});
}
for (const b of kids) {
const member = await guild.members.fetch(b.user_id).catch(() => null);
await member?.roles.add(roleId).catch(() => {});
}
}
}
if (kids.length === 0) return;
const channelId = birthdayChannelId();
if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased()) return;
const mentions = kids.map((b) => `<@${b.user_id}>`).join(' ');
await channel.send({
content: mentions,
embeds: [
brandEmbed(client, 'GEBURTSTAG')
.setColor(brandColor2())
.setTitle(kids.length === 1 ? '🎂 Alles Gute zum Geburtstag!' : '🎂 Heute wird gleich mehrfach gefeiert!')
.setDescription(
`${mentions} ${kids.length === 1 ? 'hat' : 'haben'} heute Geburtstag — ` +
'lasst mal ordentlich 🎉 da!'
),
],
allowedMentions: { users: kids.map((b) => b.user_id) },
}).catch(() => {});
console.log(`[birthday] ${kids.length} Gratulation(en) für den ${day}.${month}.`);
}
export function startBirthdays(client) {
setInterval(() => birthdayTick(client).catch((e) => console.error('[birthday]', e)), CHECK_INTERVAL_MS);
birthdayTick(client).catch(() => {});
}