Files
d4rkbot/src/bot/client.js
T
D4rkst3randClaude Fable 5 db9c7d07c9 Carl-Bot-Paket: Moderation, Auto-/Sticky-Roles, Level-System, Tags, geplante Posts
- Moderation: /warn (DM + Mod-Log), /warns (Historie + löschen), /timeout (max 28d),
  /purge (bulk, 14-Tage-Limit sauber gemeldet); Mod-Log erweitert um Join/Leave
  und Nick-/Rollen-Änderungen
- Auto-Rolle bei Join + Sticky-Roles (Rollen bei Leave gesichert, bei Rejoin
  wiederhergestellt; managed/@everyone ausgenommen) — Rollen-Tab
- Level-System (opt-in): 15-25 XP/Nachricht mit 60s-Cooldown, MEE6-Formel,
  Level-Up-Announce, Rollen-Belohnungen (Level=RolleID), /rank mit Fortschritt,
  öffentliche Bestenliste /level; Community-Tab
- Tags: /tag mit Autocomplete, Verwaltung im Composer-Tab (/api/tags)
- Geplante Posts: einmalig/täglich/wöchentlich, Minuten-Scheduler, DST-sicher
  über Neuberechnung aus Zeitfeldern; Composer-Tab (/api/scheduled)
- Autocomplete-Dispatch im InteractionCreate; alles smoke-getestet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:30:23 +02:00

318 lines
14 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 } from '../runtime-settings.js';
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import { registerCommunityListeners } from './community.js';
import { registerModTools } from './mod-tools.js';
import { registerLevels } from './levels.js';
import { handleRoleMenuButton } from './role-menus.js';
import {
addPlaytester, removePlaytester, isWish, bumpWish,
getGiveaway, toggleGiveawayEntry, giveawayEntries,
saveTicket, getTicket, closeTicket, 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';
// 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,
];
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,
],
// 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}`);
await registerCommands();
});
// 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;
}
// 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: 10080,
});
await thread.members.add(interaction.user.id);
saveTicket(thread.id, interaction.user.id);
await thread.send({
content: `<@${interaction.user.id}>`,
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setDescription(
'Willkommen in deinem Ticket! Beschreib dein Anliegen — das Team meldet sich.\n' +
'Wenn alles geklärt ist, kann das Ticket hier geschlossen werden.'
)
.setFooter({ text: 'D4RKST3R // 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 {
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(() => {});
} catch (error) {
console.error('[ticket] Schließen 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: hasRole
? '🚪 Von der Playtester-Liste **abgemeldet**.'
: '🧪 **Willkommen im Playtest-Team!** Du stehst auf der Liste — sobald es losgeht, meldet sich der Bot per DM.',
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);
registerLevels(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 route = config.discordGuildId
? Routes.applicationGuildCommands(config.discordClientId, config.discordGuildId)
: Routes.applicationCommands(config.discordClientId);
await rest.put(route, { body });
console.log(
`[bot] ${body.length} Slash-Command(s) registriert (${config.discordGuildId ? 'Guild' : 'global'})`
);
}