From 137452da11659c1e16829128c016aa956d2129fa Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sat, 1 Aug 2026 05:57:57 +0200 Subject: [PATCH] Linked Roles: Rollen, die an geprueften Werten haengen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Modul, standardmaessig aus. Ein Mitglied klickt in Discord auf "Rolle holen", landet auf /linked-roles, bestaetigt den OAuth-Dialog — und der Bot schiebt ihm drei Kennzahlen zu, die Discord dann gegen die Rollen-Einstellung prueft: level Zahl >= eingestelltem Wert dabei_seit Datum <= "vor X Tagen", also Mitglied seit mindestens X Tagen playtester Wahrheitswert Der Bot vergibt dabei keine Rollen. Er liefert nur die Zahlen; was daraus wird, entscheidet die Rollen-Einstellung im Server. Die Daten kommen aus dem, was ohnehin da ist: Level-Tabelle, Playtester-Liste, Discords Beitrittsdatum. Eigener OAuth-Weg statt /auth/login, weil die Berechtigungen andere sind — role_connections.write hat der normale Login nicht. Die Tokens werden gespeichert, und das ist keine Bequemlichkeit: ohne Refresh-Token bliebe jede Rolle auf dem Stand des Verknuepfungs-Tages stehen, ein Level-Up kaeme nie an. Discords eigene Anleitung sagt dasselbe. Der Umfang ist eng (nur identify + role_connections.write), beim Widerruf oder Entkoppeln fliegt der Datensatz sofort raus. Steht so auch als Kommentar an der Tabelle. Abgeglichen wird alle 6 Stunden, einstellbar. Dafuer kann everyTuned jetzt auch Stunden — und wirft bei einer unbekannten Einheit, statt den Abstand still um Faktor 60 oder 3600 danebenzulegen. Beim Bauen gefunden: moduleEnabled fehlte im Import von client.js. Der Bot waere beim ClientReady mit ReferenceError gestorben. Geprueft: alle drei Kennzahlen gegen Discords Formatgrenzen (Schluessel-Regex, Laengen, gueltige Typ-Codes), Modul- und Stellwert-Register, die Stunden-Umrechnung (6 h = 21600000 ms), Speichern und Loeschen einer Verknuepfung, dazu 186 SQL-Abfragen und alle Routen auf Rechtepruefung. Co-Authored-By: Claude Fable 5 --- frontend/src/locales/en.js | 2 + frontend/src/module-icons.jsx | 1 + src/bot/client.js | 13 +++ src/bot/linked-roles.js | 149 ++++++++++++++++++++++++++++++++++ src/db.js | 36 ++++++++ src/modules.js | 5 ++ src/tuning.js | 16 +++- src/web/linked-roles.js | 136 +++++++++++++++++++++++++++++++ src/web/server.js | 2 + 9 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 src/bot/linked-roles.js create mode 100644 src/web/linked-roles.js diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index b386a1e..c0d8a45 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -291,6 +291,8 @@ export default { 'feat.alpha_keys.desc': 'A pool of keys handed out by direct message.', 'feat.role_menus': 'Role menus', 'feat.role_menus.desc': 'Self-service roles at the press of a button — on Discord and in the profile.', + 'feat.linked_roles': 'Linked roles', + 'feat.linked_roles.desc': 'Roles tied to verified numbers — level, playtester, how long someone has been around.', 'feat.applications': 'Applications', 'feat.applications.desc': 'Forms as input dialogs, review with accept and reject.', diff --git a/frontend/src/module-icons.jsx b/frontend/src/module-icons.jsx index 63d57a0..84551b8 100644 --- a/frontend/src/module-icons.jsx +++ b/frontend/src/module-icons.jsx @@ -20,6 +20,7 @@ export const MODULE_ICONS = { events: IconEvents, playtester: IconGame, alpha_keys: IconKey, role_menus: IconRoles, applications: IconApplications, moderation: IconShield, anti_raid: IconLock, modlog: IconArchive, modmail: IconMail, + linked_roles: IconLink, tickets: IconSupport, welcome: IconMagic, autorole: IconTeam, sticky_roles: IconRefresh, bug_reports: IconWarning, server_monitor: IconServer, watchdog: IconMonitor, temp_voice: IconVoice, diff --git a/src/bot/client.js b/src/bot/client.js index b6c8c99..8f23529 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -4,10 +4,12 @@ import { config } from '../config.js'; import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId, brandColor, brandFooter, discordGuildId } from '../runtime-settings.js'; import { renderTemplate } from '../templates.js'; import { tuning } from '../tuning.js'; +import { moduleEnabled } from '../modules.js'; import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js'; import { registerCommunityListeners } from './community.js'; import { registerModTools } from './mod-tools.js'; import { registerAntiRaid } from './anti-raid.js'; +import { registerMetadata, startLinkedRoles } from './linked-roles.js'; import { registerLevels } from './levels.js'; import { registerPresence } from './presence.js'; import { registerExtras } from './extras.js'; @@ -86,6 +88,17 @@ export async function startBot() { } catch (error) { console.error('[bot] Slash-Commands konnten nicht registriert werden:', error.message); } + // Kennzahlen fuer verknuepfte Rollen anmelden — idempotent, aber ein + // Fehlschlag darf den Start nicht mitnehmen (siehe oben) + if (moduleEnabled('linked_roles')) { + try { + const n = await registerMetadata(); + console.log(`[linkedroles] ${n} Kennzahlen bei Discord angemeldet`); + } catch (error) { + console.error('[linkedroles] Kennzahlen nicht angemeldet:', error.message); + } + } + startLinkedRoles(client); }); // Live-Archivierung: neue Devlogs (Webhook-Posts im Devlog-Kanal) sofort sichern diff --git a/src/bot/linked-roles.js b/src/bot/linked-roles.js new file mode 100644 index 0000000..306c52c --- /dev/null +++ b/src/bot/linked-roles.js @@ -0,0 +1,149 @@ +// Linked Roles: Rollen, die an geprüfte Daten aus dem Bot hängen. +// +// Der Ablauf ist Discords: Ein Mitglied klickt in Discord auf „Rolle holen", +// landet auf unserer Verifizierungs-Adresse, bestätigt den OAuth-Dialog, und +// wir schieben ihm seine Kennzahlen zu. Discord vergleicht die dann mit dem, +// was du an der Rolle eingestellt hast, und setzt sie oder eben nicht. +// +// Wir vergeben also keine Rollen — wir liefern nur die Zahlen. Was daraus +// wird, entscheidet die Rollen-Einstellung im Server. +import { config } from '../config.js'; +import { getLevelRow, isPlaytester, getRoleConnection, saveRoleConnection, allRoleConnections, deleteRoleConnection } from '../db.js'; +import { moduleEnabled } from '../modules.js'; +import { brandName, discordGuildId } from '../runtime-settings.js'; +import { everyTuned } from '../tuning.js'; + +const API = 'https://discord.com/api/v10'; + +/** + * Die Kennzahlen, die Discord kennt. Höchstens fünf erlaubt, wir nutzen drei. + * + * Die Typen sind Discords Vergleichs-Codes: + * 2 = Zahl ≥ eingestelltem Wert + * 5 = Datum ≤ „vor X Tagen", also „dabei seit mindestens X Tagen" + * 7 = Wahrheitswert ist wahr + */ +export const METADATA = [ + { key: 'level', name: 'Level', description: 'Mindest-Level im Discord', type: 2 }, + { key: 'dabei_seit', name: 'Dabei seit', description: 'Mitglied seit mindestens X Tagen', type: 5 }, + { key: 'playtester', name: 'Playtester', description: 'Im Playtester-Programm', type: 7 }, +]; + +/** Kennzahlen einmalig bei Discord anmelden — ohne das kennt die Rolle sie nicht */ +export async function registerMetadata() { + const res = await fetch(`${API}/applications/${config.discordClientId}/role-connections/metadata`, { + method: 'PUT', + headers: { + Authorization: `Bot ${config.discordToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(METADATA), + }); + if (!res.ok) { + throw new Error(`${res.status} ${(await res.text()).slice(0, 200)}`); + } + return METADATA.length; +} + +/** Was wir über ein Mitglied wissen — in der Form, die Discord erwartet */ +export async function metadataFor(client, userId) { + const level = getLevelRow(userId)?.level ?? 0; + + // Beitrittsdatum kommt von Discord, nicht aus unserer Datenbank + let dabeiSeit = null; + const guildId = discordGuildId(); + if (guildId) { + const guild = client.guilds.cache.get(guildId); + const member = guild ? await guild.members.fetch(userId).catch(() => null) : null; + dabeiSeit = member?.joinedAt?.toISOString() ?? null; + } + + return { + level, + // Fehlt das Datum, lieber weglassen als etwas Falsches behaupten + ...(dabeiSeit ? { dabei_seit: dabeiSeit } : {}), + playtester: isPlaytester(userId) ? 1 : 0, + }; +} + +/** Abgelaufenes Zugriffs-Token erneuern; gibt den gültigen Datensatz zurück */ +async function gueltigeTokens(row) { + // Eine Minute Puffer: ein Token, das in zehn Sekunden abläuft, ist wertlos + if (row.expires_at > Date.now() + 60_000) return row; + + const res = await fetch(`${API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: config.discordClientId, + client_secret: config.discordClientSecret, + grant_type: 'refresh_token', + refresh_token: row.refresh_token, + }), + }); + if (!res.ok) { + // Widerrufen oder abgelaufen — Verknüpfung ist tot, Datensatz weg + if (res.status === 400 || res.status === 401) { + deleteRoleConnection(row.user_id); + console.warn(`[linkedroles] Verknüpfung von ${row.user_id} widerrufen, entfernt`); + } + return null; + } + const t = await res.json(); + const neu = { + user_id: row.user_id, + username: row.username, + access_token: t.access_token, + refresh_token: t.refresh_token, + expires_at: Date.now() + t.expires_in * 1000, + }; + saveRoleConnection(neu); + return neu; +} + +/** Kennzahlen eines Mitglieds zu Discord schieben */ +export async function pushConnection(client, userId) { + const row = getRoleConnection(userId); + if (!row) return false; + const tokens = await gueltigeTokens(row); + if (!tokens) return false; + + const res = await fetch(`${API}/users/@me/applications/${config.discordClientId}/role-connection`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${tokens.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + platform_name: brandName(), + platform_username: tokens.username ?? undefined, + metadata: await metadataFor(client, userId), + }), + }); + if (!res.ok) { + console.error(`[linkedroles] Push für ${userId} fehlgeschlagen: ${res.status}`); + return false; + } + return true; +} + +/** + * Alle Verknüpfungen auffrischen. Nötig, weil Discord die Kennzahlen nicht + * selbst nachfragt — ohne das bliebe jede Rolle auf dem Stand des Tages, an + * dem verknüpft wurde. + */ +export async function syncAll(client) { + if (!moduleEnabled('linked_roles')) return; + const alle = allRoleConnections(); + let ok = 0; + for (const row of alle) { + // Nacheinander statt alle auf einmal: bei vielen Mitgliedern liefe + // man sonst in Discords Rate-Limit + if (await pushConnection(client, row.user_id).catch(() => false)) ok++; + } + if (alle.length > 0) console.log(`[linkedroles] ${ok}/${alle.length} Verknüpfungen aufgefrischt`); +} + +export function startLinkedRoles(client) { + everyTuned('linked_sync', 'hours', () => syncAll(client), 'linkedroles'); +} diff --git a/src/db.js b/src/db.js index 6805d1e..f87f854 100644 --- a/src/db.js +++ b/src/db.js @@ -1074,6 +1074,42 @@ export function setSetting(key, value) { setSettingStmt.run(key, value); } +// Linked Roles: OAuth-Tokens der Mitglieder, die ihr Konto verknüpft haben. +// +// Das Speichern ist nicht optional — ohne Refresh-Token bliebe die verknüpfte +// Rolle für immer auf dem Stand des Verknüpfungs-Tages stehen, ein Level-Up +// käme nie an. Discords eigene Anleitung sagt dasselbe. +// +// Die Tokens gelten NUR für role_connections.write und identify. Damit kann +// man weder Nachrichten lesen noch im Namen des Users handeln. Trotzdem: +// wer die Datenbank hat, hat diese Tokens — sie liegen im selben Volume wie +// alles andere und werden beim Entkoppeln sofort gelöscht. +db.exec(` + CREATE TABLE IF NOT EXISTS role_connections ( + user_id TEXT PRIMARY KEY, + username TEXT, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at INTEGER NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +`); +const upsertRoleConnStmt = db.prepare(` + INSERT INTO role_connections (user_id, username, access_token, refresh_token, expires_at, updated_at) + VALUES (@user_id, @username, @access_token, @refresh_token, @expires_at, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + username = excluded.username, access_token = excluded.access_token, + refresh_token = excluded.refresh_token, expires_at = excluded.expires_at, + updated_at = datetime('now') +`); +const getRoleConnStmt = db.prepare('SELECT * FROM role_connections WHERE user_id = ?'); +const allRoleConnStmt = db.prepare('SELECT * FROM role_connections'); +const deleteRoleConnStmt = db.prepare('DELETE FROM role_connections WHERE user_id = ?'); +export const saveRoleConnection = (r) => upsertRoleConnStmt.run(r); +export const getRoleConnection = (userId) => getRoleConnStmt.get(userId) ?? null; +export const allRoleConnections = () => allRoleConnStmt.all(); +export const deleteRoleConnection = (userId) => deleteRoleConnStmt.run(userId).changes > 0; + // Einmalige Übernahme der alten kommagetrennten Watchdog-Liste in die // Dienst-Tabelle. Muss hier unten stehen, weil sie getSetting() braucht. // diff --git a/src/modules.js b/src/modules.js index cca311d..eb5d088 100644 --- a/src/modules.js +++ b/src/modules.js @@ -198,6 +198,11 @@ export const MODULES = [ tab: 'community', default: true, requires: [{ setting: 'autorole_id', label: 'Auto-Rolle', create: { rolle: true, name: 'Member' } }], }, + { + id: 'linked_roles', group: 'community', name: 'Verknüpfte Rollen', + desc: 'Rollen, die an geprüfte Werte hängen — Level, Playtester, Dauer der Mitgliedschaft.', + tab: 'rollen', setting: 'linked_roles_enabled', defaultOn: false, + }, { id: 'sticky_roles', group: 'moderation', name: 'Rollen merken', desc: 'Wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder.', diff --git a/src/tuning.js b/src/tuning.js index 1c9f275..22e2853 100644 --- a/src/tuning.js +++ b/src/tuning.js @@ -103,6 +103,11 @@ export const TUNING = [ id: 'raid_window', module: 'anti_raid', group: 'grenzen', label: 'Raid — Zeitfenster', unit: 'Sekunden', default: 20, min: 5, max: 600, }, + { + id: 'linked_sync', module: 'linked_roles', group: 'pruefung', label: 'Verknüpfte Rollen abgleichen alle', + hint: 'Discord fragt die Werte nicht selbst nach — ohne Abgleich bliebe eine Rolle auf dem Stand des Verknüpfungs-Tages.', + unit: 'Stunden', default: 6, min: 1, max: 168, + }, { id: 'raid_lockdown', module: 'anti_raid', group: 'zeiten', label: 'Raid-Sperre dauert', hint: 'Danach hebt der Bot sie von selbst wieder auf.', @@ -152,6 +157,7 @@ export function tuning(id) { export const tuningMs = { seconds: (id) => tuning(id) * 1000, minutes: (id) => tuning(id) * 60 * 1000, + hours: (id) => tuning(id) * 60 * 60 * 1000, }; /** @@ -207,14 +213,20 @@ export function tuningStates() { * festem Abstand könnte das nicht.) * * @param {string} id — Stellwert - * @param {'minutes'|'seconds'} unit + * @param {'hours'|'minutes'|'seconds'} unit * @param {() => unknown} task * @param {string} tag — Kennung fürs Fehler-Log * @returns {() => void} Abbrechen */ export function everyTuned(id, unit, task, tag = id) { let timer; - const delay = () => (unit === 'minutes' ? tuningMs.minutes(id) : tuningMs.seconds(id)); + // Unbekannte Einheit wäre schlimmer als ein Tippfehler: der Abstand läge + // um Faktor 60 oder 3600 daneben, ohne dass es jemand merkt. + const delay = () => { + const f = tuningMs[unit]; + if (!f) throw new Error(`everyTuned(${id}): unbekannte Einheit "${unit}"`); + return f(id); + }; const run = async () => { try { await task(); diff --git a/src/web/linked-roles.js b/src/web/linked-roles.js new file mode 100644 index 0000000..15bbb65 --- /dev/null +++ b/src/web/linked-roles.js @@ -0,0 +1,136 @@ +// Verifizierungs-Adresse für Linked Roles. +// +// Diese Adresse trägst du im Discord-Portal unter „Linked Roles Verification +// URL" ein. Discord schickt jeden hierher, der eine verknüpfte Rolle holen +// will; wir holen uns die Erlaubnis, schieben die Kennzahlen zurück und +// zeigen eine kurze Bestätigung. +// +// Eigener OAuth-Weg statt /auth/login, weil die Berechtigungen andere sind: +// hier braucht es role_connections.write, das der normale Login nicht hat. +import crypto from 'node:crypto'; +import { config } from '../config.js'; +import { saveRoleConnection, deleteRoleConnection } from '../db.js'; +import { moduleEnabled } from '../modules.js'; +import { brandName, publicUrl, botUrl, hubUrl } from '../runtime-settings.js'; +import { pushConnection } from '../bot/linked-roles.js'; +import { nurUeberHttps } from './auth.js'; + +const API = 'https://discord.com/api/v10'; +const STATE_COOKIE = 'd4rkbot_lr_state'; + +function basis(request) { + const host = String(request?.headers?.host ?? '').toLowerCase().split(':')[0]; + const treffer = [botUrl(), hubUrl(), publicUrl()].find((u) => { + try { return new URL(u).hostname === host; } catch { return false; } + }); + return treffer ?? publicUrl(); +} + +const redirectUri = (request) => `${basis(request)}/linked-roles/callback`; + +/** Kleine Antwortseite — hier gibt es keine React-App, das ist ein Zwischenstopp */ +function seite(titel, text, ok = true) { + const farbe = ok ? '#3ba55d' : '#f23f43'; + return ` + +${titel} +

${titel}

${text}

+${brandName()}
`; +} + +export function registerLinkedRoleRoutes(app, client) { + // Discords Einstiegspunkt + app.get('/linked-roles', async (request, reply) => { + if (!moduleEnabled('linked_roles')) { + return reply.code(503).type('text/html; charset=utf-8') + .send(seite('Nicht aktiv', 'Verknüpfte Rollen sind für diesen Server ausgeschaltet.', false)); + } + + const state = crypto.randomBytes(16).toString('hex'); + const params = new URLSearchParams({ + client_id: config.discordClientId, + redirect_uri: redirectUri(request), + response_type: 'code', + // identify für den Namen, role_connections.write fürs Zurückschieben + scope: 'identify role_connections.write', + state, + prompt: 'consent', + }); + return reply + .setCookie(STATE_COOKIE, state, { + path: '/linked-roles', httpOnly: true, sameSite: 'lax', + maxAge: 600, signed: true, secure: nurUeberHttps(request), + }) + .redirect(`https://discord.com/oauth2/authorize?${params}`); + }); + + app.get('/linked-roles/callback', async (request, reply) => { + const html = (t, x, ok) => reply.type('text/html; charset=utf-8').send(seite(t, x, ok)); + const { code, state } = request.query; + const keks = request.cookies[STATE_COOKIE] + ? request.unsignCookie(request.cookies[STATE_COOKIE]) + : null; + if (!code || !state || !keks?.valid || keks.value !== state) { + return html('Abgebrochen', 'Der Sicherheits-Code passte nicht. Versuch es in Discord noch einmal.', false); + } + reply.clearCookie(STATE_COOKIE, { path: '/linked-roles' }); + + const tokenRes = await fetch(`${API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: config.discordClientId, + client_secret: config.discordClientSecret, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri(request), + }), + }); + if (!tokenRes.ok) { + request.log.error(`Linked-Roles-Token fehlgeschlagen: ${tokenRes.status}`); + return html('Fehlgeschlagen', 'Discord hat die Anmeldung abgelehnt. Bitte noch einmal versuchen.', false); + } + const t = await tokenRes.json(); + + const meRes = await fetch(`${API}/users/@me`, { + headers: { Authorization: `Bearer ${t.access_token}` }, + }); + if (!meRes.ok) return html('Fehlgeschlagen', 'Deine Discord-Daten waren nicht abrufbar.', false); + const me = await meRes.json(); + + saveRoleConnection({ + user_id: me.id, + username: me.global_name || me.username, + access_token: t.access_token, + refresh_token: t.refresh_token, + expires_at: Date.now() + t.expires_in * 1000, + }); + + // Direkt losschicken — sonst stünde die Rolle bis zum nächsten + // Auffrischen auf leeren Werten und der Nutzer sähe nichts passieren + const ok = await pushConnection(client, me.id).catch(() => false); + if (!ok) { + // Verknüpfung ist da, nur das Übertragen klemmte — Tokens behalten, + // der nächste Auffrisch-Lauf holt es nach + return html('Fast geschafft', 'Verknüpft, aber die Übertragung deiner Werte hat gehakt. ' + + 'Sie wird beim nächsten Abgleich automatisch nachgeholt.', false); + } + return html('Verknüpft', 'Deine Werte liegen jetzt bei Discord. ' + + 'Du kannst dieses Fenster schließen und in Discord die Rolle holen.'); + }); + + // Entkoppeln: löscht die Tokens bei uns. In Discord selbst nimmt der Nutzer + // die Verknüpfung unter Einstellungen → Autorisierte Apps zurück. + app.post('/api/linked-roles/unlink', async (request, reply) => { + const { getSessionUser } = await import('./auth.js'); + const user = getSessionUser(request); + if (!user) return reply.code(401).send({ error: 'nicht angemeldet' }); + return { deleted: deleteRoleConnection(user.id) }; + }); +} diff --git a/src/web/server.js b/src/web/server.js index f668dc8..01bfc21 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -18,6 +18,7 @@ import { registerApiV1 } from './api-v1.js'; import { registerDevlogEndpoint } from './devlog-endpoint.js'; import { registerSsoRoutes } from './sso.js'; import { registerBrandRoutes } from './brand.js'; +import { registerLinkedRoleRoutes } from './linked-roles.js'; import { imagesDir } from '../bot/devlog-archive.js'; // Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden, @@ -71,6 +72,7 @@ export async function startWebServer(client) { registerDevlogEndpoint(app, client); registerSsoRoutes(app, client); registerBrandRoutes(app); + registerLinkedRoleRoutes(app, client); // Healthcheck (für Portainer/NPM) app.get('/health', async () => ({ status: 'ok' }));