Server-Tab (DiscordGSM-Stil), Team-Rechte und Bot-Profil-Beschreibung

- Game-Server: eigene Tabelle statt CSV-Setting (automatische Migration), eigener
  Setup-Tab mit Server-Builder (Name, Typ fivem/http, Query-URL, Anzeige-Adresse);
  pro Server ein Live-Embed (gruen/rot, Spieler-Balken, Map, Adresse als Code-Block),
  edit in place; Embed wird beim Server-Loeschen mit entfernt
- Team-System: web_admins mit Bereichs-Scopes (content/community/rollen/bewerbungen/
  server/settings/devlogs); Owner behaelt Brand/System/API-Keys/Team exklusiv;
  32 Routen-Guards auf Scopes umgestellt, sensible PUT-Felder fuer Team gestrippt;
  Team-Tab (Owner) + Tab-Filterung im Frontend, /api/me liefert Scopes
- Bot-Karte: Profil-Beschreibung (Ueber mich) via application.edit
- Fix: settings-Tabelle wird vor der Gameserver-Migration angelegt (frische DBs
  crashten sonst beim Start)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 01:23:11 +02:00
co-authored by Claude Fable 5
parent 19ec2802ac
commit 332e89dcbd
6 changed files with 533 additions and 115 deletions
+67 -57
View File
@@ -1,96 +1,106 @@
// Game-Server-Monitor: pollt FiveM-kompatible HTTP-Endpoints (/dynamic.json)
// und pflegt ein persistentes Status-Embed im Status-Kanal + Bot-Presence.
// Setting "gameservers": kommagetrennt "Name=http://ip:30120, Name2=…"
// Game-Server-Monitor (DiscordGSM-Stil): pro Server ein eigenes Status-Embed
// im Status-Kanal (edit in place, grün/rot), alle 2 Minuten aktualisiert.
// Server werden im Setup-Tab "Server" verwaltet (Typ: fivem | http).
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
import { getSetting, setSetting } from '../db.js';
import { brandColor, brandFooter, botStatusText } from '../runtime-settings.js';
import { getSetting, listGameservers, setGameserverMessage } from '../db.js';
import { brandFooter, botStatusText } from '../runtime-settings.js';
const CHECK_INTERVAL_MS = 2 * 60 * 1000;
const TIMEOUT_MS = 8000;
const GREEN = 0x23a55a;
const RED = 0xf23f43;
function serverList() {
return (getSetting('gameservers') ?? '')
.split(',')
.map((entry) => {
const [name, ...rest] = entry.split('=');
const url = rest.join('=').trim().replace(/\/$/, '');
return name?.trim() && /^https?:\/\//.test(url) ? { name: name.trim(), url } : null;
})
.filter(Boolean);
}
/** Einen Server abfragen (FiveM-Format: clients, sv_maxclients, hostname) */
async function queryServer(server) {
/** Einen Server abfragen — je nach Typ */
export async function queryServer(server) {
const base = { ...server, online: false, players: null, max: null, map: null };
try {
const res = await fetch(`${server.url}/dynamic.json`, {
if (server.type === 'fivem') {
const res = await fetch(`${server.query_url.replace(/\/$/, '')}/dynamic.json`, {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'User-Agent': 'd4rkbot-monitor/2.0' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return {
...base,
online: true,
players: Number(data.clients) || 0,
max: Number(data.sv_maxclients) || 0,
map: data.mapname ?? null,
};
}
// Typ 'http': generischer Healthcheck (2xx/3xx/4xx = erreichbar)
const res = await fetch(server.query_url, {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'User-Agent': 'd4rkbot-monitor/1.0' },
redirect: 'follow',
headers: { 'User-Agent': 'd4rkbot-monitor/2.0' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return {
...server,
online: true,
players: Number(data.clients) || 0,
max: Number(data.sv_maxclients) || 0,
map: data.mapname ?? null,
};
return { ...base, online: res.status < 500 };
} catch {
return { ...server, online: false, players: 0, max: 0, map: null };
return base;
}
}
/** Status-Embed bauen */
function buildEmbed(results) {
const lines = results.map((r) =>
r.online
? `🟢 **${r.name}** — ${r.players}/${r.max} Spieler${r.map ? ` · ${r.map}` : ''}`
: `🔴 **${r.name}** — offline`
);
return new EmbedBuilder()
.setColor(brandColor())
.setTitle('🎮 Server-Status')
.setDescription(lines.join('\n'))
.setFooter({ text: `${brandFooter('STATUS')} • aktualisiert alle 2 min` })
/** Status-Embed für einen Server bauen (DiscordGSM-Look) */
export function buildServerEmbed(r) {
const embed = new EmbedBuilder()
.setColor(r.online ? GREEN : RED)
.setTitle(`${r.online ? '🟢' : '🔴'} ${r.name}`)
.addFields({ name: 'Status', value: r.online ? '**Online**' : '**Offline**', inline: true })
.setFooter({ text: `${brandFooter('STATUS')} • alle 2 min` })
.setTimestamp();
if (r.online && r.players != null && r.max) {
const pct = Math.min(1, r.players / r.max);
const bar = '█'.repeat(Math.round(pct * 10)).padEnd(10, '░');
embed.addFields({ name: 'Spieler', value: `\`${bar}\` ${r.players}/${r.max}`, inline: true });
}
if (r.map) embed.addFields({ name: 'Map', value: r.map, inline: true });
if (r.address) embed.addFields({ name: 'Adresse', value: `\`\`\`\n${r.address}\n\`\`\`` });
return embed;
}
/** Ein Poll-Durchlauf — exportiert für Tests und den Timer */
export async function monitorTick(client) {
const servers = serverList();
const channelId = getSetting('status_channel_id');
const servers = listGameservers();
if (servers.length === 0) return;
const results = await Promise.all(servers.map(queryServer));
// Bot-Presence: Gesamtspielerzahl — nur wenn kein eigener Status (Brand-Tab) gesetzt ist
const online = results.filter((r) => r.online);
const totalPlayers = online.reduce((sum, r) => sum + r.players, 0);
const withPlayers = results.filter((r) => r.online && r.players != null);
const totalPlayers = withPlayers.reduce((sum, r) => sum + r.players, 0);
if (!botStatusText()) {
try {
client.user?.setActivity?.(
online.length > 0 ? `${totalPlayers} Spieler online` : 'Server offline',
results.some((r) => r.online) ? `${totalPlayers} Spieler online` : 'Server offline',
{ type: ActivityType.Watching }
);
} catch { /* Presence ist nice-to-have */ }
}
// Persistentes Embed im Status-Kanal pflegen (einmal posten, danach editieren)
// Pro Server ein Embed pflegen
const channelId = getSetting('status_channel_id');
if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased()) return;
const embed = buildEmbed(results);
const messageId = getSetting('status_message_id');
if (messageId) {
const existing = await channel.messages.fetch(messageId).catch(() => null);
if (existing) {
await existing.edit({ embeds: [embed] });
return;
for (const r of results) {
const payload = { embeds: [buildServerEmbed(r)] };
try {
if (r.message_id) {
const existing = await channel.messages.fetch(r.message_id).catch(() => null);
if (existing) {
await existing.edit(payload);
continue;
}
}
const message = await channel.send(payload);
setGameserverMessage(r.id, message.id);
} catch (error) {
console.error(`[monitor] Embed für ${r.name} fehlgeschlagen:`, error.message);
}
}
const message = await channel.send({ embeds: [embed] });
setSetting('status_message_id', message.id);
}
export function startServerMonitor(client) {
+66
View File
@@ -404,6 +404,72 @@ export const listTempVoice = () => listTempVoiceStmt.all().map((r) => r.channel_
const isTempVoiceStmt = db.prepare('SELECT 1 FROM temp_voice WHERE channel_id = ?');
export const isTempVoice = (id) => Boolean(isTempVoiceStmt.get(id));
// Team: Web-Zugriff mit Bereichs-Rechten (Owner = ADMIN_DISCORD_ID darf immer alles)
db.exec(`
CREATE TABLE IF NOT EXISTS web_admins (
user_id TEXT PRIMARY KEY,
username TEXT,
scopes TEXT NOT NULL DEFAULT ''
);
`);
const upsertWebAdmin = db.prepare(`
INSERT INTO web_admins (user_id, username, scopes) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, scopes = excluded.scopes
`);
const webAdminStmt = db.prepare('SELECT * FROM web_admins WHERE user_id = ?');
const listWebAdminsStmt = db.prepare('SELECT * FROM web_admins ORDER BY username');
const deleteWebAdminStmt = db.prepare('DELETE FROM web_admins WHERE user_id = ?');
export const saveWebAdmin = (userId, username, scopes) =>
upsertWebAdmin.run(userId, username, scopes.join(','));
export const webAdminScopes = (userId) =>
(webAdminStmt.get(userId)?.scopes ?? '').split(',').filter(Boolean);
export const listWebAdmins = () => listWebAdminsStmt.all();
export const deleteWebAdmin = (userId) => deleteWebAdminStmt.run(userId).changes > 0;
// Game-Server (DiscordGSM-Stil): strukturierte Server statt CSV-Setting
db.exec(`
CREATE TABLE IF NOT EXISTS gameservers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'fivem',
query_url TEXT NOT NULL,
address TEXT NOT NULL DEFAULT '',
message_id TEXT
);
`);
// Einmalige Migration aus dem alten "gameservers"-CSV-Setting ("Name=URL, …")
{
// settings-Tabelle kann hier noch fehlen (wird sonst erst weiter unten angelegt)
db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
const hasServers = db.prepare('SELECT count(*) AS n FROM gameservers').get().n > 0;
const legacy = db.prepare(`SELECT value FROM settings WHERE key = 'gameservers'`).get()?.value;
if (!hasServers && legacy) {
const ins = db.prepare(`INSERT INTO gameservers (name, type, query_url) VALUES (?, 'fivem', ?)`);
for (const entry of legacy.split(',')) {
const [name, ...rest] = entry.split('=');
const url = rest.join('=').trim().replace(/\/$/, '');
if (name?.trim() && /^https?:\/\//.test(url)) ins.run(name.trim(), url);
}
db.prepare(`DELETE FROM settings WHERE key = 'gameservers'`).run();
}
}
const insertGameserver = db.prepare(`
INSERT INTO gameservers (name, type, query_url, address) VALUES (@name, @type, @query_url, @address)
`);
const updateGameserverStmt = db.prepare(`
UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address WHERE id = @id
`);
const setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?');
const listGameserversStmt = db.prepare('SELECT * FROM gameservers ORDER BY id');
const getGameserverStmt = db.prepare('SELECT * FROM gameservers WHERE id = ?');
const deleteGameserverStmt = db.prepare('DELETE FROM gameservers WHERE id = ?');
export const createGameserver = (s) => insertGameserver.run(s).lastInsertRowid;
export const updateGameserver = (s) => updateGameserverStmt.run(s).changes > 0;
export const setGameserverMessage = (id, messageId) => setGameserverMessageStmt.run(messageId, id);
export const listGameservers = () => listGameserversStmt.all();
export const getGameserver = (id) => getGameserverStmt.get(id) ?? null;
export const deleteGameserver = (id) => deleteGameserverStmt.run(id).changes > 0;
// Moderation: Verwarnungen + Sticky-Roles
db.exec(`
CREATE TABLE IF NOT EXISTS warns (
+173 -33
View File
@@ -10,6 +10,8 @@ import {
addAlphaKeys, freeAlphaKeyCount, assignedAlphaKeys, alphaKeyOf, reserveAlphaKey, unreserveAlphaKey,
createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm,
saveTrigger, listTriggers, deleteTrigger, activityRange,
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
} from '../db.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
@@ -28,7 +30,7 @@ function paging(request) {
return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page };
}
/** Admin-Guard: null = okay, sonst wurde bereits eine Fehler-Antwort gesendet */
/** Owner-Guard (ADMIN_DISCORD_ID): null = okay, sonst wurde bereits geantwortet */
function requireAdmin(request, reply) {
const user = getSessionUser(request);
if (!user) return reply.code(401).send({ error: 'login required' });
@@ -36,11 +38,36 @@ function requireAdmin(request, reply) {
return null;
}
/** Web-Scopes des eingeloggten Users: Owner = ['*'], Team = aus web_admins */
function scopesOf(user) {
if (!user) return [];
if (isAdmin(user)) return ['*'];
return webAdminScopes(user.id);
}
/** Bereichs-Guard: Owner oder Team-Mitglied mit passendem Scope */
function requireScope(request, reply, scope) {
const user = getSessionUser(request);
if (!user) return reply.code(401).send({ error: 'login required' });
const scopes = scopesOf(user);
if (scopes.includes('*') || scopes.includes(scope)) return null;
return reply.code(403).send({ error: `scope '${scope}' required` });
}
/** Mindestens irgendein Team-Zugang (für gemeinsame Daten wie Kanal-Listen) */
function requireAnyScope(request, reply) {
const user = getSessionUser(request);
if (!user) return reply.code(401).send({ error: 'login required' });
if (scopesOf(user).length === 0) return reply.code(403).send({ error: 'no access' });
return null;
}
export function registerApiRoutes(app, client) {
// Wer bin ich? (fürs Frontend: Login-Status + Admin-Flag)
// Wer bin ich? (fürs Frontend: Login-Status, Owner-Flag + Team-Scopes)
app.get('/api/me', async (request) => {
const user = getSessionUser(request);
return user ? { user, admin: isAdmin(user) } : { user: null, admin: false };
if (!user) return { user: null, admin: false, scopes: [] };
return { user, admin: isAdmin(user), scopes: scopesOf(user) };
});
// Devlog-Archiv — öffentlich (wie der Discord-Kanal); ?q= für Volltextsuche
@@ -76,7 +103,7 @@ export function registerApiRoutes(app, client) {
// Playtester-Liste — Admin
app.get('/api/playtesters', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'community')) return;
return { playtesters: listPlaytesters() };
});
@@ -181,7 +208,7 @@ ${rssItems}
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
app.delete('/api/devlogs/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'devlogs')) return;
const deleted = await removeDevlog(request.params.id);
request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`);
@@ -253,7 +280,6 @@ ${rssItems}
status_channel_id: getSetting('status_channel_id') ?? '',
voting_channel_id: getSetting('voting_channel_id') ?? '',
ticket_channel_id: getSetting('ticket_channel_id') ?? '',
gameservers: getSetting('gameservers') ?? '',
autorole_id: getSetting('autorole_id') ?? '',
brand_name: getSetting('brand_name') ?? 'D4RKST3R',
brand_color: getSetting('brand_color') ?? '#f5c518',
@@ -277,8 +303,10 @@ ${rssItems}
}
app.get('/api/settings', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireAnyScope(request, reply)) return;
// Application-Daten (Beschreibung) nachladen — best effort
await client.application?.fetch?.().catch(() => {});
const stats = archiveStats();
return {
channels: listChannels(),
@@ -288,6 +316,7 @@ ${rssItems}
botTag: client.user?.tag ?? null,
botName: client.user?.username ?? null,
botAvatar: client.user?.displayAvatarURL?.({ size: 128 }) ?? null,
botDescription: client.application?.description ?? '',
uptimeSeconds: Math.floor(process.uptime()),
guilds: client.guilds.cache.size,
giteaTokenConfigured: Boolean(giteaApiToken()),
@@ -298,10 +327,18 @@ ${rssItems}
});
app.put('/api/settings', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'settings')) return;
const body = request.body ?? {};
// Sensible Felder sind Owner-only — für Team-Mitglieder still ignorieren
if (!isAdmin(getSessionUser(request))) {
for (const key of [
'gitea_api_token', 'twitch_client_id', 'twitch_client_secret',
'discord_guild_id', 'bot_name', 'bot_description', 'public_url', 'gitea_url',
]) delete body[key];
}
// Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus)
const OPTIONAL_CHANNELS = [
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
@@ -409,6 +446,15 @@ ${rssItems}
return reply.code(502).send({ error: `Bot-Name: Discord lehnt ab (${error.message?.slice(0, 80)})` });
}
}
// Bot-Profil-Beschreibung („Über mich" der App)
if (body.bot_description !== undefined &&
String(body.bot_description) !== (client.application?.description ?? '')) {
try {
await client.application.edit({ description: String(body.bot_description).slice(0, 400) });
} catch (error) {
return reply.code(502).send({ error: `Beschreibung: Discord lehnt ab (${error.message?.slice(0, 80)})` });
}
}
if (statusChanged) {
const { applyBotStatus } = await import('../bot/presence.js');
applyBotStatus(client);
@@ -429,7 +475,7 @@ ${rssItems}
setSetting(key, String(body[key]).trim());
}
}
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', 'gameservers']) {
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo']) {
if (body[key] !== undefined) {
setSetting(key, String(body[key]).trim());
}
@@ -501,12 +547,12 @@ ${rssItems}
const menuToJson = (m) => ({ ...m, exclusive: Boolean(m.exclusive), entries: JSON.parse(m.entries || '[]') });
app.get('/api/rolemenus', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'rollen')) return;
return { menus: listRoleMenus().map(menuToJson) };
});
app.post('/api/rolemenus', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'rollen')) return;
const menu = parseMenuBody(request, reply);
if (!menu) return;
const id = createRoleMenu(menu);
@@ -515,7 +561,7 @@ ${rssItems}
});
app.put('/api/rolemenus/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'rollen')) return;
const id = Number(request.params.id);
if (!getRoleMenu(id)) return reply.code(404).send({ error: 'Menü nicht gefunden' });
const menu = parseMenuBody(request, reply);
@@ -535,7 +581,7 @@ ${rssItems}
});
app.post('/api/rolemenus/:id/publish', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'rollen')) return;
try {
const messageId = await publishRoleMenu(client, Number(request.params.id));
request.log.info(`Rollen-Menü ${request.params.id} publiziert (${messageId})`);
@@ -546,7 +592,7 @@ ${rssItems}
});
app.delete('/api/rolemenus/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'rollen')) return;
const menu = getRoleMenu(Number(request.params.id));
if (!menu) return { deleted: false };
await unpublishRoleMenu(client, menu);
@@ -564,12 +610,12 @@ ${rssItems}
});
app.get('/api/alphakeys', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'community')) return;
return keyStatus();
});
app.post('/api/alphakeys', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'community')) return;
const keys = String(request.body?.keys ?? '')
.split(/\r?\n/)
.map((k) => k.trim())
@@ -582,7 +628,7 @@ ${rssItems}
// Verteilen: jeder Playtester ohne Key bekommt einen per DM (Key bleibt frei bei DM-Fehler)
app.post('/api/alphakeys/distribute', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'community')) return;
let sent = 0;
const failed = [];
for (const p of keyStatus().playtestersWithout) {
@@ -641,12 +687,12 @@ ${rssItems}
}
app.get('/api/appforms', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'bewerbungen')) return;
return { forms: listAppForms().map(formToJson) };
});
app.post('/api/appforms', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'bewerbungen')) return;
const form = parseFormBody(request, reply);
if (!form) return;
const id = createAppForm(form);
@@ -654,7 +700,7 @@ ${rssItems}
});
app.put('/api/appforms/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'bewerbungen')) return;
const id = Number(request.params.id);
if (!getAppForm(id)) return reply.code(404).send({ error: 'Formular nicht gefunden' });
const form = parseFormBody(request, reply);
@@ -669,7 +715,7 @@ ${rssItems}
});
app.post('/api/appforms/:id/publish', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'bewerbungen')) return;
try {
const messageId = await publishAppForm(client, Number(request.params.id));
return { ok: true, message_id: messageId };
@@ -679,7 +725,7 @@ ${rssItems}
});
app.delete('/api/appforms/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'bewerbungen')) return;
const form = getAppForm(Number(request.params.id));
if (!form) return { deleted: false };
await unpublishAppForm(client, form);
@@ -687,15 +733,72 @@ ${rssItems}
return { deleted: true };
});
// --- Game-Server (Admin) — DiscordGSM-Stil ---
function parseServerBody(request, reply) {
const body = request.body ?? {};
const name = String(body.name ?? '').trim();
const queryUrl = String(body.query_url ?? '').trim().replace(/\/$/, '');
const type = ['fivem', 'http'].includes(body.type) ? body.type : 'fivem';
if (!name || !/^https?:\/\/.+/.test(queryUrl)) {
reply.code(400).send({ error: 'name und query_url (http/https) nötig' });
return null;
}
return {
name: name.slice(0, 60),
type,
query_url: queryUrl,
address: String(body.address ?? '').trim().slice(0, 120),
};
}
app.get('/api/gameservers', async (request, reply) => {
if (requireScope(request, reply, 'server')) return;
return { servers: listGameservers() };
});
app.post('/api/gameservers', async (request, reply) => {
if (requireScope(request, reply, 'server')) return;
const server = parseServerBody(request, reply);
if (!server) return;
const id = createGameserver(server);
return { ok: true, server: getGameserver(id) };
});
app.put('/api/gameservers/:id', async (request, reply) => {
if (requireScope(request, reply, 'server')) return;
const id = Number(request.params.id);
if (!getGameserver(id)) return reply.code(404).send({ error: 'Server nicht gefunden' });
const server = parseServerBody(request, reply);
if (!server) return;
updateGameserver({ ...server, id });
return { ok: true, server: getGameserver(id) };
});
app.delete('/api/gameservers/:id', async (request, reply) => {
if (requireScope(request, reply, 'server')) return;
const server = getGameserver(Number(request.params.id));
if (!server) return { deleted: false };
// Status-Embed mit aufräumen
if (server.message_id) {
const channelId = getSetting('status_channel_id');
const channel = channelId ? await client.channels.fetch(channelId).catch(() => null) : null;
const message = await channel?.messages?.fetch(server.message_id).catch(() => null);
await message?.delete().catch(() => {});
}
deleteGameserver(server.id);
return { deleted: true };
});
// --- Triggers (Admin) ---
app.get('/api/triggers', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'content')) return;
return { triggers: listTriggers() };
});
app.put('/api/triggers', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'content')) return;
const keyword = String(request.body?.keyword ?? '').trim().toLowerCase();
const replyText = String(request.body?.reply ?? '').trim();
if (keyword.length < 3 || !replyText) {
@@ -706,19 +809,19 @@ ${rssItems}
});
app.delete('/api/triggers/:keyword', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'content')) return;
return { deleted: deleteTrigger(String(request.params.keyword).toLowerCase()) };
});
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
app.get('/api/tags', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'content')) return;
return { tags: listTags() };
});
app.put('/api/tags/:name', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'content')) return;
const name = String(request.params.name).trim().toLowerCase();
const content = String(request.body?.content ?? '').trim();
if (!/^[a-z0-9äöüß_-]{1,40}$/.test(name)) {
@@ -730,19 +833,19 @@ ${rssItems}
});
app.delete('/api/tags/:name', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'content')) return;
return { deleted: deleteTag(String(request.params.name).trim().toLowerCase()) };
});
// --- Geplante Posts (Admin) ---
app.get('/api/scheduled', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'content')) return;
return { posts: listScheduledPosts() };
});
app.post('/api/scheduled', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'content')) return;
const body = request.body ?? {};
const channelId = String(body.channel_id ?? '');
const content = String(body.content ?? '').trim();
@@ -774,13 +877,13 @@ ${rssItems}
});
app.delete('/api/scheduled/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'content')) return;
return { deleted: deleteScheduledPost(Number(request.params.id)) };
});
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
app.post('/api/compose', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'content')) return;
const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle } = request.body ?? {};
const text = String(content ?? '').trim();
@@ -851,6 +954,43 @@ ${rssItems}
}
});
// --- Team-Verwaltung (nur Owner) ---
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs'];
app.get('/api/webadmins', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { admins: listWebAdmins(), scopes: TEAM_SCOPES };
});
app.put('/api/webadmins', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const userId = String(request.body?.user_id ?? '').trim();
const scopes = (Array.isArray(request.body?.scopes) ? request.body.scopes : [])
.filter((s) => TEAM_SCOPES.includes(s));
if (!/^\d{15,21}$/.test(userId)) {
return reply.code(400).send({ error: 'user_id: Discord-User-ID erwartet' });
}
if (userId === config.adminDiscordId) {
return reply.code(400).send({ error: 'Der Owner hat immer alle Rechte' });
}
if (scopes.length === 0) return reply.code(400).send({ error: 'mindestens ein Bereich' });
// Username für die Anzeige holen (best effort)
let username = String(request.body?.username ?? '').trim();
if (!username) {
const user = await client.users.fetch(userId).catch(() => null);
username = user?.username ?? userId;
}
saveWebAdmin(userId, username, scopes);
request.log.info(`Team-Mitglied ${username} (${userId}): ${scopes.join(',')}`);
return { ok: true, admins: listWebAdmins() };
});
app.delete('/api/webadmins/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { deleted: deleteWebAdmin(String(request.params.id)), admins: listWebAdmins() };
});
// --- API-Keys (Admin) — für /api/v1/* ---
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
@@ -883,7 +1023,7 @@ ${rssItems}
// Test-Embed in den konfigurierten Kanal senden (prüft die Kanal-Wahl ohne Push/Devlog)
app.post('/api/settings/test/:target', async (request, reply) => {
if (requireAdmin(request, reply)) return;
if (requireScope(request, reply, 'settings')) return;
const target = request.params.target;