Server-Monitor v3, Composer v2, Temp-Voice-Panel und Embed-Polish

- Server-Monitor v3: gamedig-Integration (300+ Spiele), GSM-Stil-Embeds
  (Spieler-Balken mit %, Map, Spieler-Liste, klickbarer Connect-Link,
  Ping im Footer), Down/Up-Alerts (2 Fails → 🚨, Recovery →  mit
  Downtime) in konfigurierbaren Alert-Kanal; Server-Tab mit 19
  Spiel-Presets, Host/Port bzw. Query-URL und Connect-URL
- Composer v2: visueller Embed-Builder mit Live-Discord-Preview
  (Autor, Felder, Bilder, Farbe, Footer, Timestamp), speicherbare
  Vorlagen (/api/templates), /api/compose versteht volle Embeds
- Temp-Voice: Control-Panel im erstellten Kanal (Umbenennen, Sperren,
  Limit, Löschen) — nur der Kanal-Besitzer darf bedienen
- Embed-Polish: brandEmbed-Factory (src/embeds.js), Footer überall
  mit Bot-Avatar-Icon

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:17:30 +02:00
co-authored by Claude Fable 5
parent 332e89dcbd
commit 00249f9d8b
17 changed files with 1379 additions and 116 deletions
+6 -1
View File
@@ -20,12 +20,17 @@ function requireScope(request, reply, scope) {
}
/** Nur erlaubte Embed-Felder durchreichen (kein Blind-Passthrough) */
function sanitizeEmbed(raw) {
export function sanitizeEmbed(raw) {
if (!raw || typeof raw !== 'object') return null;
const embed = {};
for (const key of ['title', 'description', 'url', 'color', 'timestamp']) {
if (raw[key] !== undefined) embed[key] = raw[key];
}
if (raw.author?.name) {
embed.author = { name: String(raw.author.name).slice(0, 256) };
if (raw.author.icon_url) embed.author.icon_url = String(raw.author.icon_url);
if (raw.author.url) embed.author.url = String(raw.author.url);
}
if (raw.footer?.text) embed.footer = { text: String(raw.footer.text) };
if (raw.image?.url) embed.image = { url: String(raw.image.url) };
if (raw.thumbnail?.url) embed.thumbnail = { url: String(raw.thumbnail.url) };
+65 -13
View File
@@ -12,8 +12,10 @@ import {
saveTrigger, listTriggers, deleteTrigger, activityRange,
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate,
} from '../db.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js';
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js';
@@ -299,6 +301,7 @@ ${rssItems}
twitch_channel: getSetting('twitch_channel') ?? '',
twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')),
tempvoice_channel_id: getSetting('tempvoice_channel_id') ?? '',
server_alert_channel_id: getSetting('server_alert_channel_id') ?? '',
};
}
@@ -344,7 +347,7 @@ ${rssItems}
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_channel_id',
'events_announce_channel_id', 'social_announce_channel_id',
'events_announce_channel_id', 'social_announce_channel_id', 'server_alert_channel_id',
];
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
if (body[key] === undefined) continue;
@@ -738,10 +741,23 @@ ${rssItems}
function parseServerBody(request, reply) {
const body = request.body ?? {};
const name = String(body.name ?? '').trim();
const type = String(body.type ?? 'fivem').trim().toLowerCase().replace(/[^a-z0-9]/g, '');
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' });
const host = String(body.host ?? '').trim();
const port = Number(body.port) || null;
if (!name || !type) {
reply.code(400).send({ error: 'name und type nötig' });
return null;
}
if (['fivem', 'http'].includes(type)) {
if (!/^https?:\/\/.+/.test(queryUrl)) {
reply.code(400).send({ error: 'query_url (http/https) nötig' });
return null;
}
} else if (!host) {
// gamedig-Typ: Host (+ optionaler Port) statt URL
reply.code(400).send({ error: 'host nötig (gamedig-Query)' });
return null;
}
return {
@@ -749,6 +765,9 @@ ${rssItems}
type,
query_url: queryUrl,
address: String(body.address ?? '').trim().slice(0, 120),
host: host.slice(0, 120),
port,
connect_url: String(body.connect_url ?? '').trim().slice(0, 200),
};
}
@@ -885,19 +904,23 @@ ${rssItems}
app.post('/api/compose', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle } = request.body ?? {};
const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle, embed } = request.body ?? {};
const text = String(content ?? '').trim();
if (!channelId || !text) {
return reply.code(400).send({ error: 'channel_id und content nötig' });
const cleanEmbed = embed ? sanitizeEmbed(embed) : null;
if (!channelId || (!text && !cleanEmbed)) {
return reply.code(400).send({ error: 'channel_id und content oder embed nötig' });
}
const channel = await client.channels.fetch(String(channelId)).catch(() => null);
if (!channel?.isTextBased()) {
return reply.code(404).send({ error: 'Kanal nicht gefunden' });
}
// Mit Embed-Titel → gebrandetes Embed, sonst Plaintext (Discord-Markdown)
const payload = embedTitle?.trim()
? {
// Drei Modi: volles Embed (Composer v2) > Embed-Titel-Kurzform > Plaintext
let payload;
if (cleanEmbed) {
payload = { content: text ? text.slice(0, 2000) : null, embeds: [cleanEmbed] };
} else if (embedTitle?.trim()) {
payload = {
content: null,
embeds: [
new EmbedBuilder()
@@ -907,8 +930,10 @@ ${rssItems}
.setFooter({ text: brandName() })
.setTimestamp(),
],
}
: { content: text.slice(0, 2000), embeds: [] };
};
} else {
payload = { content: text.slice(0, 2000), embeds: [] };
}
if (messageId) {
const message = await channel.messages.fetch(String(messageId)).catch(() => null);
@@ -954,6 +979,33 @@ ${rssItems}
}
});
// --- Composer-Vorlagen (Scope: content) ---
app.get('/api/templates', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return { templates: listTemplates() };
});
app.put('/api/templates/:name', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
const name = String(request.params.name).trim().slice(0, 60);
if (!name) return reply.code(400).send({ error: 'name nötig' });
const payload = {
content: String(request.body?.content ?? '').slice(0, 2000),
embed: request.body?.embed ? sanitizeEmbed(request.body.embed) : null,
};
if (!payload.content && !payload.embed) {
return reply.code(400).send({ error: 'Vorlage ist leer' });
}
saveTemplate(name, payload);
return { ok: true, templates: listTemplates() };
});
app.delete('/api/templates/:name', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return { deleted: deleteTemplate(String(request.params.name)), templates: listTemplates() };
});
// --- Team-Verwaltung (nur Owner) ---
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs'];
@@ -1068,7 +1120,7 @@ ${rssItems}
.setDescription(
`Test-Nachricht für den **${{ commit: 'Commit', devlog: 'Devlog', release: 'Release' }[target]}-Kanal** — von der Settings-Seite ausgelöst.`
)
.setFooter({ text: brandFooter('SETUP') }),
.setFooter({ text: brandFooter('SETUP'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
],
});
return { ok: true };
+1
View File
@@ -103,6 +103,7 @@ export function registerDevlogEndpoint(app, client) {
.setDescription(prose)
.setFooter({
text: `${brandFooter('DEVLOG')}${commitCount ? `${commitCount} Commits` : ''}`,
iconURL: client?.user?.displayAvatarURL?.({ size: 64 }),
})
.setTimestamp();
if (project) {