diff --git a/README.md b/README.md
index 98fe7d9..99c72e2 100644
--- a/README.md
+++ b/README.md
@@ -91,7 +91,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen.
| 🚨 **Raid-Schutz** | Erkennt Beitritts-Wellen und sperrt den Server vorübergehend (Prüfstufe hoch, Einladungen aus). `/raid an\|aus\|status` auch von Hand |
| 📋 **Protokoll** | Gelöschte und bearbeitete Nachrichten, Joins/Leaves, Namens- und Rollen-Änderungen in einem privaten Kanal |
| 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück |
-| 🎫 **Tickets** | Button öffnet einen privaten Thread (ein offenes Ticket pro User). Schließen = **Transcript per DM** an den Ersteller + Kopie ins Mod-Log |
+| 🎫 **Tickets** | Button öffnet einen privaten Thread (ein offenes Ticket pro User). **Anliegen** als Auswahlmenü — je eigener Name, Einstiegstext und Rolle, die benachrichtigt wird. Das Team kann ein Ticket **übernehmen**, damit klar ist, wer sich kümmert. Schließen = **Transcript per DM** an den Ersteller + Kopie ins Mod-Log, inklusive Anliegen und Bearbeiter |
| 👋 **Willkommens-Karten** | Begrüßung als gerendertes Bild (Avatar mit Neon-Ring, Member-Nummer, Brand-Look), konfigurierbar; Fallback aufs Text-Embed |
| 🪪 **Auto- & Sticky-Roles** | Start-Rolle für neue Member; Rollen werden bei Leave gesichert und bei Rejoin wiederhergestellt |
| 🐛 **`/bug`** | Eingabefenster (was ist passiert, was war erwartet, wie nachstellen) → Gitea-Issue inkl. Screenshot-Upload; Issue geschlossen → DM an den Reporter |
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 992660a..55a5726 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -344,6 +344,8 @@ export default function Settings({ me }) {
const [serverStats, setServerStats] = useState(null);
const [watchdog, setWatchdog] = useState(null);
const [automod, setAutomod] = useState(null);
+ const [anliegen, setAnliegen] = useState([]);
+ const [anliegenDraft, setAnliegenDraft] = useState({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
const [owner, setOwner] = useState(null);
const [auditAlle, setAuditAlle] = useState(false);
@@ -473,6 +475,7 @@ export default function Settings({ me }) {
apiGet('/api/serverstats').then(setServerStats).catch(() => {});
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
apiGet('/api/automod').then(setAutomod).catch(() => {});
+ apiGet('/api/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
@@ -1835,6 +1838,33 @@ export default function Settings({ me }) {
>
);
+ async function anliegenSpeichern() {
+ const body = { ...anliegenDraft };
+ try {
+ const res = body.id
+ ? await apiPut(`/api/ticket-categories/${body.id}`, body)
+ : await apiPost('/api/ticket-categories', body);
+ setAnliegen(res.kategorien ?? []);
+ setAnliegenDraft({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
+ setFeedback(body.id ? 'Anliegen gespeichert.' : 'Anliegen angelegt.');
+ } catch (e) {
+ setFeedback(e.body?.error ?? 'Speichern fehlgeschlagen.');
+ }
+ setTimeout(() => setFeedback(null), 3000);
+ }
+
+ async function anliegenLoeschen(id, name) {
+ if (!window.confirm(`„${name}" wirklich entfernen? Offene Tickets bleiben bestehen.`)) return;
+ try {
+ const res = await apiDelete(`/api/ticket-categories/${id}`);
+ setAnliegen(res.kategorien ?? []);
+ if (anliegenDraft.id === id) setAnliegenDraft({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
+ } catch {
+ setFeedback('Entfernen fehlgeschlagen.');
+ setTimeout(() => setFeedback(null), 3000);
+ }
+ }
+
/** AutoMod-Regel an- oder abschalten */
async function automodSchalten(id, aktiv) {
try {
@@ -1849,6 +1879,97 @@ export default function Settings({ me }) {
const tabSupport = (
<>
+
+
// Ticket-Anliegen
+
+ Ohne Anliegen hat der Ticket-Post einen Knopf und jedes Ticket sieht gleich aus.
+ Mit Anliegen wird daraus ein Auswahlmenü: der Titel steht im Thread-Namen,
+ der Einstiegstext gleich im ersten Beitrag, und die hinterlegte Rolle erfährt
+ sofort, dass etwas anliegt. Nach dem Ändern /ticket-setup neu
+ ausführen — der alte Post behält sonst sein altes Menü.
+
+
+ {anliegen.length === 0 && (
+
Noch keine Anliegen — es bleibt beim einfachen Knopf.
+ )}
+
+ {anliegen.map((k) => (
+
+
+ {k.emoji}
+ {k.name}
+ {k.ping_role_id && (
+
+ pingt {roleItems.find((r) => r.id === k.ping_role_id)?.label ?? 'Rolle'}
+
+ )}
+ setAnliegenDraft({
+ id: k.id, name: k.name, emoji: k.emoji,
+ beschreibung: k.beschreibung ?? '',
+ ping_role_id: k.ping_role_id ?? '',
+ intro: k.intro ?? '',
+ })}
+ >
+ Bearbeiten
+
+ anliegenLoeschen(k.id, k.name)}>✕
+
+ {(k.beschreibung || k.intro) && (
+
+ {k.beschreibung && {k.beschreibung} }
+ {k.intro && Einstiegstext hinterlegt }
+
+ )}
+
+ ))}
+
+
+ {anliegenDraft.id ? 'Anliegen bearbeiten' : 'Anliegen hinzufügen'}
+
+
+ Name steht im Menü und im Thread-Namen
+ setAnliegenDraft({ ...anliegenDraft, name: e.target.value })} />
+
+
+ Emoji ein Zeichen oder ein Server-Emoji wie <:name:123> — unpassendes wird weggelassen
+ setAnliegenDraft({ ...anliegenDraft, emoji: e.target.value })} />
+
+
+ Kurzbeschreibung graue Zeile unter dem Namen im Menü — höchstens 100 Zeichen
+ setAnliegenDraft({ ...anliegenDraft, beschreibung: e.target.value })} />
+
+
+
Rolle benachrichtigen wird beim Öffnen im Thread erwähnt — leer = kein Ping
+
setAnliegenDraft({ ...anliegenDraft, ping_role_id: v })}
+ empty="— kein Ping —"
+ />
+
+
+ Einstiegstext steht im ersten Beitrag — gute Stelle für „was wir wissen müssen"
+
+
+
+ {anliegenDraft.id ? 'Änderungen speichern' : 'Hinzufügen'}
+
+ {anliegenDraft.id && (
+ setAnliegenDraft({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' })}>
+ Abbrechen
+
+ )}
+
+
{automod && (
// AutoMod
diff --git a/frontend/src/style.css b/frontend/src/style.css
index a313a1a..1e7b8ef 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1884,6 +1884,10 @@ label.toggle {
font-size: .62rem; letter-spacing: .06em; color: var(--muted2); white-space: nowrap;
}
+/* Emoji eines Ticket-Anliegens — gleiche Breite wie der Statuspunkt daneben,
+ damit die Namen untereinander auf einer Linie stehen */
+.tk-emoji { font-size: 1rem; line-height: 1; width: 1.2rem; text-align: center; flex: 0 0 auto; }
+
/* ── AUTOMOD-REGELN ────────────────────────────────── */
.am-row { padding: .9rem 1.1rem; margin-bottom: .7rem; min-width: 0; }
.am-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
diff --git a/src/bot/client.js b/src/bot/client.js
index 69aa65d..a006441 100644
--- a/src/bot/client.js
+++ b/src/bot/client.js
@@ -1,9 +1,8 @@
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
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 { devlogChannelId, devlogPingRoleId, playtesterRoleId, discordGuildId } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js';
-import { tuning } from '../tuning.js';
import { moduleEnabled } from '../modules.js';
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import { registerCommunityListeners } from './community.js';
@@ -16,15 +15,16 @@ import { registerLevels } from './levels.js';
import { registerPresence } from './presence.js';
import { registerExtras } from './extras.js';
import { handleApplyButton, handleApplyModal, handleReviewButton } from './app-forms.js';
-import { closeTicketWithTranscript } from './tickets.js';
+import {
+ closeTicketWithTranscript, createTicket,
+ claimTicketButton, unclaimTicketButton,
+} from './tickets.js';
import { handleRoleMenuButton } from './role-menus.js';
import { handleGiveawayAdminButton } from './giveaways.js';
import {
addPlaytester, removePlaytester, isWish, bumpWish,
getGiveaway, toggleGiveawayEntry, giveawayEntries,
- saveTicket, openTicketOf,
} from '../db.js';
-import { ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
import * as ping from './commands/ping.js';
import * as devlogBackfill from './commands/devlog-backfill.js';
import * as bug from './commands/bug.js';
@@ -240,53 +240,13 @@ export async function startBot() {
return;
}
- // 🎫-Button: Ticket erstellen (privater Thread) / schließen
- if (interaction.isButton() && interaction.customId === 'ticket_create') {
+ // Ticket: erstellen (Knopf ohne Anliegen, Menue mit), uebernehmen, schliessen
+ const ticketErstellen = (interaction.isButton() && interaction.customId === 'ticket_create')
+ || (interaction.isStringSelectMenu() && interaction.customId === 'ticket_pick');
+ if (ticketErstellen) {
try {
- const channelId = ticketChannelId();
- if (!channelId) throw new Error('Kein Ticket-Kanal konfiguriert');
-
- // Pro User nur ein offenes Ticket
- const existing = openTicketOf(interaction.user.id);
- if (existing) {
- await interaction.reply({
- content: `❕ Du hast schon ein offenes Ticket: <#${existing.thread_id}>`,
- flags: MessageFlags.Ephemeral,
- });
- return;
- }
-
- const channel = await interaction.client.channels.fetch(channelId);
- const thread = await channel.threads.create({
- name: `🎫 ${interaction.user.username}`,
- type: ChannelType.PrivateThread,
- invitable: false,
- autoArchiveDuration: tuning('thread_archive_days') * 1440,
- });
- await thread.members.add(interaction.user.id);
- saveTicket(thread.id, interaction.user.id);
-
- await thread.send({
- content: `<@${interaction.user.id}>`,
- embeds: [
- new EmbedBuilder()
- .setColor(brandColor())
- .setDescription(renderTemplate('ticket.opened', {
- user: interaction.member?.displayName ?? interaction.user.username,
- mention: `<@${interaction.user.id}>`,
- }))
- .setFooter({ text: brandFooter('SUPPORT') }),
- ],
- components: [
- new ActionRowBuilder().addComponents(
- new ButtonBuilder().setCustomId('ticket_close').setStyle(ButtonStyle.Danger).setLabel('Ticket schließen').setEmoji('🔒')
- ),
- ],
- });
- await interaction.reply({
- content: `✅ Dein Ticket: <#${thread.id}>`,
- flags: MessageFlags.Ephemeral,
- });
+ const gewaehlt = interaction.isStringSelectMenu() ? Number(interaction.values[0]) : null;
+ await createTicket(interaction, Number.isFinite(gewaehlt) ? gewaehlt : null);
} catch (error) {
console.error('[ticket] Erstellung fehlgeschlagen:', error);
await interaction
@@ -295,12 +255,28 @@ export async function startBot() {
}
return;
}
+ if (interaction.isButton() && interaction.customId === 'ticket_claim') {
+ try {
+ await claimTicketButton(interaction);
+ } catch (error) {
+ console.error('[ticket] Uebernehmen fehlgeschlagen:', error);
+ }
+ return;
+ }
+ if (interaction.isButton() && interaction.customId === 'ticket_unclaim') {
+ try {
+ await unclaimTicketButton(interaction);
+ } catch (error) {
+ console.error('[ticket] Freigeben fehlgeschlagen:', error);
+ }
+ return;
+ }
if (interaction.isButton() && interaction.customId === 'ticket_close') {
try {
- // Transcript per DM + Mod-Log sichern, dann Thread löschen
+ // Transcript per DM + Mod-Log sichern, dann Thread loeschen
await closeTicketWithTranscript(interaction);
} catch (error) {
- console.error('[ticket] Schließen fehlgeschlagen:', error);
+ console.error('[ticket] Schliessen fehlgeschlagen:', error);
}
return;
}
diff --git a/src/bot/commands/ticket-setup.js b/src/bot/commands/ticket-setup.js
index 894c0e6..8e044b3 100644
--- a/src/bot/commands/ticket-setup.js
+++ b/src/bot/commands/ticket-setup.js
@@ -1,10 +1,9 @@
// /ticket-setup — postet das Ticket-Embed mit 🎫-Button in den aktuellen Kanal (Admin)
-import {
- SlashCommandBuilder, PermissionFlagsBits, MessageFlags,
- ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
-} from 'discord.js';
+import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { ticketChannelId, brandColor, brandFooter } from '../../runtime-settings.js';
import { renderTemplate } from '../../templates.js';
+import { ticketPanelComponents } from '../tickets.js';
+import { listTicketCategories } from '../../db.js';
export const data = new SlashCommandBuilder()
.setName('ticket-setup')
@@ -26,14 +25,13 @@ export async function execute(interaction) {
.setDescription(renderTemplate('ticket.panel_text'))
.setFooter({ text: brandFooter('SUPPORT') });
- const row = new ActionRowBuilder().addComponents(
- new ButtonBuilder()
- .setCustomId('ticket_create')
- .setStyle(ButtonStyle.Primary)
- .setLabel('Ticket erstellen')
- .setEmoji('🎫')
- );
-
- await interaction.channel.send({ embeds: [embed], components: [row] });
- await interaction.reply({ content: '✅ Ticket-Post erstellt.', flags: MessageFlags.Ephemeral });
+ // Ohne Anliegen ein Knopf, mit Anliegen ein Auswahlmenue
+ const anzahl = listTicketCategories().length;
+ await interaction.channel.send({ embeds: [embed], components: ticketPanelComponents() });
+ await interaction.reply({
+ content: anzahl > 0
+ ? `✅ Ticket-Post erstellt — mit Auswahl aus ${anzahl} Anliegen.`
+ : '✅ Ticket-Post erstellt. Anliegen zum Auswählen legst du in der Config unter Support an.',
+ flags: MessageFlags.Ephemeral,
+ });
}
diff --git a/src/bot/tickets.js b/src/bot/tickets.js
index fc452f9..8aaa4b4 100644
--- a/src/bot/tickets.js
+++ b/src/bot/tickets.js
@@ -1,11 +1,205 @@
-// Ticket-Schließen: Transcript sichern (DM an den Ersteller + Kopie ins Mod-Log),
-// danach wird der Thread gelöscht statt nur archiviert.
-import { AttachmentBuilder, EmbedBuilder, MessageFlags } from 'discord.js';
-import { getTicket, closeTicket } from '../db.js';
-import { modlogChannelId, brandColor, brandFooter } from '../runtime-settings.js';
+// Tickets: anlegen, übernehmen, schließen.
+//
+// Beim Schließen wird ein Transcript gesichert (DM an den Ersteller + Kopie ins
+// Mod-Log), danach wird der Thread gelöscht statt nur archiviert.
+//
+// Anliegen (Kategorien) sind optional: gibt es keine, bleibt es beim einen
+// Knopf wie bisher. Wer welche anlegt, bekommt stattdessen ein Auswahlmenü —
+// jedes Anliegen mit eigenem Namen, eigenem Einstiegstext und optional einer
+// Rolle, die beim Öffnen erfährt, dass etwas anliegt.
+import {
+ ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, ChannelType,
+ EmbedBuilder, MessageFlags, PermissionFlagsBits, StringSelectMenuBuilder,
+} from 'discord.js';
+import {
+ getTicket, closeTicket, saveTicket, openTicketOf,
+ claimTicket, unclaimTicket, listTicketCategories, getTicketCategory,
+} from '../db.js';
+import { modlogChannelId, ticketChannelId, brandColor, brandFooter } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js';
import { tuning } from '../tuning.js';
+const segmenter = new Intl.Segmenter('de', { granularity: 'grapheme' });
+
+/**
+ * Emoji für Discord-Komponenten. Ein ungültiger Wert lässt Discord die ganze
+ * Nachricht ablehnen — im Zweifel lieber kein Emoji als kein Panel.
+ */
+export function alsEmoji(text) {
+ const s = String(text ?? '').trim();
+ if (!s) return null;
+ const custom = s.match(/^<(a?):(\w{2,32}):(\d{17,20})>$/);
+ if (custom) return { id: custom[3], name: custom[2], animated: custom[1] === 'a' };
+ // Genau ein Emoji, nicht die ganze Eingabe: Discord will hier ein einzelnes
+ // Zeichen. Der Segmenter hält zusammen, was zusammengehört — Hautfarbe,
+ // Variantenwähler, ZWJ-Ketten wie 🙋♀️ — und schneidet nie mitten durch ein
+ // Surrogatpaar, was eine kaputte Zeichenkette ergäbe.
+ const [erstes] = segmenter.segment(s);
+ const zeichen = erstes?.segment ?? '';
+ return /\p{Extended_Pictographic}/u.test(zeichen) ? { name: zeichen } : null;
+}
+
+/** Knopf oder Auswahlmenü fürs Panel — je nachdem, ob es Anliegen gibt */
+export function ticketPanelComponents() {
+ const kategorien = listTicketCategories();
+ if (kategorien.length === 0) {
+ return [new ActionRowBuilder().addComponents(
+ new ButtonBuilder()
+ .setCustomId('ticket_create')
+ .setStyle(ButtonStyle.Primary)
+ .setLabel('Ticket erstellen')
+ .setEmoji('🎫')
+ )];
+ }
+
+ const menu = new StringSelectMenuBuilder()
+ .setCustomId('ticket_pick')
+ .setPlaceholder('Worum geht es?')
+ // Discord erlaubt 25 Einträge — mehr wären auch keine Auswahl mehr
+ .addOptions(kategorien.slice(0, 25).map((k) => {
+ const option = { label: k.name.slice(0, 100), value: String(k.id) };
+ if (k.beschreibung) option.description = k.beschreibung.slice(0, 100);
+ const emoji = alsEmoji(k.emoji);
+ if (emoji) option.emoji = emoji;
+ return option;
+ }));
+ return [new ActionRowBuilder().addComponents(menu)];
+}
+
+/** Knopfleiste im Ticket-Thread — der mittlere Knopf hängt am Bearbeiter */
+export function ticketThreadComponents(claimedBy) {
+ const uebernehmen = claimedBy
+ ? new ButtonBuilder().setCustomId('ticket_unclaim').setStyle(ButtonStyle.Secondary)
+ .setLabel('Freigeben').setEmoji('↩️')
+ : new ButtonBuilder().setCustomId('ticket_claim').setStyle(ButtonStyle.Success)
+ .setLabel('Übernehmen').setEmoji('🙋');
+ return [new ActionRowBuilder().addComponents(
+ uebernehmen,
+ new ButtonBuilder().setCustomId('ticket_close').setStyle(ButtonStyle.Danger)
+ .setLabel('Ticket schließen').setEmoji('🔒')
+ )];
+}
+
+/** Ticket anlegen — aus dem Knopf (ohne Anliegen) oder aus dem Menü (mit) */
+export async function createTicket(interaction, categoryId = null) {
+ const channelId = ticketChannelId();
+ if (!channelId) throw new Error('Kein Ticket-Kanal konfiguriert');
+
+ // Pro User nur ein offenes Ticket
+ const existing = openTicketOf(interaction.user.id);
+ if (existing) {
+ await interaction.reply({
+ content: `❕ Du hast schon ein offenes Ticket: <#${existing.thread_id}>`,
+ flags: MessageFlags.Ephemeral,
+ });
+ return;
+ }
+
+ const kategorie = categoryId ? getTicketCategory(categoryId) : null;
+ const channel = await interaction.client.channels.fetch(channelId);
+ const name = kategorie
+ ? `${kategorie.emoji} ${kategorie.name} · ${interaction.user.username}`
+ : `🎫 ${interaction.user.username}`;
+ const thread = await channel.threads.create({
+ name: name.slice(0, 100),
+ type: ChannelType.PrivateThread,
+ invitable: false,
+ autoArchiveDuration: tuning('thread_archive_days') * 1440,
+ });
+ await thread.members.add(interaction.user.id);
+ saveTicket(thread.id, interaction.user.id, kategorie?.id ?? null);
+
+ const embed = new EmbedBuilder()
+ .setColor(brandColor())
+ .setDescription(renderTemplate('ticket.opened', {
+ user: interaction.member?.displayName ?? interaction.user.username,
+ mention: `<@${interaction.user.id}>`,
+ }))
+ .setFooter({ text: brandFooter('SUPPORT') });
+ if (kategorie) {
+ embed.setTitle(`${kategorie.emoji} ${kategorie.name}`);
+ if (kategorie.intro) embed.addFields({ name: 'Dazu vorab', value: kategorie.intro.slice(0, 1024) });
+ }
+
+ await thread.send({
+ content: `<@${interaction.user.id}>`,
+ embeds: [embed],
+ components: ticketThreadComponents(null),
+ });
+
+ // Rolle Bescheid geben — als eigene Nachricht, damit die Erwähnung auch
+ // wirklich eine Benachrichtigung auslöst und nicht im Embed versandet
+ if (kategorie?.ping_role_id) {
+ await thread.send({
+ content: `<@&${kategorie.ping_role_id}> — neues Ticket.`,
+ allowedMentions: { roles: [kategorie.ping_role_id] },
+ }).catch(() => {});
+ }
+
+ await interaction.reply({
+ content: `✅ Dein Ticket: <#${thread.id}>`,
+ flags: MessageFlags.Ephemeral,
+ });
+}
+
+/** Nur wer im Server aufräumen darf, darf auch Tickets übernehmen */
+const istTeam = (interaction) =>
+ Boolean(interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages));
+
+/** 🙋 Übernehmen */
+export async function claimTicketButton(interaction) {
+ const ticket = getTicket(interaction.channelId);
+ if (!ticket) {
+ await interaction.reply({ content: '❌ Das ist kein Ticket-Thread.', flags: MessageFlags.Ephemeral });
+ return;
+ }
+ if (!istTeam(interaction)) {
+ await interaction.reply({ content: '❌ Das kann nur das Team.', flags: MessageFlags.Ephemeral });
+ return;
+ }
+ if (!claimTicket(interaction.channelId, interaction.user.id)) {
+ const jetzt = getTicket(interaction.channelId);
+ await interaction.reply({
+ content: `❕ <@${jetzt?.claimed_by}> kümmert sich schon darum.`,
+ flags: MessageFlags.Ephemeral,
+ allowedMentions: { parse: [] },
+ });
+ return;
+ }
+
+ await interaction.update({ components: ticketThreadComponents(interaction.user.id) }).catch(() => {});
+ await interaction.channel.send({
+ content: `🙋 **${interaction.member?.displayName ?? interaction.user.username}** kümmert sich um dieses Ticket.`,
+ allowedMentions: { parse: [] },
+ }).catch(() => {});
+}
+
+/** ↩️ Freigeben — der Bearbeiter selbst oder jemand mit mehr Rechten */
+export async function unclaimTicketButton(interaction) {
+ const ticket = getTicket(interaction.channelId);
+ if (!ticket) {
+ await interaction.reply({ content: '❌ Das ist kein Ticket-Thread.', flags: MessageFlags.Ephemeral });
+ return;
+ }
+ const darf = ticket.claimed_by === interaction.user.id
+ || interaction.memberPermissions?.has(PermissionFlagsBits.ManageThreads);
+ if (!darf) {
+ await interaction.reply({
+ content: `❌ Nur <@${ticket.claimed_by}> kann das Ticket wieder freigeben.`,
+ flags: MessageFlags.Ephemeral,
+ allowedMentions: { parse: [] },
+ });
+ return;
+ }
+
+ unclaimTicket(interaction.channelId);
+ await interaction.update({ components: ticketThreadComponents(null) }).catch(() => {});
+ await interaction.channel.send({
+ content: '↩️ Das Ticket ist wieder frei.',
+ allowedMentions: { parse: [] },
+ }).catch(() => {});
+}
+
/** Alle Thread-Nachrichten chronologisch als Text-Transcript */
async function fetchTranscript(channel) {
@@ -44,8 +238,20 @@ export async function closeTicketWithTranscript(interaction) {
const threadName = interaction.channel.name ?? `ticket-${ticket.thread_id}`;
const closedBy = interaction.member?.displayName ?? interaction.user.username;
+ const kategorie = ticket.category_id ? getTicketCategory(ticket.category_id) : null;
+ // Wer sich gekümmert hat, gehört ins Protokoll — sonst steht später nur da,
+ // wer zufällig auf „Schließen" geklickt hat
+ const bearbeiter = ticket.claimed_by
+ ? await interaction.client.users.fetch(ticket.claimed_by).then((u) => u.username).catch(() => ticket.claimed_by)
+ : null;
+ const kopf = [
+ `Ticket: ${threadName}`,
+ kategorie ? `Anliegen: ${kategorie.name}` : null,
+ bearbeiter ? `Bearbeitet von: ${bearbeiter}` : null,
+ `Geschlossen von: ${closedBy}`,
+ ].filter(Boolean).join('\n');
const transcript =
- `Ticket: ${threadName}\nGeschlossen von: ${closedBy}\n${'—'.repeat(40)}\n` +
+ `${kopf}\n${'—'.repeat(40)}\n` +
((await fetchTranscript(interaction.channel).catch(() => '')) || 'Keine Nachrichten.');
const fileName = `ticket-${threadName.replace(/[^\wäöüß-]+/g, '-').replace(/^-+|-+$/g, '') || ticket.thread_id}.txt`;
const makeFile = () => new AttachmentBuilder(Buffer.from(transcript, 'utf8'), { name: fileName });
@@ -71,7 +277,12 @@ export async function closeTicketWithTranscript(interaction) {
new EmbedBuilder()
.setColor(brandColor())
.setTitle(`🔒 Ticket geschlossen: ${threadName}`)
- .setDescription(`Ersteller: <@${ticket.user_id}> · geschlossen von **${closedBy}**${dmOk ? '' : '\n⚠️ Transcript-DM nicht zustellbar'}`)
+ .setDescription([
+ `Ersteller: <@${ticket.user_id}> · geschlossen von **${closedBy}**`,
+ kategorie ? `Anliegen: **${kategorie.name}**` : null,
+ ticket.claimed_by ? `Bearbeitet von: <@${ticket.claimed_by}>` : '_Niemand hatte es übernommen._',
+ dmOk ? null : '⚠️ Transcript-DM nicht zustellbar',
+ ].filter(Boolean).join('\n'))
.setFooter({ text: brandFooter('SUPPORT') })
.setTimestamp(),
],
diff --git a/src/db.js b/src/db.js
index cd6e0f4..878cc20 100644
--- a/src/db.js
+++ b/src/db.js
@@ -1012,16 +1012,70 @@ db.exec(`
opened_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
-const insertTicket = db.prepare('INSERT INTO tickets (thread_id, user_id) VALUES (?, ?)');
+// Migration: Anliegen (Kategorie) und wer sich darum kümmert
+{
+ const cols = db.prepare('PRAGMA table_info(tickets)').all().map((c) => c.name);
+ if (!cols.includes('category_id')) db.exec('ALTER TABLE tickets ADD COLUMN category_id INTEGER');
+ if (!cols.includes('claimed_by')) db.exec('ALTER TABLE tickets ADD COLUMN claimed_by TEXT');
+ if (!cols.includes('claimed_at')) db.exec('ALTER TABLE tickets ADD COLUMN claimed_at TEXT');
+}
+const insertTicket = db.prepare(
+ 'INSERT INTO tickets (thread_id, user_id, category_id) VALUES (?, ?, ?)'
+);
const getTicketStmt = db.prepare('SELECT * FROM tickets WHERE thread_id = ?');
const closeTicketStmt = db.prepare(`UPDATE tickets SET status = 'closed' WHERE thread_id = ?`);
const openTicketByUserStmt = db.prepare(
`SELECT * FROM tickets WHERE user_id = ? AND status = 'open'`
);
-export const saveTicket = (threadId, userId) => insertTicket.run(threadId, userId);
+// Übernehmen nur, solange niemand anders dran ist — die Bedingung gehört in die
+// Abfrage und nicht davor: zwei gleichzeitige Klicks würden sonst beide gewinnen.
+const claimTicketStmt = db.prepare(`
+ UPDATE tickets SET claimed_by = ?, claimed_at = datetime('now')
+ WHERE thread_id = ? AND claimed_by IS NULL
+`);
+const unclaimTicketStmt = db.prepare(
+ 'UPDATE tickets SET claimed_by = NULL, claimed_at = NULL WHERE thread_id = ?'
+);
+export const saveTicket = (threadId, userId, categoryId = null) =>
+ insertTicket.run(threadId, userId, categoryId ?? null);
export const getTicket = (threadId) => getTicketStmt.get(threadId) ?? null;
export const closeTicket = (threadId) => closeTicketStmt.run(threadId);
export const openTicketOf = (userId) => openTicketByUserStmt.get(userId) ?? null;
+/** @returns {boolean} true, wenn dieser Aufruf das Ticket übernommen hat */
+export const claimTicket = (threadId, userId) => claimTicketStmt.run(userId, threadId).changes > 0;
+export const unclaimTicket = (threadId) => unclaimTicketStmt.run(threadId);
+
+// Anliegen: „Bug melden", „Bewerbung", „Sonstiges" … Ohne Eintrag bleibt es beim
+// einen Knopf — wer nur eine Sorte Tickets hat, soll nichts einrichten müssen.
+db.exec(`
+ CREATE TABLE IF NOT EXISTS ticket_categories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ emoji TEXT NOT NULL DEFAULT '🎫',
+ beschreibung TEXT NOT NULL DEFAULT '',
+ ping_role_id TEXT,
+ intro TEXT NOT NULL DEFAULT '',
+ sort INTEGER NOT NULL DEFAULT 0
+ );
+`);
+const listTicketCatsStmt = db.prepare('SELECT * FROM ticket_categories ORDER BY sort, id');
+const getTicketCatStmt = db.prepare('SELECT * FROM ticket_categories WHERE id = ?');
+const insertTicketCatStmt = db.prepare(`
+ INSERT INTO ticket_categories (name, emoji, beschreibung, ping_role_id, intro, sort)
+ VALUES (@name, @emoji, @beschreibung, @ping_role_id, @intro, @sort)
+`);
+const updateTicketCatStmt = db.prepare(`
+ UPDATE ticket_categories SET
+ name = @name, emoji = @emoji, beschreibung = @beschreibung,
+ ping_role_id = @ping_role_id, intro = @intro, sort = @sort
+ WHERE id = @id
+`);
+const deleteTicketCatStmt = db.prepare('DELETE FROM ticket_categories WHERE id = ?');
+export const listTicketCategories = () => listTicketCatsStmt.all();
+export const getTicketCategory = (id) => getTicketCatStmt.get(id) ?? null;
+export const createTicketCategory = (c) => insertTicketCatStmt.run(c).lastInsertRowid;
+export const updateTicketCategory = (c) => updateTicketCatStmt.run(c);
+export const deleteTicketCategory = (id) => deleteTicketCatStmt.run(id).changes > 0;
// Modmail: DM-Konversationen ↔ Staff-Threads
db.exec(`
diff --git a/src/web/api.js b/src/web/api.js
index c53a797..ef80aec 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -17,6 +17,7 @@ import {
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
heartbeatBuckets, heartbeatFirst,
+ listTicketCategories, createTicketCategory, updateTicketCategory, deleteTicketCategory,
listIncidents, listMonitored, createMonitored, updateMonitored, deleteMonitored,
deleteUserData,
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
@@ -296,6 +297,60 @@ export function registerApiRoutes(app, client) {
return { deleted: deleteMonitored(Number(request.params.id)), dienste: listMonitored() };
});
+ // Ticket-Anliegen. Ohne Eintrag bleibt es beim einen Knopf — deshalb ist
+ // eine leere Liste kein Fehler, sondern der Normalfall fuer kleine Server.
+ app.get('/api/ticket-categories', async (request, reply) => {
+ if (requireAnyScope(request, reply)) return;
+ return { kategorien: listTicketCategories() };
+ });
+
+ /** Eingaben pruefen — gibt null zurueck und hat dann schon geantwortet */
+ function anliegenBody(request, reply) {
+ const b = request.body ?? {};
+ const name = String(b.name ?? '').trim();
+ if (!name) {
+ reply.code(400).send({ error: 'Name fehlt.' });
+ return null;
+ }
+ return {
+ name: name.slice(0, 80),
+ // Discord lehnt die ganze Nachricht ab, wenn ein Emoji nicht passt —
+ // geprueft wird beim Bauen des Menues, hier nur gekuerzt
+ emoji: String(b.emoji ?? '').trim().slice(0, 32) || '🎫',
+ beschreibung: String(b.beschreibung ?? '').trim().slice(0, 100),
+ ping_role_id: String(b.ping_role_id ?? '').trim() || null,
+ intro: String(b.intro ?? '').trim().slice(0, 1024),
+ sort: Number(b.sort) || 0,
+ };
+ }
+
+ app.post('/api/ticket-categories', async (request, reply) => {
+ if (requireScope(request, reply, 'settings')) return;
+ const k = anliegenBody(request, reply);
+ if (!k) return;
+ createTicketCategory(k);
+ logAudit(getSessionUser(request), 'ticket-anliegen angelegt', k.name);
+ return { ok: true, kategorien: listTicketCategories() };
+ });
+
+ app.put('/api/ticket-categories/:id', async (request, reply) => {
+ if (requireScope(request, reply, 'settings')) return;
+ const k = anliegenBody(request, reply);
+ if (!k) return;
+ updateTicketCategory({ ...k, id: Number(request.params.id) });
+ logAudit(getSessionUser(request), 'ticket-anliegen geaendert', k.name);
+ return { ok: true, kategorien: listTicketCategories() };
+ });
+
+ app.delete('/api/ticket-categories/:id', async (request, reply) => {
+ if (requireScope(request, reply, 'settings')) return;
+ logAudit(getSessionUser(request), 'ticket-anliegen entfernt', String(request.params.id));
+ return {
+ deleted: deleteTicketCategory(Number(request.params.id)),
+ kategorien: listTicketCategories(),
+ };
+ });
+
// AutoMod: Discords eigene Regeln lesen und schalten. Die Wortlisten
// bleiben in Discord — dort ist das Bearbeiten besser geloest.
app.get('/api/automod', async (request, reply) => {