Files
d4rkbot/src/bot/client.js
T
D4rkst3randClaude Fable 5 c8b1f604f0
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Umfragen: /umfrage als native Discord-Abstimmung
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>
2026-08-01 06:34:04 +02:00

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'})`
);
}