diff --git a/README.md b/README.md
index 7325492..1971b6b 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,16 @@ Definiert sind sie zentral in [`src/modules.js`](src/modules.js). Funktionen mit
einem bereits vorhandenen Schalter (Level-System, Backups …) nutzen weiterhin
dieselbe Einstellung — es gibt also keine zweite Wahrheit.
+### Texte
+
+Was der Bot nach außen schreibt — Begrüßungen, Bestätigungen, Direktnachrichten —
+steht als Vorlage im Tab *Texte*. Platzhalter wie `{user}` oder `{count}` setzt der
+Bot beim Senden ein; ein Klick auf den Baustein fügt ihn an der Cursor-Position ein.
+Ein leeres Feld stellt den Standardtext wieder her.
+
+Die Vorlagen stehen in [`src/templates.js`](src/templates.js). Der Standard lebt im
+Code, die Datenbank enthält nur echte Abweichungen.
+
### Setup-Seite (`/settings`)
Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community ·
Rollen · Support · Bewerbungen · Composer · Server · System · API · Team) —
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index b76e104..0de4a03 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -5,7 +5,7 @@ import {
IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport,
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
- IconHome as IconLayers, IconWarning, IconCheck,
+ IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh,
} from '../icons.jsx';
import { Markdown } from '../markdown.jsx';
@@ -19,6 +19,7 @@ const API_SCOPES = [
const TABS = [
{ id: 'status', Icon: IconStatus, label: 'Status' },
{ id: 'module', Icon: IconLayers, label: 'Module' },
+ { id: 'texte', Icon: IconMessage, label: 'Texte' },
{ id: 'brand', Icon: IconBrand, label: 'Brand' },
{ id: 'feeds', Icon: IconFeeds, label: 'Feeds' },
{ id: 'community', Icon: IconCommunity, label: 'Community' },
@@ -37,6 +38,7 @@ const TABS = [
const TAB_SCOPES = {
status: 'any',
module: 'settings',
+ texte: 'settings',
brand: null,
feeds: 'settings',
community: 'community',
@@ -175,6 +177,10 @@ export default function Settings({ me }) {
// Module (An/Aus für alle Funktionen)
const [modules, setModules] = useState([]);
const [moduleGroups, setModuleGroups] = useState([]);
+ // Text-Vorlagen (was der Bot nach außen schreibt)
+ const [templates, setTemplates] = useState([]);
+ const [templateGroups, setTemplateGroups] = useState([]);
+ const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text
// Eigene Seiten (Regeln, Über uns, …)
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
const [pages, setPages] = useState([]);
@@ -236,6 +242,10 @@ export default function Settings({ me }) {
setModules(d.modules);
setModuleGroups(d.groups);
}).catch(() => {});
+ apiGet('/api/texts').then((d) => {
+ setTemplates(d.templates);
+ setTemplateGroups(d.groups);
+ }).catch(() => {});
}, [me.admin, me.scopes?.length]);
useEffect(() => {
@@ -281,6 +291,34 @@ export default function Settings({ me }) {
}
}
+ /** Vorlage speichern. Leerer Text stellt den Standard wieder her. */
+ async function saveTemplate(id, text) {
+ try {
+ const res = await apiPut(`/api/texts/${encodeURIComponent(id)}`, { text });
+ setTemplates(res.templates);
+ setTplDraft(({ [id]: _drop, ...rest }) => rest);
+ flash(text.trim() ? '✓ Text gespeichert' : '✓ Auf den Standard zurückgesetzt');
+ } catch {
+ flash('✗ Speichern fehlgeschlagen');
+ }
+ }
+
+ /** Platzhalter an der Cursor-Position einfügen */
+ function insertPlaceholder(tpl, key) {
+ const field = document.getElementById(`tpl-${tpl.id}`);
+ const current = tplDraft[tpl.id] ?? tpl.text;
+ const at = field?.selectionStart ?? current.length;
+ const next = `${current.slice(0, at)}{${key}}${current.slice(field?.selectionEnd ?? at)}`;
+ setTplDraft((old) => ({ ...old, [tpl.id]: next }));
+ // Cursor hinter den eingefügten Platzhalter setzen
+ requestAnimationFrame(() => {
+ if (!field) return;
+ field.focus();
+ const pos = at + key.length + 2;
+ field.setSelectionRange(pos, pos);
+ });
+ }
+
/** Titel → Adress-Kürzel (Umlaute mit auflösen) */
function slugify(value) {
return String(value).toLowerCase()
@@ -1756,6 +1794,90 @@ export default function Settings({ me }) {
);
+ const tabTexte = (
+
+
// Texte
+
+ Alles, was der Bot nach außen schreibt — Begrüßungen, Bestätigungen,
+ Direktnachrichten. Die Bausteine in geschweiften Klammern setzt der Bot
+ beim Senden ein; klick einen an, um ihn an der Cursor-Position einzufügen.
+ Ein leeres Feld bedeutet: wieder der Standardtext.
+
+
+ {templateGroups.map((group) => {
+ const items = templates.filter((t) => t.group === group.id);
+ if (items.length === 0) return null;
+ return (
+
+
+ {group.label}
+
+
+ {items.map((t) => {
+ const draft = tplDraft[t.id];
+ const value = draft ?? t.text;
+ const dirty = draft != null && draft !== t.text;
+ const off = t.module && modules.some((m) => m.id === t.module && !m.enabled);
+ return (
+
+
+ {t.name}
+ {t.customized && angepasst}
+ {off && Modul aus}
+
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+
const tabSeiten = (
// Eigene Seiten
@@ -1943,6 +2065,7 @@ export default function Settings({ me }) {
const content = {
status: tabStatus,
module: tabModule,
+ texte: tabTexte,
brand: tabBrand,
feeds: tabFeeds,
community: tabCommunity,
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 0fa52d9..6f77f12 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1650,3 +1650,48 @@ button.with-icon, a.with-icon { justify-content: center; }
}
.mod-switch.on .mod-knob { transform: translateX(18px); background: #0a0a0a; }
.mod-switch:hover { border-color: var(--neon); }
+
+/* ── TEXT-VORLAGEN ─────────────────────────────────── */
+/* Kurze Vorlagen teilen sich eine Zeile, mehrzeilige bekommen die volle Breite */
+.tpl-list {
+ display: grid; gap: .9rem;
+ grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
+}
+.tpl-card.long { grid-column: 1 / -1; }
+.tpl-card {
+ display: flex; flex-direction: column; gap: .6rem;
+ padding: 1.1rem 1.2rem;
+ transition: border-color .2s;
+}
+.tpl-card.dirty { border-color: var(--neon); }
+.tpl-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
+.tpl-name {
+ font-family: var(--display); font-size: 1.05rem;
+ letter-spacing: .04em; color: var(--text);
+}
+.tpl-badge {
+ font-family: var(--mono); font-size: .55rem;
+ letter-spacing: .12em; text-transform: uppercase;
+ color: var(--neon); border: 1px solid var(--neon);
+ border-radius: 4px; padding: .1rem .4rem;
+}
+.tpl-badge.muted { color: var(--muted2); border-color: var(--border); }
+.tpl-input {
+ width: 100%; resize: vertical;
+ font-family: var(--mono); font-size: .82rem; line-height: 1.55;
+ color: var(--text); background: var(--bg2);
+ border: 1px solid var(--border); border-radius: 5px;
+ padding: .6rem .7rem;
+}
+.tpl-input:focus { outline: none; border-color: var(--neon); }
+.tpl-vars { display: flex; flex-wrap: wrap; gap: .35rem; }
+.tpl-var {
+ font-family: var(--mono); font-size: .6rem; letter-spacing: .05em;
+ color: var(--muted); background: var(--bg3);
+ border: 1px solid var(--border); border-radius: 4px;
+ padding: .18rem .45rem; cursor: pointer;
+ transition: color .15s, border-color .15s;
+}
+.tpl-var:hover { color: var(--neon); border-color: var(--neon); }
+.tpl-actions { display: flex; gap: .5rem; align-items: center; }
+.tpl-actions .btn { font-size: .6rem; padding: .4rem .9rem; }
diff --git a/src/bot/app-forms.js b/src/bot/app-forms.js
index 975e7d8..30fb33c 100644
--- a/src/bot/app-forms.js
+++ b/src/bot/app-forms.js
@@ -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])
diff --git a/src/bot/birthdays.js b/src/bot/birthdays.js
index 14539f8..b6a3037 100644
--- a/src/bot/birthdays.js
+++ b/src/bot/birthdays.js
@@ -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(() => {});
diff --git a/src/bot/client.js b/src/bot/client.js
index c5d5c5c..0b5bb29 100644
--- a/src/bot/client.js
+++ b/src/bot/client.js
@@ -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) {
diff --git a/src/bot/commands/bug.js b/src/bot/commands/bug.js
index 0be4487..754192e 100644
--- a/src/bot/commands/bug.js
+++ b/src/bot/commands/bug.js
@@ -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.');
diff --git a/src/bot/commands/playtester-setup.js b/src/bot/commands/playtester-setup.js
index c4980e7..b42a83a 100644
--- a/src/bot/commands/playtester-setup.js
+++ b/src/bot/commands/playtester-setup.js
@@ -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(
diff --git a/src/bot/commands/ticket-setup.js b/src/bot/commands/ticket-setup.js
index 8f9ad88..ee14ead 100644
--- a/src/bot/commands/ticket-setup.js
+++ b/src/bot/commands/ticket-setup.js
@@ -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(
diff --git a/src/bot/commands/wunsch.js b/src/bot/commands/wunsch.js
index 3bac959..7e5de98 100644
--- a/src/bot/commands/wunsch.js
+++ b/src/bot/commands/wunsch.js
@@ -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,
+ }));
}
diff --git a/src/bot/extras.js b/src/bot/extras.js
index 5857b83..49ce7f0 100644
--- a/src/bot/extras.js
+++ b/src/bot/extras.js
@@ -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(() => {});
}
}
diff --git a/src/bot/giveaways.js b/src/bot/giveaways.js
index bb506bd..1e31184 100644
--- a/src/bot/giveaways.js
+++ b/src/bot/giveaways.js
@@ -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] },
});
}
diff --git a/src/bot/levels.js b/src/bot/levels.js
index 70d2422..7de1903 100644
--- a/src/bot/levels.js
+++ b/src/bot/levels.js
@@ -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(() => {});
diff --git a/src/bot/mod-tools.js b/src/bot/mod-tools.js
index 777a45c..2ee08cc 100644
--- a/src/bot/mod-tools.js
+++ b/src/bot/mod-tools.js
@@ -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();
diff --git a/src/bot/tickets.js b/src/bot/tickets.js
index 2ddf678..6280a38 100644
--- a/src/bot/tickets.js
+++ b/src/bot/tickets.js
@@ -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 {
diff --git a/src/templates.js b/src/templates.js
new file mode 100644
index 0000000..2d5f61a
--- /dev/null
+++ b/src/templates.js
@@ -0,0 +1,273 @@
+// Text-Vorlagen: eine Stelle, an der alle Nachrichten stehen, die der Bot
+// nach außen schreibt — Begrüßungen, Bestätigungen, Direktnachrichten.
+//
+// Jede Vorlage hat einen Standardtext. Wird sie im Webinterface geändert,
+// landet die Fassung als Einstellung `tpl_` in der Datenbank; ein leerer
+// Wert bedeutet „wieder der Standard". So bleibt der Code die Quelle für den
+// Standard und die Datenbank enthält nur echte Abweichungen.
+//
+// Platzhalter sind `{name}`. Unbekannte Namen bleiben unverändert stehen,
+// damit ein Tippfehler in der Nachricht sichtbar wird statt still zu
+// verschwinden.
+import { getSetting, setSetting } from './db.js';
+
+export const TEMPLATE_GROUPS = [
+ { id: 'willkommen', label: 'Ankommen & Community' },
+ { id: 'support', label: 'Support & Bewerbung' },
+ { id: 'spass', label: 'Verlosung, Geburtstag & Level' },
+ { id: 'sonstiges', label: 'Sonstiges' },
+];
+
+/** Platzhalter, die es in fast jeder Vorlage gibt */
+const WHO = [
+ { key: 'user', desc: 'Anzeigename' },
+ { key: 'mention', desc: 'Erwähnung (@Name)' },
+];
+
+export const TEMPLATES = [
+ /* ── Ankommen & Community ─────────────────────────── */
+ {
+ id: 'welcome.title', group: 'willkommen', module: 'welcome',
+ name: 'Willkommen — Überschrift',
+ vars: [...WHO, { key: 'server', desc: 'Servername' }, { key: 'count', desc: 'Mitglieder-Nummer' }],
+ default: '👋 Willkommen, {user}!',
+ },
+ {
+ id: 'welcome.text', group: 'willkommen', module: 'welcome',
+ name: 'Willkommen — Text', long: true,
+ vars: [...WHO, { key: 'server', desc: 'Servername' }, { key: 'count', desc: 'Mitglieder-Nummer' }, { key: 'url', desc: 'Adresse der Webseite' }],
+ default:
+ 'Schön, dass du da bist — du bist Mitglied **#{count}**.\n\n' +
+ '📔 Devlogs & Roadmap: {url}\n' +
+ '🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' +
+ '🐛 Bug gefunden? Einfach `/bug` benutzen.',
+ },
+ {
+ id: 'playtester.joined', group: 'willkommen', module: 'playtester',
+ name: 'Playtester — angemeldet', long: true,
+ vars: WHO,
+ default: '🧪 **Willkommen im Playtest-Team!** Du stehst auf der Liste — sobald es losgeht, meldet sich der Bot per DM.',
+ },
+ {
+ id: 'playtester.left', group: 'willkommen', module: 'playtester',
+ name: 'Playtester — abgemeldet',
+ vars: WHO,
+ default: '🚪 Von der Playtester-Liste **abgemeldet**.',
+ },
+ {
+ id: 'tempvoice.title', group: 'willkommen', module: 'temp_voice',
+ name: 'Sprachkanal — Überschrift',
+ vars: WHO,
+ default: '🔊 Dein Kanal',
+ },
+ {
+ id: 'tempvoice.text', group: 'willkommen', module: 'temp_voice',
+ name: 'Sprachkanal — Text', long: true,
+ vars: WHO,
+ default: '{mention}, das hier ist dein temporärer Voice-Kanal — er verschwindet automatisch, wenn alle raus sind. Steuern kannst du ihn hier:',
+ },
+
+ /* ── Support & Bewerbung ──────────────────────────── */
+ {
+ id: 'ticket.panel_title', group: 'support', module: 'tickets',
+ name: 'Ticket-Post — Überschrift',
+ vars: [],
+ default: '🎫 Support-Ticket',
+ },
+ {
+ id: 'ticket.panel_text', group: 'support', module: 'tickets',
+ name: 'Ticket-Post — Text', long: true,
+ vars: [],
+ default:
+ '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.',
+ },
+ {
+ id: 'ticket.opened', group: 'support', module: 'tickets',
+ name: 'Ticket — Begrüßung im Thread', long: true,
+ vars: WHO,
+ default:
+ 'Willkommen in deinem Ticket! Beschreib dein Anliegen — das Team meldet sich.\n' +
+ 'Wenn alles geklärt ist, kann das Ticket hier geschlossen werden.',
+ },
+ {
+ id: 'ticket.closed_dm', group: 'support', module: 'tickets',
+ name: 'Ticket — Direktnachricht beim Schließen', long: true,
+ vars: [{ key: 'ticket', desc: 'Name des Tickets' }, { key: 'closedBy', desc: 'wer geschlossen hat' }],
+ default: '🔒 Dein Ticket **{ticket}** wurde geschlossen — hier dein Transcript. Danke für deine Anfrage!',
+ },
+ {
+ id: 'modmail.thread_intro', group: 'support', module: 'modmail',
+ name: 'Modmail — Kopf des Staff-Threads', long: true,
+ vars: WHO,
+ default:
+ 'Neue Modmail-Konversation mit **{user}** ({mention}).\n' +
+ 'Antworten in diesem Thread gehen als DM an den User.',
+ },
+ {
+ id: 'application.submitted', group: 'support', module: 'applications',
+ name: 'Bewerbung — Bestätigung', long: true,
+ vars: [...WHO, { key: 'form', desc: 'Name des Formulars' }],
+ default: '✅ Bewerbung eingereicht — danke! Du bekommst eine DM, sobald sie geprüft wurde.',
+ },
+ {
+ id: 'application.approved', group: 'support', module: 'applications',
+ name: 'Bewerbung — angenommen', long: true,
+ vars: [...WHO, { key: 'form', desc: 'Name des Formulars' }],
+ default: '✅ Deine Bewerbung **„{form}"** wurde angenommen — willkommen! 🎉',
+ },
+ {
+ id: 'application.denied', group: 'support', module: 'applications',
+ name: 'Bewerbung — abgelehnt', long: true,
+ vars: [...WHO, { key: 'form', desc: 'Name des Formulars' }],
+ default: '❌ Deine Bewerbung **„{form}"** wurde leider abgelehnt.',
+ },
+
+ /* ── Verlosung, Geburtstag & Level ────────────────── */
+ {
+ id: 'giveaway.winner', group: 'spass', module: 'giveaways',
+ name: 'Verlosung — Gewinner', long: true,
+ vars: [
+ { key: 'prize', desc: 'Gewinn' },
+ { key: 'winners', desc: 'Erwähnungen der Gewinner' },
+ { key: 'entries', desc: 'Anzahl Teilnahmen' },
+ ],
+ default: '🎉 **{prize}** geht an: {winners} — Glückwunsch! 🏆',
+ },
+ {
+ id: 'giveaway.empty', group: 'spass', module: 'giveaways',
+ name: 'Verlosung — keine Teilnahmen',
+ vars: [{ key: 'prize', desc: 'Gewinn' }],
+ default: 'Keine Teilnahmen — niemand hat gewonnen. 😢',
+ },
+ {
+ id: 'giveaway.reroll', group: 'spass', module: 'giveaways',
+ name: 'Verlosung — neu ausgelost', long: true,
+ vars: [{ key: 'prize', desc: 'Gewinn' }, { key: 'winners', desc: 'Erwähnung des Gewinners' }],
+ default: '🔁 Neu ausgelost: **{prize}** geht jetzt an {winners} — Glückwunsch! 🏆',
+ },
+ {
+ id: 'birthday.title', group: 'spass', module: 'birthdays',
+ name: 'Geburtstag — Überschrift',
+ vars: [{ key: 'count', desc: 'Anzahl Geburtstagskinder' }],
+ default: '🎂 Alles Gute zum Geburtstag!',
+ },
+ {
+ id: 'birthday.title_multi', group: 'spass', module: 'birthdays',
+ name: 'Geburtstag — Überschrift (mehrere)',
+ vars: [{ key: 'count', desc: 'Anzahl Geburtstagskinder' }],
+ default: '🎂 Heute wird gleich mehrfach gefeiert!',
+ },
+ {
+ id: 'birthday.text', group: 'spass', module: 'birthdays',
+ name: 'Geburtstag — Text', long: true,
+ vars: [{ key: 'mentions', desc: 'Erwähnungen' }, { key: 'count', desc: 'Anzahl' }],
+ default: '{mentions} hat heute Geburtstag — lasst mal ordentlich 🎉 da!',
+ },
+ {
+ id: 'birthday.text_multi', group: 'spass', module: 'birthdays',
+ name: 'Geburtstag — Text (mehrere)', long: true,
+ vars: [{ key: 'mentions', desc: 'Erwähnungen' }, { key: 'count', desc: 'Anzahl' }],
+ default: '{mentions} haben heute Geburtstag — lasst mal ordentlich 🎉 da!',
+ },
+ {
+ id: 'level.up', group: 'spass', module: 'levels',
+ name: 'Level-Aufstieg', long: true,
+ vars: [...WHO, { key: 'level', desc: 'neues Level' }, { key: 'xp', desc: 'Gesamt-XP' }],
+ default: '🎉 **{user}** ist jetzt **Level {level}**!',
+ },
+
+ /* ── Sonstiges ────────────────────────────────────── */
+ {
+ id: 'reminder.dm', group: 'sonstiges', module: 'reminders',
+ name: 'Erinnerung', long: true,
+ vars: [...WHO, { key: 'text', desc: 'der erinnerte Text' }],
+ default: '⏰ **Erinnerung:** {text}',
+ },
+ {
+ id: 'wish.submitted', group: 'sonstiges', module: 'voting',
+ name: 'Feature-Wunsch — Bestätigung', long: true,
+ vars: [...WHO, { key: 'url', desc: 'Link zum Voting-Post' }],
+ default: '✅ Wunsch eingereicht — [zur Abstimmung]({url})!',
+ },
+ {
+ id: 'bug.submitted', group: 'sonstiges', module: 'bug_reports',
+ name: 'Fehlermeldung — Bestätigung', long: true,
+ vars: [
+ ...WHO,
+ { key: 'number', desc: 'Nummer des Issues' },
+ { key: 'url', desc: 'Link zum Issue' },
+ { key: 'title', desc: 'Titel der Meldung' },
+ ],
+ default: '✅ Danke! Dein Bug ist erfasst als [#{number}]({url}) — du bekommst eine DM, sobald er behoben ist.',
+ },
+ {
+ id: 'playtester.panel_title', group: 'sonstiges', module: 'playtester',
+ name: 'Playtester-Post — Überschrift',
+ vars: [],
+ default: '🧪 Playtester werden',
+ },
+ {
+ id: 'playtester.panel_text', group: 'sonstiges', module: 'playtester',
+ name: 'Playtester-Post — Text', long: true,
+ vars: [],
+ default:
+ '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.',
+ },
+];
+
+const byId = new Map(TEMPLATES.map((t) => [t.id, t]));
+
+/** Standardtext einer Vorlage */
+export function templateDefault(id) {
+ return byId.get(id)?.default ?? '';
+}
+
+/** Aktueller Rohtext — eigene Fassung, sonst der Standard */
+export function getTemplate(id) {
+ const custom = getSetting(`tpl_${id}`);
+ return custom?.trim() ? custom : templateDefault(id);
+}
+
+/** Eigene Fassung setzen. Leerer Text stellt den Standard wieder her. */
+export function setTemplate(id, text) {
+ if (!byId.has(id)) return false;
+ setSetting(`tpl_${id}`, String(text ?? '').trim());
+ return true;
+}
+
+/** Platzhalter ersetzen — Unbekanntes bleibt stehen */
+export function fillPlaceholders(text, vars = {}) {
+ return String(text).replace(/\{(\w+)\}/g, (match, key) =>
+ Object.hasOwn(vars, key) ? String(vars[key] ?? '') : match
+ );
+}
+
+/**
+ * Vorlage rendern.
+ * @param {string} id
+ * @param {Record} vars
+ */
+export function renderTemplate(id, vars = {}) {
+ return fillPlaceholders(getTemplate(id), vars);
+}
+
+/** Alle Vorlagen mit Zustand — fürs Webinterface */
+export function templateStates() {
+ return TEMPLATES.map((t) => {
+ const custom = getSetting(`tpl_${t.id}`);
+ return {
+ id: t.id,
+ name: t.name,
+ group: t.group,
+ module: t.module ?? null,
+ long: Boolean(t.long),
+ vars: t.vars ?? [],
+ default: t.default,
+ text: custom?.trim() ? custom : t.default,
+ customized: Boolean(custom?.trim()),
+ };
+ });
+}
diff --git a/src/web/api.js b/src/web/api.js
index dcb49bb..3110378 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -21,6 +21,7 @@ import {
} from '../db.js';
import crypto from 'node:crypto';
import { moduleStates, setModuleEnabled, MODULE_GROUPS } from '../modules.js';
+import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js';
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js';
@@ -184,6 +185,29 @@ export function registerApiRoutes(app, client) {
return { ok: true, modules: moduleStates() };
});
+ // --- Text-Vorlagen: was der Bot nach außen schreibt ---
+ // (nicht /api/templates — das sind die gespeicherten Composer-Nachrichten)
+
+ app.get('/api/texts', async (request, reply) => {
+ if (requireAnyScope(request, reply)) return;
+ return { templates: templateStates(), groups: TEMPLATE_GROUPS };
+ });
+
+ app.put('/api/texts/:id', async (request, reply) => {
+ if (requireScope(request, reply, 'settings')) return;
+ const id = String(request.params.id);
+ const text = String(request.body?.text ?? '');
+ if (text.length > 4000) {
+ return reply.code(400).send({ error: 'Text zu lang (max. 4000 Zeichen)' });
+ }
+ if (!setTemplate(id, text)) {
+ return reply.code(404).send({ error: 'Vorlage unbekannt' });
+ }
+ const reset = !text.trim();
+ logAudit(getSessionUser(request), reset ? 'vorlage zurückgesetzt' : 'vorlage geändert', id);
+ return { ok: true, templates: templateStates() };
+ });
+
// --- Frei angelegte Seiten (Regeln, Über uns, …) ---
// Adressen, die schon vom Frontend oder Server belegt sind