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:
@@ -5,6 +5,7 @@ import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId }
|
||||
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
||||
import { registerCommunityListeners } from './community.js';
|
||||
import { registerModTools } from './mod-tools.js';
|
||||
import { handleRoleMenuButton } from './role-menus.js';
|
||||
import {
|
||||
addPlaytester, removePlaytester, isWish, bumpWish,
|
||||
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
||||
@@ -123,6 +124,19 @@ export async function startBot() {
|
||||
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
|
||||
if (interaction.isButton() && interaction.customId === 'ticket_create') {
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -252,6 +252,38 @@ export function commitHeatmap() {
|
||||
.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)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
|
||||
+108
@@ -4,7 +4,9 @@ import {
|
||||
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
|
||||
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
||||
listGallery, listPlaytesters, topWishes, commitHeatmap,
|
||||
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
|
||||
} from '../db.js';
|
||||
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
|
||||
import { config } from '../config.js';
|
||||
import { removeDevlog } from '../bot/devlog-archive.js';
|
||||
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo } from '../runtime-settings.js';
|
||||
@@ -313,6 +315,112 @@ ${rssItems}
|
||||
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
|
||||
app.post('/api/compose', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
|
||||
@@ -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
|
||||
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
|
||||
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 {
|
||||
done(null, JSON.parse(body.toString('utf8')));
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user