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
+9 -6
View File
@@ -1,11 +1,12 @@
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials, REST, Routes } from 'discord.js';
import { config } from '../config.js';
import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId } from '../runtime-settings.js';
import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId, brandColor, brandFooter, discordGuildId } from '../runtime-settings.js';
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import { registerCommunityListeners } from './community.js';
import { registerModTools } from './mod-tools.js';
import { registerLevels } from './levels.js';
import { registerPresence } from './presence.js';
import { handleRoleMenuButton } from './role-menus.js';
import {
addPlaytester, removePlaytester, isWish, bumpWish,
@@ -186,12 +187,12 @@ export async function startBot() {
content: `<@${interaction.user.id}>`,
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setColor(brandColor())
.setDescription(
'Willkommen in deinem Ticket! Beschreib dein Anliegen — das Team meldet sich.\n' +
'Wenn alles geklärt ist, kann das Ticket hier geschlossen werden.'
)
.setFooter({ text: 'D4RKST3R // SUPPORT' }),
.setFooter({ text: brandFooter('SUPPORT') }),
],
components: [
new ActionRowBuilder().addComponents(
@@ -296,6 +297,7 @@ export async function startBot() {
registerCommunityListeners(client);
registerModTools(client);
registerLevels(client);
registerPresence(client);
await client.login(config.discordToken);
return client;
@@ -306,12 +308,13 @@ async function registerCommands() {
const rest = new REST().setToken(config.discordToken);
const body = commandModules.map((c) => c.data.toJSON());
const route = config.discordGuildId
? Routes.applicationGuildCommands(config.discordClientId, config.discordGuildId)
const guildId = discordGuildId();
const route = guildId
? Routes.applicationGuildCommands(config.discordClientId, guildId)
: Routes.applicationCommands(config.discordClientId);
await rest.put(route, { body });
console.log(
`[bot] ${body.length} Slash-Command(s) registriert (${config.discordGuildId ? 'Guild' : 'global'})`
`[bot] ${body.length} Slash-Command(s) registriert (${guildId ? 'Guild' : 'global'})`
);
}
+3 -3
View File
@@ -1,7 +1,7 @@
// /bug — Bug-Report aus Discord: erstellt ein Gitea-Issue (inkl. Screenshot-Upload)
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
import { config } from '../../config.js';
import { bugReportRepo } from '../../runtime-settings.js';
import { bugReportRepo, giteaApiToken } from '../../runtime-settings.js';
import { createIssue, uploadIssueAsset } from '../../gitea-api.js';
import { saveBugReport } from '../../db.js';
@@ -24,7 +24,7 @@ export const data = new SlashCommandBuilder()
export async function execute(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
if (!config.giteaApiToken) {
if (!giteaApiToken()) {
await interaction.editReply('❌ Bug-Reports sind gerade nicht konfiguriert (kein Gitea-Token).');
return;
}
+3 -2
View File
@@ -4,6 +4,7 @@ import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js';
import { saveGiveaway } from '../../db.js';
import { brandColor, brandFooter } from '../../runtime-settings.js';
/** "30m" / "2h" / "1d" → Millisekunden (null bei Unsinn) */
export function parseDuration(input) {
@@ -36,13 +37,13 @@ export async function execute(interaction) {
const endsAt = new Date(Date.now() + duration);
const embed = new EmbedBuilder()
.setColor(0xf5c518)
.setColor(brandColor())
.setTitle(`🎉 Giveaway: ${prize}`)
.setDescription(
`Klick auf den Button zum Teilnehmen!\n\n` +
`🏆 **${winners}** Gewinner · ⏰ Ziehung <t:${Math.floor(endsAt.getTime() / 1000)}:R>`
)
.setFooter({ text: 'D4RKST3R // GIVEAWAY' })
.setFooter({ text: brandFooter('GIVEAWAY') })
.setTimestamp(endsAt);
const row = new ActionRowBuilder().addComponents(
+3 -3
View File
@@ -3,7 +3,7 @@ import {
SlashCommandBuilder, PermissionFlagsBits, MessageFlags,
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js';
import { playtesterRoleId } from '../../runtime-settings.js';
import { playtesterRoleId, brandColor, brandFooter } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder()
.setName('playtester-setup')
@@ -20,7 +20,7 @@ export async function execute(interaction) {
}
const embed = new EmbedBuilder()
.setColor(0xf5c518)
.setColor(brandColor())
.setTitle('🧪 Playtester werden')
.setDescription(
'Du willst **EcoGame** vor allen anderen spielen und mit deinem Feedback ' +
@@ -29,7 +29,7 @@ export async function execute(interaction) {
'sobald die Alpha startet, bekommst du deinen Zugang direkt per DM. ' +
'Abmelden geht jederzeit mit einem zweiten Klick.'
)
.setFooter({ text: 'D4RKST3R // PLAYTEST' });
.setFooter({ text: brandFooter('PLAYTEST') });
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
+2 -2
View File
@@ -1,6 +1,6 @@
// /purge — Nachrichten im aktuellen Kanal massenlöschen (max 100, jünger als 14 Tage)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { modlogChannelId } from '../../runtime-settings.js';
import { modlogChannelId, brandColor2 } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder()
.setName('purge')
@@ -29,7 +29,7 @@ export async function execute(interaction) {
await channel?.send({
embeds: [
new EmbedBuilder()
.setColor(0xff4d00)
.setColor(brandColor2())
.setTitle('🧹 Purge')
.setDescription(
`**${deleted.size}** Nachrichten in <#${interaction.channelId}> gelöscht von **${interaction.user.username}**`
+3 -3
View File
@@ -2,7 +2,7 @@
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import { getLevelRow, rankOf } from '../../db.js';
import { xpForLevel } from '../levels.js';
import { levelsEnabled, publicUrl } from '../../runtime-settings.js';
import { levelsEnabled, publicUrl, brandColor, brandFooter } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder()
.setName('rank')
@@ -32,12 +32,12 @@ export async function execute(interaction) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setColor(brandColor())
.setAuthor({ name: user.username, iconURL: user.displayAvatarURL({ size: 64 }) })
.setTitle(`Level ${row.level} · Platz #${rankOf(user.id)}`)
.setDescription(`\`${bar}\` ${pct}%\n**${progress} / ${needed} XP** bis Level ${row.level + 1} · gesamt ${row.xp} XP`)
.setURL(`${publicUrl()}/level`)
.setFooter({ text: 'D4RKST3R // LEVEL' }),
.setFooter({ text: brandFooter('LEVEL') }),
],
});
}
+3 -3
View File
@@ -3,7 +3,7 @@ import {
SlashCommandBuilder, PermissionFlagsBits, MessageFlags,
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js';
import { ticketChannelId } from '../../runtime-settings.js';
import { ticketChannelId, brandColor, brandFooter } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder()
.setName('ticket-setup')
@@ -20,14 +20,14 @@ export async function execute(interaction) {
}
const embed = new EmbedBuilder()
.setColor(0xf5c518)
.setColor(brandColor())
.setTitle('🎫 Support-Ticket')
.setDescription(
'Du hast eine Frage, ein Problem oder ein privates Anliegen?\n\n' +
'Klick auf den Button — es öffnet sich ein **privater Thread**, ' +
'den nur du und das Team sehen können.'
)
.setFooter({ text: 'D4RKST3R // SUPPORT' });
.setFooter({ text: brandFooter('SUPPORT') });
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
+2 -2
View File
@@ -1,7 +1,7 @@
// /timeout — Member stummschalten (Discord-Timeout) mit Mod-Log
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { parseDuration } from './giveaway.js';
import { modlogChannelId } from '../../runtime-settings.js';
import { modlogChannelId, brandColor2 } from '../../runtime-settings.js';
const MAX_TIMEOUT_MS = 28 * 86_400_000; // Discord-Limit
@@ -42,7 +42,7 @@ export async function execute(interaction) {
await channel?.send({
embeds: [
new EmbedBuilder()
.setColor(0xff4d00)
.setColor(brandColor2())
.setTitle('🔇 Timeout')
.addFields(
{ name: 'Member', value: `${user.username} (<@${user.id}>)`, inline: true },
+2 -2
View File
@@ -1,7 +1,7 @@
// /warn — Member verwarnen: DB-Eintrag, DM an den User, Mod-Log (Moderatoren)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { addWarn, warnsOf } from '../../db.js';
import { modlogChannelId } from '../../runtime-settings.js';
import { modlogChannelId, brandColor2 } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder()
.setName('warn')
@@ -34,7 +34,7 @@ export async function execute(interaction) {
await channel?.send({
embeds: [
new EmbedBuilder()
.setColor(0xff4d00)
.setColor(brandColor2())
.setTitle(`⚠️ Verwarnung #${count}`)
.addFields(
{ name: 'Member', value: `${user.username} (<@${user.id}>)`, inline: true },
+2 -1
View File
@@ -1,6 +1,7 @@
// /warns — Verwarnungs-Historie eines Members einsehen / Eintrag löschen (Moderatoren)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { warnsOf, deleteWarn } from '../../db.js';
import { brandColor } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder()
.setName('warns')
@@ -34,7 +35,7 @@ export async function execute(interaction) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setColor(brandColor())
.setTitle(`⚠️ Verwarnungen: ${user.username} (${warns.length})`)
.setDescription(lines.join('\n'))
.setFooter({ text: 'Löschen: /warns member:<user> loeschen:<ID>' }),
+3 -3
View File
@@ -1,6 +1,6 @@
// /wunsch — Feature-Wunsch einreichen: Voting-Post mit 👍, Rangliste auf der Webseite
import { SlashCommandBuilder, MessageFlags, EmbedBuilder } from 'discord.js';
import { votingChannelId, publicUrl } from '../../runtime-settings.js';
import { votingChannelId, publicUrl, brandColor, brandFooter } from '../../runtime-settings.js';
import { saveWish } from '../../db.js';
export const data = new SlashCommandBuilder()
@@ -25,7 +25,7 @@ export async function execute(interaction) {
const channel = await interaction.client.channels.fetch(channelId);
const embed = new EmbedBuilder()
.setColor(0xf5c518)
.setColor(brandColor())
.setAuthor({
name: interaction.member?.displayName ?? interaction.user.username,
iconURL: interaction.user.displayAvatarURL({ size: 64 }),
@@ -33,7 +33,7 @@ export async function execute(interaction) {
.setTitle('💡 Feature-Wunsch')
.setDescription(idea)
.setURL(`${publicUrl()}/roadmap`)
.setFooter({ text: 'D4RKST3R // VOTING • 👍 = will ich!' });
.setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` });
const message = await channel.send({ embeds: [embed] });
await message.react('👍');
+2 -3
View File
@@ -7,10 +7,9 @@ import {
hasStarboard, saveStarboard,
saveGalleryItem, hasGalleryItem, deleteGalleryItem,
} from '../db.js';
import { starboardChannelId, starboardThreshold, screenshotChannelId } from '../runtime-settings.js';
import { starboardChannelId, starboardThreshold, screenshotChannelId, brandColor } from '../runtime-settings.js';
import { config } from '../config.js';
const BRAND_YELLOW = 0xf5c518;
const MAX_GALLERY_IMAGES = 4;
// Galerie-Bilder liegen neben der SQLite: data/gallery/
@@ -83,7 +82,7 @@ async function handleStarReaction(reaction) {
a.contentType?.startsWith('image/')
);
const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setColor(brandColor())
.setAuthor({
name: message.member?.displayName ?? message.author?.username ?? 'unbekannt',
iconURL: message.author?.displayAvatarURL?.() ?? undefined,
+2 -1
View File
@@ -1,6 +1,7 @@
// Giveaway-Ziehung: Minuten-Scheduler beendet fällige Verlosungen automatisch
import { EmbedBuilder } from 'discord.js';
import { dueGiveaways, markGiveawayEnded, giveawayEntries } from '../db.js';
import { brandFooter } from '../runtime-settings.js';
const CHECK_INTERVAL_MS = 60 * 1000;
@@ -39,7 +40,7 @@ export async function giveawayTick(client) {
? `🏆 Gewonnen: ${mentions}\n👥 ${entries.length} Teilnahmen`
: 'Keine Teilnahmen — niemand hat gewonnen. 😢'
)
.setFooter({ text: 'D4RKST3R // GIVEAWAY' }),
.setFooter({ text: brandFooter('GIVEAWAY') }),
],
components: [],
}).catch(() => {});
+9 -14
View File
@@ -1,13 +1,8 @@
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
import { ChannelType, EmbedBuilder, Events } from 'discord.js';
import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js';
import {
modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl,
autoroleId, stickyRolesEnabled,
} from '../runtime-settings.js';
import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
const BRAND_YELLOW = 0xf5c518;
const BRAND_ORANGE = 0xff4d00;
/* ── Modmail ───────────────────────────────────────── */
@@ -36,12 +31,12 @@ async function ensureModmailThread(client, user) {
await thread.send({
embeds: [
new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setColor(brandColor())
.setDescription(
`Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` +
'Antworten in diesem Thread gehen als DM an den User.'
)
.setFooter({ text: 'D4RKST3R // MODMAIL' }),
.setFooter({ text: brandFooter('MODMAIL') }),
],
});
return thread;
@@ -89,7 +84,7 @@ async function handleMemberAdd(member) {
if (!channel?.isTextBased()) return;
const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setColor(brandColor())
.setTitle(`👋 Willkommen, ${member.displayName}!`)
.setThumbnail(member.user.displayAvatarURL({ size: 128 }))
.setDescription(
@@ -98,7 +93,7 @@ async function handleMemberAdd(member) {
'🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' +
'🐛 Bug gefunden? Einfach `/bug` benutzen.'
)
.setFooter({ text: 'D4RKST3R // COMMUNITY' })
.setFooter({ text: brandFooter('COMMUNITY') })
.setTimestamp();
await channel.send({ content: `<@${member.id}>`, embeds: [embed] });
@@ -183,7 +178,7 @@ export function registerModTools(client) {
if (roleIds.length > 0) saveStickyRoles(member.id, roleIds);
await logToModlog(client, new EmbedBuilder()
.setColor(BRAND_ORANGE)
.setColor(brandColor2())
.setTitle('📤 Member gegangen')
.setDescription(`**${member.user?.username ?? member.id}**${roleIds.length ? ` — Rollen gesichert (${roleIds.length})` : ''}`)
.setTimestamp());
@@ -211,7 +206,7 @@ export function registerModTools(client) {
if (changes.length === 0) return;
await logToModlog(client, new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setColor(brandColor())
.setTitle('👤 Member geändert')
.setDescription(`**${newMember.user?.username ?? newMember.id}**`)
.addFields(changes)
@@ -227,7 +222,7 @@ export function registerModTools(client) {
if (!message.guildId || message.author?.bot) return;
if (message.channelId === modlogChannelId()) return;
await logToModlog(client, new EmbedBuilder()
.setColor(BRAND_ORANGE)
.setColor(brandColor2())
.setTitle('🗑️ Nachricht gelöscht')
.addFields(
{ name: 'User', value: message.author ? `${message.author.username}` : 'unbekannt', inline: true },
@@ -245,7 +240,7 @@ export function registerModTools(client) {
if (!newMessage.guildId || newMessage.author?.bot) return;
if (oldMessage.content === newMessage.content) return; // z. B. nur Embed-Update
await logToModlog(client, new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setColor(brandColor())
.setTitle('✏️ Nachricht bearbeitet')
.addFields(
{ name: 'User', value: newMessage.author?.username ?? 'unbekannt', inline: true },
+29
View File
@@ -0,0 +1,29 @@
// Bot-Status (Presence) aus den Branding-Settings — hat Vorrang vor dem
// Server-Monitor (der die Presence nur nutzt, wenn hier nichts gesetzt ist).
import { ActivityType, Events } from 'discord.js';
import { botStatusText, botStatusType } from '../runtime-settings.js';
const TYPES = {
playing: ActivityType.Playing,
watching: ActivityType.Watching,
listening: ActivityType.Listening,
custom: ActivityType.Custom,
};
/** Presence aus den Settings anwenden (leerer Text = Presence löschen) */
export function applyBotStatus(client) {
const text = botStatusText();
try {
if (!text) {
client.user?.setPresence?.({ activities: [] });
return;
}
client.user?.setActivity?.(text, { type: TYPES[botStatusType()] ?? ActivityType.Custom });
} catch (error) {
console.error('[presence] Status setzen fehlgeschlagen:', error.message);
}
}
export function registerPresence(client) {
client.once(Events.ClientReady, () => applyBotStatus(client));
}
+3 -4
View File
@@ -1,9 +1,8 @@
// Release-Ankündigungen: großes Embed in den konfigurierten Kanal (Setup-Seite)
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
import { releaseChannelId, publicUrl } from '../runtime-settings.js';
import { releaseChannelId, publicUrl, brandColor2, brandFooter } from '../runtime-settings.js';
import { config } from '../config.js';
const BRAND_ORANGE = 0xff4d00;
/**
* Release ankündigen (falls ein Kanal konfiguriert ist).
@@ -26,12 +25,12 @@ export async function postReleaseEmbed(client, release) {
const title = release.name?.trim() || release.tag;
const embed = new EmbedBuilder()
.setColor(BRAND_ORANGE)
.setColor(brandColor2())
.setTitle(`🚀 ${title}${release.prerelease ? ' (Pre-Release)' : ''}`)
.setDescription(body || '*Keine Release-Notes.*')
.addFields({ name: 'Version', value: `\`${release.tag}\``, inline: true },
{ name: 'Projekt', value: release.repo, inline: true })
.setFooter({ text: 'D4RKST3R // RELEASE' })
.setFooter({ text: brandFooter('RELEASE') })
.setTimestamp();
const buttons = new ActionRowBuilder().addComponents(
+12 -4
View File
@@ -3,16 +3,24 @@
// pro Menü, z. B. für Farb- oder Plattform-Rollen). Verwaltet über die /rollen-Seite.
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags } from 'discord.js';
import { getRoleMenu, setRoleMenuMessage } from '../db.js';
import { brandColor, brandFooter } from '../runtime-settings.js';
const BRAND_YELLOW = 0xf5c518;
export const MAX_ENTRIES = 25; // Discord: 5 Button-Reihen à 5 Buttons
// Discord-Button-Farben (pro Menü-Eintrag wählbar)
export const BUTTON_STYLES = {
secondary: ButtonStyle.Secondary, // grau
primary: ButtonStyle.Primary, // blau
success: ButtonStyle.Success, // grün
danger: ButtonStyle.Danger, // rot
};
/** Embed + Button-Reihen aus einem Menü-Datensatz bauen */
function buildMenuMessage(menu) {
const entries = JSON.parse(menu.entries || '[]');
const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setColor(brandColor())
.setTitle(menu.title)
.setDescription(
(menu.description?.trim() ? `${menu.description.trim()}\n\n` : '') +
@@ -20,7 +28,7 @@ function buildMenuMessage(menu) {
? '*Klick = Rolle nehmen — es ist immer nur eine Rolle aus diesem Menü aktiv.*'
: '*Klick = Rolle nehmen, nochmal klicken = wieder abgeben.*')
)
.setFooter({ text: 'D4RKST3R // ROLLEN' });
.setFooter({ text: brandFooter('ROLLEN') });
const rows = [];
for (let i = 0; i < entries.length; i += 5) {
@@ -29,7 +37,7 @@ function buildMenuMessage(menu) {
entries.slice(i, i + 5).map((e) => {
const button = new ButtonBuilder()
.setCustomId(`rolemenu:${menu.id}:${e.role_id}`)
.setStyle(ButtonStyle.Secondary)
.setStyle(BUTTON_STYLES[e.style] ?? ButtonStyle.Secondary)
.setLabel(e.label.slice(0, 80));
if (e.emoji) button.setEmoji(e.emoji);
return button;
+12 -10
View File
@@ -3,10 +3,10 @@
// Setting "gameservers": kommagetrennt "Name=http://ip:30120, Name2=…"
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
import { getSetting, setSetting } from '../db.js';
import { brandColor, brandFooter, botStatusText } from '../runtime-settings.js';
const CHECK_INTERVAL_MS = 2 * 60 * 1000;
const TIMEOUT_MS = 8000;
const BRAND_YELLOW = 0xf5c518;
function serverList() {
return (getSetting('gameservers') ?? '')
@@ -48,10 +48,10 @@ function buildEmbed(results) {
: `🔴 **${r.name}** — offline`
);
return new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setColor(brandColor())
.setTitle('🎮 Server-Status')
.setDescription(lines.join('\n'))
.setFooter({ text: 'D4RKST3R // STATUS • aktualisiert alle 2 min' })
.setFooter({ text: `${brandFooter('STATUS')} • aktualisiert alle 2 min` })
.setTimestamp();
}
@@ -63,15 +63,17 @@ export async function monitorTick(client) {
const results = await Promise.all(servers.map(queryServer));
// Bot-Presence: Gesamtspielerzahl (nur wenn mindestens ein Server online)
// 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);
try {
client.user?.setActivity?.(
online.length > 0 ? `${totalPlayers} Spieler online` : 'Server offline',
{ type: ActivityType.Watching }
);
} catch { /* Presence ist nice-to-have */ }
if (!botStatusText()) {
try {
client.user?.setActivity?.(
online.length > 0 ? `${totalPlayers} Spieler online` : 'Server offline',
{ type: ActivityType.Watching }
);
} catch { /* Presence ist nice-to-have */ }
}
// Persistentes Embed im Status-Kanal pflegen (einmal posten, danach editieren)
if (!channelId) return;
+3 -4
View File
@@ -2,10 +2,9 @@
// eine Zusammenfassung der Woche in den Devlog-Kanal.
import { EmbedBuilder } from 'discord.js';
import { weeklyStats, getSetting, setSetting } from '../db.js';
import { devlogChannelId, publicUrl } from '../runtime-settings.js';
import { devlogChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
import { config } from '../config.js';
const BRAND_YELLOW = 0xf5c518;
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
/** Balken-Zeile: `Mo 21.07 ████████ 12` */
@@ -46,7 +45,7 @@ export async function postWeeklyRecap(client) {
.join(' · ');
const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setColor(brandColor())
.setTitle('📊 Wochen-Rückblick')
.setURL(`${publicUrl()}/devlogs`)
.setDescription(
@@ -57,7 +56,7 @@ export async function postWeeklyRecap(client) {
{ name: 'Devlogs', value: String(stats.devlogs), inline: true },
...(repoLine ? [{ name: 'Aktive Projekte', value: repoLine, inline: false }] : [])
)
.setFooter({ text: 'D4RKST3R // WEEKLY' })
.setFooter({ text: brandFooter('WEEKLY') })
.setTimestamp();
await channel.send({ embeds: [embed] });
+4 -4
View File
@@ -1,10 +1,10 @@
// Minimaler Gitea-API-Client (Token-Auth) — für /bug → Issues
import { config } from './config.js';
import { giteaUrl } from './runtime-settings.js';
import { giteaUrl, giteaApiToken } from './runtime-settings.js';
function headers(extra = {}) {
return {
Authorization: `token ${config.giteaApiToken}`,
Authorization: `token ${giteaApiToken()}`,
Accept: 'application/json',
...extra,
};
@@ -27,7 +27,7 @@ export async function createIssue(repo, title, body) {
/** Milestones eines Repos (für die Roadmap-Seite); Token optional bei öffentlichen Repos */
export async function getMilestones(repo) {
const res = await fetch(`${giteaUrl()}/api/v1/repos/${repo}/milestones?state=all&per_page=50`, {
headers: config.giteaApiToken ? headers() : { Accept: 'application/json' },
headers: giteaApiToken() ? headers() : { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`Gitea getMilestones ${res.status}`);
+46
View File
@@ -47,6 +47,52 @@ export function starboardThreshold() {
return Number.isInteger(n) && n >= 1 ? n : 3;
}
/** Gitea-API-Token (für /bug) — Setting hat Vorrang vor Env */
export function giteaApiToken() {
return getSetting('gitea_api_token') || config.giteaApiToken || null;
}
/** Guild-ID für die Slash-Command-Registrierung (greift beim nächsten Start) */
export function discordGuildId() {
return getSetting('discord_guild_id') || config.discordGuildId || null;
}
/* ── Branding (Setup → Brand-Tab) ── */
/** Brand-Name für Footer/Embeds (Default: D4RKST3R) */
export function brandName() {
return getSetting('brand_name') || 'D4RKST3R';
}
/** Primärfarbe als Discord-Int (Default: #f5c518) */
export function brandColor() {
const hex = (getSetting('brand_color') || '#f5c518').replace('#', '');
const n = parseInt(hex, 16);
return Number.isNaN(n) ? 0xf5c518 : n;
}
/** Sekundärfarbe (Default: #ff4d00) */
export function brandColor2() {
const hex = (getSetting('brand_color2') || '#ff4d00').replace('#', '');
const n = parseInt(hex, 16);
return Number.isNaN(n) ? 0xff4d00 : n;
}
/** Embed-Footer im Brand-Stil: "D4RKST3R // DEVLOG" */
export function brandFooter(tag) {
return `${brandName()} // ${tag}`;
}
/** Bot-Status: Text (leer = keiner; Server-Monitor darf dann die Presence nutzen) */
export function botStatusText() {
return getSetting('bot_status_text') || '';
}
/** Bot-Status: Typ (playing|watching|listening|custom) */
export function botStatusType() {
return getSetting('bot_status_type') || 'custom';
}
/** Auto-Rolle für neue Member — leer = aus */
export function autoroleId() {
return getSetting('autorole_id') || null;
+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(),
],
};