Tickets: Transcript beim Schließen + Thread-Löschung
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| ⭐ **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` |
|
| 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche `/galerie` (lokal gespeichert); Historie via `/galerie-backfill` |
|
||||||
| 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück |
|
| 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück |
|
||||||
| 🎫 **Tickets** | `/ticket-setup` postet den Button; Klick öffnet einen privaten Thread (ein offenes Ticket pro User, Schließen-Button) |
|
| 🎫 **Tickets** | `/ticket-setup` postet den Button; Klick öffnet einen privaten Thread (ein offenes Ticket pro User). Schließen = **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 |
|
| 🎭 **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 |
|
| 🛡️ **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 |
|
| 🪪 **Auto- & Sticky-Roles** | Start-Rolle für neue Member; Rollen werden bei Leave gesichert und bei Rejoin wiederhergestellt |
|
||||||
|
|||||||
+4
-10
@@ -9,11 +9,12 @@ 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 { handleRoleMenuButton } from './role-menus.js';
|
import { handleRoleMenuButton } from './role-menus.js';
|
||||||
import {
|
import {
|
||||||
addPlaytester, removePlaytester, isWish, bumpWish,
|
addPlaytester, removePlaytester, isWish, bumpWish,
|
||||||
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
||||||
saveTicket, getTicket, closeTicket, openTicketOf,
|
saveTicket, openTicketOf,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import { ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
|
import { ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
|
||||||
import * as ping from './commands/ping.js';
|
import * as ping from './commands/ping.js';
|
||||||
@@ -242,15 +243,8 @@ export async function startBot() {
|
|||||||
}
|
}
|
||||||
if (interaction.isButton() && interaction.customId === 'ticket_close') {
|
if (interaction.isButton() && interaction.customId === 'ticket_close') {
|
||||||
try {
|
try {
|
||||||
const ticket = getTicket(interaction.channelId);
|
// Transcript per DM + Mod-Log sichern, dann Thread löschen
|
||||||
if (!ticket) {
|
await closeTicketWithTranscript(interaction);
|
||||||
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(() => {});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ticket] Schließen fehlgeschlagen:', error);
|
console.error('[ticket] Schließen fehlgeschlagen:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user