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
+10
View File
@@ -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 einem bereits vorhandenen Schalter (Level-System, Backups …) nutzen weiterhin
dieselbe Einstellung — es gibt also keine zweite Wahrheit. 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`) ### Setup-Seite (`/settings`)
Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community · Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community ·
Rollen · Support · Bewerbungen · Composer · Server · System · API · Team) — Rollen · Support · Bewerbungen · Composer · Server · System · API · Team) —
+124 -1
View File
@@ -5,7 +5,7 @@ import {
IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport, IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport,
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam, IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX, IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
IconHome as IconLayers, IconWarning, IconCheck, IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh,
} from '../icons.jsx'; } from '../icons.jsx';
import { Markdown } from '../markdown.jsx'; import { Markdown } from '../markdown.jsx';
@@ -19,6 +19,7 @@ const API_SCOPES = [
const TABS = [ const TABS = [
{ id: 'status', Icon: IconStatus, label: 'Status' }, { id: 'status', Icon: IconStatus, label: 'Status' },
{ id: 'module', Icon: IconLayers, label: 'Module' }, { id: 'module', Icon: IconLayers, label: 'Module' },
{ id: 'texte', Icon: IconMessage, label: 'Texte' },
{ id: 'brand', Icon: IconBrand, label: 'Brand' }, { id: 'brand', Icon: IconBrand, label: 'Brand' },
{ id: 'feeds', Icon: IconFeeds, label: 'Feeds' }, { id: 'feeds', Icon: IconFeeds, label: 'Feeds' },
{ id: 'community', Icon: IconCommunity, label: 'Community' }, { id: 'community', Icon: IconCommunity, label: 'Community' },
@@ -37,6 +38,7 @@ const TABS = [
const TAB_SCOPES = { const TAB_SCOPES = {
status: 'any', status: 'any',
module: 'settings', module: 'settings',
texte: 'settings',
brand: null, brand: null,
feeds: 'settings', feeds: 'settings',
community: 'community', community: 'community',
@@ -175,6 +177,10 @@ export default function Settings({ me }) {
// Module (An/Aus für alle Funktionen) // Module (An/Aus für alle Funktionen)
const [modules, setModules] = useState([]); const [modules, setModules] = useState([]);
const [moduleGroups, setModuleGroups] = 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, …) // Eigene Seiten (Regeln, Über uns, …)
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true }; const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
const [pages, setPages] = useState([]); const [pages, setPages] = useState([]);
@@ -236,6 +242,10 @@ export default function Settings({ me }) {
setModules(d.modules); setModules(d.modules);
setModuleGroups(d.groups); setModuleGroups(d.groups);
}).catch(() => {}); }).catch(() => {});
apiGet('/api/texts').then((d) => {
setTemplates(d.templates);
setTemplateGroups(d.groups);
}).catch(() => {});
}, [me.admin, me.scopes?.length]); }, [me.admin, me.scopes?.length]);
useEffect(() => { 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) */ /** Titel → Adress-Kürzel (Umlaute mit auflösen) */
function slugify(value) { function slugify(value) {
return String(value).toLowerCase() return String(value).toLowerCase()
@@ -1756,6 +1794,90 @@ export default function Settings({ me }) {
</div> </div>
); );
const tabTexte = (
<div className="settings-section">
<h2 className="settings-title">// Texte</h2>
<p className="section-intro">
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.
</p>
{templateGroups.map((group) => {
const items = templates.filter((t) => t.group === group.id);
if (items.length === 0) return null;
return (
<div key={group.id} style={{ marginBottom: '1.8rem' }}>
<h3 className="settings-title" style={{ fontSize: '.66rem', marginBottom: '.8rem' }}>
{group.label}
</h3>
<div className="tpl-list">
{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 (
<div className={`card tpl-card${t.long ? ' long' : ''}${dirty ? ' dirty' : ''}`} key={t.id}>
<div className="tpl-head">
<span className="tpl-name">{t.name}</span>
{t.customized && <span className="tpl-badge">angepasst</span>}
{off && <span className="tpl-badge muted">Modul aus</span>}
</div>
<textarea
id={`tpl-${t.id}`}
className="tpl-input"
rows={t.long ? 4 : 2}
value={value}
spellCheck={false}
onChange={(e) => setTplDraft((old) => ({ ...old, [t.id]: e.target.value }))}
/>
{t.vars.length > 0 && (
<div className="tpl-vars">
{t.vars.map((v) => (
<button
key={v.key}
className="tpl-var"
title={v.desc}
onClick={() => insertPlaceholder(t, v.key)}
>
{`{${v.key}}`}
</button>
))}
</div>
)}
<div className="tpl-actions">
<button
className="btn"
disabled={!dirty}
onClick={() => saveTemplate(t.id, value)}
>
Speichern
</button>
{(t.customized || dirty) && (
<button
className="btn btn-ghost with-icon"
onClick={() => saveTemplate(t.id, '')}
title="Standardtext wiederherstellen"
>
<IconRefresh size={13} /> Standard
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
const tabSeiten = ( const tabSeiten = (
<div className="settings-section"> <div className="settings-section">
<h2 className="settings-title">// Eigene Seiten</h2> <h2 className="settings-title">// Eigene Seiten</h2>
@@ -1943,6 +2065,7 @@ export default function Settings({ me }) {
const content = { const content = {
status: tabStatus, status: tabStatus,
module: tabModule, module: tabModule,
texte: tabTexte,
brand: tabBrand, brand: tabBrand,
feeds: tabFeeds, feeds: tabFeeds,
community: tabCommunity, community: tabCommunity,
+45
View File
@@ -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.on .mod-knob { transform: translateX(18px); background: #0a0a0a; }
.mod-switch:hover { border-color: var(--neon); } .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; }
+11 -6
View File
@@ -9,6 +9,7 @@ import {
createApplication, getApplication, openApplicationOf, setApplicationStatus, createApplication, getApplication, openApplicationOf, setApplicationStatus,
} from '../db.js'; } from '../db.js';
import { brandColor, brandColor2, brandFooter } from '../runtime-settings.js'; import { brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js';
export const MAX_QUESTIONS = 5; // Discord-Modal-Limit export const MAX_QUESTIONS = 5; // Discord-Modal-Limit
@@ -143,7 +144,11 @@ export async function handleApplyModal(interaction) {
} }
await interaction.reply({ 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, flags: MessageFlags.Ephemeral,
}); });
} }
@@ -169,11 +174,11 @@ export async function handleReviewButton(interaction) {
} }
// DM an den Bewerber // DM an den Bewerber
const user = await interaction.client.users.fetch(application.user_id).catch(() => null); const user = await interaction.client.users.fetch(application.user_id).catch(() => null);
await user?.send( await user?.send(renderTemplate(approved ? 'application.approved' : 'application.denied', {
approved user: user.username,
? `✅ Deine Bewerbung **„${form?.title ?? ''}"** wurde angenommen — willkommen! 🎉` mention: `<@${application.user_id}>`,
: `❌ Deine Bewerbung **„${form?.title ?? ''}"** wurde leider abgelehnt.` form: form?.title ?? '',
).catch(() => {}); })).catch(() => {});
// Review-Embed abschließen // Review-Embed abschließen
const original = EmbedBuilder.from(interaction.message.embeds[0]) 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 { birthdayChannelId, birthdayRoleId, discordGuildId, brandColor2 } from '../runtime-settings.js';
import { brandEmbed } from '../embeds.js'; import { brandEmbed } from '../embeds.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js';
const CHECK_INTERVAL_MS = 15 * 60 * 1000; const CHECK_INTERVAL_MS = 15 * 60 * 1000;
@@ -49,16 +50,15 @@ export async function birthdayTick(client) {
if (!channel?.isTextBased()) return; if (!channel?.isTextBased()) return;
const mentions = kids.map((b) => `<@${b.user_id}>`).join(' '); const mentions = kids.map((b) => `<@${b.user_id}>`).join(' ');
const many = kids.length > 1;
const vars = { mentions, count: kids.length };
await channel.send({ await channel.send({
content: mentions, content: mentions,
embeds: [ embeds: [
brandEmbed(client, 'GEBURTSTAG') brandEmbed(client, 'GEBURTSTAG')
.setColor(brandColor2()) .setColor(brandColor2())
.setTitle(kids.length === 1 ? '🎂 Alles Gute zum Geburtstag!' : '🎂 Heute wird gleich mehrfach gefeiert!') .setTitle(renderTemplate(many ? 'birthday.title_multi' : 'birthday.title', vars))
.setDescription( .setDescription(renderTemplate(many ? 'birthday.text_multi' : 'birthday.text', vars)),
`${mentions} ${kids.length === 1 ? 'hat' : 'haben'} heute Geburtstag — ` +
'lasst mal ordentlich 🎉 da!'
),
], ],
allowedMentions: { users: kids.map((b) => b.user_id) }, allowedMentions: { users: kids.map((b) => b.user_id) },
}).catch(() => {}); }).catch(() => {});
+9 -7
View File
@@ -2,6 +2,7 @@
import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials, REST, Routes } from 'discord.js'; import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials, REST, Routes } from 'discord.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId, brandColor, brandFooter, discordGuildId } from '../runtime-settings.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 { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import { registerCommunityListeners } from './community.js'; import { registerCommunityListeners } from './community.js';
import { registerModTools } from './mod-tools.js'; import { registerModTools } from './mod-tools.js';
@@ -219,10 +220,10 @@ export async function startBot() {
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(brandColor()) .setColor(brandColor())
.setDescription( .setDescription(renderTemplate('ticket.opened', {
'Willkommen in deinem Ticket! Beschreib dein Anliegen — das Team meldet sich.\n' + user: interaction.member?.displayName ?? interaction.user.username,
'Wenn alles geklärt ist, kann das Ticket hier geschlossen werden.' mention: `<@${interaction.user.id}>`,
) }))
.setFooter({ text: brandFooter('SUPPORT') }), .setFooter({ text: brandFooter('SUPPORT') }),
], ],
components: [ components: [
@@ -296,9 +297,10 @@ export async function startBot() {
addPlaytester(interaction.user.id, interaction.user.username); addPlaytester(interaction.user.id, interaction.user.username);
} }
await interaction.reply({ await interaction.reply({
content: hasRole content: renderTemplate(hasRole ? 'playtester.left' : 'playtester.joined', {
? '🚪 Von der Playtester-Liste **abgemeldet**.' user: member.displayName ?? interaction.user.username,
: '🧪 **Willkommen im Playtest-Team!** Du stehst auf der Liste — sobald es losgeht, meldet sich der Bot per DM.', mention: `<@${interaction.user.id}>`,
}),
flags: MessageFlags.Ephemeral, flags: MessageFlags.Ephemeral,
}); });
} catch (error) { } catch (error) {
+8 -4
View File
@@ -4,6 +4,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js';
import { bugReportRepo, giteaApiToken } from '../../runtime-settings.js'; import { bugReportRepo, giteaApiToken } from '../../runtime-settings.js';
import { createIssue, uploadIssueAsset } from '../../gitea-api.js'; import { createIssue, uploadIssueAsset } from '../../gitea-api.js';
import { saveBugReport } from '../../db.js'; import { saveBugReport } from '../../db.js';
import { renderTemplate } from '../../templates.js';
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
.setName('bug') .setName('bug')
@@ -64,10 +65,13 @@ export async function execute(interaction) {
title: titel, title: titel,
}); });
await interaction.editReply( await interaction.editReply(renderTemplate('bug.submitted', {
`✅ Danke! Dein Bug ist erfasst als [#${issue.number}](${issue.html_url}) — ` + user: interaction.member?.displayName ?? interaction.user.username,
`du bekommst eine DM, sobald er behoben ist.` mention: `<@${interaction.user.id}>`,
); number: issue.number,
url: issue.html_url,
title: titel,
}));
} catch (error) { } catch (error) {
console.error('[bug] Issue-Erstellung fehlgeschlagen:', error); console.error('[bug] Issue-Erstellung fehlgeschlagen:', error);
await interaction.editReply('❌ Konnte das Issue nicht anlegen — bitte später nochmal versuchen.'); 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, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js'; } from 'discord.js';
import { playtesterRoleId, brandColor, brandFooter } from '../../runtime-settings.js'; import { playtesterRoleId, brandColor, brandFooter } from '../../runtime-settings.js';
import { renderTemplate } from '../../templates.js';
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
.setName('playtester-setup') .setName('playtester-setup')
@@ -21,14 +22,8 @@ export async function execute(interaction) {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(brandColor()) .setColor(brandColor())
.setTitle('🧪 Playtester werden') .setTitle(renderTemplate('playtester.panel_title'))
.setDescription( .setDescription(renderTemplate('playtester.panel_text'))
'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.'
)
.setFooter({ text: brandFooter('PLAYTEST') }); .setFooter({ text: brandFooter('PLAYTEST') });
const row = new ActionRowBuilder().addComponents( const row = new ActionRowBuilder().addComponents(
+3 -6
View File
@@ -4,6 +4,7 @@ import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js'; } from 'discord.js';
import { ticketChannelId, brandColor, brandFooter } from '../../runtime-settings.js'; import { ticketChannelId, brandColor, brandFooter } from '../../runtime-settings.js';
import { renderTemplate } from '../../templates.js';
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
.setName('ticket-setup') .setName('ticket-setup')
@@ -21,12 +22,8 @@ export async function execute(interaction) {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(brandColor()) .setColor(brandColor())
.setTitle('🎫 Support-Ticket') .setTitle(renderTemplate('ticket.panel_title'))
.setDescription( .setDescription(renderTemplate('ticket.panel_text'))
'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.'
)
.setFooter({ text: brandFooter('SUPPORT') }); .setFooter({ text: brandFooter('SUPPORT') });
const row = new ActionRowBuilder().addComponents( const row = new ActionRowBuilder().addComponents(
+6 -1
View File
@@ -2,6 +2,7 @@
import { SlashCommandBuilder, MessageFlags, EmbedBuilder } from 'discord.js'; import { SlashCommandBuilder, MessageFlags, EmbedBuilder } from 'discord.js';
import { votingChannelId, publicUrl, brandColor, brandFooter } from '../../runtime-settings.js'; import { votingChannelId, publicUrl, brandColor, brandFooter } from '../../runtime-settings.js';
import { saveWish } from '../../db.js'; import { saveWish } from '../../db.js';
import { renderTemplate } from '../../templates.js';
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
.setName('wunsch') .setName('wunsch')
@@ -39,5 +40,9 @@ export async function execute(interaction) {
await message.react('👍'); await message.react('👍');
saveWish(message.id, interaction.user.username, idea); 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, tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter,
} from '../runtime-settings.js'; } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js';
/* ── Triggers ──────────────────────────────────────── */ /* ── Triggers ──────────────────────────────────────── */
@@ -50,15 +51,13 @@ async function handleVoiceState(oldState, newState) {
await newState.member.voice.setChannel(channel).catch(() => {}); await newState.member.voice.setChannel(channel).catch(() => {});
// Control-Panel in den Kanal-Textchat (nur der Besitzer darf bedienen) // Control-Panel in den Kanal-Textchat (nur der Besitzer darf bedienen)
const tvVars = { user: newState.member.displayName, mention: `<@${newState.member.id}>` };
await channel.send({ await channel.send({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(brandColor()) .setColor(brandColor())
.setTitle('🔊 Dein Kanal') .setTitle(renderTemplate('tempvoice.title', tvVars))
.setDescription( .setDescription(renderTemplate('tempvoice.text', tvVars))
`<@${newState.member.id}>, das hier ist dein temporärer Voice-Kanal — ` +
'er verschwindet automatisch, wenn alle raus sind. Steuern kannst du ihn hier:'
)
.setFooter({ text: brandFooter('VOICE') }), .setFooter({ text: brandFooter('VOICE') }),
], ],
components: [ components: [
@@ -102,7 +101,11 @@ export async function remindersTick(client) {
for (const r of dueReminders()) { for (const r of dueReminders()) {
markReminderSent(r.id); markReminderSent(r.id);
const user = await client.users.fetch(r.user_id).catch(() => null); 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 { dueGiveaways, markGiveawayEnded, giveawayEntries, getGiveaway } from '../db.js';
import { brandFooter } from '../runtime-settings.js'; import { brandFooter } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js';
const CHECK_INTERVAL_MS = 60 * 1000; const CHECK_INTERVAL_MS = 60 * 1000;
@@ -41,7 +42,7 @@ export async function giveawayTick(client) {
.setDescription( .setDescription(
winners.length winners.length
? `🏆 Gewonnen: ${mentions}\n👥 ${entries.length} Teilnahmen` ? `🏆 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 }) }), .setFooter({ text: brandFooter('GIVEAWAY'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
], ],
@@ -51,7 +52,9 @@ export async function giveawayTick(client) {
if (winners.length) { if (winners.length) {
await channel.send({ 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 }, allowedMentions: { users: winners },
components: [giveawayAdminRow(g.message_id)], components: [giveawayAdminRow(g.message_id)],
}); });
@@ -104,7 +107,7 @@ export async function handleGiveawayAdminButton(interaction) {
} }
const [winner] = drawWinners(entries, 1); const [winner] = drawWinners(entries, 1);
await interaction.reply({ 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] }, allowedMentions: { users: [winner] },
}); });
} }
+8 -1
View File
@@ -3,6 +3,7 @@
import { Events } from 'discord.js'; import { Events } from 'discord.js';
import { getLevelRow, setLevelRow } from '../db.js'; import { getLevelRow, setLevelRow } from '../db.js';
import { levelsEnabled, levelsAnnounce, levelRewards } from '../runtime-settings.js'; import { levelsEnabled, levelsAnnounce, levelRewards } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js';
const XP_COOLDOWN_MS = 60_000; const XP_COOLDOWN_MS = 60_000;
const XP_MIN = 15; const XP_MIN = 15;
@@ -62,9 +63,15 @@ export function registerLevels(client) {
if (message.member) await grantRewards(message.member, result.level); if (message.member) await grantRewards(message.member, result.level);
if (levelsAnnounce()) { if (levelsAnnounce()) {
const name = message.member?.displayName ?? message.author.username;
await message.channel await message.channel
.send({ .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: [] }, allowedMentions: { parse: [] },
}) })
.catch(() => {}); .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 { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js';
import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js'; import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { renderTemplate } from '../templates.js';
/* ── Modmail ───────────────────────────────────────── */ /* ── Modmail ───────────────────────────────────────── */
@@ -33,10 +34,10 @@ async function ensureModmailThread(client, user) {
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(brandColor()) .setColor(brandColor())
.setDescription( .setDescription(renderTemplate('modmail.thread_intro', {
`Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` + user: user.username,
'Antworten in diesem Thread gehen als DM an den User.' mention: `<@${user.id}>`,
) }))
.setFooter({ text: brandFooter('MODMAIL'), iconURL: client.user?.displayAvatarURL?.({ size: 64 }) }), .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); const channel = await member.client.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased()) return; 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() const embed = new EmbedBuilder()
.setColor(brandColor()) .setColor(brandColor())
.setTitle(`👋 Willkommen, ${member.displayName}!`) .setTitle(renderTemplate('welcome.title', vars))
.setDescription( .setDescription(renderTemplate('welcome.text', vars))
`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.'
)
.setFooter({ text: brandFooter('COMMUNITY'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) }) .setFooter({ text: brandFooter('COMMUNITY'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp(); .setTimestamp();
+2 -1
View File
@@ -3,6 +3,7 @@
import { AttachmentBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; import { AttachmentBuilder, EmbedBuilder, MessageFlags } from 'discord.js';
import { getTicket, closeTicket } from '../db.js'; import { getTicket, closeTicket } from '../db.js';
import { modlogChannelId, brandColor, brandFooter } from '../runtime-settings.js'; import { modlogChannelId, brandColor, brandFooter } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js';
const MAX_MESSAGES = 500; const MAX_MESSAGES = 500;
@@ -53,7 +54,7 @@ export async function closeTicketWithTranscript(interaction) {
try { try {
const user = await interaction.client.users.fetch(ticket.user_id); const user = await interaction.client.users.fetch(ticket.user_id);
await user.send({ 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()], files: [makeFile()],
}); });
} catch { } catch {
+273
View File
@@ -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_<id>` 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<string, unknown>} 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()),
};
});
}
+24
View File
@@ -21,6 +21,7 @@ import {
} from '../db.js'; } from '../db.js';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { moduleStates, setModuleEnabled, MODULE_GROUPS } from '../modules.js'; 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 { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js'; import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js'; import { sanitizeEmbed } from './api-v1.js';
@@ -184,6 +185,29 @@ export function registerApiRoutes(app, client) {
return { ok: true, modules: moduleStates() }; 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, …) --- // --- Frei angelegte Seiten (Regeln, Über uns, …) ---
// Adressen, die schon vom Frontend oder Server belegt sind // Adressen, die schon vom Frontend oder Server belegt sind