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>
This commit is contained in:
2026-07-30 00:30:23 +02:00
co-authored by Claude Fable 5
parent a27a594a07
commit db9c7d07c9
19 changed files with 1066 additions and 5 deletions
+45
View File
@@ -0,0 +1,45 @@
// /purge — Nachrichten im aktuellen Kanal massenlöschen (max 100, jünger als 14 Tage)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { modlogChannelId } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder()
.setName('purge')
.setDescription('Löscht die letzten N Nachrichten in diesem Kanal')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addIntegerOption((o) =>
o.setName('anzahl').setDescription('Wie viele? (1100)').setRequired(true).setMinValue(1).setMaxValue(100)
);
export async function execute(interaction) {
const count = interaction.options.getInteger('anzahl');
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
let deleted;
try {
// true = Nachrichten >14 Tage still überspringen (Discord-API-Limit)
deleted = await interaction.channel.bulkDelete(count, true);
} catch {
await interaction.editReply('❌ Löschen fehlgeschlagen — Bot braucht „Nachrichten verwalten" hier.');
return;
}
const logChannelId = modlogChannelId();
if (logChannelId && deleted.size > 0) {
const channel = await interaction.client.channels.fetch(logChannelId).catch(() => null);
await channel?.send({
embeds: [
new EmbedBuilder()
.setColor(0xff4d00)
.setTitle('🧹 Purge')
.setDescription(
`**${deleted.size}** Nachrichten in <#${interaction.channelId}> gelöscht von **${interaction.user.username}**`
)
.setTimestamp(),
],
}).catch(() => {});
}
await interaction.editReply(
`🧹 **${deleted.size}** Nachrichten gelöscht.${deleted.size < count ? ' (ältere als 14 Tage kann Discord nicht massenlöschen)' : ''}`
);
}
+43
View File
@@ -0,0 +1,43 @@
// /rank — eigenes Level, XP-Fortschritt und Platzierung anzeigen
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import { getLevelRow, rankOf } from '../../db.js';
import { xpForLevel } from '../levels.js';
import { levelsEnabled, publicUrl } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder()
.setName('rank')
.setDescription('Dein Level, XP und Platz in der Bestenliste')
.addUserOption((o) => o.setName('member').setDescription('Optional: anderen Member anschauen'));
export async function execute(interaction) {
if (!levelsEnabled()) {
await interaction.reply({ content: '❌ Das Level-System ist nicht aktiviert.', ephemeral: true });
return;
}
const user = interaction.options.getUser('member') ?? interaction.user;
const row = getLevelRow(user.id);
if (!row) {
await interaction.reply({ content: `**${user.username}** hat noch keine XP — einfach mitschreiben! ✍️`, ephemeral: true });
return;
}
const currentBase = xpForLevel(row.level);
const nextAt = xpForLevel(row.level + 1);
const progress = row.xp - currentBase;
const needed = nextAt - currentBase;
const pct = Math.floor((progress / needed) * 100);
const barLen = 14;
const bar = '█'.repeat(Math.round((pct / 100) * barLen)).padEnd(barLen, '·');
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setAuthor({ name: user.username, iconURL: user.displayAvatarURL({ size: 64 }) })
.setTitle(`Level ${row.level} · Platz #${rankOf(user.id)}`)
.setDescription(`\`${bar}\` ${pct}%\n**${progress} / ${needed} XP** bis Level ${row.level + 1} · gesamt ${row.xp} XP`)
.setURL(`${publicUrl()}/level`)
.setFooter({ text: 'D4RKST3R // LEVEL' }),
],
});
}
+33
View File
@@ -0,0 +1,33 @@
// /tag — gespeicherte Text-Bausteine abrufen (verwaltet über die Setup-Seite)
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
import { getTag, listTags } from '../../db.js';
export const data = new SlashCommandBuilder()
.setName('tag')
.setDescription('Gespeicherten Text-Baustein posten (z. B. /tag name:server-ip)')
.addStringOption((o) =>
o.setName('name').setDescription('Name des Tags').setRequired(true).setAutocomplete(true)
);
export async function autocomplete(interaction) {
const input = interaction.options.getFocused().toLowerCase();
const choices = listTags()
.filter((t) => t.name.toLowerCase().includes(input))
.slice(0, 25)
.map((t) => ({ name: t.name, value: t.name }));
await interaction.respond(choices).catch(() => {});
}
export async function execute(interaction) {
const name = interaction.options.getString('name').trim().toLowerCase();
const tag = getTag(name);
if (!tag) {
const available = listTags().map((t) => `\`${t.name}\``).slice(0, 25).join(', ');
await interaction.reply({
content: `❌ Tag \`${name}\` gibt es nicht.${available ? `\nVerfügbar: ${available}` : ''}`,
flags: MessageFlags.Ephemeral,
});
return;
}
await interaction.reply({ content: tag.content.slice(0, 2000), allowedMentions: { parse: [] } });
}
+62
View File
@@ -0,0 +1,62 @@
// /timeout — Member stummschalten (Discord-Timeout) mit Mod-Log
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { parseDuration } from './giveaway.js';
import { modlogChannelId } from '../../runtime-settings.js';
const MAX_TIMEOUT_MS = 28 * 86_400_000; // Discord-Limit
export const data = new SlashCommandBuilder()
.setName('timeout')
.setDescription('Member vorübergehend stummschalten')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.addUserOption((o) => o.setName('member').setDescription('Wen?').setRequired(true))
.addStringOption((o) => o.setName('dauer').setDescription('z. B. 10m, 2h, 1d (max 28d)').setRequired(true))
.addStringOption((o) => o.setName('grund').setDescription('Grund').setMaxLength(400));
export async function execute(interaction) {
const duration = parseDuration(interaction.options.getString('dauer'));
if (!duration || duration > MAX_TIMEOUT_MS) {
await interaction.reply({ content: '❌ Dauer bitte als `10m`, `2h` oder `1d` (max 28d).', flags: MessageFlags.Ephemeral });
return;
}
const user = interaction.options.getUser('member');
const reason = interaction.options.getString('grund') ?? 'Kein Grund angegeben';
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
if (!member) {
await interaction.reply({ content: '❌ Member nicht gefunden.', flags: MessageFlags.Ephemeral });
return;
}
try {
await member.timeout(duration, reason);
} catch {
await interaction.reply({ content: '❌ Timeout fehlgeschlagen — Rechte/Rollen-Reihenfolge prüfen.', flags: MessageFlags.Ephemeral });
return;
}
const until = Math.floor((Date.now() + duration) / 1000);
const logChannelId = modlogChannelId();
if (logChannelId) {
const channel = await interaction.client.channels.fetch(logChannelId).catch(() => null);
await channel?.send({
embeds: [
new EmbedBuilder()
.setColor(0xff4d00)
.setTitle('🔇 Timeout')
.addFields(
{ name: 'Member', value: `${user.username} (<@${user.id}>)`, inline: true },
{ name: 'Moderator', value: interaction.user.username, inline: true },
{ name: 'Bis', value: `<t:${until}:R>`, inline: true },
{ name: 'Grund', value: reason }
)
.setTimestamp(),
],
}).catch(() => {});
}
await interaction.reply({
content: `🔇 **${user.username}** ist stumm bis <t:${until}:R>.`,
flags: MessageFlags.Ephemeral,
});
}
+53
View File
@@ -0,0 +1,53 @@
// /warn — Member verwarnen: DB-Eintrag, DM an den User, Mod-Log (Moderatoren)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { addWarn, warnsOf } from '../../db.js';
import { modlogChannelId } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder()
.setName('warn')
.setDescription('Member verwarnen (mit DM und Mod-Log)')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.addUserOption((o) => o.setName('member').setDescription('Wen verwarnen?').setRequired(true))
.addStringOption((o) => o.setName('grund').setDescription('Grund der Verwarnung').setRequired(true).setMaxLength(500));
export async function execute(interaction) {
const user = interaction.options.getUser('member');
const reason = interaction.options.getString('grund');
if (user.bot) {
await interaction.reply({ content: '❌ Bots kann man nicht verwarnen.', flags: MessageFlags.Ephemeral });
return;
}
addWarn(user.id, interaction.user.username, reason);
const count = warnsOf(user.id).length;
// DM — best effort, User kann DMs blocken
let dmOk = true;
await user
.send(`⚠️ Du wurdest auf **${interaction.guild.name}** verwarnt (Verwarnung #${count}):\n> ${reason}`)
.catch(() => { dmOk = false; });
// Mod-Log
const logChannelId = modlogChannelId();
if (logChannelId) {
const channel = await interaction.client.channels.fetch(logChannelId).catch(() => null);
await channel?.send({
embeds: [
new EmbedBuilder()
.setColor(0xff4d00)
.setTitle(`⚠️ Verwarnung #${count}`)
.addFields(
{ name: 'Member', value: `${user.username} (<@${user.id}>)`, inline: true },
{ name: 'Moderator', value: interaction.user.username, inline: true },
{ name: 'Grund', value: reason }
)
.setTimestamp(),
],
}).catch(() => {});
}
await interaction.reply({
content: `⚠️ **${user.username}** verwarnt (#${count}).${dmOk ? '' : ' (DM nicht zustellbar)'}`,
flags: MessageFlags.Ephemeral,
});
}
+44
View File
@@ -0,0 +1,44 @@
// /warns — Verwarnungs-Historie eines Members einsehen / Eintrag löschen (Moderatoren)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { warnsOf, deleteWarn } from '../../db.js';
export const data = new SlashCommandBuilder()
.setName('warns')
.setDescription('Verwarnungen eines Members anzeigen (oder eine löschen)')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.addUserOption((o) => o.setName('member').setDescription('Wessen Verwarnungen?').setRequired(true))
.addIntegerOption((o) => o.setName('loeschen').setDescription('Optional: Verwarnungs-ID entfernen'));
export async function execute(interaction) {
const user = interaction.options.getUser('member');
const removeId = interaction.options.getInteger('loeschen');
if (removeId) {
const removed = deleteWarn(removeId);
await interaction.reply({
content: removed ? `🗑️ Verwarnung #${removeId} entfernt.` : `❌ Verwarnung #${removeId} nicht gefunden.`,
flags: MessageFlags.Ephemeral,
});
return;
}
const warns = warnsOf(user.id);
if (warns.length === 0) {
await interaction.reply({ content: `✅ **${user.username}** hat keine Verwarnungen.`, flags: MessageFlags.Ephemeral });
return;
}
const lines = warns
.slice(-15)
.map((w) => `\`#${w.id}\` ${w.created_at.slice(0, 10)}${w.reason} *(${w.moderator ?? '—'})*`);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setTitle(`⚠️ Verwarnungen: ${user.username} (${warns.length})`)
.setDescription(lines.join('\n'))
.setFooter({ text: 'Löschen: /warns member:<user> loeschen:<ID>' }),
],
flags: MessageFlags.Ephemeral,
});
}