diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js
index c9f421b..2e933a7 100644
--- a/frontend/src/locales/de.js
+++ b/frontend/src/locales/de.js
@@ -253,6 +253,7 @@ export default {
'commands.warns': 'Zeigt die Verwarnungs-Historie.',
'commands.timeout': 'Setzt eine Auszeit.',
'commands.purge': 'Löscht mehrere Nachrichten auf einmal.',
+ 'commands.raid': 'Zeigt die Raid-Sperre an, setzt sie von Hand oder hebt sie sofort auf.',
'commands.giveaway': 'Startet eine Verlosung mit Teilnahme-Knopf und automatischer Ziehung.',
'commands.ticketSetup': 'Postet den Knopf, über den Support-Tickets als private Threads entstehen.',
'commands.playtesterSetup': 'Postet den Bewerbungs-Knopf für das Playtester-Programm.',
diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js
index 4f131ab..a27a8d4 100644
--- a/frontend/src/locales/en.js
+++ b/frontend/src/locales/en.js
@@ -336,6 +336,7 @@ export default {
'commands.warns': 'Shows the warning history.',
'commands.timeout': 'Puts someone in timeout.',
'commands.purge': 'Deletes several messages at once.',
+ 'commands.raid': 'Shows the raid lockdown, sets it by hand or lifts it right away.',
'commands.giveaway': 'Starts a giveaway with an entry button and an automatic draw.',
'commands.ticketSetup': 'Posts the button that turns support tickets into private threads.',
'commands.playtesterSetup': 'Posts the application button for the playtester programme.',
diff --git a/frontend/src/pages/BotCommands.jsx b/frontend/src/pages/BotCommands.jsx
index 63a219a..9c09c9d 100644
--- a/frontend/src/pages/BotCommands.jsx
+++ b/frontend/src/pages/BotCommands.jsx
@@ -22,6 +22,7 @@ const GROUPS = [
['/purge anzahl:…', 'commands.purge'],
]],
['commands.g.setup', true, [
+ ['/raid status | an | aus', 'commands.raid'],
['/giveaway preis:… dauer:… gewinner:…', 'commands.giveaway'],
['/ticket-setup', 'commands.ticketSetup'],
['/playtester-setup', 'commands.playtesterSetup'],
diff --git a/frontend/src/pages/BotLanding.jsx b/frontend/src/pages/BotLanding.jsx
index 1092efa..ee9fe15 100644
--- a/frontend/src/pages/BotLanding.jsx
+++ b/frontend/src/pages/BotLanding.jsx
@@ -67,7 +67,7 @@ export default function BotLanding() {
)}
- 16
+ 17
{t('bot.statCommands')}
diff --git a/src/bot/anti-raid.js b/src/bot/anti-raid.js
index 5cc861d..0c68a51 100644
--- a/src/bot/anti-raid.js
+++ b/src/bot/anti-raid.js
@@ -50,18 +50,26 @@ async function melden(client, titel, text) {
}
export function istGesperrt() {
- const bis = getSetting(BIS);
- return Boolean(bis) && new Date(bis).getTime() > jetzt();
+ return sperrStand().gesperrt;
}
-/** Server dichtmachen. Gibt zurück, was tatsächlich griff. */
-async function sperren(client, guild, anzahl, fenster) {
+/** 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 = [];
- const grund = `Anti-Raid: ${anzahl} Beitritte in ${fenster} s`;
-
if (vorher < GuildVerificationLevel.High) {
const ok = await guild.setVerificationLevel(GuildVerificationLevel.High, grund)
.then(() => true)
@@ -81,20 +89,45 @@ async function sperren(client, guild, anzahl, fenster) {
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.\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`);
}
-/** Sperre zurücknehmen — auch beim Start, falls über einen Neustart hinweg */
-export async function entsperren(client, { melde = true } = {}) {
+/** 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();
@@ -120,9 +153,10 @@ export async function entsperren(client, { melde = true } = {}) {
if (melde) {
await melden(client, '✅ Sperre aufgehoben',
- getan.length ? getan.map((g) => `• ${g}`).join('\n') : 'Nichts zurückzunehmen.');
+ (durch ? `Von Hand durch **${durch}**.\n\n` : '')
+ + (getan.length ? getan.map((g) => `• ${g}`).join('\n') : 'Nichts zurückzunehmen.'));
}
- console.log('[antiraid] Sperre aufgehoben');
+ console.log(`[antiraid] Sperre aufgehoben${durch ? ` (durch ${durch})` : ''}`);
return true;
}
diff --git a/src/bot/client.js b/src/bot/client.js
index 6eae950..c8ee40f 100644
--- a/src/bot/client.js
+++ b/src/bot/client.js
@@ -37,11 +37,13 @@ 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';
// 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,
];
export async function startBot() {
diff --git a/src/bot/commands/raid.js b/src/bot/commands/raid.js
new file mode 100644
index 0000000..3be95e3
--- /dev/null
+++ b/src/bot/commands/raid.js
@@ -0,0 +1,60 @@
+// /raid — Raid-Sperre von Hand setzen, aufheben oder nachsehen.
+//
+// Der Bot sperrt bei einer Beitritts-Welle selbst, aber zwei Fälle braucht
+// man von Hand: der Fehlalarm um drei Uhr nachts, und die Welle, die man
+// kommen sieht, bevor die Schwelle reißt.
+import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js';
+import { sperrenVonHand, entsperren, sperrStand } from '../anti-raid.js';
+import { moduleEnabled } from '../../modules.js';
+import { tuning } from '../../tuning.js';
+
+export const data = new SlashCommandBuilder()
+ .setName('raid')
+ .setDescription('Raid-Sperre setzen, aufheben oder Stand ansehen')
+ // ManageGuild, weil die Sperre genau das anfasst: Verifizierung und Invites
+ .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
+ .addSubcommand((s) => s.setName('status').setDescription('Ist gerade gesperrt?'))
+ .addSubcommand((s) => s.setName('an').setDescription('Jetzt sperren'))
+ .addSubcommand((s) => s.setName('aus').setDescription('Sperre sofort aufheben'));
+
+export async function execute(interaction) {
+ const was = interaction.options.getSubcommand();
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
+
+ if (!moduleEnabled('anti_raid')) {
+ await interaction.editReply(
+ '⚠️ Der Raid-Schutz ist ausgeschaltet. Einschalten im Panel unter **Support → Raid-Schutz**.'
+ );
+ return;
+ }
+
+ const stand = sperrStand();
+
+ if (was === 'status') {
+ await interaction.editReply(stand.gesperrt
+ ? `🔒 **Gesperrt** — läuft ab.`
+ : `🔓 **Offen.** Sperre greift ab **${tuning('raid_joins')} Beitritten** in `
+ + `**${tuning('raid_window')} Sekunden**.`);
+ return;
+ }
+
+ if (was === 'an') {
+ if (stand.gesperrt) {
+ await interaction.editReply(`🔒 Ist schon gesperrt — läuft ab.`);
+ return;
+ }
+ const erg = await sperrenVonHand(interaction.client, interaction.guild, interaction.user.username);
+ await interaction.editReply(erg.getan.length
+ ? `🔒 Gesperrt für **${erg.minuten} Minuten**:\n${erg.getan.map((g) => `• ${g}`).join('\n')}`
+ : '❌ Keine Maßnahme griff — fehlt dem Bot das Recht „Server verwalten"?');
+ return;
+ }
+
+ // aus
+ if (!stand.gesperrt) {
+ await interaction.editReply('🔓 Es ist gerade nichts gesperrt.');
+ return;
+ }
+ await entsperren(interaction.client, { durch: interaction.user.username });
+ await interaction.editReply('🔓 Sperre aufgehoben.');
+}