Der fehlende Befehl zum Raid-Schutz. Drei Unterbefehle: status ist gerade gesperrt, und wie lange noch an jetzt sperren — fuer die Welle, die man kommen sieht aus sofort aufheben — fuer den Fehlalarm um drei Uhr nachts Rechte: ManageGuild, weil die Sperre genau das anfasst — Verifizierung und Invites. Antworten sind ephemeral. Die Sperr-Massnahmen sind in dichtmachen() herausgezogen, damit automatisch und von Hand denselben Weg nehmen und sich nicht auseinanderentwickeln. Die Meldung sagt jeweils dazu, wodurch sie ausgeloest wurde und wer es war. Ist der Raid-Schutz ausgeschaltet, verweist der Befehl aufs Panel statt stillschweigend nichts zu tun. Dazu: der Befehl steht auf der oeffentlichen Befehls-Seite, in beiden Sprachen, und die Zahl auf der Produktseite geht von 16 auf 17. Geprueft: 17 Befehle laden, keine doppelten Namen, Rechte-Bitmaske 32 (ManageGuild), alle neuen Exporte vorhanden, kein Sprachschluessel fehlt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
211 lines
8.4 KiB
JavaScript
211 lines
8.4 KiB
JavaScript
// 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(() => {});
|
|
});
|
|
}
|