Texte: 27 Bot-Nachrichten im Panel bearbeitbar
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Bisher standen Begrüßung, Level-Ansage, Ticket-Texte und alle
Bestätigungs-Nachrichten fest im Code. Wer den Ton ändern wollte, musste
die Quelldateien anfassen und neu deployen — für alle außer mir also gar
nicht.

src/templates.js hält jetzt alle nach außen gehenden Texte an einer
Stelle: Standardtext, Platzhalter mit Erklärung und die Zuordnung zum
Modul. Der neue Setup-Tab „Texte" zeigt sie als Karten mit Textfeld;
die Bausteine darunter fügen sich per Klick an der Cursor-Position ein.

Der Standard bleibt im Code, die Datenbank speichert nur echte
Abweichungen. Ein leeres Feld heißt deshalb „wieder Standard" — und
angepasste Vorlagen sind an einer Marke erkennbar. Vorlagen zu
ausgeschalteten Modulen sind als solche gekennzeichnet, damit niemand
einen Text feilt, den gerade nichts sendet.

Unbekannte Platzhalter bleiben stehen statt still zu verschwinden, damit
ein Tippfehler in der Nachricht sichtbar wird.

Die Route heißt /api/texts, nicht /api/templates — letzteres sind seit je
die gespeicherten Composer-Nachrichten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:06:27 +02:00
co-authored by Claude Fable 5
parent 59aad27534
commit dadd75d452
17 changed files with 560 additions and 60 deletions
+11 -6
View File
@@ -9,6 +9,7 @@ import {
createApplication, getApplication, openApplicationOf, setApplicationStatus,
} from '../db.js';
import { brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js';
export const MAX_QUESTIONS = 5; // Discord-Modal-Limit
@@ -143,7 +144,11 @@ export async function handleApplyModal(interaction) {
}
await interaction.reply({
content: '✅ Bewerbung eingereicht — danke! Du bekommst eine DM, sobald sie geprüft wurde.',
content: renderTemplate('application.submitted', {
user: interaction.member?.displayName ?? interaction.user.username,
mention: `<@${interaction.user.id}>`,
form: form.title,
}),
flags: MessageFlags.Ephemeral,
});
}
@@ -169,11 +174,11 @@ export async function handleReviewButton(interaction) {
}
// DM an den Bewerber
const user = await interaction.client.users.fetch(application.user_id).catch(() => null);
await user?.send(
approved
? `✅ Deine Bewerbung **„${form?.title ?? ''}"** wurde angenommen — willkommen! 🎉`
: `❌ Deine Bewerbung **„${form?.title ?? ''}"** wurde leider abgelehnt.`
).catch(() => {});
await user?.send(renderTemplate(approved ? 'application.approved' : 'application.denied', {
user: user.username,
mention: `<@${application.user_id}>`,
form: form?.title ?? '',
})).catch(() => {});
// Review-Embed abschließen
const original = EmbedBuilder.from(interaction.message.embeds[0])
+5 -5
View File
@@ -4,6 +4,7 @@ import { getSetting, setSetting, birthdaysToday } from '../db.js';
import { birthdayChannelId, birthdayRoleId, discordGuildId, brandColor2 } from '../runtime-settings.js';
import { brandEmbed } from '../embeds.js';
import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js';
const CHECK_INTERVAL_MS = 15 * 60 * 1000;
@@ -49,16 +50,15 @@ export async function birthdayTick(client) {
if (!channel?.isTextBased()) return;
const mentions = kids.map((b) => `<@${b.user_id}>`).join(' ');
const many = kids.length > 1;
const vars = { mentions, count: kids.length };
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!'
),
.setTitle(renderTemplate(many ? 'birthday.title_multi' : 'birthday.title', vars))
.setDescription(renderTemplate(many ? 'birthday.text_multi' : 'birthday.text', vars)),
],
allowedMentions: { users: kids.map((b) => b.user_id) },
}).catch(() => {});
+9 -7
View File
@@ -2,6 +2,7 @@
import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials, REST, Routes } from 'discord.js';
import { config } from '../config.js';
import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId, brandColor, brandFooter, discordGuildId } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js';
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import { registerCommunityListeners } from './community.js';
import { registerModTools } from './mod-tools.js';
@@ -219,10 +220,10 @@ export async function startBot() {
embeds: [
new EmbedBuilder()
.setColor(brandColor())
.setDescription(
'Willkommen in deinem Ticket! Beschreib dein Anliegen — das Team meldet sich.\n' +
'Wenn alles geklärt ist, kann das Ticket hier geschlossen werden.'
)
.setDescription(renderTemplate('ticket.opened', {
user: interaction.member?.displayName ?? interaction.user.username,
mention: `<@${interaction.user.id}>`,
}))
.setFooter({ text: brandFooter('SUPPORT') }),
],
components: [
@@ -296,9 +297,10 @@ export async function startBot() {
addPlaytester(interaction.user.id, interaction.user.username);
}
await interaction.reply({
content: hasRole
? '🚪 Von der Playtester-Liste **abgemeldet**.'
: '🧪 **Willkommen im Playtest-Team!** Du stehst auf der Liste — sobald es losgeht, meldet sich der Bot per DM.',
content: renderTemplate(hasRole ? 'playtester.left' : 'playtester.joined', {
user: member.displayName ?? interaction.user.username,
mention: `<@${interaction.user.id}>`,
}),
flags: MessageFlags.Ephemeral,
});
} catch (error) {
+8 -4
View File
@@ -4,6 +4,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js';
import { bugReportRepo, giteaApiToken } from '../../runtime-settings.js';
import { createIssue, uploadIssueAsset } from '../../gitea-api.js';
import { saveBugReport } from '../../db.js';
import { renderTemplate } from '../../templates.js';
export const data = new SlashCommandBuilder()
.setName('bug')
@@ -64,10 +65,13 @@ export async function execute(interaction) {
title: titel,
});
await interaction.editReply(
`✅ Danke! Dein Bug ist erfasst als [#${issue.number}](${issue.html_url}) — ` +
`du bekommst eine DM, sobald er behoben ist.`
);
await interaction.editReply(renderTemplate('bug.submitted', {
user: interaction.member?.displayName ?? interaction.user.username,
mention: `<@${interaction.user.id}>`,
number: issue.number,
url: issue.html_url,
title: titel,
}));
} catch (error) {
console.error('[bug] Issue-Erstellung fehlgeschlagen:', error);
await interaction.editReply('❌ Konnte das Issue nicht anlegen — bitte später nochmal versuchen.');
+3 -8
View File
@@ -4,6 +4,7 @@ import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js';
import { playtesterRoleId, brandColor, brandFooter } from '../../runtime-settings.js';
import { renderTemplate } from '../../templates.js';
export const data = new SlashCommandBuilder()
.setName('playtester-setup')
@@ -21,14 +22,8 @@ export async function execute(interaction) {
const embed = new EmbedBuilder()
.setColor(brandColor())
.setTitle('🧪 Playtester werden')
.setDescription(
'Du willst **EcoGame** vor allen anderen spielen und mit deinem Feedback ' +
'die Entwicklung mitgestalten?\n\n' +
'Ein Klick auf den Button setzt dich auf die Playtester-Liste — ' +
'sobald die Alpha startet, bekommst du deinen Zugang direkt per DM. ' +
'Abmelden geht jederzeit mit einem zweiten Klick.'
)
.setTitle(renderTemplate('playtester.panel_title'))
.setDescription(renderTemplate('playtester.panel_text'))
.setFooter({ text: brandFooter('PLAYTEST') });
const row = new ActionRowBuilder().addComponents(
+3 -6
View File
@@ -4,6 +4,7 @@ import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js';
import { ticketChannelId, brandColor, brandFooter } from '../../runtime-settings.js';
import { renderTemplate } from '../../templates.js';
export const data = new SlashCommandBuilder()
.setName('ticket-setup')
@@ -21,12 +22,8 @@ export async function execute(interaction) {
const embed = new EmbedBuilder()
.setColor(brandColor())
.setTitle('🎫 Support-Ticket')
.setDescription(
'Du hast eine Frage, ein Problem oder ein privates Anliegen?\n\n' +
'Klick auf den Button — es öffnet sich ein **privater Thread**, ' +
'den nur du und das Team sehen können.'
)
.setTitle(renderTemplate('ticket.panel_title'))
.setDescription(renderTemplate('ticket.panel_text'))
.setFooter({ text: brandFooter('SUPPORT') });
const row = new ActionRowBuilder().addComponents(
+6 -1
View File
@@ -2,6 +2,7 @@
import { SlashCommandBuilder, MessageFlags, EmbedBuilder } from 'discord.js';
import { votingChannelId, publicUrl, brandColor, brandFooter } from '../../runtime-settings.js';
import { saveWish } from '../../db.js';
import { renderTemplate } from '../../templates.js';
export const data = new SlashCommandBuilder()
.setName('wunsch')
@@ -39,5 +40,9 @@ export async function execute(interaction) {
await message.react('👍');
saveWish(message.id, interaction.user.username, idea);
await interaction.editReply(`✅ Wunsch eingereicht — [zur Abstimmung](${message.url})!`);
await interaction.editReply(renderTemplate('wish.submitted', {
user: interaction.member?.displayName ?? interaction.user.username,
mention: `<@${interaction.user.id}>`,
url: message.url,
}));
}
+9 -6
View File
@@ -12,6 +12,7 @@ import {
tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter,
} from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js';
/* ── Triggers ──────────────────────────────────────── */
@@ -50,15 +51,13 @@ async function handleVoiceState(oldState, newState) {
await newState.member.voice.setChannel(channel).catch(() => {});
// Control-Panel in den Kanal-Textchat (nur der Besitzer darf bedienen)
const tvVars = { user: newState.member.displayName, mention: `<@${newState.member.id}>` };
await channel.send({
embeds: [
new EmbedBuilder()
.setColor(brandColor())
.setTitle('🔊 Dein Kanal')
.setDescription(
`<@${newState.member.id}>, das hier ist dein temporärer Voice-Kanal — ` +
'er verschwindet automatisch, wenn alle raus sind. Steuern kannst du ihn hier:'
)
.setTitle(renderTemplate('tempvoice.title', tvVars))
.setDescription(renderTemplate('tempvoice.text', tvVars))
.setFooter({ text: brandFooter('VOICE') }),
],
components: [
@@ -102,7 +101,11 @@ export async function remindersTick(client) {
for (const r of dueReminders()) {
markReminderSent(r.id);
const user = await client.users.fetch(r.user_id).catch(() => null);
await user?.send(`⏰ **Erinnerung:** ${r.text}`).catch(() => {});
await user?.send(renderTemplate('reminder.dm', {
text: r.text,
user: user?.username ?? '',
mention: `<@${r.user_id}>`,
})).catch(() => {});
}
}
+6 -3
View File
@@ -4,6 +4,7 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlag
import { dueGiveaways, markGiveawayEnded, giveawayEntries, getGiveaway } from '../db.js';
import { brandFooter } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js';
const CHECK_INTERVAL_MS = 60 * 1000;
@@ -41,7 +42,7 @@ export async function giveawayTick(client) {
.setDescription(
winners.length
? `🏆 Gewonnen: ${mentions}\n👥 ${entries.length} Teilnahmen`
: 'Keine Teilnahmen — niemand hat gewonnen. 😢'
: renderTemplate('giveaway.empty', { prize: g.prize })
)
.setFooter({ text: brandFooter('GIVEAWAY'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
],
@@ -51,7 +52,9 @@ export async function giveawayTick(client) {
if (winners.length) {
await channel.send({
content: `🎉 **${g.prize}** geht an: ${mentions} — Glückwunsch! 🏆`,
content: renderTemplate('giveaway.winner', {
prize: g.prize, winners: mentions, entries: entries.length,
}),
allowedMentions: { users: winners },
components: [giveawayAdminRow(g.message_id)],
});
@@ -104,7 +107,7 @@ export async function handleGiveawayAdminButton(interaction) {
}
const [winner] = drawWinners(entries, 1);
await interaction.reply({
content: `🔁 Neu ausgelost: **${g.prize}** geht jetzt an <@${winner}> — Glückwunsch! 🏆`,
content: renderTemplate('giveaway.reroll', { prize: g.prize, winners: `<@${winner}>` }),
allowedMentions: { users: [winner] },
});
}
+8 -1
View File
@@ -3,6 +3,7 @@
import { Events } from 'discord.js';
import { getLevelRow, setLevelRow } from '../db.js';
import { levelsEnabled, levelsAnnounce, levelRewards } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js';
const XP_COOLDOWN_MS = 60_000;
const XP_MIN = 15;
@@ -62,9 +63,15 @@ export function registerLevels(client) {
if (message.member) await grantRewards(message.member, result.level);
if (levelsAnnounce()) {
const name = message.member?.displayName ?? message.author.username;
await message.channel
.send({
content: `🎉 **${message.member?.displayName ?? message.author.username}** ist jetzt **Level ${result.level}**!`,
content: renderTemplate('level.up', {
user: name,
mention: `<@${message.author.id}>`,
level: result.level,
xp: result.xp,
}),
allowedMentions: { parse: [] },
})
.catch(() => {});
+14 -11
View File
@@ -3,6 +3,7 @@ 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';
import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js';
/* ── Modmail ───────────────────────────────────────── */
@@ -33,10 +34,10 @@ async function ensureModmailThread(client, user) {
embeds: [
new EmbedBuilder()
.setColor(brandColor())
.setDescription(
`Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` +
'Antworten in diesem Thread gehen als DM an den User.'
)
.setDescription(renderTemplate('modmail.thread_intro', {
user: user.username,
mention: `<@${user.id}>`,
}))
.setFooter({ text: brandFooter('MODMAIL'), iconURL: client.user?.displayAvatarURL?.({ size: 64 }) }),
],
});
@@ -86,15 +87,17 @@ async function handleMemberAdd(member) {
const channel = await member.client.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased()) return;
const vars = {
user: member.displayName,
mention: `<@${member.id}>`,
server: member.guild.name,
count: member.guild.memberCount,
url: publicUrl(),
};
const embed = new EmbedBuilder()
.setColor(brandColor())
.setTitle(`👋 Willkommen, ${member.displayName}!`)
.setDescription(
`Schön, dass du da bist — du bist Mitglied **#${member.guild.memberCount}**.\n\n` +
`📔 Devlogs & Roadmap: ${publicUrl()}\n` +
'🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' +
'🐛 Bug gefunden? Einfach `/bug` benutzen.'
)
.setTitle(renderTemplate('welcome.title', vars))
.setDescription(renderTemplate('welcome.text', vars))
.setFooter({ text: brandFooter('COMMUNITY'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp();
+2 -1
View File
@@ -3,6 +3,7 @@
import { AttachmentBuilder, EmbedBuilder, MessageFlags } from 'discord.js';
import { getTicket, closeTicket } from '../db.js';
import { modlogChannelId, brandColor, brandFooter } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js';
const MAX_MESSAGES = 500;
@@ -53,7 +54,7 @@ export async function closeTicketWithTranscript(interaction) {
try {
const user = await interaction.client.users.fetch(ticket.user_id);
await user.send({
content: `🔒 Dein Ticket **${threadName}** wurde geschlossen — hier dein Transcript. Danke für deine Anfrage!`,
content: renderTemplate('ticket.closed_dm', { ticket: threadName, closedBy }),
files: [makeFile()],
});
} catch {