From 19ec2802ac8e01d34e9856f9e65be60caff4b0e1 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Thu, 30 Jul 2026 01:11:16 +0200 Subject: [PATCH] =?UTF-8?q?Tickets:=20Transcript=20beim=20Schlie=C3=9Fen?= =?UTF-8?q?=20+=20Thread-L=C3=B6schung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schließen-Button erstellt jetzt ein Text-Transcript (chronologisch, inkl. Attachment-URLs, max 500 Nachrichten) und schickt es dem Ersteller per DM - Kopie ins Mod-Log (Aufbewahrung fürs Team, mit Hinweis falls DM blockiert) - Thread wird danach gelöscht statt nur archiviert (Fallback: sperren+archivieren, falls dem Bot Lösch-Rechte fehlen) Co-Authored-By: Claude Fable 5 --- README.md | 2 +- src/bot/client.js | 14 +++----- src/bot/tickets.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 src/bot/tickets.js diff --git a/README.md b/README.md index f6da1c5..e5c28d7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | ⭐ **Starboard** | Nachrichten mit genug ⭐-Reaktionen landen automatisch im Best-of-Kanal | | 📸 **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 | -| 🎫 **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 = **Transcript per DM** an den Ersteller + Kopie ins Mod-Log, Thread wird gelöscht | | 🎭 **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 | | 🛡️ **Moderation** | `/warn` (DM + Historie), `/warns`, `/timeout`, `/purge` — alles im Mod-Log; dazu Join/Leave- und Nick-/Rollen-Änderungs-Logging | | 🪪 **Auto- & Sticky-Roles** | Start-Rolle für neue Member; Rollen werden bei Leave gesichert und bei Rejoin wiederhergestellt | diff --git a/src/bot/client.js b/src/bot/client.js index 7fef245..8e3f1ec 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -9,11 +9,12 @@ 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 { handleRoleMenuButton } from './role-menus.js'; import { addPlaytester, removePlaytester, isWish, bumpWish, getGiveaway, toggleGiveawayEntry, giveawayEntries, - saveTicket, getTicket, closeTicket, openTicketOf, + saveTicket, openTicketOf, } from '../db.js'; import { ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import * as ping from './commands/ping.js'; @@ -242,15 +243,8 @@ export async function startBot() { } if (interaction.isButton() && interaction.customId === 'ticket_close') { try { - const ticket = getTicket(interaction.channelId); - if (!ticket) { - await interaction.reply({ content: '❌ Das ist kein Ticket-Thread.', flags: MessageFlags.Ephemeral }); - return; - } - closeTicket(interaction.channelId); - await interaction.reply(`🔒 Ticket geschlossen von **${interaction.member?.displayName ?? interaction.user.username}**.`); - await interaction.channel.setLocked(true).catch(() => {}); - await interaction.channel.setArchived(true).catch(() => {}); + // Transcript per DM + Mod-Log sichern, dann Thread löschen + await closeTicketWithTranscript(interaction); } catch (error) { console.error('[ticket] Schließen fehlgeschlagen:', error); } diff --git a/src/bot/tickets.js b/src/bot/tickets.js new file mode 100644 index 0000000..2ddf678 --- /dev/null +++ b/src/bot/tickets.js @@ -0,0 +1,86 @@ +// 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'; + +const MAX_MESSAGES = 500; + +/** Alle Thread-Nachrichten chronologisch als Text-Transcript */ +async function fetchTranscript(channel) { + const all = []; + let before; + while (all.length < MAX_MESSAGES) { + const batch = await channel.messages.fetch({ limit: 100, before }); + if (batch.size === 0) break; + all.push(...batch.values()); + before = batch.last().id; + } + all.sort((a, b) => a.createdTimestamp - b.createdTimestamp); + return all + .map((m) => { + const ts = new Date(m.createdTimestamp).toISOString().slice(0, 16).replace('T', ' '); + const parts = [ + m.content, + [...(m.attachments?.values?.() ?? [])].map((a) => a.url).join(' '), + m.embeds?.length ? '[Embed]' : '', + ].filter(Boolean).join(' '); + return `[${ts}] ${m.member?.displayName ?? m.author?.username ?? '?'}: ${parts}`; + }) + .join('\n'); +} + +/** 🔒-Button: Transcript verteilen, Thread löschen */ +export async function closeTicketWithTranscript(interaction) { + const ticket = getTicket(interaction.channelId); + if (!ticket) { + await interaction.reply({ content: '❌ Das ist kein Ticket-Thread.', flags: MessageFlags.Ephemeral }); + return; + } + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + closeTicket(interaction.channelId); + + const threadName = interaction.channel.name ?? `ticket-${ticket.thread_id}`; + const closedBy = interaction.member?.displayName ?? interaction.user.username; + const transcript = + `Ticket: ${threadName}\nGeschlossen von: ${closedBy}\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 }); + + // DM an den Ticket-Ersteller + let dmOk = true; + try { + const user = await interaction.client.users.fetch(ticket.user_id); + await user.send({ + content: `🔒 Dein Ticket **${threadName}** wurde geschlossen — hier dein Transcript. Danke für deine Anfrage!`, + files: [makeFile()], + }); + } catch { + dmOk = false; + } + + // Kopie ins Mod-Log (Aufbewahrung fürs Team) + const logChannelId = modlogChannelId(); + if (logChannelId) { + const log = await interaction.client.channels.fetch(logChannelId).catch(() => null); + await log?.send({ + embeds: [ + new EmbedBuilder() + .setColor(brandColor()) + .setTitle(`🔒 Ticket geschlossen: ${threadName}`) + .setDescription(`Ersteller: <@${ticket.user_id}> · geschlossen von **${closedBy}**${dmOk ? '' : '\n⚠️ Transcript-DM nicht zustellbar'}`) + .setFooter({ text: brandFooter('SUPPORT') }) + .setTimestamp(), + ], + files: [makeFile()], + }).catch(() => {}); + } + + await interaction.editReply(`🔒 Ticket geschlossen${dmOk ? ', Transcript per DM verschickt' : ' (DM blockiert — Transcript liegt im Mod-Log)'} — Thread wird gelöscht.`); + await interaction.channel.delete().catch(async () => { + // Löschen nicht erlaubt → wenigstens sperren + archivieren + await interaction.channel.setLocked(true).catch(() => {}); + await interaction.channel.setArchived(true).catch(() => {}); + }); +}