// 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 { 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'; import { registerModTools } from './mod-tools.js'; import { registerAntiRaid } from './anti-raid.js'; import { registerMetadata, startLinkedRoles } from './linked-roles.js'; 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 { 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'; import * as playtesterSetup from './commands/playtester-setup.js'; import * as galerieBackfill from './commands/galerie-backfill.js'; import * as wunsch from './commands/wunsch.js'; import * as giveaway from './commands/giveaway.js'; import * as ticketSetup from './commands/ticket-setup.js'; import * as warn from './commands/warn.js'; import * as warns from './commands/warns.js'; import * as timeout from './commands/timeout.js'; import * as purge from './commands/purge.js'; import * as rank from './commands/rank.js'; import * as tag from './commands/tag.js'; import * as remind from './commands/remind.js'; import * as geburtstag from './commands/geburtstag.js'; import * as raid from './commands/raid.js'; import * as umfrage from './commands/umfrage.js'; // Alle Commands hier eintragen — jedes Modul exportiert { data, execute } const commandModules = [ ping, devlogBackfill, bug, playtesterSetup, galerieBackfill, wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag, raid, umfrage, ]; export async function startBot() { const client = new Client({ intents: [ GatewayIntentBits.Guilds, // Für das Devlog-Archiv: Nachrichten im Devlog-Kanal mitlesen // (erfordert aktivierten "Message Content Intent" im Developer Portal!) GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, // Für das Starboard (⭐-Reaktionen) GatewayIntentBits.GuildMessageReactions, // Für Modmail (DMs) — Willkommens-Embeds brauchen zusätzlich den // privilegierten "Server Members Intent" im Developer Portal! GatewayIntentBits.DirectMessages, GatewayIntentBits.GuildMembers, // Temp-Voice (Join to Create) + Event-Ankündigungen GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildScheduledEvents, ], // Partials: Delete/Reaction-Events für ungecachte Nachrichten + DM-Kanäle partials: [Partials.Message, Partials.Reaction, Partials.Channel], }); // Commands in Collection ablegen für schnellen Zugriff im Interaction-Handler client.commands = new Collection(); for (const command of commandModules) { client.commands.set(command.data.name, command); } client.once(Events.ClientReady, async (readyClient) => { console.log(`[bot] Eingeloggt als ${readyClient.user.tag}`); // Nicht mitreißen lassen: seit Node 15 beendet eine unbehandelte // Rejection den Prozess. Ein Rate-Limit oder Netz-Schluckauf beim // Registrieren würde den Bot sonst in eine Neustart-Schleife schicken, // obwohl die zuvor registrierten Befehle bei Discord weiter stehen und // alles andere — Devlogs, Monitor, Moderation — laufen könnte. try { await registerCommands(); } catch (error) { console.error('[bot] Slash-Commands konnten nicht registriert werden:', error.message); } // Kennzahlen fuer verknuepfte Rollen anmelden — idempotent, aber ein // Fehlschlag darf den Start nicht mitnehmen (siehe oben) if (moduleEnabled('linked_roles')) { try { const n = await registerMetadata(); console.log(`[linkedroles] ${n} Kennzahlen bei Discord angemeldet`); } catch (error) { console.error('[linkedroles] Kennzahlen nicht angemeldet:', error.message); } } startLinkedRoles(client); }); // Live-Archivierung: neue Devlogs (Webhook-Posts im Devlog-Kanal) sofort sichern client.on(Events.MessageCreate, async (message) => { if (message.channelId !== devlogChannelId()) return; try { if (await archiveDevlogMessage(message)) { console.log(`[devlog] Neues Devlog archiviert (${message.id})`); } } catch (error) { console.error('[devlog] Archivierung fehlgeschlagen:', error); } }); // 👍-Reaktionen auf Feature-Wünsche zählen (für die Rangliste auf der Webseite) const trackWishReaction = (delta) => async (reaction) => { try { if (reaction.emoji.name !== '👍') return; if (reaction.partial) await reaction.fetch().catch(() => {}); if (isWish(reaction.message.id)) bumpWish(reaction.message.id, delta); } catch (error) { console.error('[voting] Reaction-Tracking fehlgeschlagen:', error); } }; client.on(Events.MessageReactionAdd, trackWishReaction(1)); client.on(Events.MessageReactionRemove, trackWishReaction(-1)); // In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder) client.on(Events.MessageDelete, async (message) => { if (message.channelId !== devlogChannelId()) return; try { if (await removeDevlog(message.id)) { console.log(`[devlog] Archiv-Eintrag entfernt (Discord-Nachricht ${message.id} gelöscht)`); } } catch (error) { console.error('[devlog] Archiv-Löschung fehlgeschlagen:', error); } }); client.on(Events.InteractionCreate, async (interaction) => { // Autocomplete (z. B. /tag) if (interaction.isAutocomplete()) { const command = client.commands.get(interaction.commandName); await command?.autocomplete?.(interaction).catch((e) => console.error(`[bot] Autocomplete /${interaction.commandName}:`, e) ); return; } // 🔔-Button unterm Devlog: Ping-Rolle selbst an-/abmelden if (interaction.isButton() && interaction.customId === 'devlog_ping_toggle') { const roleId = devlogPingRoleId(); try { if (!roleId || !interaction.inGuild()) { throw new Error('Keine Ping-Rolle konfiguriert'); } const member = interaction.member; const hasRole = member.roles.cache.has(roleId); if (hasRole) { await member.roles.remove(roleId); } else { await member.roles.add(roleId); } await interaction.reply({ content: hasRole ? '🔕 Devlog-Benachrichtigungen **abbestellt**.' : '🔔 Devlog-Benachrichtigungen **abonniert** — du wirst ab jetzt gepingt.', flags: MessageFlags.Ephemeral, }); } catch (error) { console.error('[ping-rolle] Toggle fehlgeschlagen:', error); await interaction .reply({ content: '❌ Konnte die Rolle nicht ändern — dem Bot fehlt vermutlich „Rollen verwalten" oder die Rolle steht über seiner.', flags: MessageFlags.Ephemeral, }) .catch(() => {}); } return; } // Eingabefenster von /bug und /wunsch if (interaction.isModalSubmit() && (interaction.customId === 'bugmodal' || interaction.customId === 'wunschmodal')) { const handler = interaction.customId === 'bugmodal' ? bug.handleModal : wunsch.handleModal; try { await handler(interaction); } catch (error) { console.error(`[${interaction.customId}] Fehler:`, error); const meldung = { content: '❌ Da ist etwas schiefgelaufen.', flags: MessageFlags.Ephemeral }; await (interaction.deferred || interaction.replied ? interaction.editReply(meldung.content) : interaction.reply(meldung)).catch(() => {}); } return; } // Bewerbungen: Button → Modal → Review-Entscheidung try { if (interaction.isButton() && interaction.customId.startsWith('apply:')) { await handleApplyButton(interaction); return; } if (interaction.isModalSubmit() && interaction.customId.startsWith('applymodal:')) { await handleApplyModal(interaction); return; } if (interaction.isButton() && interaction.customId.startsWith('appdec:')) { await handleReviewButton(interaction); return; } } catch (error) { console.error('[bewerbung] Fehler:', error); if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: '❌ Da ist etwas schiefgelaufen.', flags: MessageFlags.Ephemeral }).catch(() => {}); } return; } // Rollen-Menü-Buttons: rolemenu:: if (interaction.isButton() && interaction.customId.startsWith('rolemenu:')) { try { await handleRoleMenuButton(interaction); } catch (error) { console.error('[rollen] Toggle fehlgeschlagen:', error); await interaction .reply({ content: '❌ Rolle konnte nicht geändert werden — Bot-Rechte/Rollen-Reihenfolge prüfen.', flags: MessageFlags.Ephemeral }) .catch(() => {}); } return; } // 🎫-Button: Ticket erstellen (privater Thread) / schließen if (interaction.isButton() && interaction.customId === 'ticket_create') { try { 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, }); } catch (error) { console.error('[ticket] Erstellung fehlgeschlagen:', error); await interaction .reply({ content: '❌ Ticket konnte nicht erstellt werden — dem Bot fehlen vermutlich Thread-Rechte.', flags: MessageFlags.Ephemeral }) .catch(() => {}); } return; } if (interaction.isButton() && interaction.customId === 'ticket_close') { try { // Transcript per DM + Mod-Log sichern, dann Thread löschen await closeTicketWithTranscript(interaction); } catch (error) { console.error('[ticket] Schließen fehlgeschlagen:', error); } return; } // 🔁/👥-Buttons am Gewinner-Post: Reroll (Admin) + Teilnehmerliste if (interaction.isButton() && /^(greroll|glist):/.test(interaction.customId)) { try { await handleGiveawayAdminButton(interaction); } catch (error) { console.error('[giveaway] Admin-Button fehlgeschlagen:', error); } return; } // 🎉-Button: Giveaway-Teilnahme togglen if (interaction.isButton() && interaction.customId === 'giveaway_enter') { const g = getGiveaway(interaction.message.id); if (!g || g.ended) { await interaction.reply({ content: '❌ Dieses Giveaway ist schon vorbei.', flags: MessageFlags.Ephemeral }).catch(() => {}); return; } const entered = toggleGiveawayEntry(g.message_id, interaction.user.id); const count = giveawayEntries(g.message_id).length; await interaction.reply({ content: entered ? `🎉 Du bist dabei! (${count} Teilnahmen)` : `🚪 Teilnahme zurückgezogen. (${count} Teilnahmen)`, flags: MessageFlags.Ephemeral, }).catch(() => {}); return; } // 🧪-Button: Playtester-Rolle + Liste togglen if (interaction.isButton() && interaction.customId === 'playtester_toggle') { const roleId = playtesterRoleId(); try { if (!roleId || !interaction.inGuild()) throw new Error('Keine Playtester-Rolle konfiguriert'); const member = interaction.member; const hasRole = member.roles.cache.has(roleId); if (hasRole) { await member.roles.remove(roleId); removePlaytester(interaction.user.id); } else { await member.roles.add(roleId); addPlaytester(interaction.user.id, interaction.user.username); } await interaction.reply({ content: renderTemplate(hasRole ? 'playtester.left' : 'playtester.joined', { user: member.displayName ?? interaction.user.username, mention: `<@${interaction.user.id}>`, }), flags: MessageFlags.Ephemeral, }); } catch (error) { console.error('[playtester] Toggle fehlgeschlagen:', error); await interaction .reply({ content: '❌ Konnte die Rolle nicht ändern — Rechte/Rollen-Reihenfolge prüfen.', flags: MessageFlags.Ephemeral }) .catch(() => {}); } return; } if (!interaction.isChatInputCommand()) return; const command = client.commands.get(interaction.commandName); if (!command) return; try { await command.execute(interaction); } catch (error) { console.error(`[bot] Fehler bei /${interaction.commandName}:`, error); const reply = { content: '❌ Da ist etwas schiefgelaufen.', ephemeral: true }; if (interaction.replied || interaction.deferred) { await interaction.followUp(reply).catch(() => {}); } else { await interaction.reply(reply).catch(() => {}); } } }); registerCommunityListeners(client); registerModTools(client); // Vor den anderen Beitritts-Handlern egal — die Erkennung zaehlt nur mit registerAntiRaid(client); registerLevels(client); registerPresence(client); registerExtras(client); await client.login(config.discordToken); return client; } /** Slash-Commands bei Discord registrieren (Guild = sofort sichtbar, global = bis zu 1h Delay) */ async function registerCommands() { const rest = new REST().setToken(config.discordToken); const body = commandModules.map((c) => c.data.toJSON()); const guildId = discordGuildId(); const route = guildId ? Routes.applicationGuildCommands(config.discordClientId, guildId) : Routes.applicationCommands(config.discordClientId); await rest.put(route, { body }); console.log( `[bot] ${body.length} Slash-Command(s) registriert (${guildId ? 'Guild' : 'global'})` ); }