Tickets: Anliegen zur Auswahl, und das Team kann uebernehmen
Bisher sah jedes Ticket gleich aus: ein Knopf, ein Thread, und im Thread stand als Erstes die Frage, worum es ueberhaupt geht. Und wer sich darum kuemmert, stand nirgends — man sah hinterher nur, wer zufaellig auf "Schliessen" geklickt hat. Anliegen sind Kategorien, die man in der Config anlegt: Name, Emoji, eine Zeile Beschreibung, ein Einstiegstext und optional eine Rolle, die beim Oeffnen erfaehrt, dass etwas anliegt. Der Ticket-Post wird dadurch vom Knopf zum Auswahlmenue; das gewaehlte Anliegen steht im Thread-Namen und der Einstiegstext gleich im ersten Beitrag — gute Stelle fuer "was wir wissen muessen". Wer keine anlegt, merkt nichts: ohne Eintrag bleibt es beim einen Knopf. Wer nur eine Sorte Tickets hat, soll nichts einrichten muessen. Uebernehmen ist ein Knopf im Thread, sichtbar fuers Team (wer aufraeumen darf, darf auch uebernehmen). Danach steht im Thread, wer sich kuemmert, und der Knopf wird zu "Freigeben". Die Bedingung "nur wenn noch niemand dran ist" steckt im UPDATE und nicht davor — zwei gleichzeitige Klicks wuerden sonst beide gewinnen. Beim Schliessen landen Anliegen und Bearbeiter im Transcript und im Mod-Log-Eintrag. Die Erstellung ist dabei aus client.js nach tickets.js gewandert. Knopf und Menue gehen jetzt denselben Weg, und in client.js sind drei Importzeilen weggefallen, die nur noch dort standen. Geprueft: alsEmoji gegen 20 Eingaben (Unicode, Hautfarbe, ZWJ-Ketten, Server-Emoji, Murks) und das fertige Panel gegen discord.js selbst — ein ungueltiges Emoji oder ein zu langes Label laesst Discord sonst die ganze Nachricht fallen, und das faellt erst beim Senden auf. Im Browser: Liste, Bearbeiten-Formular und der leere Zustand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| 🚨 **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 |
|
| 📋 **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 |
|
| 📬 **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 |
|
| 👋 **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 |
|
| 🪪 **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 |
|
| 🐛 **`/bug`** | Eingabefenster (was ist passiert, was war erwartet, wie nachstellen) → Gitea-Issue inkl. Screenshot-Upload; Issue geschlossen → DM an den Reporter |
|
||||||
|
|||||||
@@ -344,6 +344,8 @@ export default function Settings({ me }) {
|
|||||||
const [serverStats, setServerStats] = useState(null);
|
const [serverStats, setServerStats] = useState(null);
|
||||||
const [watchdog, setWatchdog] = useState(null);
|
const [watchdog, setWatchdog] = useState(null);
|
||||||
const [automod, setAutomod] = 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 [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
|
||||||
const [owner, setOwner] = useState(null);
|
const [owner, setOwner] = useState(null);
|
||||||
const [auditAlle, setAuditAlle] = useState(false);
|
const [auditAlle, setAuditAlle] = useState(false);
|
||||||
@@ -473,6 +475,7 @@ export default function Settings({ me }) {
|
|||||||
apiGet('/api/serverstats').then(setServerStats).catch(() => {});
|
apiGet('/api/serverstats').then(setServerStats).catch(() => {});
|
||||||
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
||||||
apiGet('/api/automod').then(setAutomod).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/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
||||||
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
|
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
|
||||||
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).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 */
|
/** AutoMod-Regel an- oder abschalten */
|
||||||
async function automodSchalten(id, aktiv) {
|
async function automodSchalten(id, aktiv) {
|
||||||
try {
|
try {
|
||||||
@@ -1849,6 +1879,97 @@ export default function Settings({ me }) {
|
|||||||
|
|
||||||
const tabSupport = (
|
const tabSupport = (
|
||||||
<>
|
<>
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Ticket-Anliegen</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
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 <strong>/ticket-setup</strong> neu
|
||||||
|
ausführen — der alte Post behält sonst sein altes Menü.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{anliegen.length === 0 && (
|
||||||
|
<p className="notice">Noch keine Anliegen — es bleibt beim einfachen Knopf.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{anliegen.map((k) => (
|
||||||
|
<div className="am-row card" key={k.id}>
|
||||||
|
<div className="am-head">
|
||||||
|
<span className="tk-emoji">{k.emoji}</span>
|
||||||
|
<span className="st-name">{k.name}</span>
|
||||||
|
{k.ping_role_id && (
|
||||||
|
<span className="tm-chip">
|
||||||
|
pingt {roleItems.find((r) => r.id === k.ping_role_id)?.label ?? 'Rolle'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-mini"
|
||||||
|
style={{ marginLeft: 'auto' }}
|
||||||
|
onClick={() => setAnliegenDraft({
|
||||||
|
id: k.id, name: k.name, emoji: k.emoji,
|
||||||
|
beschreibung: k.beschreibung ?? '',
|
||||||
|
ping_role_id: k.ping_role_id ?? '',
|
||||||
|
intro: k.intro ?? '',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button className="card-delete" title="Anliegen entfernen" onClick={() => anliegenLoeschen(k.id, k.name)}>✕</button>
|
||||||
|
</div>
|
||||||
|
{(k.beschreibung || k.intro) && (
|
||||||
|
<div className="am-meta">
|
||||||
|
{k.beschreibung && <span>{k.beschreibung}</span>}
|
||||||
|
{k.intro && <span className="am-exempt">Einstiegstext hinterlegt</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<h3 className="tm-form-title" style={{ marginTop: '1.6rem' }}>
|
||||||
|
{anliegenDraft.id ? 'Anliegen bearbeiten' : 'Anliegen hinzufügen'}
|
||||||
|
</h3>
|
||||||
|
<div className="field">
|
||||||
|
<label>Name <span className="field-hint">steht im Menü und im Thread-Namen</span></label>
|
||||||
|
<input type="text" maxLength={80} placeholder="z. B. Fehler melden" value={anliegenDraft.name}
|
||||||
|
onChange={(e) => setAnliegenDraft({ ...anliegenDraft, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Emoji <span className="field-hint">ein Zeichen oder ein Server-Emoji wie <:name:123> — unpassendes wird weggelassen</span></label>
|
||||||
|
<input type="text" maxLength={32} placeholder="🐛" value={anliegenDraft.emoji}
|
||||||
|
onChange={(e) => setAnliegenDraft({ ...anliegenDraft, emoji: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Kurzbeschreibung <span className="field-hint">graue Zeile unter dem Namen im Menü — höchstens 100 Zeichen</span></label>
|
||||||
|
<input type="text" maxLength={100} placeholder="Etwas funktioniert nicht wie erwartet" value={anliegenDraft.beschreibung}
|
||||||
|
onChange={(e) => setAnliegenDraft({ ...anliegenDraft, beschreibung: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Rolle benachrichtigen <span className="field-hint">wird beim Öffnen im Thread erwähnt — leer = kein Ping</span></label>
|
||||||
|
<PickSelect
|
||||||
|
items={roleItems}
|
||||||
|
value={anliegenDraft.ping_role_id ?? ''}
|
||||||
|
onChange={(v) => setAnliegenDraft({ ...anliegenDraft, ping_role_id: v })}
|
||||||
|
empty="— kein Ping —"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Einstiegstext <span className="field-hint">steht im ersten Beitrag — gute Stelle für „was wir wissen müssen"</span></label>
|
||||||
|
<textarea rows={3} maxLength={1024} placeholder="Schreib uns bitte gleich dazu: was hast du gemacht, was ist passiert?"
|
||||||
|
value={anliegenDraft.intro}
|
||||||
|
onChange={(e) => setAnliegenDraft({ ...anliegenDraft, intro: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="btn btn-save" onClick={anliegenSpeichern}>
|
||||||
|
{anliegenDraft.id ? 'Änderungen speichern' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
{anliegenDraft.id && (
|
||||||
|
<button className="btn btn-ghost" onClick={() => setAnliegenDraft({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' })}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{automod && (
|
{automod && (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// AutoMod</h2>
|
<h2 className="settings-title">// AutoMod</h2>
|
||||||
|
|||||||
@@ -1884,6 +1884,10 @@ label.toggle {
|
|||||||
font-size: .62rem; letter-spacing: .06em; color: var(--muted2); white-space: nowrap;
|
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 ────────────────────────────────── */
|
/* ── AUTOMOD-REGELN ────────────────────────────────── */
|
||||||
.am-row { padding: .9rem 1.1rem; margin-bottom: .7rem; min-width: 0; }
|
.am-row { padding: .9rem 1.1rem; margin-bottom: .7rem; min-width: 0; }
|
||||||
.am-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
.am-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
|||||||
+29
-53
@@ -1,9 +1,8 @@
|
|||||||
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
|
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
|
||||||
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, discordGuildId } from '../runtime-settings.js';
|
||||||
import { renderTemplate } from '../templates.js';
|
import { renderTemplate } from '../templates.js';
|
||||||
import { tuning } from '../tuning.js';
|
|
||||||
import { moduleEnabled } from '../modules.js';
|
import { moduleEnabled } from '../modules.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';
|
||||||
@@ -16,15 +15,16 @@ import { registerLevels } from './levels.js';
|
|||||||
import { registerPresence } from './presence.js';
|
import { registerPresence } from './presence.js';
|
||||||
import { registerExtras } from './extras.js';
|
import { registerExtras } from './extras.js';
|
||||||
import { handleApplyButton, handleApplyModal, handleReviewButton } from './app-forms.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 { handleRoleMenuButton } from './role-menus.js';
|
||||||
import { handleGiveawayAdminButton } from './giveaways.js';
|
import { handleGiveawayAdminButton } from './giveaways.js';
|
||||||
import {
|
import {
|
||||||
addPlaytester, removePlaytester, isWish, bumpWish,
|
addPlaytester, removePlaytester, isWish, bumpWish,
|
||||||
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
||||||
saveTicket, openTicketOf,
|
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import { ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
|
|
||||||
import * as ping from './commands/ping.js';
|
import * as ping from './commands/ping.js';
|
||||||
import * as devlogBackfill from './commands/devlog-backfill.js';
|
import * as devlogBackfill from './commands/devlog-backfill.js';
|
||||||
import * as bug from './commands/bug.js';
|
import * as bug from './commands/bug.js';
|
||||||
@@ -240,53 +240,13 @@ export async function startBot() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎫-Button: Ticket erstellen (privater Thread) / schließen
|
// Ticket: erstellen (Knopf ohne Anliegen, Menue mit), uebernehmen, schliessen
|
||||||
if (interaction.isButton() && interaction.customId === 'ticket_create') {
|
const ticketErstellen = (interaction.isButton() && interaction.customId === 'ticket_create')
|
||||||
|
|| (interaction.isStringSelectMenu() && interaction.customId === 'ticket_pick');
|
||||||
|
if (ticketErstellen) {
|
||||||
try {
|
try {
|
||||||
const channelId = ticketChannelId();
|
const gewaehlt = interaction.isStringSelectMenu() ? Number(interaction.values[0]) : null;
|
||||||
if (!channelId) throw new Error('Kein Ticket-Kanal konfiguriert');
|
await createTicket(interaction, Number.isFinite(gewaehlt) ? gewaehlt : null);
|
||||||
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ticket] Erstellung fehlgeschlagen:', error);
|
console.error('[ticket] Erstellung fehlgeschlagen:', error);
|
||||||
await interaction
|
await interaction
|
||||||
@@ -295,12 +255,28 @@ export async function startBot() {
|
|||||||
}
|
}
|
||||||
return;
|
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') {
|
if (interaction.isButton() && interaction.customId === 'ticket_close') {
|
||||||
try {
|
try {
|
||||||
// Transcript per DM + Mod-Log sichern, dann Thread löschen
|
// Transcript per DM + Mod-Log sichern, dann Thread loeschen
|
||||||
await closeTicketWithTranscript(interaction);
|
await closeTicketWithTranscript(interaction);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ticket] Schließen fehlgeschlagen:', error);
|
console.error('[ticket] Schliessen fehlgeschlagen:', error);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// /ticket-setup — postet das Ticket-Embed mit 🎫-Button in den aktuellen Kanal (Admin)
|
// /ticket-setup — postet das Ticket-Embed mit 🎫-Button in den aktuellen Kanal (Admin)
|
||||||
import {
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
|
||||||
SlashCommandBuilder, PermissionFlagsBits, MessageFlags,
|
|
||||||
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
|
|
||||||
} from 'discord.js';
|
|
||||||
import { ticketChannelId, brandColor, brandFooter } from '../../runtime-settings.js';
|
import { ticketChannelId, brandColor, brandFooter } from '../../runtime-settings.js';
|
||||||
import { renderTemplate } from '../../templates.js';
|
import { renderTemplate } from '../../templates.js';
|
||||||
|
import { ticketPanelComponents } from '../tickets.js';
|
||||||
|
import { listTicketCategories } from '../../db.js';
|
||||||
|
|
||||||
export const data = new SlashCommandBuilder()
|
export const data = new SlashCommandBuilder()
|
||||||
.setName('ticket-setup')
|
.setName('ticket-setup')
|
||||||
@@ -26,14 +25,13 @@ export async function execute(interaction) {
|
|||||||
.setDescription(renderTemplate('ticket.panel_text'))
|
.setDescription(renderTemplate('ticket.panel_text'))
|
||||||
.setFooter({ text: brandFooter('SUPPORT') });
|
.setFooter({ text: brandFooter('SUPPORT') });
|
||||||
|
|
||||||
const row = new ActionRowBuilder().addComponents(
|
// Ohne Anliegen ein Knopf, mit Anliegen ein Auswahlmenue
|
||||||
new ButtonBuilder()
|
const anzahl = listTicketCategories().length;
|
||||||
.setCustomId('ticket_create')
|
await interaction.channel.send({ embeds: [embed], components: ticketPanelComponents() });
|
||||||
.setStyle(ButtonStyle.Primary)
|
await interaction.reply({
|
||||||
.setLabel('Ticket erstellen')
|
content: anzahl > 0
|
||||||
.setEmoji('🎫')
|
? `✅ 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,
|
||||||
await interaction.channel.send({ embeds: [embed], components: [row] });
|
});
|
||||||
await interaction.reply({ content: '✅ Ticket-Post erstellt.', flags: MessageFlags.Ephemeral });
|
|
||||||
}
|
}
|
||||||
|
|||||||
+218
-7
@@ -1,11 +1,205 @@
|
|||||||
// Ticket-Schließen: Transcript sichern (DM an den Ersteller + Kopie ins Mod-Log),
|
// Tickets: anlegen, übernehmen, schließen.
|
||||||
// danach wird der Thread gelöscht statt nur archiviert.
|
//
|
||||||
import { AttachmentBuilder, EmbedBuilder, MessageFlags } from 'discord.js';
|
// Beim Schließen wird ein Transcript gesichert (DM an den Ersteller + Kopie ins
|
||||||
import { getTicket, closeTicket } from '../db.js';
|
// Mod-Log), danach wird der Thread gelöscht statt nur archiviert.
|
||||||
import { modlogChannelId, brandColor, brandFooter } from '../runtime-settings.js';
|
//
|
||||||
|
// 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 { renderTemplate } from '../templates.js';
|
||||||
import { tuning } from '../tuning.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 */
|
/** Alle Thread-Nachrichten chronologisch als Text-Transcript */
|
||||||
async function fetchTranscript(channel) {
|
async function fetchTranscript(channel) {
|
||||||
@@ -44,8 +238,20 @@ export async function closeTicketWithTranscript(interaction) {
|
|||||||
|
|
||||||
const threadName = interaction.channel.name ?? `ticket-${ticket.thread_id}`;
|
const threadName = interaction.channel.name ?? `ticket-${ticket.thread_id}`;
|
||||||
const closedBy = interaction.member?.displayName ?? interaction.user.username;
|
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 =
|
const transcript =
|
||||||
`Ticket: ${threadName}\nGeschlossen von: ${closedBy}\n${'—'.repeat(40)}\n` +
|
`${kopf}\n${'—'.repeat(40)}\n` +
|
||||||
((await fetchTranscript(interaction.channel).catch(() => '')) || 'Keine Nachrichten.');
|
((await fetchTranscript(interaction.channel).catch(() => '')) || 'Keine Nachrichten.');
|
||||||
const fileName = `ticket-${threadName.replace(/[^\wäöüß-]+/g, '-').replace(/^-+|-+$/g, '') || ticket.thread_id}.txt`;
|
const fileName = `ticket-${threadName.replace(/[^\wäöüß-]+/g, '-').replace(/^-+|-+$/g, '') || ticket.thread_id}.txt`;
|
||||||
const makeFile = () => new AttachmentBuilder(Buffer.from(transcript, 'utf8'), { name: fileName });
|
const makeFile = () => new AttachmentBuilder(Buffer.from(transcript, 'utf8'), { name: fileName });
|
||||||
@@ -71,7 +277,12 @@ export async function closeTicketWithTranscript(interaction) {
|
|||||||
new EmbedBuilder()
|
new EmbedBuilder()
|
||||||
.setColor(brandColor())
|
.setColor(brandColor())
|
||||||
.setTitle(`🔒 Ticket geschlossen: ${threadName}`)
|
.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') })
|
.setFooter({ text: brandFooter('SUPPORT') })
|
||||||
.setTimestamp(),
|
.setTimestamp(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1012,16 +1012,70 @@ db.exec(`
|
|||||||
opened_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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 getTicketStmt = db.prepare('SELECT * FROM tickets WHERE thread_id = ?');
|
||||||
const closeTicketStmt = db.prepare(`UPDATE tickets SET status = 'closed' WHERE thread_id = ?`);
|
const closeTicketStmt = db.prepare(`UPDATE tickets SET status = 'closed' WHERE thread_id = ?`);
|
||||||
const openTicketByUserStmt = db.prepare(
|
const openTicketByUserStmt = db.prepare(
|
||||||
`SELECT * FROM tickets WHERE user_id = ? AND status = 'open'`
|
`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 getTicket = (threadId) => getTicketStmt.get(threadId) ?? null;
|
||||||
export const closeTicket = (threadId) => closeTicketStmt.run(threadId);
|
export const closeTicket = (threadId) => closeTicketStmt.run(threadId);
|
||||||
export const openTicketOf = (userId) => openTicketByUserStmt.get(userId) ?? null;
|
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
|
// Modmail: DM-Konversationen ↔ Staff-Threads
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||||
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
||||||
heartbeatBuckets, heartbeatFirst,
|
heartbeatBuckets, heartbeatFirst,
|
||||||
|
listTicketCategories, createTicketCategory, updateTicketCategory, deleteTicketCategory,
|
||||||
listIncidents, listMonitored, createMonitored, updateMonitored, deleteMonitored,
|
listIncidents, listMonitored, createMonitored, updateMonitored, deleteMonitored,
|
||||||
deleteUserData,
|
deleteUserData,
|
||||||
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
|
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
|
||||||
@@ -296,6 +297,60 @@ export function registerApiRoutes(app, client) {
|
|||||||
return { deleted: deleteMonitored(Number(request.params.id)), dienste: listMonitored() };
|
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
|
// AutoMod: Discords eigene Regeln lesen und schalten. Die Wortlisten
|
||||||
// bleiben in Discord — dort ist das Bearbeiten besser geloest.
|
// bleiben in Discord — dort ist das Bearbeiten besser geloest.
|
||||||
app.get('/api/automod', async (request, reply) => {
|
app.get('/api/automod', async (request, reply) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user