Bewusst Discords eigenes Poll-Objekt statt einer Nachbildung mit Knoepfen — Oberflaeche, Auszaehlen und Ergebnisanzeige macht Discord dann selbst, auch auf dem Handy. Wir bauen nur die Frage zusammen. Bis zu vier Antworten (Discord erlaubt zehn, vier halten den Befehl uebersichtlich), Laufzeit von einer Stunde bis einer Woche, Mehrfachauswahl optional. Rechte: ManageMessages. Zwei gleiche Antworten werden abgefangen. Discord stoert das nicht, aber abstimmen kann darauf niemand sinnvoll. Nicht zu verwechseln mit /wunsch: das sammelt Feature-Wuensche dauerhaft und zeigt sie auf der Roadmap. /umfrage ist die schnelle Frage zwischendurch und laeuft von selbst ab. Steht so auch als Kommentar im Befehl. Geprueft gegen die installierte discord.js-Fassung (14.27) statt gegen die Dokumentation: Feldnamen aus den Typdefinitionen gelesen, und die fertige Nutzlast durch discord.js' eigene Serialisierung gejagt. Kommt als poll_media / allow_multiselect / layout_type 1 raus, genau wie Discords API es erwartet. Dazu: 18 Befehle laden ohne Namenskollision, Befehl steht auf der oeffentlichen Liste in beiden Sprachen, Zahl auf der Produktseite von 17 auf 18. Nutzungsbedingungen als Entwurf unter docs/ — Text fuer den Seiten-Editor, keine Rechtsberatung. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
402 lines
18 KiB
JavaScript
402 lines
18 KiB
JavaScript
// 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:<menuId>:<roleId>
|
|
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'})`
|
|
);
|
|
}
|