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:
@@ -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(() => {});
|
||||
}
|
||||
+2
-1
@@ -33,11 +33,12 @@ import * as purge from './commands/purge.js';
|
||||
import * as rank from './commands/rank.js';
|
||||
import * as tag from './commands/tag.js';
|
||||
import * as remind from './commands/remind.js';
|
||||
import * as geburtstag from './commands/geburtstag.js';
|
||||
|
||||
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
||||
const commandModules = [
|
||||
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
||||
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind,
|
||||
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
|
||||
];
|
||||
|
||||
export async function startBot() {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// /geburtstag — eintragen/entfernen; der Bot gratuliert morgens im Geburtstags-Kanal
|
||||
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
||||
import { setBirthday, deleteBirthday } from '../../db.js';
|
||||
import { birthdayChannelId } from '../../runtime-settings.js';
|
||||
|
||||
const DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('geburtstag')
|
||||
.setDescription('Geburtstag eintragen — der Bot gratuliert dir am großen Tag 🎂')
|
||||
.addSubcommand((s) =>
|
||||
s.setName('setzen').setDescription('Deinen Geburtstag eintragen (ohne Jahr)')
|
||||
.addIntegerOption((o) => o.setName('tag').setDescription('Tag (1–31)').setRequired(true).setMinValue(1).setMaxValue(31))
|
||||
.addIntegerOption((o) => o.setName('monat').setDescription('Monat (1–12)').setRequired(true).setMinValue(1).setMaxValue(12))
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
s.setName('entfernen').setDescription('Deinen Geburtstag wieder austragen')
|
||||
);
|
||||
|
||||
export async function execute(interaction) {
|
||||
if (interaction.options.getSubcommand() === 'entfernen') {
|
||||
const deleted = deleteBirthday(interaction.user.id);
|
||||
await interaction.reply({
|
||||
content: deleted ? '🗑️ Geburtstag ausgetragen.' : '❕ Es war kein Geburtstag eingetragen.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const day = interaction.options.getInteger('tag');
|
||||
const month = interaction.options.getInteger('monat');
|
||||
if (day > DAYS_IN_MONTH[month - 1]) {
|
||||
await interaction.reply({ content: `❌ Der ${day}.${month}. existiert nicht.`, flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
setBirthday(interaction.user.id, interaction.user.username, day, month);
|
||||
await interaction.reply({
|
||||
content:
|
||||
`🎂 Gemerkt: **${day}.${month}.** — ich gratuliere dir dann morgens` +
|
||||
(birthdayChannelId() ? '!' : '. (Hinweis an die Admins: noch kein Geburtstags-Kanal gesetzt.)'),
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
}
|
||||
+18
-3
@@ -1,5 +1,5 @@
|
||||
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
|
||||
import { ChannelType, EmbedBuilder, Events } from 'discord.js';
|
||||
import { AttachmentBuilder, ChannelType, EmbedBuilder, Events } from 'discord.js';
|
||||
import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js';
|
||||
import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
|
||||
|
||||
@@ -86,7 +86,6 @@ async function handleMemberAdd(member) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(brandColor())
|
||||
.setTitle(`👋 Willkommen, ${member.displayName}!`)
|
||||
.setThumbnail(member.user.displayAvatarURL({ size: 128 }))
|
||||
.setDescription(
|
||||
`Schön, dass du da bist — du bist Mitglied **#${member.guild.memberCount}**.\n\n` +
|
||||
`📔 Devlogs & Roadmap: ${publicUrl()}\n` +
|
||||
@@ -96,7 +95,23 @@ async function handleMemberAdd(member) {
|
||||
.setFooter({ text: brandFooter('COMMUNITY'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) })
|
||||
.setTimestamp();
|
||||
|
||||
await channel.send({ content: `<@${member.id}>`, embeds: [embed] });
|
||||
// Gerenderte Willkommens-Karte — wenn das Rendering klemmt, gibt's das Embed pur
|
||||
let files = [];
|
||||
try {
|
||||
const { buildWelcomeCard } = await import('./welcome-card.js');
|
||||
const png = await buildWelcomeCard({
|
||||
username: member.displayName,
|
||||
avatarUrl: member.user.displayAvatarURL({ extension: 'png', size: 128 }),
|
||||
memberNumber: member.guild.memberCount,
|
||||
});
|
||||
files = [new AttachmentBuilder(png, { name: 'welcome.png' })];
|
||||
embed.setImage('attachment://welcome.png');
|
||||
} catch (error) {
|
||||
embed.setThumbnail(member.user.displayAvatarURL({ size: 128 }));
|
||||
console.error('[welcome] Karte fehlgeschlagen, nutze Embed:', error.message);
|
||||
}
|
||||
|
||||
await channel.send({ content: `<@${member.id}>`, embeds: [embed], files });
|
||||
}
|
||||
|
||||
/* ── Auto- & Sticky-Roles ──────────────────────────── */
|
||||
|
||||
@@ -44,6 +44,8 @@ export async function postReleaseEmbed(client, release) {
|
||||
);
|
||||
}
|
||||
|
||||
await channel.send({ embeds: [embed], components: [buttons] });
|
||||
const message = await channel.send({ embeds: [embed], components: [buttons] });
|
||||
const { maybeCrosspost } = await import('../embeds.js');
|
||||
await maybeCrosspost(message);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Willkommens-Karte: gerendertes PNG (SVG → sharp) im D4RKST3R-Look —
|
||||
// Avatar mit Neon-Ring, großer Willkommens-Schriftzug, Member-Nummer.
|
||||
import sharp from 'sharp';
|
||||
import { brandName } from '../runtime-settings.js';
|
||||
|
||||
const W = 900;
|
||||
const H = 300;
|
||||
|
||||
/** XML-Sonderzeichen im Usernamen entschärfen */
|
||||
function esc(s) {
|
||||
return String(s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Karte rendern.
|
||||
* @returns {Promise<Buffer>} PNG-Buffer
|
||||
*/
|
||||
export async function buildWelcomeCard({ username, avatarUrl, memberNumber }) {
|
||||
// Avatar holen und als Data-URI einbetten (128px reicht für den 150px-Kreis)
|
||||
let avatarData = '';
|
||||
try {
|
||||
const res = await fetch(avatarUrl, { signal: AbortSignal.timeout(5000) });
|
||||
if (res.ok) {
|
||||
avatarData = `data:image/png;base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`;
|
||||
}
|
||||
} catch { /* ohne Avatar rendern */ }
|
||||
|
||||
const name = esc(username.length > 22 ? `${username.slice(0, 21)}…` : username);
|
||||
const svg = `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<linearGradient id="line" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#f5c518" stop-opacity="0"/>
|
||||
<stop offset=".3" stop-color="#f5c518"/>
|
||||
<stop offset=".7" stop-color="#ff4d00"/>
|
||||
<stop offset="1" stop-color="#ff4d00" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#f5c518"/>
|
||||
<stop offset="1" stop-color="#ff4d00"/>
|
||||
</linearGradient>
|
||||
<pattern id="grid" width="45" height="45" patternUnits="userSpaceOnUse">
|
||||
<path d="M 45 0 L 0 0 0 45" fill="none" stroke="#f5c518" stroke-opacity=".06" stroke-width="1"/>
|
||||
</pattern>
|
||||
<clipPath id="avatar"><circle cx="150" cy="150" r="75"/></clipPath>
|
||||
</defs>
|
||||
|
||||
<rect width="${W}" height="${H}" fill="#0a0a0a"/>
|
||||
<rect width="${W}" height="${H}" fill="url(#grid)"/>
|
||||
<text x="${W - 18}" y="${H - 24}" text-anchor="end" font-family="DejaVu Sans, sans-serif" font-weight="bold"
|
||||
font-size="120" fill="#f5c518" fill-opacity=".05">${esc(brandName())}</text>
|
||||
<rect x="0" y="0" width="${W}" height="3" fill="url(#line)"/>
|
||||
<rect x="0" y="${H - 3}" width="${W}" height="3" fill="url(#line)"/>
|
||||
|
||||
<circle cx="150" cy="150" r="80" fill="none" stroke="url(#ring)" stroke-width="4"/>
|
||||
${avatarData
|
||||
? `<image href="${avatarData}" x="75" y="75" width="150" height="150" clip-path="url(#avatar)" preserveAspectRatio="xMidYMid slice"/>`
|
||||
: `<circle cx="150" cy="150" r="75" fill="#161616"/>
|
||||
<text x="150" y="172" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="64" fill="#8a8378">?</text>`}
|
||||
|
||||
<text x="270" y="118" font-family="DejaVu Sans, sans-serif" font-weight="bold" font-size="26"
|
||||
letter-spacing="10" fill="#8a8378">WILLKOMMEN</text>
|
||||
<text x="270" y="182" font-family="DejaVu Sans, sans-serif" font-weight="bold" font-size="52"
|
||||
fill="#e8e0d0">${name}</text>
|
||||
<text x="270" y="228" font-family="DejaVu Sans, sans-serif" font-size="24"
|
||||
fill="#f5c518">Member #${Number(memberNumber) || '?'}</text>
|
||||
</svg>`;
|
||||
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
Reference in New Issue
Block a user