Linked Roles: Rollen, die an geprueften Werten haengen
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.',
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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.',
|
||||
|
||||
+14
-2
@@ -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();
|
||||
|
||||
@@ -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 `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${titel}</title>
|
||||
<style>
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0a0a0a;
|
||||
color:#e8e0d0;font-family:system-ui,sans-serif;text-align:center;padding:1.5rem}
|
||||
.k{max-width:26rem}h1{font-size:1.25rem;margin:0 0 .6rem;color:${farbe}}
|
||||
p{color:#8a8378;line-height:1.6;margin:0}
|
||||
small{display:block;margin-top:1.5rem;color:#5f5b54;font-size:.75rem;letter-spacing:.1em}
|
||||
</style></head><body><div class="k"><h1>${titel}</h1><p>${text}</p>
|
||||
<small>${brandName()}</small></div></body></html>`;
|
||||
}
|
||||
|
||||
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) };
|
||||
});
|
||||
}
|
||||
@@ -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' }));
|
||||
|
||||
Reference in New Issue
Block a user