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:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user