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
+76
View File
@@ -0,0 +1,76 @@
// Level-System (Carl-/MEE6-Stil): XP pro Nachricht mit Cooldown, Level-Up-Announce,
// Rollen-Belohnungen; Bestenliste öffentlich auf der Webseite (/level).
import { Events } from 'discord.js';
import { getLevelRow, setLevelRow } from '../db.js';
import { levelsEnabled, levelsAnnounce, levelRewards } from '../runtime-settings.js';
const XP_COOLDOWN_MS = 60_000;
const XP_MIN = 15;
const XP_SPREAD = 11; // 1525 XP pro gewerteter Nachricht
/** Kumulierte XP-Schwelle, ab der ein Level erreicht ist (MEE6-Formel pro Stufe) */
export function xpForLevel(level) {
let total = 0;
for (let n = 0; n < level; n++) total += 5 * n * n + 50 * n + 100;
return total;
}
export function levelFromXp(xp) {
let level = 0;
while (xp >= xpForLevel(level + 1)) level++;
return level;
}
/**
* XP für eine Nachricht gutschreiben (Cooldown-gesteuert).
* @returns {{ xp: number, level: number, leveledUp: boolean } | null} null = Cooldown aktiv
*/
export function awardXp(userId, username, now = Date.now()) {
const row = getLevelRow(userId) ?? { user_id: userId, username, xp: 0, level: 0, last_xp_ms: 0 };
if (now - row.last_xp_ms < XP_COOLDOWN_MS) return null;
const xp = row.xp + XP_MIN + Math.floor(Math.random() * XP_SPREAD);
const level = levelFromXp(xp);
const leveledUp = level > row.level;
setLevelRow({ user_id: userId, username, xp, level, last_xp_ms: now });
return { xp, level, leveledUp };
}
/** Beim Level-Up fällige Belohnungs-Rollen (alle Schwellen ≤ Level) vergeben */
async function grantRewards(member, level) {
for (const reward of levelRewards()) {
if (reward.level > level) break;
if (!member.roles.cache.has(reward.roleId)) {
await member.roles.add(reward.roleId).catch((e) =>
console.error(`[level] Belohnungs-Rolle ${reward.roleId} fehlgeschlagen: ${e.message}`)
);
}
}
}
export function registerLevels(client) {
client.on(Events.MessageCreate, async (message) => {
try {
if (!levelsEnabled()) return;
if (!message.guildId || message.author?.bot || message.webhookId) return;
const result = awardXp(
message.author.id,
message.member?.displayName ?? message.author.username
);
if (!result?.leveledUp) return;
if (message.member) await grantRewards(message.member, result.level);
if (levelsAnnounce()) {
await message.channel
.send({
content: `🎉 **${message.member?.displayName ?? message.author.username}** ist jetzt **Level ${result.level}**!`,
allowedMentions: { parse: [] },
})
.catch(() => {});
}
} catch (error) {
console.error('[level] XP-Verarbeitung fehlgeschlagen:', error);
}
});
}