Branding-Tab: Name/Farben/Bot-Status/Avatar/Banner + Button-Farben für Rollen-Menüs

- Alle Embeds nutzen jetzt brandName/brandColor/brandColor2/brandFooter aus den
  Settings (Codemod über 20 Module) — Farbe & Name serverweit per Klick änderbar
- Bot-Status (Presence): Typ (Spielt/Schaut/Hört/Status) + Text, sofort angewendet;
  Server-Monitor nutzt die Presence nur noch, wenn kein eigener Status gesetzt ist
- Avatar-/Banner-Upload direkt aufs Bot-Profil (Base64, 8-MB-Limit,
  Discord-Rate-Limit sauber gemeldet)
- Env-Verlagerung: GITEA_API_TOKEN (write-only Setting) und DISCORD_GUILD_ID
  jetzt auch über den Brand-Tab pflegbar — weniger Redeploys
- Rollen-Menüs: Button-Farbe pro Eintrag wählbar (Grau/Blau/Grün/Rot)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:40:08 +02:00
co-authored by Claude Fable 5
parent db9c7d07c9
commit f7b261c648
27 changed files with 395 additions and 93 deletions
+79 -7
View File
@@ -12,7 +12,7 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men
import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo } from '../runtime-settings.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken } from '../runtime-settings.js';
import { getMilestones } from '../gitea-api.js';
import { getSessionUser, isAdmin } from './auth.js';
@@ -137,7 +137,7 @@ export function registerApiRoutes(app, client) {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>D4RKST3R // DEVLOG</title>
<title>${brandFooter('DEVLOG')}</title>
<link>${publicUrl()}/devlogs</link>
<description>Entwicklungs-Updates, automatisch archiviert.</description>
<language>de</language>
@@ -223,6 +223,13 @@ ${rssItems}
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',
brand_color2: getSetting('brand_color2') ?? '#ff4d00',
bot_status_type: getSetting('bot_status_type') ?? 'custom',
bot_status_text: getSetting('bot_status_text') ?? '',
discord_guild_id: getSetting('discord_guild_id') ?? '',
gitea_api_token_set: Boolean(giteaApiToken()),
sticky_roles_enabled: getSetting('sticky_roles_enabled') === '1',
levels_enabled: getSetting('levels_enabled') === '1',
levels_announce: getSetting('levels_announce') !== '0',
@@ -242,7 +249,7 @@ ${rssItems}
botTag: client.user?.tag ?? null,
uptimeSeconds: Math.floor(process.uptime()),
guilds: client.guilds.cache.size,
giteaTokenConfigured: Boolean(config.giteaApiToken),
giteaTokenConfigured: Boolean(giteaApiToken()),
lastBackup: getSetting('last_backup'),
...stats,
},
@@ -319,6 +326,42 @@ ${rssItems}
if (body.level_rewards !== undefined) {
setSetting('level_rewards', String(body.level_rewards).trim());
}
// Branding
if (body.brand_name !== undefined) {
setSetting('brand_name', String(body.brand_name).trim().slice(0, 40) || 'D4RKST3R');
}
for (const key of ['brand_color', 'brand_color2']) {
if (body[key] === undefined) continue;
const value = String(body[key]).trim();
if (!/^#[0-9a-fA-F]{6}$/.test(value)) {
return reply.code(400).send({ error: `${key}: Hex-Farbe wie #f5c518 erwartet` });
}
setSetting(key, value.toLowerCase());
}
// Bot-Status (Presence) — wird sofort angewendet
let statusChanged = false;
if (body.bot_status_type !== undefined) {
if (!['playing', 'watching', 'listening', 'custom'].includes(body.bot_status_type)) {
return reply.code(400).send({ error: 'bot_status_type ungültig' });
}
setSetting('bot_status_type', body.bot_status_type);
statusChanged = true;
}
if (body.bot_status_text !== undefined) {
setSetting('bot_status_text', String(body.bot_status_text).trim().slice(0, 120));
statusChanged = true;
}
if (statusChanged) {
const { applyBotStatus } = await import('../bot/presence.js');
applyBotStatus(client);
}
// Aus der Env verlagerbar: Guild-ID (greift beim nächsten Start) + Gitea-Token (write-only)
if (body.discord_guild_id !== undefined) {
setSetting('discord_guild_id', String(body.discord_guild_id).trim());
}
if (body.gitea_api_token !== undefined && String(body.gitea_api_token).trim() !== '') {
setSetting('gitea_api_token', String(body.gitea_api_token).trim());
}
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', 'gameservers']) {
if (body[key] !== undefined) {
setSetting(key, String(body[key]).trim());
@@ -353,6 +396,7 @@ ${rssItems}
emoji: String(e.emoji ?? '').trim(),
label: String(e.label ?? '').trim(),
role_id: String(e.role_id ?? '').trim(),
style: ['primary', 'secondary', 'success', 'danger'].includes(e.style) ? e.style : 'secondary',
}))
.filter((e) => e.label && e.role_id);
if (entries.length > MAX_ENTRIES) {
@@ -532,10 +576,10 @@ ${rssItems}
content: null,
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setColor(brandColor())
.setTitle(String(embedTitle).slice(0, 200))
.setDescription(text.slice(0, 4000))
.setFooter({ text: 'D4RKST3R' })
.setFooter({ text: brandName() })
.setTimestamp(),
],
}
@@ -557,6 +601,34 @@ ${rssItems}
return { ok: true, edited: false, message_id: message.id };
});
// Bot-Avatar/-Banner setzen (Admin) — Bild als Base64-Data-URL im Body
app.post('/api/branding/:target', { bodyLimit: 12 * 1024 * 1024 }, async (request, reply) => {
if (requireAdmin(request, reply)) return;
const target = request.params.target;
if (!['avatar', 'banner'].includes(target)) {
return reply.code(400).send({ error: 'target: avatar oder banner' });
}
const dataUrl = String(request.body?.data ?? '');
const match = dataUrl.match(/^data:image\/(png|jpe?g|gif|webp);base64,(.+)$/s);
if (!match) return reply.code(400).send({ error: 'data: Base64-Data-URL eines Bildes erwartet' });
const buffer = Buffer.from(match[2], 'base64');
if (buffer.length > 8 * 1024 * 1024) {
return reply.code(400).send({ error: 'Bild zu groß (max 8 MB)' });
}
try {
if (target === 'avatar') await client.user.setAvatar(buffer);
else await client.user.setBanner(buffer);
request.log.info(`Bot-${target} aktualisiert (${(buffer.length / 1024).toFixed(0)} KB)`);
return { ok: true };
} catch (error) {
request.log.error({ err: error }, `Bot-${target} fehlgeschlagen`);
// Discord rate-limitet Avatar-Änderungen hart (~2 pro 10 min)
return reply.code(502).send({ error: `Discord lehnt ab: ${error.message?.slice(0, 120)}` });
}
});
// --- API-Keys (Admin) — für /api/v1/* ---
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
@@ -629,12 +701,12 @@ ${rssItems}
await channel.send({
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setColor(brandColor())
.setTitle('🔧 Test')
.setDescription(
`Test-Nachricht für den **${{ commit: 'Commit', devlog: 'Devlog', release: 'Release' }[target]}-Kanal** — von der Settings-Seite ausgelöst.`
)
.setFooter({ text: 'D4RKST3R // SETUP' }),
.setFooter({ text: brandFooter('SETUP') }),
],
});
return { ok: true };
+4 -5
View File
@@ -10,10 +10,9 @@ import { join } from 'node:path';
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
import { config } from '../config.js';
import { saveDevlog } from '../db.js';
import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled, publicUrl } from '../runtime-settings.js';
import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
import { imagesDir } from '../bot/devlog-archive.js';
const BRAND_YELLOW = 0xf5c518;
const MAX_IMAGES = 4;
/** Secret timing-safe vergleichen */
@@ -98,12 +97,12 @@ export function registerDevlogEndpoint(app, client) {
(f, i) => new AttachmentBuilder(f.buffer, { name: `devlog_${i}.${f.name.split('.').pop().toLowerCase()}` })
);
const mainEmbed = new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setColor(brandColor())
.setTitle(`📔 Devlog — ${dateStr}`)
.setURL(groupUrl)
.setDescription(prose)
.setFooter({
text: `D4RKST3R // DEVLOG${commitCount ? `${commitCount} Commits` : ''}`,
text: `${brandFooter('DEVLOG')}${commitCount ? `${commitCount} Commits` : ''}`,
})
.setTimestamp();
if (project) {
@@ -180,7 +179,7 @@ export function registerDevlogEndpoint(app, client) {
message_id: message.id,
channel_id: message.channelId,
content: `${title}\n\n${description}`,
author_name: 'D4RKST3R // DEVLOG',
author_name: brandFooter('DEVLOG'),
posted_at: message.createdAt.toISOString(),
images: JSON.stringify(imageFiles),
});
+3 -2
View File
@@ -3,6 +3,7 @@
// Zeiten sind lokale Serverzeit (TZ im Compose = Europe/Berlin), gespeichert als UTC.
import { EmbedBuilder } from 'discord.js';
import { dueScheduledPosts, markScheduledRun } from '../db.js';
import { brandColor, brandName } from '../runtime-settings.js';
const CHECK_INTERVAL_MS = 60 * 1000;
@@ -47,10 +48,10 @@ export function buildScheduledPayload(post) {
return {
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setColor(brandColor())
.setTitle(post.embed_title.slice(0, 200))
.setDescription(post.content.slice(0, 4000))
.setFooter({ text: 'D4RKST3R' })
.setFooter({ text: brandName() })
.setTimestamp(),
],
};