diff --git a/src/bot/commands/whitelist.js b/src/bot/commands/whitelist.js index 2c0a72f..8a18e56 100644 --- a/src/bot/commands/whitelist.js +++ b/src/bot/commands/whitelist.js @@ -14,17 +14,36 @@ // Matrix. // // ============================================================================ -// DIESER BEFEHL LIEST NUR +// DREI UNTERBEFEHLE, EINE RECHTEPRUEFUNG // ============================================================================ -// `status` beantwortet drei Fragen an einem Ort: hat die Person die Rolle, -// kennt der Spielserver ihr Konto, und welche Teamrolle hat sie bei uns. +// `status` liest, `add` und `remove` schreiben. Alle drei fragen zuerst den +// Spielserver, ob der Aufrufer `d4rk.whitelist` hat. // -// Das Vergeben und Entziehen kommt als eigener Schritt — erst wenn dieser -// Weg nachweislich durchläuft. Ein Schreibbefehl auf einem Kanal, den niemand -// gemessen hat, ist ein Schreibbefehl ins Ungewisse. +// Das Schreiben kam bewusst SPAETER als das Lesen: erst am 04.09.2026, nachdem +// der lesende Weg mit echten Daten durchgelaufen war („ja (2 von 2 +// Zutrittsrollen)"). Ein Schreibbefehl auf einem Kanal, den niemand gemessen +// hat, ist ein Schreibbefehl ins Ungewisse. +// +// ============================================================================ +// WAS DER BOT VERGIBT, ENTSCHEIDET DER SPIELSERVER +// ============================================================================ +// Die Rolle steht in `d4rk_discord:grantrole` und MUSS eine der Rollen sein, +// die auch Zutritt oeffnen. Ist sie es nicht, verweigert dieser Befehl — eine +// Rolle zu vergeben, die niemanden hereinlaesst, waere ein Knopf, der nichts +// tut und dabei „erledigt" meldet. +// +// ============================================================================ +// WO DAS LANDET +// ============================================================================ +// Im Devlog-Kanal des Bots, nicht im Serverprotokoll. Der Weg dorthin ginge +// nur ueber einen Schreibzugang fuer den Bot, und der wuerde die absichtlich +// schmale Tuer aufmachen (siehe panel.js). Die Freischaltung selbst steht +// ausserdem in Discords eigenem Audit-Log, und wenn die Person verbindet, +// protokolliert d4rk_lib die Zulassung mit Namen. import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { person, panelBereit, darfWhitelist, konfig } from '../../panel.js'; +import { inDevlog } from '../../melden.js'; /* DIE ROLLEN WERDEN GELESEN, NICHT GEPFLEGT. @@ -71,6 +90,26 @@ export const data = new SlashCommandBuilder() .addUserOption((o) => o.setName('person').setDescription('Wen nachschlagen?').setRequired(true) ) + ) + .addSubcommand((s) => + s.setName('add') + .setDescription('Jemanden freischalten') + .addUserOption((o) => + o.setName('person').setDescription('Wen freischalten?').setRequired(true) + ) + .addStringOption((o) => + o.setName('grund').setDescription('Warum? Steht später im Log.').setRequired(false) + ) + ) + .addSubcommand((s) => + s.setName('remove') + .setDescription('Freischaltung zurücknehmen') + .addUserOption((o) => + o.setName('person').setDescription('Wem?').setRequired(true) + ) + .addStringOption((o) => + o.setName('grund').setDescription('Warum? Steht später im Log.').setRequired(false) + ) ); export async function execute(interaction) { @@ -106,6 +145,12 @@ export async function execute(interaction) { } const ziel = interaction.options.getUser('person', true); + const was = interaction.options.getSubcommand(); + + if (was === 'add' || was === 'remove') { + return schreiben(interaction, ziel, was, erlaubnis.rolle); + } + const p = await person(ziel.id); if (!p.ok) { @@ -174,3 +219,120 @@ export async function execute(interaction) { return interaction.editReply(zeilen.filter(Boolean).join('\n')); } + +/** + * Freischalten oder zurücknehmen. + * + * SIEBEN PRÜFUNGEN, BEVOR ETWAS PASSIERT. Jede davon hat einen eigenen Satz — + * „geht nicht" ist keine Auskunft, mit der jemand weiterkommt. + */ +async function schreiben(interaction, ziel, was, aufruferRolle) { + const z = await zutritt(); + + if (!z) { + return interaction.editReply( + 'Der Spielserver sagt gerade nicht, welche Rollen gelten — ich schalte nichts frei, was ich nicht prüfen kann.' + ); + } + + if (!z.vergaberolle) { + return interaction.editReply( + 'Es ist keine Rolle zum Vergeben eingetragen (`d4rk_discord:grantrole` in `secrets.cfg`). Bis dahin kann ich niemanden freischalten.' + ); + } + + /* + DIE ROLLE MUSS AUCH ZUTRITT OEFFNEN. + + Sonst vergibt der Befehl etwas Wirkungsloses und meldet Erfolg — die + unangenehmste Sorte Fehler, weil sie erst auffaellt, wenn jemand vor der + Tuer steht. + */ + if (!z.vergabeGueltig) { + return interaction.editReply( + `Die eingetragene Vergaberolle (\`${z.vergaberolle}\`) ist **keine** der Rollen, die Zutritt öffnen. ` + + 'Ich vergebe sie nicht — sie würde niemanden hereinlassen.' + ); + } + + const mitglied = await interaction.guild?.members.fetch(ziel.id).catch(() => null); + + if (!mitglied) { + return interaction.editReply(`${ziel.tag} ist nicht auf diesem Discord.`); + } + + /* + KANN ICH DIE ROLLE UEBERHAUPT VERGEBEN? + + Discord laesst einen Bot nur Rollen UNTERHALB seiner eigenen hoechsten + vergeben. Ohne diese Pruefung kommt eine nackte HTTP-50013, und die liest + sich wie „der Bot ist kaputt" statt wie „die Rolle steht zu weit oben". + */ + const rolle = interaction.guild.roles.cache.get(z.vergaberolle) + ?? await interaction.guild.roles.fetch(z.vergaberolle).catch(() => null); + + if (!rolle) { + return interaction.editReply( + `Die Rolle \`${z.vergaberolle}\` gibt es auf diesem Discord nicht. Steht in \`secrets.cfg\` die richtige ID?` + ); + } + + const ich = interaction.guild.members.me; + + if (!ich?.permissions.has('ManageRoles')) { + return interaction.editReply('Mir fehlt das Recht **Rollen verwalten** auf diesem Discord.'); + } + + if (rolle.position >= ich.roles.highest.position) { + return interaction.editReply( + `**${rolle.name}** steht in der Rollenliste über meiner höchsten Rolle — Discord lässt mich sie deshalb nicht vergeben. ` + + 'Schieb meine Rolle darüber.' + ); + } + + const hatte = mitglied.roles.cache.has(rolle.id); + + // NICHTS ZU TUN IST EINE ANTWORT, KEIN FEHLSCHLAG. + if (was === 'add' && hatte) { + return interaction.editReply(`${ziel.tag} hat **${rolle.name}** bereits.`); + } + + if (was === 'remove' && !hatte) { + return interaction.editReply(`${ziel.tag} hat **${rolle.name}** gar nicht.`); + } + + const grund = interaction.options.getString('grund') || null; + const spur = `${interaction.user.tag} (${aufruferRolle})${grund ? ` — ${grund}` : ''}`; + + try { + if (was === 'add') await mitglied.roles.add(rolle, spur); + else await mitglied.roles.remove(rolle, spur); + } catch (fehler) { + return interaction.editReply( + `Discord hat es abgelehnt: ${fehler?.message ?? 'unbekannt'}` + ); + } + + /* + ERST HANDELN, DANN MELDEN - und die Meldung darf nicht mitreissen. + + Ein fehlgeschlagener Logeintrag waere ein schlechter Grund, eine bereits + vergebene Rolle als Fehler darzustellen. + */ + await inDevlog(interaction.client, { + title: was === 'add' ? 'Whitelist: freigeschaltet' : 'Whitelist: zurückgenommen', + description: + `**${ziel.tag}** (\`${ziel.id}\`)\n` + + `Rolle: **${rolle.name}**\n` + + `Durch: **${interaction.user.tag}** (${aufruferRolle})` + + (grund ? `\nGrund: ${grund}` : ''), + color: was === 'add' ? 0x3fb950 : 0xd29922, + timestamp: new Date().toISOString(), + }); + + return interaction.editReply( + was === 'add' + ? `${ziel.tag} hat jetzt **${rolle.name}** und kommt auf den Server.` + : `${ziel.tag} hat **${rolle.name}** nicht mehr.` + ); +} diff --git a/src/melden.js b/src/melden.js index a7f52cd..70cd41d 100644 --- a/src/melden.js +++ b/src/melden.js @@ -55,3 +55,35 @@ export async function dmAdminEinmalig(client, schluessel, zustand, embedBauen = setSetting(merker, zustand); return dmAdmin(client, embedBauen()); } + +/** + * Ein Embed in den Devlog-Kanal. + * + * WARUM NICHT ALS DM: eine DM erreicht genau eine Person. Was das Team tut, + * gehört dorthin, wo das Team es sieht — sonst ist es protokolliert und + * trotzdem unsichtbar. + * + * WIRFT NIE. Ein fehlgeschlagener Logeintrag darf den Vorgang nicht + * mitreißen, über den er berichtet — dieselbe Regel wie bei `dmAdmin`. Der + * Rückgabewert sagt, ob es geklappt hat; wer es wissen muss, fragt ihn. + * + * @returns {Promise} + */ +export async function inDevlog(client, embed) { + if (!config.devlogChannelId || !client) return false; + + try { + const kanal = await client.channels.fetch(config.devlogChannelId).catch(() => null); + + // Kein Textkanal ist kein Absturz: der Kanal kann gelöscht oder in + // eine Kategorie umgewandelt worden sein, und der Bot soll dann + // weiterarbeiten statt zu scheitern. + if (!kanal?.isTextBased?.()) return false; + + await kanal.send({ embeds: [embed] }); + + return true; + } catch { + return false; + } +}