Rollen-Menüs (Carl-Bot-Ersatz) + Setup-Redesign mit Tab-Sidebar

- Rollen-Menüs: Builder im Webinterface (Titel, Beschreibung, Kanal, Emoji/Label/Rolle,
  Exklusiv-Modus = max. 1 Rolle pro Menü), Bot postet Button-Embeds; Klick = Rolle
  nehmen/abgeben (ephemeres Feedback), Update editiert den Discord-Post in place,
  Löschen räumt ihn mit auf
- Bewusst Buttons statt klassischer Emoji-Reaktionen: kein Reaction-Spam am Post,
  kein Custom-Emoji-Parsing, exklusive Menüs möglich; halbfertige Reaction-Variante
  aus abgebrochener Session entfernt und konsolidiert
- Setup-Seite: komplett neu strukturiert in 8 Tabs mit sticky Sidebar
  (Status/Feeds/Community/Rollen/Support/Composer/System/API), Feedback als Toast
- Modern-Polish: weiche Radien auf Karten/Inputs/Buttons, ruhigere Input-Flächen
  mit Fokus-Glow, Sektions-Hover; JSON-Parser akzeptiert leere Bodies
- API: /api/rolemenus CRUD + publish (Admin), README aktualisiert

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:15:29 +02:00
co-authored by Claude Fable 5
parent 4f23f821c5
commit a27a594a07
8 changed files with 982 additions and 499 deletions
+5 -2
View File
@@ -27,6 +27,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
| 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche `/galerie` (lokal gespeichert); Historie via `/galerie-backfill` | | 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche `/galerie` (lokal gespeichert); Historie via `/galerie-backfill` |
| 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück | | 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück |
| 🎫 **Tickets** | `/ticket-setup` postet den Button; Klick öffnet einen privaten Thread (ein offenes Ticket pro User, Schließen-Button) | | 🎫 **Tickets** | `/ticket-setup` postet den Button; Klick öffnet einen privaten Thread (ein offenes Ticket pro User, Schließen-Button) |
| 🎭 **Rollen-Menüs** | Carl-Bot-Ersatz: Menüs im Webinterface bauen (Emoji, Label, Rolle, optional exklusiv), Bot postet Button-Embeds; Klick = Rolle nehmen/abgeben, Posts jederzeit editierbar |
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) | | 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal | | 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
| 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence | | 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence |
@@ -51,8 +52,9 @@ Design: D4RKST3R-Brand (Neon-Gelb/Orange auf Schwarz, Bebas Neue + Barlow Conden
Share Tech Mono, selbst gehostet). Share Tech Mono, selbst gehostet).
### Setup-Seite (`/settings`) ### Setup-Seite (`/settings`)
Alles zur Laufzeit änderbar — gespeichert in SQLite, Env-Variablen sind nur Fallback, Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Feeds · Community · Rollen ·
kein Redeploy nötig: Support · Composer · System · API). Alles zur Laufzeit änderbar — gespeichert in
SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig:
- **Allgemein:** Öffentliche URL, Gitea-URL - **Allgemein:** Öffentliche URL, Gitea-URL
- **Kanäle** (Devlog / Commit / Release) als Dropdown + „Test senden"-Button je Kanal - **Kanäle** (Devlog / Commit / Release) als Dropdown + „Test senden"-Button je Kanal
@@ -237,6 +239,7 @@ urllib.request.urlopen(req)
| `GET /api/playtesters` | Admin | Playtester-Liste | | `GET /api/playtesters` | Admin | Playtester-Liste |
| `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben | | `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben |
| `GET/POST/DELETE /api/apikeys` | Admin | API-Key-Verwaltung | | `GET/POST/DELETE /api/apikeys` | Admin | API-Key-Verwaltung |
| `GET/POST/PUT/DELETE /api/rolemenus`, `POST /api/rolemenus/:id/publish` | Admin | Rollen-Menüs |
| `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite | | `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite |
| `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen | | `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen |
| `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 | | `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 |
File diff suppressed because it is too large Load Diff
+154
View File
@@ -544,6 +544,115 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
font-size: .68rem; color: var(--muted2); white-space: nowrap; font-size: .68rem; color: var(--muted2); white-space: nowrap;
} }
/* ── SETTINGS-LAYOUT (Sidebar + Content) ───────────── */
.content-wide { max-width: 1060px; }
.settings-layout {
display: flex;
gap: 2rem;
align-items: flex-start;
}
.settings-nav {
display: flex;
flex-direction: column;
gap: .25rem;
position: sticky;
top: 5rem;
min-width: 11rem;
flex-shrink: 0;
}
.settings-nav-item {
display: flex; align-items: center; gap: .7rem;
font-family: var(--mono);
font-size: .72rem; letter-spacing: .18em; text-transform: uppercase;
color: var(--muted);
background: none;
border: 1px solid transparent;
border-left: 2px solid transparent;
padding: .55rem .9rem;
text-align: left;
transition: color .15s, background .15s, border-color .15s;
}
.settings-nav-item:hover { color: var(--text); background: rgba(255, 255, 255, .03); }
.settings-nav-item.active {
color: var(--neon);
background: rgba(245, 197, 24, .06);
border-left-color: var(--neon);
}
.settings-nav-icon { font-size: .95rem; }
.settings-content { flex: 1; min-width: 0; animation: riseIn .3s ease both; }
.section-intro {
color: var(--muted);
font-weight: 300;
margin-bottom: 1.2rem;
max-width: 38rem;
}
/* Reaction-Roles-Editor */
.rr-row {
display: flex; gap: .5rem; align-items: center;
margin-bottom: .45rem;
}
.rr-row select { flex: 1; }
.rr-row input[type="text"]:not(.rr-emoji) { flex: 1; }
.rr-emoji { width: 5.5rem !important; flex: none !important; text-align: center; }
/* Kleine Buttons in Listen-Zeilen + Ghost-Variante */
.btn-mini {
font-family: var(--mono);
font-size: .6rem; letter-spacing: .15em; text-transform: uppercase;
color: var(--muted);
background: none;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 5px;
padding: .25rem .6rem;
white-space: nowrap;
transition: color .15s, border-color .15s;
}
.btn-mini:hover { color: var(--neon); border-color: var(--neon); }
.btn-ghost {
background: none;
color: var(--muted);
border-color: rgba(255, 255, 255, .12);
}
.btn-ghost:hover:not(:disabled) { color: var(--text); background: rgba(255, 255, 255, .04); box-shadow: none; }
/* Feedback-Toast unten rechts */
.feedback-float {
position: fixed;
right: 1.5rem; bottom: 1.5rem;
z-index: 500;
font-family: var(--mono);
font-size: .78rem; letter-spacing: .1em;
color: var(--success);
background: #0d0d0d;
border: 1px solid rgba(0, 255, 136, .3);
padding: .6rem 1.1rem;
animation: riseIn .25s ease both;
}
@media (max-width: 760px) {
.settings-layout { flex-direction: column; }
.settings-nav {
position: static;
flex-direction: row;
overflow-x: auto;
width: 100%;
min-width: 0;
gap: .35rem;
padding-bottom: .35rem;
}
.settings-nav-item {
border-left: none;
border-bottom: 2px solid transparent;
white-space: nowrap;
padding: .45rem .7rem;
}
.settings-nav-item.active { border-left: none; border-bottom-color: var(--neon); }
}
/* ── SETTINGS ──────────────────────────────────────── */ /* ── SETTINGS ──────────────────────────────────────── */
.settings-section { .settings-section {
background: var(--bg2); background: var(--bg2);
@@ -727,3 +836,48 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
.commit-row { flex-wrap: wrap; row-gap: .35rem; } .commit-row { flex-wrap: wrap; row-gap: .35rem; }
.commit-msg { flex-basis: 100%; order: -1; white-space: normal; } .commit-msg { flex-basis: 100%; order: -1; white-space: normal; }
} }
/* ── MODERN POLISH ─────────────────────────────────────
Weiche Radien + feinere Flächen, überschreibt gezielt
die eckigen Basis-Styles weiter oben. */
.card, .settings-section, .commit-list, .key-list, .key-created,
.wish-row, .release-card, .milestone {
border-radius: 12px;
}
.settings-section {
background: linear-gradient(180deg, rgba(255, 255, 255, .015), transparent 40%), var(--bg2);
border-color: rgba(255, 255, 255, .08);
transition: border-color .2s;
}
.settings-section:hover { border-color: rgba(245, 197, 24, .18); }
.settings-section + .settings-section,
.settings-content > .settings-section { margin-bottom: 1.25rem; }
.btn, .btn-login, .field select, .field input[type="text"], .field textarea,
.search, .user-chip, .feedback-float, .sha, .card-project, .card-delete,
.commits th:first-child, .commits th:last-child {
border-radius: 8px;
}
.chip, .key-scopes { border-radius: 99px; }
.settings-nav-item { border-radius: 9px; border-left-width: 2px; }
.key-list .key-row:first-child { border-radius: 12px 12px 0 0; }
.key-list .key-row:last-child { border-radius: 0 0 12px 12px; }
.commit-list .commit-row:first-child { border-radius: 12px 12px 0 0; }
.commit-list .commit-row:last-child { border-radius: 0 0 12px 12px; }
.card-images a, .shot, .progress, .hm-cell { border-radius: 6px; }
.card-images.n1 a { border-radius: 10px; }
/* Inputs: ruhigerer Grundzustand, klarer Fokus */
.field select, .field input[type="text"], .field textarea {
background: rgba(0, 0, 0, .35);
border-color: rgba(255, 255, 255, .09);
}
.field select:hover, .field input[type="text"]:hover, .field textarea:hover {
border-color: rgba(255, 255, 255, .2);
}
.field select:focus, .field input[type="text"]:focus, .field textarea:focus {
border-color: var(--neon);
box-shadow: 0 0 0 3px rgba(245, 197, 24, .12);
}
.toggle input { border-radius: 99px; }
.toggle input::after { border-radius: 50%; }
+14
View File
@@ -5,6 +5,7 @@ import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId }
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';
import { handleRoleMenuButton } from './role-menus.js';
import { import {
addPlaytester, removePlaytester, isWish, bumpWish, addPlaytester, removePlaytester, isWish, bumpWish,
getGiveaway, toggleGiveawayEntry, giveawayEntries, getGiveaway, toggleGiveawayEntry, giveawayEntries,
@@ -123,6 +124,19 @@ export async function startBot() {
return; return;
} }
// Rollen-Menü-Buttons: rolemenu:<menuId>:<roleId>
if (interaction.isButton() && interaction.customId.startsWith('rolemenu:')) {
try {
await handleRoleMenuButton(interaction);
} catch (error) {
console.error('[rollen] Toggle fehlgeschlagen:', error);
await interaction
.reply({ content: '❌ Rolle konnte nicht geändert werden — Bot-Rechte/Rollen-Reihenfolge prüfen.', flags: MessageFlags.Ephemeral })
.catch(() => {});
}
return;
}
// 🎫-Button: Ticket erstellen (privater Thread) / schließen // 🎫-Button: Ticket erstellen (privater Thread) / schließen
if (interaction.isButton() && interaction.customId === 'ticket_create') { if (interaction.isButton() && interaction.customId === 'ticket_create') {
try { try {
+115
View File
@@ -0,0 +1,115 @@
// Rollen-Menüs (Carl-Bot-Ersatz): Der Bot postet ein Embed mit Rollen-Buttons,
// Member weisen sich die Rollen selbst zu (Toggle, optional exklusiv = max. 1 Rolle
// pro Menü, z. B. für Farb- oder Plattform-Rollen). Verwaltet über die /rollen-Seite.
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags } from 'discord.js';
import { getRoleMenu, setRoleMenuMessage } from '../db.js';
const BRAND_YELLOW = 0xf5c518;
export const MAX_ENTRIES = 25; // Discord: 5 Button-Reihen à 5 Buttons
/** Embed + Button-Reihen aus einem Menü-Datensatz bauen */
function buildMenuMessage(menu) {
const entries = JSON.parse(menu.entries || '[]');
const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setTitle(menu.title)
.setDescription(
(menu.description?.trim() ? `${menu.description.trim()}\n\n` : '') +
(menu.exclusive
? '*Klick = Rolle nehmen — es ist immer nur eine Rolle aus diesem Menü aktiv.*'
: '*Klick = Rolle nehmen, nochmal klicken = wieder abgeben.*')
)
.setFooter({ text: 'D4RKST3R // ROLLEN' });
const rows = [];
for (let i = 0; i < entries.length; i += 5) {
rows.push(
new ActionRowBuilder().addComponents(
entries.slice(i, i + 5).map((e) => {
const button = new ButtonBuilder()
.setCustomId(`rolemenu:${menu.id}:${e.role_id}`)
.setStyle(ButtonStyle.Secondary)
.setLabel(e.label.slice(0, 80));
if (e.emoji) button.setEmoji(e.emoji);
return button;
})
)
);
}
return { embeds: [embed], components: rows };
}
/**
* Menü in seinen Kanal posten bzw. den bestehenden Post aktualisieren.
* @returns {Promise<string>} Message-ID des Posts
*/
export async function publishRoleMenu(client, menuId) {
const menu = getRoleMenu(menuId);
if (!menu) throw new Error('Menü nicht gefunden');
if (!menu.channel_id) throw new Error('Kein Kanal gewählt');
const entries = JSON.parse(menu.entries || '[]');
if (entries.length === 0) throw new Error('Menü hat keine Rollen');
const channel = await client.channels.fetch(menu.channel_id);
if (!channel?.isTextBased()) throw new Error('Kanal nicht gefunden');
const payload = buildMenuMessage(menu);
if (menu.message_id) {
const existing = await channel.messages.fetch(menu.message_id).catch(() => null);
if (existing) {
await existing.edit(payload);
return menu.message_id;
}
}
const message = await channel.send(payload);
setRoleMenuMessage(menu.id, message.id);
return message.id;
}
/** Discord-Post eines Menüs löschen (beim Menü-Löschen) */
export async function unpublishRoleMenu(client, menu) {
if (!menu.channel_id || !menu.message_id) return;
const channel = await client.channels.fetch(menu.channel_id).catch(() => null);
const message = await channel?.messages?.fetch(menu.message_id).catch(() => null);
await message?.delete().catch(() => {});
}
/** Button-Klick verarbeiten: rolemenu:<menuId>:<roleId> */
export async function handleRoleMenuButton(interaction) {
const [, menuId, roleId] = interaction.customId.split(':');
const menu = getRoleMenu(Number(menuId));
const entries = menu ? JSON.parse(menu.entries || '[]') : [];
const entry = entries.find((e) => e.role_id === roleId);
if (!menu || !entry) {
await interaction.reply({ content: '❌ Dieses Menü ist nicht mehr aktiv.', flags: MessageFlags.Ephemeral });
return;
}
const member = interaction.member;
const hasRole = member.roles.cache.has(roleId);
if (hasRole) {
await member.roles.remove(roleId);
await interaction.reply({
content: `🚪 Rolle **${entry.label}** abgegeben.`,
flags: MessageFlags.Ephemeral,
});
return;
}
// Exklusiv-Modus: andere Rollen dieses Menüs vorher entfernen
if (menu.exclusive) {
for (const other of entries) {
if (other.role_id !== roleId && member.roles.cache.has(other.role_id)) {
await member.roles.remove(other.role_id).catch(() => {});
}
}
}
await member.roles.add(roleId);
await interaction.reply({
content: `✅ Rolle **${entry.label}** genommen!`,
flags: MessageFlags.Ephemeral,
});
}
+32
View File
@@ -252,6 +252,38 @@ export function commitHeatmap() {
.all(); .all();
} }
// Rollen-Menüs (Carl-Bot-Ersatz): selbst zuweisbare Rollen per Button-Post
db.exec(`
CREATE TABLE IF NOT EXISTS role_menus (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
channel_id TEXT,
message_id TEXT,
exclusive INTEGER NOT NULL DEFAULT 0,
entries TEXT NOT NULL DEFAULT '[]'
);
`);
const insertRoleMenu = db.prepare(`
INSERT INTO role_menus (title, description, channel_id, exclusive, entries)
VALUES (@title, @description, @channel_id, @exclusive, @entries)
`);
const updateRoleMenuStmt = db.prepare(`
UPDATE role_menus SET title = @title, description = @description,
channel_id = @channel_id, exclusive = @exclusive, entries = @entries
WHERE id = @id
`);
const setRoleMenuMessageStmt = db.prepare('UPDATE role_menus SET message_id = ? WHERE id = ?');
const getRoleMenuStmt = db.prepare('SELECT * FROM role_menus WHERE id = ?');
const listRoleMenusStmt = db.prepare('SELECT * FROM role_menus ORDER BY id');
const deleteRoleMenuStmt = db.prepare('DELETE FROM role_menus WHERE id = ?');
export const createRoleMenu = (m) => insertRoleMenu.run(m).lastInsertRowid;
export const updateRoleMenu = (m) => updateRoleMenuStmt.run(m).changes > 0;
export const setRoleMenuMessage = (id, messageId) => setRoleMenuMessageStmt.run(messageId, id);
export const getRoleMenu = (id) => getRoleMenuStmt.get(id) ?? null;
export const listRoleMenus = () => listRoleMenusStmt.all();
export const deleteRoleMenu = (id) => deleteRoleMenuStmt.run(id).changes > 0;
// Ticket-System (Button → privater Thread) // Ticket-System (Button → privater Thread)
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
+108
View File
@@ -4,7 +4,9 @@ import {
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats, listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey, getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
listGallery, listPlaytesters, topWishes, commitHeatmap, listGallery, listPlaytesters, topWishes, commitHeatmap,
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
} from '../db.js'; } from '../db.js';
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.js'; import { removeDevlog } from '../bot/devlog-archive.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo } from '../runtime-settings.js'; import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo } from '../runtime-settings.js';
@@ -313,6 +315,112 @@ ${rssItems}
return { ok: true, settings: currentSettings() }; return { ok: true, settings: currentSettings() };
}); });
// --- Rollen-Menüs (Admin) — Carl-Bot-Ersatz ---
/** Menü-Body validieren + normalisieren; sendet bei Fehler selbst die Antwort */
function parseMenuBody(request, reply) {
const body = request.body ?? {};
const title = String(body.title ?? '').trim();
if (!title) {
reply.code(400).send({ error: 'title nötig' });
return null;
}
const entries = (Array.isArray(body.entries) ? body.entries : [])
.map((e) => ({
emoji: String(e.emoji ?? '').trim(),
label: String(e.label ?? '').trim(),
role_id: String(e.role_id ?? '').trim(),
}))
.filter((e) => e.label && e.role_id);
if (entries.length > MAX_ENTRIES) {
reply.code(400).send({ error: `maximal ${MAX_ENTRIES} Rollen pro Menü` });
return null;
}
// Rollen müssen existieren, keine Duplikate
const seen = new Set();
for (const e of entries) {
if (seen.has(e.role_id)) {
reply.code(400).send({ error: 'doppelte Rolle im Menü' });
return null;
}
seen.add(e.role_id);
const exists = [...client.guilds.cache.values()].some((g) => g.roles?.cache?.has?.(e.role_id));
if (!exists) {
reply.code(400).send({ error: `Rolle ${e.role_id} nicht gefunden` });
return null;
}
}
const channelId = String(body.channel_id ?? '').trim();
if (channelId && !client.channels.cache.get(channelId)?.isTextBased?.()) {
reply.code(400).send({ error: 'Kanal nicht gefunden' });
return null;
}
return {
title: title.slice(0, 200),
description: String(body.description ?? '').trim().slice(0, 2000),
channel_id: channelId || null,
exclusive: body.exclusive ? 1 : 0,
entries: JSON.stringify(entries),
};
}
const menuToJson = (m) => ({ ...m, exclusive: Boolean(m.exclusive), entries: JSON.parse(m.entries || '[]') });
app.get('/api/rolemenus', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { menus: listRoleMenus().map(menuToJson) };
});
app.post('/api/rolemenus', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const menu = parseMenuBody(request, reply);
if (!menu) return;
const id = createRoleMenu(menu);
request.log.info(`Rollen-Menü ${id} erstellt`);
return { ok: true, menu: menuToJson(getRoleMenu(id)) };
});
app.put('/api/rolemenus/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const id = Number(request.params.id);
if (!getRoleMenu(id)) return reply.code(404).send({ error: 'Menü nicht gefunden' });
const menu = parseMenuBody(request, reply);
if (!menu) return;
updateRoleMenu({ ...menu, id });
// Bereits gepostet? Dann den Discord-Post direkt mitziehen
let published = false;
if (getRoleMenu(id).message_id) {
try {
await publishRoleMenu(client, id);
published = true;
} catch (error) {
request.log.warn(`Menü ${id}: Discord-Update fehlgeschlagen: ${error.message}`);
}
}
return { ok: true, published, menu: menuToJson(getRoleMenu(id)) };
});
app.post('/api/rolemenus/:id/publish', async (request, reply) => {
if (requireAdmin(request, reply)) return;
try {
const messageId = await publishRoleMenu(client, Number(request.params.id));
request.log.info(`Rollen-Menü ${request.params.id} publiziert (${messageId})`);
return { ok: true, message_id: messageId };
} catch (error) {
return reply.code(400).send({ error: error.message });
}
});
app.delete('/api/rolemenus/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const menu = getRoleMenu(Number(request.params.id));
if (!menu) return { deleted: false };
await unpublishRoleMenu(client, menu);
deleteRoleMenu(menu.id);
request.log.info(`Rollen-Menü ${menu.id} gelöscht`);
return { deleted: true };
});
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten // Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
app.post('/api/compose', async (request, reply) => { app.post('/api/compose', async (request, reply) => {
if (requireAdmin(request, reply)) return; if (requireAdmin(request, reply)) return;
+2
View File
@@ -48,6 +48,8 @@ export async function startWebServer(client) {
// JSON als Buffer parsen, damit wir den Raw-Body für die Signatur-Prüfung behalten // JSON als Buffer parsen, damit wir den Raw-Body für die Signatur-Prüfung behalten
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => { app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
req.rawBody = body; req.rawBody = body;
// Leerer Body (z. B. DELETE mit gesetztem Content-Type) → leeres Objekt statt Parse-Fehler
if (body.length === 0) return done(null, {});
try { try {
done(null, JSON.parse(body.toString('utf8'))); done(null, JSON.parse(body.toString('utf8')));
} catch (error) { } catch (error) {