- 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>
318 lines
14 KiB
JavaScript
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'})`
|
|
);
|
|
}
|