// Anti-Raid: erkennt Beitritts-Wellen und macht den Server dicht. // // Bewusst zurückhaltend: es wird niemand gekickt oder gebannt. Bei einem // Fehlalarm — ein Stream-Shoutout, ein geteilter Invite — wäre ein Massenkick // schlimmer als der Raid selbst. Stattdessen wird die Verifizierungsstufe // hochgesetzt und der Invite-Zugang gesperrt, beides ist reversibel und // trifft niemanden, der schon drin ist. // // Der Sperrzustand steht in den Einstellungen, nicht nur im Speicher: startet // der Bot mitten in einer Sperre neu, wäre der Timer sonst weg und der Server // bliebe für immer dicht. import { EmbedBuilder, Events, GuildVerificationLevel } from 'discord.js'; import { getSetting, setSetting } from '../db.js'; import { config } from '../config.js'; import { moduleEnabled } from '../modules.js'; import { tuning } from '../tuning.js'; import { brandColor2, brandFooter, modlogChannelId } from '../runtime-settings.js'; const BIS = 'antiraid_locked_until'; // ISO-Zeit, leer = offen const VORHER = 'antiraid_prev_level'; // Verifizierungsstufe vor der Sperre // Zeitstempel der letzten Beitritte. Nur im Speicher: ein Raid spielt sich in // Sekunden ab, über einen Neustart hinweg ist das Fenster wertlos. let beitritte = []; let aufhebeTimer = null; function jetzt() { return Date.now(); } /** Meldung an Protokoll-Kanal und per DM an den Owner */ async function melden(client, titel, text) { const embed = new EmbedBuilder() .setColor(brandColor2()) .setTitle(titel) .setDescription(text) .setFooter({ text: brandFooter('ANTI-RAID') }) .setTimestamp(); const channelId = modlogChannelId(); if (channelId) { const channel = await client.channels.fetch(channelId).catch(() => null); if (channel?.isTextBased()) await channel.send({ embeds: [embed] }).catch(() => {}); } // Die DM ist der eigentliche Alarm — der Protokoll-Kanal wird nachts nicht gelesen if (config.adminDiscordId) { const user = await client.users.fetch(config.adminDiscordId).catch(() => null); await user?.send({ embeds: [embed] }).catch(() => {}); } } export function istGesperrt() { return sperrStand().gesperrt; } /** Läuft gerade eine Sperre, und bis wann? */ export function sperrStand() { const bis = getSetting(BIS); const ms = bis ? new Date(bis).getTime() : 0; return { gesperrt: ms > jetzt(), bis: ms || null }; } /** * Die eigentlichen Maßnahmen. Von Hand und automatisch nehmen denselben Weg, * damit sich die beiden nicht auseinanderentwickeln. * @returns {Promise<{getan: string[], minuten: number}>} */ async function dichtmachen(client, guild, grund) { const minuten = tuning('raid_lockdown'); const vorher = guild.verificationLevel; const getan = []; if (vorher < GuildVerificationLevel.High) { const ok = await guild.setVerificationLevel(GuildVerificationLevel.High, grund) .then(() => true) .catch((e) => { console.error('[antiraid] Verifizierung:', e.message); return false; }); if (ok) getan.push('Verifizierung auf **Hoch**'); } // Erst ab discord.js 14.4 vorhanden und nur mit ManageGuild — beides prüfen, // statt dem Bot beim Ernstfall einen Fehler zu werfen if (typeof guild.disableInvites === 'function') { const ok = await guild.disableInvites(true) .then(() => true) .catch((e) => { console.error('[antiraid] Invites:', e.message); return false; }); if (ok) getan.push('Einladungen **gesperrt**'); } setSetting(VORHER, String(vorher)); setSetting(BIS, new Date(jetzt() + minuten * 60000).toISOString()); planeAufhebung(client, minuten * 60000); return { getan, minuten }; } /** Automatisch, weil das Beitritts-Fenster übergelaufen ist */ async function sperren(client, guild, anzahl, fenster) { const { getan, minuten } = await dichtmachen(client, guild, `Anti-Raid: ${anzahl} Beitritte in ${fenster} s`); await melden( client, '🚨 Raid-Verdacht — Server gesperrt', `**${anzahl} Beitritte in ${fenster} Sekunden.**\n\n` + (getan.length ? `${getan.map((g) => `• ${g}`).join('\n')}\n\n` : '_Keine Maßnahme griff — fehlen dem Bot Rechte?_\n\n') + `Wird in **${minuten} Minuten** automatisch aufgehoben, ` + 'vorher mit `/raid aus`.\n' + 'Es wurde niemand gekickt oder gebannt.' ); console.warn(`[antiraid] Sperre aktiv: ${anzahl} Beitritte in ${fenster}s`); } /** Von Hand über /raid an — für die Welle, die man kommen sieht */ export async function sperrenVonHand(client, guild, durch) { const { getan, minuten } = await dichtmachen(client, guild, `Raid-Sperre von Hand durch ${durch}`); await melden( client, '🔒 Server von Hand gesperrt', `Ausgelöst von **${durch}**.\n\n` + (getan.length ? `${getan.map((g) => `• ${g}`).join('\n')}\n\n` : '_Keine Maßnahme griff — fehlen dem Bot Rechte?_\n\n') + `Läuft nach **${minuten} Minuten** aus, vorher mit \`/raid aus\`.` ); console.warn(`[antiraid] Sperre von Hand durch ${durch}`); return { getan, minuten }; } /** * Sperre zurücknehmen — vom Timer, von /raid aus, oder beim Start, wenn eine * Sperre einen Neustart überlebt hat. */ export async function entsperren(client, { melde = true, durch = null } = {}) { if (!getSetting(BIS)) return false; const guild = client.guilds.cache.first(); const zurueck = Number(getSetting(VORHER)); const getan = []; if (guild) { if (Number.isInteger(zurueck) && zurueck !== guild.verificationLevel) { const ok = await guild.setVerificationLevel(zurueck, 'Anti-Raid: Sperre aufgehoben') .then(() => true).catch(() => false); if (ok) getan.push('Verifizierung zurückgesetzt'); } if (typeof guild.disableInvites === 'function') { const ok = await guild.disableInvites(false).then(() => true).catch(() => false); if (ok) getan.push('Einladungen wieder offen'); } } setSetting(BIS, ''); setSetting(VORHER, ''); beitritte = []; if (aufhebeTimer) { clearTimeout(aufhebeTimer); aufhebeTimer = null; } if (melde) { await melden(client, '✅ Sperre aufgehoben', (durch ? `Von Hand durch **${durch}**.\n\n` : '') + (getan.length ? getan.map((g) => `• ${g}`).join('\n') : 'Nichts zurückzunehmen.')); } console.log(`[antiraid] Sperre aufgehoben${durch ? ` (durch ${durch})` : ''}`); return true; } function planeAufhebung(client, ms) { if (aufhebeTimer) clearTimeout(aufhebeTimer); // setTimeout kippt über ~24,8 Tagen ins Negative und feuert sofort aufhebeTimer = setTimeout(() => { entsperren(client).catch((e) => console.error('[antiraid]', e)); }, Math.min(Math.max(ms, 0), 2 ** 31 - 1)); } /** * Einen Beitritt verbuchen und prüfen, ob das Fenster überläuft. * Exportiert, damit sich die Erkennung ohne Discord testen lässt. */ export function beitrittZaehlen(zeit = jetzt(), { schwelle = tuning('raid_joins'), fenster = tuning('raid_window') } = {}) { const grenze = zeit - fenster * 1000; beitritte = beitritte.filter((t) => t > grenze); beitritte.push(zeit); return { anzahl: beitritte.length, ausgeloest: beitritte.length >= schwelle, fenster }; } /** Nur für Tests: Fenster leeren */ export function fensterLeeren() { beitritte = []; } export function registerAntiRaid(client) { client.on(Events.GuildMemberAdd, async (member) => { try { if (!moduleEnabled('anti_raid')) return; if (istGesperrt()) return; // schon dicht, nicht doppelt melden const { anzahl, ausgeloest, fenster } = beitrittZaehlen(); if (ausgeloest) await sperren(client, member.guild, anzahl, fenster); } catch (error) { console.error('[antiraid]', error); } }); // Nach einem Neustart: hängt noch eine Sperre, den Rest der Zeit absitzen // und danach aufheben. Ist sie längst abgelaufen, sofort. client.once(Events.ClientReady, () => { const bis = getSetting(BIS); if (!bis) return; const rest = new Date(bis).getTime() - jetzt(); console.log(`[antiraid] Sperre aus vorherigem Lauf, Rest ${Math.round(rest / 1000)}s`); if (rest > 0) planeAufhebung(client, rest); else entsperren(client, { melde: false }).catch(() => {}); }); }