Der Waechter prueft alles ausser sich selbst — ist der Bot weg, prueft niemand mehr, und hinterher sieht der Verlauf aus, als waere nie etwas gewesen. Im Dashboard stand deshalb nur "seit 12d 4h", also wie lange es diesmal gutgegangen ist. Was davor war, wusste niemand. Jetzt schreibt der Bot alle zwei Minuten eine Zeile: laeuft der Prozess, und steht die Verbindung zu Discord? Ein Bot, der laeuft aber nicht verbunden ist, ist fuer alle draussen genauso weg — deshalb zaehlt beides. Der Takt ist absichtlich nicht einstellbar. Ein spaeter geaenderter Abstand wuerde den alten Verlauf falsch aussehen lassen: wie viele Proben eine Stunde haette haben muessen, liesse sich rueckwirkend nicht mehr sagen. Ausgewertet wird vor allem die Abwesenheit. Fuer einen fremden Dienst heisst eine fehlende Stunde "niemand hat gemessen" und wird grau; beim Bot selbst ist genau das die Aussage — fehlende Stunden werden rot. Grau bleibt nur, was vor der allerersten Aufzeichnung liegt. Die angebrochene erste und die laufende letzte Stunde rechnen anteilig, sonst waeren sie dauerhaft gelb. Nebenbei mitgenommen: - Statusseite und die frei angelegten Seiten stehen jetzt in der Fusszeile beider Seiten — dort sucht man Rechtliches. Die Navigationsleiste zeigt weiterhin nur, was "im Menü" gesetzt hat; wer die Nutzungsbedingungen dort raus nimmt, hat sie trotzdem noch in der Fusszeile. - Die Config-Spalte hatte keine Obergrenze. min-width:0 allein reicht nicht, wenn ein Kind eine grosse Mindestbreite mitbringt — mit dem Balken wuchs sie auf 724 statt 335 px, und weil der Body waagerecht abschneidet, war der Ueberstand nicht scrollbar, sondern weg. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
412 lines
19 KiB
JavaScript
412 lines
19 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 { registerAutoMod } from './automod.js';
|
|
import { startHeartbeat } from './heartbeat.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,
|
|
// AutoMod-Treffer mitbekommen. Nicht privilegiert, aber Discord
|
|
// schickt die Ereignisse nur an Bots mit „Server verwalten".
|
|
GatewayIntentBits.AutoModerationExecution,
|
|
],
|
|
// 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);
|
|
// Ab hier zaehlt jede Minute: der Herzschlag laeuft immer,
|
|
// unabhaengig von Modulen — er ist die Grundlage dafuer, dass
|
|
// spaeter ueberhaupt jemand sagen kann, ob der Bot lief.
|
|
startHeartbeat(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);
|
|
registerAutoMod(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'})`
|
|
);
|
|
}
|