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
+4 -3
View File
@@ -33,6 +33,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
| 📈 **Level-System** | XP pro Nachricht (60s-Cooldown, MEE6-Formel), `/rank`, Level-Up-Announce, Rollen-Belohnungen, öffentliche Bestenliste auf `/level` | | 📈 **Level-System** | XP pro Nachricht (60s-Cooldown, MEE6-Formel), `/rank`, Level-Up-Announce, Rollen-Belohnungen, öffentliche Bestenliste auf `/level` |
| 🏷️ **Tags** | `/tag <name>` (mit Autocomplete) postet gespeicherte Text-Bausteine — verwaltet im Composer-Tab | | 🏷️ **Tags** | `/tag <name>` (mit Autocomplete) postet gespeicherte Text-Bausteine — verwaltet im Composer-Tab |
| ⏰ **Geplante Posts** | Einmalig, täglich oder wöchentlich — im Composer-Tab geplant, vom Bot gepostet (bei Carl Premium) | | ⏰ **Geplante Posts** | Einmalig, täglich oder wöchentlich — im Composer-Tab geplant, vom Bot gepostet (bei Carl Premium) |
| 🎨 **Branding** | Brand-Tab: Name + Embed-Farben für alle Bot-Posts, Bot-Status (Presence), Avatar-/Banner-Upload, Guild-ID & Gitea-Token ohne Env-Redeploy |
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) | | 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal | | 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
| 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence | | 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence |
@@ -58,8 +59,8 @@ Design: D4RKST3R-Brand (Neon-Gelb/Orange auf Schwarz, Bebas Neue + Barlow Conden
Share Tech Mono, selbst gehostet). Share Tech Mono, selbst gehostet).
### Setup-Seite (`/settings`) ### Setup-Seite (`/settings`)
Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Feeds · Community · Rollen · Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community ·
Support · Composer · System · API). Alles zur Laufzeit änderbar — gespeichert in Rollen · Support · Composer · System · API). Alles zur Laufzeit änderbar — gespeichert in
SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig: SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig:
- **Allgemein:** Öffentliche URL, Gitea-URL - **Allgemein:** Öffentliche URL, Gitea-URL
@@ -116,7 +117,7 @@ in Portainer als Stack-Environment-Variables.
| `GITEA_WEBHOOK_SECRET` | ✅ | HMAC-Prüfung der Gitea-Webhooks¹ | | `GITEA_WEBHOOK_SECRET` | ✅ | HMAC-Prüfung der Gitea-Webhooks¹ |
| `DEVLOG_POST_SECRET` | ✅ | Secret im Devlog-Endpoint-Pfad¹ | | `DEVLOG_POST_SECRET` | ✅ | Secret im Devlog-Endpoint-Pfad¹ |
| `DISCORD_GUILD_ID` | optional | Guild-Commands sofort statt global (bis 1h) | | `DISCORD_GUILD_ID` | optional | Guild-Commands sofort statt global (bis 1h) |
| `GITEA_API_TOKEN` | optional | Für `/bug` → Issues (Scope `write:issue`); ohne Token ist `/bug` aus | | `GITEA_API_TOKEN` | optional | Für `/bug` → Issues (Scope `write:issue`); alternativ im Brand-Tab hinterlegen |
| `GITEA_URL` | optional | Default `https://git.d4rkst3r.de` | | `GITEA_URL` | optional | Default `https://git.d4rkst3r.de` |
| `PUBLIC_URL` | optional | Default `https://bot.d4rkst3r.de` | | `PUBLIC_URL` | optional | Default `https://bot.d4rkst3r.de` |
| `COMMIT_CHANNEL_ID` / `DEVLOG_CHANNEL_ID` | optional | Fallbacks — Kanäle kommen normal von der Setup-Seite | | `COMMIT_CHANNEL_ID` / `DEVLOG_CHANNEL_ID` | optional | Fallbacks — Kanäle kommen normal von der Setup-Seite |
+113 -2
View File
@@ -10,6 +10,7 @@ const API_SCOPES = [
const TABS = [ const TABS = [
{ id: 'status', icon: '📊', label: 'Status' }, { id: 'status', icon: '📊', label: 'Status' },
{ id: 'brand', icon: '🎨', label: 'Brand' },
{ id: 'feeds', icon: '📔', label: 'Feeds' }, { id: 'feeds', icon: '📔', label: 'Feeds' },
{ id: 'community', icon: '🙌', label: 'Community' }, { id: 'community', icon: '🙌', label: 'Community' },
{ id: 'rollen', icon: '🎭', label: 'Rollen' }, { id: 'rollen', icon: '🎭', label: 'Rollen' },
@@ -43,12 +44,14 @@ export default function Settings({ me }) {
// Rollen-Menüs // Rollen-Menüs
const emptyMenu = { const emptyMenu = {
id: null, title: '', description: '', channel_id: '', exclusive: false, id: null, title: '', description: '', channel_id: '', exclusive: false,
entries: [{ emoji: '', label: '', role_id: '' }], entries: [{ emoji: '', label: '', role_id: '', style: 'secondary' }],
}; };
const [menus, setMenus] = useState([]); const [menus, setMenus] = useState([]);
const [menuDraft, setMenuDraft] = useState(emptyMenu); const [menuDraft, setMenuDraft] = useState(emptyMenu);
// Composer // Composer
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' }); const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
// Branding
const [giteaToken, setGiteaToken] = useState('');
// Tags + geplante Posts // Tags + geplante Posts
const [tags, setTags] = useState([]); const [tags, setTags] = useState([]);
const [tagDraft, setTagDraft] = useState({ name: '', content: '' }); const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
@@ -78,6 +81,33 @@ export default function Settings({ me }) {
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {}); apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
}, [me.admin]); }, [me.admin]);
async function uploadImage(target, file) {
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
await apiPost(`/api/branding/${target}`, { data: reader.result });
flash(`${target === 'avatar' ? 'Avatar' : 'Banner'} gesetzt`);
} catch {
flash('✗ Discord lehnt ab (Rate-Limit? ~2 Änderungen / 10 min)');
}
};
reader.readAsDataURL(file);
}
async function saveBrand() {
try {
const body = { ...form };
if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim();
const res = await apiPut('/api/settings', body);
setForm(res.settings);
setGiteaToken('');
flash('✓ Gespeichert & angewendet');
} catch {
flash('✗ Speichern fehlgeschlagen (Farben als #rrggbb?)');
}
}
async function saveTagDraft() { async function saveTagDraft() {
if (!tagDraft.name.trim() || !tagDraft.content.trim()) { if (!tagDraft.name.trim() || !tagDraft.content.trim()) {
flash('✗ Name und Inhalt nötig'); flash('✗ Name und Inhalt nötig');
@@ -223,7 +253,9 @@ export default function Settings({ me }) {
setMenuDraft({ setMenuDraft({
id: m.id, title: m.title, description: m.description, id: m.id, title: m.title, description: m.description,
channel_id: m.channel_id ?? '', exclusive: m.exclusive, channel_id: m.channel_id ?? '', exclusive: m.exclusive,
entries: m.entries.length ? m.entries : [{ emoji: '', label: '', role_id: '' }], entries: m.entries.length
? m.entries.map((e) => ({ style: 'secondary', ...e }))
: [{ emoji: '', label: '', role_id: '', style: 'secondary' }],
}); });
const setMenuEntry = (i, patch) => const setMenuEntry = (i, patch) =>
@@ -291,6 +323,73 @@ export default function Settings({ me }) {
</div> </div>
); );
const tabBrand = (
<>
<div className="settings-section">
<h2 className="settings-title">// Identität</h2>
<div className="field">
<label>Brand-Name <span className="field-hint">erscheint in allen Embed-Footern (NAME // DEVLOG")</span></label>
<input type="text" placeholder="D4RKST3R" value={form.brand_name} onChange={(e) => setForm({ ...form, brand_name: e.target.value })} />
</div>
<div className="field">
<label>Embed-Farben <span className="field-hint">primär (Embeds) · sekundär (Releases, Mod-Aktionen)</span></label>
<div className="field-row">
<input type="color" className="color-pick" value={form.brand_color} onChange={(e) => setForm({ ...form, brand_color: e.target.value })} />
<input type="color" className="color-pick" value={form.brand_color2} onChange={(e) => setForm({ ...form, brand_color2: e.target.value })} />
<span className="field-hint" style={{ alignSelf: 'center' }}>{form.brand_color} · {form.brand_color2}</span>
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Bot-Status</h2>
<div className="field">
<label>Presence <span className="field-hint">leer = Server-Monitor darf die Spielerzahl anzeigen</span></label>
<div className="field-row">
<select style={{ flex: 'none', width: '9rem' }} value={form.bot_status_type} onChange={(e) => setForm({ ...form, bot_status_type: e.target.value })}>
<option value="custom">Status</option>
<option value="playing">Spielt</option>
<option value="watching">Schaut</option>
<option value="listening">Hört</option>
</select>
<input type="text" placeholder="z. B. ⛏️ baut an EcoGame" value={form.bot_status_text} onChange={(e) => setForm({ ...form, bot_status_text: e.target.value })} />
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Avatar &amp; Banner</h2>
<p className="section-intro">
Wird direkt beim Bot-Account gesetzt. Discord erlaubt nur ~2 Änderungen pro 10 Minuten.
</p>
<div className="field">
<label>Bot-Avatar <span className="field-hint">PNG/JPG/GIF, quadratisch empfohlen</span></label>
<input type="file" accept="image/*" onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
</div>
<div className="field">
<label>Bot-Banner <span className="field-hint">breites Bild fürs Bot-Profil</span></label>
<input type="file" accept="image/*" onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Aus der Env verlagert</h2>
<div className="field">
<label>Guild-ID <span className="field-hint">für sofortige Slash-Commands; greift beim nächsten Neustart</span></label>
<input type="text" placeholder="Server-ID" value={form.discord_guild_id} onChange={(e) => setForm({ ...form, discord_guild_id: e.target.value })} />
</div>
<div className="field">
<label>Gitea-API-Token <span className="field-hint">{form.gitea_api_token_set ? '✓ gesetzt — neuen Wert eingeben zum Ersetzen' : 'für /bug; wird nie wieder angezeigt'}</span></label>
<input type="password" placeholder="Token (write-only)" value={giteaToken} onChange={(e) => setGiteaToken(e.target.value)} />
</div>
</div>
<div className="settings-actions">
<button className="btn btn-save" onClick={saveBrand}>Speichern &amp; anwenden</button>
</div>
</>
);
const tabFeeds = ( const tabFeeds = (
<> <>
<div className="settings-section"> <div className="settings-section">
@@ -533,6 +632,17 @@ export default function Settings({ me }) {
<option value="" disabled> Rolle </option> <option value="" disabled> Rolle </option>
{roleOptions} {roleOptions}
</select> </select>
<select
className={`btn-style-pick style-${entry.style ?? 'secondary'}`}
title="Button-Farbe"
value={entry.style ?? 'secondary'}
onChange={(e) => setMenuEntry(i, { style: e.target.value })}
>
<option value="secondary"> Grau</option>
<option value="primary">🟦 Blau</option>
<option value="success">🟩 Grün</option>
<option value="danger">🟥 Rot</option>
</select>
<button <button
className="card-delete" className="card-delete"
title="Zeile entfernen" title="Zeile entfernen"
@@ -822,6 +932,7 @@ export default function Settings({ me }) {
const content = { const content = {
status: tabStatus, status: tabStatus,
brand: tabBrand,
feeds: tabFeeds, feeds: tabFeeds,
community: tabCommunity, community: tabCommunity,
rollen: tabRollen, rollen: tabRollen,
+35
View File
@@ -920,3 +920,38 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
border-color: var(--neon); border-color: var(--neon);
box-shadow: 0 0 0 3px rgba(245, 197, 24, .12); box-shadow: 0 0 0 3px rgba(245, 197, 24, .12);
} }
/* Branding-Tab: Farb- und Datei-Inputs */
.color-pick {
width: 3.2rem; height: 2.4rem; flex: none;
padding: .2rem;
background: rgba(0, 0, 0, .35);
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 8px;
cursor: pointer;
}
.field input[type="file"] {
width: 100%;
color: var(--muted);
font-family: var(--body);
font-size: .9rem;
}
.field input[type="file"]::file-selector-button {
font-family: var(--mono);
font-size: .68rem; letter-spacing: .15em; text-transform: uppercase;
color: var(--neon);
background: none;
border: 1px solid var(--border);
border-radius: 8px;
padding: .45rem 1rem;
margin-right: .8rem;
cursor: pointer;
transition: border-color .2s, background .2s;
}
.field input[type="file"]::file-selector-button:hover {
border-color: var(--neon);
background: rgba(245, 197, 24, .08);
}
/* Button-Farb-Auswahl im Rollen-Menü-Builder */
.btn-style-pick { width: 7.5rem !important; flex: none !important; }
+9 -6
View File
@@ -1,11 +1,12 @@
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten // Discord-Client: Commands laden, registrieren und Interactions verarbeiten
import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials, REST, Routes } from 'discord.js'; import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials, REST, Routes } from 'discord.js';
import { config } from '../config.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 { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import { registerCommunityListeners } from './community.js'; import { registerCommunityListeners } from './community.js';
import { registerModTools } from './mod-tools.js'; import { registerModTools } from './mod-tools.js';
import { registerLevels } from './levels.js'; import { registerLevels } from './levels.js';
import { registerPresence } from './presence.js';
import { handleRoleMenuButton } from './role-menus.js'; import { handleRoleMenuButton } from './role-menus.js';
import { import {
addPlaytester, removePlaytester, isWish, bumpWish, addPlaytester, removePlaytester, isWish, bumpWish,
@@ -186,12 +187,12 @@ export async function startBot() {
content: `<@${interaction.user.id}>`, content: `<@${interaction.user.id}>`,
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(0xf5c518) .setColor(brandColor())
.setDescription( .setDescription(
'Willkommen in deinem Ticket! Beschreib dein Anliegen — das Team meldet sich.\n' + 'Willkommen in deinem Ticket! Beschreib dein Anliegen — das Team meldet sich.\n' +
'Wenn alles geklärt ist, kann das Ticket hier geschlossen werden.' 'Wenn alles geklärt ist, kann das Ticket hier geschlossen werden.'
) )
.setFooter({ text: 'D4RKST3R // SUPPORT' }), .setFooter({ text: brandFooter('SUPPORT') }),
], ],
components: [ components: [
new ActionRowBuilder().addComponents( new ActionRowBuilder().addComponents(
@@ -296,6 +297,7 @@ export async function startBot() {
registerCommunityListeners(client); registerCommunityListeners(client);
registerModTools(client); registerModTools(client);
registerLevels(client); registerLevels(client);
registerPresence(client);
await client.login(config.discordToken); await client.login(config.discordToken);
return client; return client;
@@ -306,12 +308,13 @@ async function registerCommands() {
const rest = new REST().setToken(config.discordToken); const rest = new REST().setToken(config.discordToken);
const body = commandModules.map((c) => c.data.toJSON()); const body = commandModules.map((c) => c.data.toJSON());
const route = config.discordGuildId const guildId = discordGuildId();
? Routes.applicationGuildCommands(config.discordClientId, config.discordGuildId) const route = guildId
? Routes.applicationGuildCommands(config.discordClientId, guildId)
: Routes.applicationCommands(config.discordClientId); : Routes.applicationCommands(config.discordClientId);
await rest.put(route, { body }); await rest.put(route, { body });
console.log( 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) // /bug — Bug-Report aus Discord: erstellt ein Gitea-Issue (inkl. Screenshot-Upload)
import { SlashCommandBuilder, MessageFlags } from 'discord.js'; 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 { createIssue, uploadIssueAsset } from '../../gitea-api.js';
import { saveBugReport } from '../../db.js'; import { saveBugReport } from '../../db.js';
@@ -24,7 +24,7 @@ export const data = new SlashCommandBuilder()
export async function execute(interaction) { export async function execute(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await interaction.deferReply({ flags: MessageFlags.Ephemeral });
if (!config.giteaApiToken) { if (!giteaApiToken()) {
await interaction.editReply('❌ Bug-Reports sind gerade nicht konfiguriert (kein Gitea-Token).'); await interaction.editReply('❌ Bug-Reports sind gerade nicht konfiguriert (kein Gitea-Token).');
return; return;
} }
+3 -2
View File
@@ -4,6 +4,7 @@ import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js'; } from 'discord.js';
import { saveGiveaway } from '../../db.js'; import { saveGiveaway } from '../../db.js';
import { brandColor, brandFooter } from '../../runtime-settings.js';
/** "30m" / "2h" / "1d" → Millisekunden (null bei Unsinn) */ /** "30m" / "2h" / "1d" → Millisekunden (null bei Unsinn) */
export function parseDuration(input) { export function parseDuration(input) {
@@ -36,13 +37,13 @@ export async function execute(interaction) {
const endsAt = new Date(Date.now() + duration); const endsAt = new Date(Date.now() + duration);
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(0xf5c518) .setColor(brandColor())
.setTitle(`🎉 Giveaway: ${prize}`) .setTitle(`🎉 Giveaway: ${prize}`)
.setDescription( .setDescription(
`Klick auf den Button zum Teilnehmen!\n\n` + `Klick auf den Button zum Teilnehmen!\n\n` +
`🏆 **${winners}** Gewinner · ⏰ Ziehung <t:${Math.floor(endsAt.getTime() / 1000)}:R>` `🏆 **${winners}** Gewinner · ⏰ Ziehung <t:${Math.floor(endsAt.getTime() / 1000)}:R>`
) )
.setFooter({ text: 'D4RKST3R // GIVEAWAY' }) .setFooter({ text: brandFooter('GIVEAWAY') })
.setTimestamp(endsAt); .setTimestamp(endsAt);
const row = new ActionRowBuilder().addComponents( const row = new ActionRowBuilder().addComponents(
+3 -3
View File
@@ -3,7 +3,7 @@ import {
SlashCommandBuilder, PermissionFlagsBits, MessageFlags, SlashCommandBuilder, PermissionFlagsBits, MessageFlags,
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js'; } from 'discord.js';
import { playtesterRoleId } from '../../runtime-settings.js'; import { playtesterRoleId, brandColor, brandFooter } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
.setName('playtester-setup') .setName('playtester-setup')
@@ -20,7 +20,7 @@ export async function execute(interaction) {
} }
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(0xf5c518) .setColor(brandColor())
.setTitle('🧪 Playtester werden') .setTitle('🧪 Playtester werden')
.setDescription( .setDescription(
'Du willst **EcoGame** vor allen anderen spielen und mit deinem Feedback ' + '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. ' + 'sobald die Alpha startet, bekommst du deinen Zugang direkt per DM. ' +
'Abmelden geht jederzeit mit einem zweiten Klick.' 'Abmelden geht jederzeit mit einem zweiten Klick.'
) )
.setFooter({ text: 'D4RKST3R // PLAYTEST' }); .setFooter({ text: brandFooter('PLAYTEST') });
const row = new ActionRowBuilder().addComponents( const row = new ActionRowBuilder().addComponents(
new ButtonBuilder() new ButtonBuilder()
+2 -2
View File
@@ -1,6 +1,6 @@
// /purge — Nachrichten im aktuellen Kanal massenlöschen (max 100, jünger als 14 Tage) // /purge — Nachrichten im aktuellen Kanal massenlöschen (max 100, jünger als 14 Tage)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js'; 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() export const data = new SlashCommandBuilder()
.setName('purge') .setName('purge')
@@ -29,7 +29,7 @@ export async function execute(interaction) {
await channel?.send({ await channel?.send({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(0xff4d00) .setColor(brandColor2())
.setTitle('🧹 Purge') .setTitle('🧹 Purge')
.setDescription( .setDescription(
`**${deleted.size}** Nachrichten in <#${interaction.channelId}> gelöscht von **${interaction.user.username}**` `**${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 { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import { getLevelRow, rankOf } from '../../db.js'; import { getLevelRow, rankOf } from '../../db.js';
import { xpForLevel } from '../levels.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() export const data = new SlashCommandBuilder()
.setName('rank') .setName('rank')
@@ -32,12 +32,12 @@ export async function execute(interaction) {
await interaction.reply({ await interaction.reply({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(0xf5c518) .setColor(brandColor())
.setAuthor({ name: user.username, iconURL: user.displayAvatarURL({ size: 64 }) }) .setAuthor({ name: user.username, iconURL: user.displayAvatarURL({ size: 64 }) })
.setTitle(`Level ${row.level} · Platz #${rankOf(user.id)}`) .setTitle(`Level ${row.level} · Platz #${rankOf(user.id)}`)
.setDescription(`\`${bar}\` ${pct}%\n**${progress} / ${needed} XP** bis Level ${row.level + 1} · gesamt ${row.xp} XP`) .setDescription(`\`${bar}\` ${pct}%\n**${progress} / ${needed} XP** bis Level ${row.level + 1} · gesamt ${row.xp} XP`)
.setURL(`${publicUrl()}/level`) .setURL(`${publicUrl()}/level`)
.setFooter({ text: 'D4RKST3R // LEVEL' }), .setFooter({ text: brandFooter('LEVEL') }),
], ],
}); });
} }
+3 -3
View File
@@ -3,7 +3,7 @@ import {
SlashCommandBuilder, PermissionFlagsBits, MessageFlags, SlashCommandBuilder, PermissionFlagsBits, MessageFlags,
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js'; } from 'discord.js';
import { ticketChannelId } from '../../runtime-settings.js'; import { ticketChannelId, brandColor, brandFooter } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
.setName('ticket-setup') .setName('ticket-setup')
@@ -20,14 +20,14 @@ export async function execute(interaction) {
} }
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(0xf5c518) .setColor(brandColor())
.setTitle('🎫 Support-Ticket') .setTitle('🎫 Support-Ticket')
.setDescription( .setDescription(
'Du hast eine Frage, ein Problem oder ein privates Anliegen?\n\n' + 'Du hast eine Frage, ein Problem oder ein privates Anliegen?\n\n' +
'Klick auf den Button — es öffnet sich ein **privater Thread**, ' + 'Klick auf den Button — es öffnet sich ein **privater Thread**, ' +
'den nur du und das Team sehen können.' 'den nur du und das Team sehen können.'
) )
.setFooter({ text: 'D4RKST3R // SUPPORT' }); .setFooter({ text: brandFooter('SUPPORT') });
const row = new ActionRowBuilder().addComponents( const row = new ActionRowBuilder().addComponents(
new ButtonBuilder() new ButtonBuilder()
+2 -2
View File
@@ -1,7 +1,7 @@
// /timeout — Member stummschalten (Discord-Timeout) mit Mod-Log // /timeout — Member stummschalten (Discord-Timeout) mit Mod-Log
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { parseDuration } from './giveaway.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 const MAX_TIMEOUT_MS = 28 * 86_400_000; // Discord-Limit
@@ -42,7 +42,7 @@ export async function execute(interaction) {
await channel?.send({ await channel?.send({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(0xff4d00) .setColor(brandColor2())
.setTitle('🔇 Timeout') .setTitle('🔇 Timeout')
.addFields( .addFields(
{ name: 'Member', value: `${user.username} (<@${user.id}>)`, inline: true }, { 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) // /warn — Member verwarnen: DB-Eintrag, DM an den User, Mod-Log (Moderatoren)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { addWarn, warnsOf } from '../../db.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() export const data = new SlashCommandBuilder()
.setName('warn') .setName('warn')
@@ -34,7 +34,7 @@ export async function execute(interaction) {
await channel?.send({ await channel?.send({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(0xff4d00) .setColor(brandColor2())
.setTitle(`⚠️ Verwarnung #${count}`) .setTitle(`⚠️ Verwarnung #${count}`)
.addFields( .addFields(
{ name: 'Member', value: `${user.username} (<@${user.id}>)`, inline: true }, { 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) // /warns — Verwarnungs-Historie eines Members einsehen / Eintrag löschen (Moderatoren)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { warnsOf, deleteWarn } from '../../db.js'; import { warnsOf, deleteWarn } from '../../db.js';
import { brandColor } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
.setName('warns') .setName('warns')
@@ -34,7 +35,7 @@ export async function execute(interaction) {
await interaction.reply({ await interaction.reply({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(0xf5c518) .setColor(brandColor())
.setTitle(`⚠️ Verwarnungen: ${user.username} (${warns.length})`) .setTitle(`⚠️ Verwarnungen: ${user.username} (${warns.length})`)
.setDescription(lines.join('\n')) .setDescription(lines.join('\n'))
.setFooter({ text: 'Löschen: /warns member:<user> loeschen:<ID>' }), .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 // /wunsch — Feature-Wunsch einreichen: Voting-Post mit 👍, Rangliste auf der Webseite
import { SlashCommandBuilder, MessageFlags, EmbedBuilder } from 'discord.js'; 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'; import { saveWish } from '../../db.js';
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
@@ -25,7 +25,7 @@ export async function execute(interaction) {
const channel = await interaction.client.channels.fetch(channelId); const channel = await interaction.client.channels.fetch(channelId);
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(0xf5c518) .setColor(brandColor())
.setAuthor({ .setAuthor({
name: interaction.member?.displayName ?? interaction.user.username, name: interaction.member?.displayName ?? interaction.user.username,
iconURL: interaction.user.displayAvatarURL({ size: 64 }), iconURL: interaction.user.displayAvatarURL({ size: 64 }),
@@ -33,7 +33,7 @@ export async function execute(interaction) {
.setTitle('💡 Feature-Wunsch') .setTitle('💡 Feature-Wunsch')
.setDescription(idea) .setDescription(idea)
.setURL(`${publicUrl()}/roadmap`) .setURL(`${publicUrl()}/roadmap`)
.setFooter({ text: 'D4RKST3R // VOTING • 👍 = will ich!' }); .setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` });
const message = await channel.send({ embeds: [embed] }); const message = await channel.send({ embeds: [embed] });
await message.react('👍'); await message.react('👍');
+2 -3
View File
@@ -7,10 +7,9 @@ import {
hasStarboard, saveStarboard, hasStarboard, saveStarboard,
saveGalleryItem, hasGalleryItem, deleteGalleryItem, saveGalleryItem, hasGalleryItem, deleteGalleryItem,
} from '../db.js'; } 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'; import { config } from '../config.js';
const BRAND_YELLOW = 0xf5c518;
const MAX_GALLERY_IMAGES = 4; const MAX_GALLERY_IMAGES = 4;
// Galerie-Bilder liegen neben der SQLite: data/gallery/ // Galerie-Bilder liegen neben der SQLite: data/gallery/
@@ -83,7 +82,7 @@ async function handleStarReaction(reaction) {
a.contentType?.startsWith('image/') a.contentType?.startsWith('image/')
); );
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW) .setColor(brandColor())
.setAuthor({ .setAuthor({
name: message.member?.displayName ?? message.author?.username ?? 'unbekannt', name: message.member?.displayName ?? message.author?.username ?? 'unbekannt',
iconURL: message.author?.displayAvatarURL?.() ?? undefined, iconURL: message.author?.displayAvatarURL?.() ?? undefined,
+2 -1
View File
@@ -1,6 +1,7 @@
// Giveaway-Ziehung: Minuten-Scheduler beendet fällige Verlosungen automatisch // Giveaway-Ziehung: Minuten-Scheduler beendet fällige Verlosungen automatisch
import { EmbedBuilder } from 'discord.js'; import { EmbedBuilder } from 'discord.js';
import { dueGiveaways, markGiveawayEnded, giveawayEntries } from '../db.js'; import { dueGiveaways, markGiveawayEnded, giveawayEntries } from '../db.js';
import { brandFooter } from '../runtime-settings.js';
const CHECK_INTERVAL_MS = 60 * 1000; const CHECK_INTERVAL_MS = 60 * 1000;
@@ -39,7 +40,7 @@ export async function giveawayTick(client) {
? `🏆 Gewonnen: ${mentions}\n👥 ${entries.length} Teilnahmen` ? `🏆 Gewonnen: ${mentions}\n👥 ${entries.length} Teilnahmen`
: 'Keine Teilnahmen — niemand hat gewonnen. 😢' : 'Keine Teilnahmen — niemand hat gewonnen. 😢'
) )
.setFooter({ text: 'D4RKST3R // GIVEAWAY' }), .setFooter({ text: brandFooter('GIVEAWAY') }),
], ],
components: [], components: [],
}).catch(() => {}); }).catch(() => {});
+9 -14
View File
@@ -1,13 +1,8 @@
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log // Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
import { ChannelType, EmbedBuilder, Events } from 'discord.js'; import { ChannelType, EmbedBuilder, Events } from 'discord.js';
import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js'; import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js';
import { import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl,
autoroleId, stickyRolesEnabled,
} from '../runtime-settings.js';
const BRAND_YELLOW = 0xf5c518;
const BRAND_ORANGE = 0xff4d00;
/* ── Modmail ───────────────────────────────────────── */ /* ── Modmail ───────────────────────────────────────── */
@@ -36,12 +31,12 @@ async function ensureModmailThread(client, user) {
await thread.send({ await thread.send({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(BRAND_YELLOW) .setColor(brandColor())
.setDescription( .setDescription(
`Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` + `Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` +
'Antworten in diesem Thread gehen als DM an den User.' 'Antworten in diesem Thread gehen als DM an den User.'
) )
.setFooter({ text: 'D4RKST3R // MODMAIL' }), .setFooter({ text: brandFooter('MODMAIL') }),
], ],
}); });
return thread; return thread;
@@ -89,7 +84,7 @@ async function handleMemberAdd(member) {
if (!channel?.isTextBased()) return; if (!channel?.isTextBased()) return;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW) .setColor(brandColor())
.setTitle(`👋 Willkommen, ${member.displayName}!`) .setTitle(`👋 Willkommen, ${member.displayName}!`)
.setThumbnail(member.user.displayAvatarURL({ size: 128 })) .setThumbnail(member.user.displayAvatarURL({ size: 128 }))
.setDescription( .setDescription(
@@ -98,7 +93,7 @@ async function handleMemberAdd(member) {
'🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' + '🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' +
'🐛 Bug gefunden? Einfach `/bug` benutzen.' '🐛 Bug gefunden? Einfach `/bug` benutzen.'
) )
.setFooter({ text: 'D4RKST3R // COMMUNITY' }) .setFooter({ text: brandFooter('COMMUNITY') })
.setTimestamp(); .setTimestamp();
await channel.send({ content: `<@${member.id}>`, embeds: [embed] }); await channel.send({ content: `<@${member.id}>`, embeds: [embed] });
@@ -183,7 +178,7 @@ export function registerModTools(client) {
if (roleIds.length > 0) saveStickyRoles(member.id, roleIds); if (roleIds.length > 0) saveStickyRoles(member.id, roleIds);
await logToModlog(client, new EmbedBuilder() await logToModlog(client, new EmbedBuilder()
.setColor(BRAND_ORANGE) .setColor(brandColor2())
.setTitle('📤 Member gegangen') .setTitle('📤 Member gegangen')
.setDescription(`**${member.user?.username ?? member.id}**${roleIds.length ? ` — Rollen gesichert (${roleIds.length})` : ''}`) .setDescription(`**${member.user?.username ?? member.id}**${roleIds.length ? ` — Rollen gesichert (${roleIds.length})` : ''}`)
.setTimestamp()); .setTimestamp());
@@ -211,7 +206,7 @@ export function registerModTools(client) {
if (changes.length === 0) return; if (changes.length === 0) return;
await logToModlog(client, new EmbedBuilder() await logToModlog(client, new EmbedBuilder()
.setColor(BRAND_YELLOW) .setColor(brandColor())
.setTitle('👤 Member geändert') .setTitle('👤 Member geändert')
.setDescription(`**${newMember.user?.username ?? newMember.id}**`) .setDescription(`**${newMember.user?.username ?? newMember.id}**`)
.addFields(changes) .addFields(changes)
@@ -227,7 +222,7 @@ export function registerModTools(client) {
if (!message.guildId || message.author?.bot) return; if (!message.guildId || message.author?.bot) return;
if (message.channelId === modlogChannelId()) return; if (message.channelId === modlogChannelId()) return;
await logToModlog(client, new EmbedBuilder() await logToModlog(client, new EmbedBuilder()
.setColor(BRAND_ORANGE) .setColor(brandColor2())
.setTitle('🗑️ Nachricht gelöscht') .setTitle('🗑️ Nachricht gelöscht')
.addFields( .addFields(
{ name: 'User', value: message.author ? `${message.author.username}` : 'unbekannt', inline: true }, { 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 (!newMessage.guildId || newMessage.author?.bot) return;
if (oldMessage.content === newMessage.content) return; // z. B. nur Embed-Update if (oldMessage.content === newMessage.content) return; // z. B. nur Embed-Update
await logToModlog(client, new EmbedBuilder() await logToModlog(client, new EmbedBuilder()
.setColor(BRAND_YELLOW) .setColor(brandColor())
.setTitle('✏️ Nachricht bearbeitet') .setTitle('✏️ Nachricht bearbeitet')
.addFields( .addFields(
{ name: 'User', value: newMessage.author?.username ?? 'unbekannt', inline: true }, { 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) // Release-Ankündigungen: großes Embed in den konfigurierten Kanal (Setup-Seite)
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; 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'; import { config } from '../config.js';
const BRAND_ORANGE = 0xff4d00;
/** /**
* Release ankündigen (falls ein Kanal konfiguriert ist). * 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 title = release.name?.trim() || release.tag;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(BRAND_ORANGE) .setColor(brandColor2())
.setTitle(`🚀 ${title}${release.prerelease ? ' (Pre-Release)' : ''}`) .setTitle(`🚀 ${title}${release.prerelease ? ' (Pre-Release)' : ''}`)
.setDescription(body || '*Keine Release-Notes.*') .setDescription(body || '*Keine Release-Notes.*')
.addFields({ name: 'Version', value: `\`${release.tag}\``, inline: true }, .addFields({ name: 'Version', value: `\`${release.tag}\``, inline: true },
{ name: 'Projekt', value: release.repo, inline: true }) { name: 'Projekt', value: release.repo, inline: true })
.setFooter({ text: 'D4RKST3R // RELEASE' }) .setFooter({ text: brandFooter('RELEASE') })
.setTimestamp(); .setTimestamp();
const buttons = new ActionRowBuilder().addComponents( 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. // 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 { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags } from 'discord.js';
import { getRoleMenu, setRoleMenuMessage } from '../db.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 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 */ /** Embed + Button-Reihen aus einem Menü-Datensatz bauen */
function buildMenuMessage(menu) { function buildMenuMessage(menu) {
const entries = JSON.parse(menu.entries || '[]'); const entries = JSON.parse(menu.entries || '[]');
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW) .setColor(brandColor())
.setTitle(menu.title) .setTitle(menu.title)
.setDescription( .setDescription(
(menu.description?.trim() ? `${menu.description.trim()}\n\n` : '') + (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 — es ist immer nur eine Rolle aus diesem Menü aktiv.*'
: '*Klick = Rolle nehmen, nochmal klicken = wieder abgeben.*') : '*Klick = Rolle nehmen, nochmal klicken = wieder abgeben.*')
) )
.setFooter({ text: 'D4RKST3R // ROLLEN' }); .setFooter({ text: brandFooter('ROLLEN') });
const rows = []; const rows = [];
for (let i = 0; i < entries.length; i += 5) { for (let i = 0; i < entries.length; i += 5) {
@@ -29,7 +37,7 @@ function buildMenuMessage(menu) {
entries.slice(i, i + 5).map((e) => { entries.slice(i, i + 5).map((e) => {
const button = new ButtonBuilder() const button = new ButtonBuilder()
.setCustomId(`rolemenu:${menu.id}:${e.role_id}`) .setCustomId(`rolemenu:${menu.id}:${e.role_id}`)
.setStyle(ButtonStyle.Secondary) .setStyle(BUTTON_STYLES[e.style] ?? ButtonStyle.Secondary)
.setLabel(e.label.slice(0, 80)); .setLabel(e.label.slice(0, 80));
if (e.emoji) button.setEmoji(e.emoji); if (e.emoji) button.setEmoji(e.emoji);
return button; return button;
+12 -10
View File
@@ -3,10 +3,10 @@
// Setting "gameservers": kommagetrennt "Name=http://ip:30120, Name2=…" // Setting "gameservers": kommagetrennt "Name=http://ip:30120, Name2=…"
import { ActivityType, EmbedBuilder, Events } from 'discord.js'; import { ActivityType, EmbedBuilder, Events } from 'discord.js';
import { getSetting, setSetting } from '../db.js'; import { getSetting, setSetting } from '../db.js';
import { brandColor, brandFooter, botStatusText } from '../runtime-settings.js';
const CHECK_INTERVAL_MS = 2 * 60 * 1000; const CHECK_INTERVAL_MS = 2 * 60 * 1000;
const TIMEOUT_MS = 8000; const TIMEOUT_MS = 8000;
const BRAND_YELLOW = 0xf5c518;
function serverList() { function serverList() {
return (getSetting('gameservers') ?? '') return (getSetting('gameservers') ?? '')
@@ -48,10 +48,10 @@ function buildEmbed(results) {
: `🔴 **${r.name}** — offline` : `🔴 **${r.name}** — offline`
); );
return new EmbedBuilder() return new EmbedBuilder()
.setColor(BRAND_YELLOW) .setColor(brandColor())
.setTitle('🎮 Server-Status') .setTitle('🎮 Server-Status')
.setDescription(lines.join('\n')) .setDescription(lines.join('\n'))
.setFooter({ text: 'D4RKST3R // STATUS • aktualisiert alle 2 min' }) .setFooter({ text: `${brandFooter('STATUS')} • aktualisiert alle 2 min` })
.setTimestamp(); .setTimestamp();
} }
@@ -63,15 +63,17 @@ export async function monitorTick(client) {
const results = await Promise.all(servers.map(queryServer)); 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 online = results.filter((r) => r.online);
const totalPlayers = online.reduce((sum, r) => sum + r.players, 0); const totalPlayers = online.reduce((sum, r) => sum + r.players, 0);
try { if (!botStatusText()) {
client.user?.setActivity?.( try {
online.length > 0 ? `${totalPlayers} Spieler online` : 'Server offline', client.user?.setActivity?.(
{ type: ActivityType.Watching } online.length > 0 ? `${totalPlayers} Spieler online` : 'Server offline',
); { type: ActivityType.Watching }
} catch { /* Presence ist nice-to-have */ } );
} catch { /* Presence ist nice-to-have */ }
}
// Persistentes Embed im Status-Kanal pflegen (einmal posten, danach editieren) // Persistentes Embed im Status-Kanal pflegen (einmal posten, danach editieren)
if (!channelId) return; if (!channelId) return;
+3 -4
View File
@@ -2,10 +2,9 @@
// eine Zusammenfassung der Woche in den Devlog-Kanal. // eine Zusammenfassung der Woche in den Devlog-Kanal.
import { EmbedBuilder } from 'discord.js'; import { EmbedBuilder } from 'discord.js';
import { weeklyStats, getSetting, setSetting } from '../db.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'; import { config } from '../config.js';
const BRAND_YELLOW = 0xf5c518;
const CHECK_INTERVAL_MS = 5 * 60 * 1000; const CHECK_INTERVAL_MS = 5 * 60 * 1000;
/** Balken-Zeile: `Mo 21.07 ████████ 12` */ /** Balken-Zeile: `Mo 21.07 ████████ 12` */
@@ -46,7 +45,7 @@ export async function postWeeklyRecap(client) {
.join(' · '); .join(' · ');
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW) .setColor(brandColor())
.setTitle('📊 Wochen-Rückblick') .setTitle('📊 Wochen-Rückblick')
.setURL(`${publicUrl()}/devlogs`) .setURL(`${publicUrl()}/devlogs`)
.setDescription( .setDescription(
@@ -57,7 +56,7 @@ export async function postWeeklyRecap(client) {
{ name: 'Devlogs', value: String(stats.devlogs), inline: true }, { name: 'Devlogs', value: String(stats.devlogs), inline: true },
...(repoLine ? [{ name: 'Aktive Projekte', value: repoLine, inline: false }] : []) ...(repoLine ? [{ name: 'Aktive Projekte', value: repoLine, inline: false }] : [])
) )
.setFooter({ text: 'D4RKST3R // WEEKLY' }) .setFooter({ text: brandFooter('WEEKLY') })
.setTimestamp(); .setTimestamp();
await channel.send({ embeds: [embed] }); await channel.send({ embeds: [embed] });
+4 -4
View File
@@ -1,10 +1,10 @@
// Minimaler Gitea-API-Client (Token-Auth) — für /bug → Issues // 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 = {}) { function headers(extra = {}) {
return { return {
Authorization: `token ${config.giteaApiToken}`, Authorization: `token ${giteaApiToken()}`,
Accept: 'application/json', Accept: 'application/json',
...extra, ...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 */ /** Milestones eines Repos (für die Roadmap-Seite); Token optional bei öffentlichen Repos */
export async function getMilestones(repo) { export async function getMilestones(repo) {
const res = await fetch(`${giteaUrl()}/api/v1/repos/${repo}/milestones?state=all&per_page=50`, { 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) { if (!res.ok) {
throw new Error(`Gitea getMilestones ${res.status}`); 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; 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 */ /** Auto-Rolle für neue Member — leer = aus */
export function autoroleId() { export function autoroleId() {
return getSetting('autorole_id') || null; 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 { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.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 { getMilestones } from '../gitea-api.js';
import { getSessionUser, isAdmin } from './auth.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"?> const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
<channel> <channel>
<title>D4RKST3R // DEVLOG</title> <title>${brandFooter('DEVLOG')}</title>
<link>${publicUrl()}/devlogs</link> <link>${publicUrl()}/devlogs</link>
<description>Entwicklungs-Updates, automatisch archiviert.</description> <description>Entwicklungs-Updates, automatisch archiviert.</description>
<language>de</language> <language>de</language>
@@ -223,6 +223,13 @@ ${rssItems}
ticket_channel_id: getSetting('ticket_channel_id') ?? '', ticket_channel_id: getSetting('ticket_channel_id') ?? '',
gameservers: getSetting('gameservers') ?? '', gameservers: getSetting('gameservers') ?? '',
autorole_id: getSetting('autorole_id') ?? '', 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', sticky_roles_enabled: getSetting('sticky_roles_enabled') === '1',
levels_enabled: getSetting('levels_enabled') === '1', levels_enabled: getSetting('levels_enabled') === '1',
levels_announce: getSetting('levels_announce') !== '0', levels_announce: getSetting('levels_announce') !== '0',
@@ -242,7 +249,7 @@ ${rssItems}
botTag: client.user?.tag ?? null, botTag: client.user?.tag ?? null,
uptimeSeconds: Math.floor(process.uptime()), uptimeSeconds: Math.floor(process.uptime()),
guilds: client.guilds.cache.size, guilds: client.guilds.cache.size,
giteaTokenConfigured: Boolean(config.giteaApiToken), giteaTokenConfigured: Boolean(giteaApiToken()),
lastBackup: getSetting('last_backup'), lastBackup: getSetting('last_backup'),
...stats, ...stats,
}, },
@@ -319,6 +326,42 @@ ${rssItems}
if (body.level_rewards !== undefined) { if (body.level_rewards !== undefined) {
setSetting('level_rewards', String(body.level_rewards).trim()); 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']) { for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', 'gameservers']) {
if (body[key] !== undefined) { if (body[key] !== undefined) {
setSetting(key, String(body[key]).trim()); setSetting(key, String(body[key]).trim());
@@ -353,6 +396,7 @@ ${rssItems}
emoji: String(e.emoji ?? '').trim(), emoji: String(e.emoji ?? '').trim(),
label: String(e.label ?? '').trim(), label: String(e.label ?? '').trim(),
role_id: String(e.role_id ?? '').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); .filter((e) => e.label && e.role_id);
if (entries.length > MAX_ENTRIES) { if (entries.length > MAX_ENTRIES) {
@@ -532,10 +576,10 @@ ${rssItems}
content: null, content: null,
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(0xf5c518) .setColor(brandColor())
.setTitle(String(embedTitle).slice(0, 200)) .setTitle(String(embedTitle).slice(0, 200))
.setDescription(text.slice(0, 4000)) .setDescription(text.slice(0, 4000))
.setFooter({ text: 'D4RKST3R' }) .setFooter({ text: brandName() })
.setTimestamp(), .setTimestamp(),
], ],
} }
@@ -557,6 +601,34 @@ ${rssItems}
return { ok: true, edited: false, message_id: message.id }; 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/* --- // --- API-Keys (Admin) — für /api/v1/* ---
const VALID_SCOPES = ['message', 'dm', 'roles', 'read']; const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
@@ -629,12 +701,12 @@ ${rssItems}
await channel.send({ await channel.send({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(0xf5c518) .setColor(brandColor())
.setTitle('🔧 Test') .setTitle('🔧 Test')
.setDescription( .setDescription(
`Test-Nachricht für den **${{ commit: 'Commit', devlog: 'Devlog', release: 'Release' }[target]}-Kanal** — von der Settings-Seite ausgelöst.` `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 }; 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 { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { saveDevlog } from '../db.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'; import { imagesDir } from '../bot/devlog-archive.js';
const BRAND_YELLOW = 0xf5c518;
const MAX_IMAGES = 4; const MAX_IMAGES = 4;
/** Secret timing-safe vergleichen */ /** 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()}` }) (f, i) => new AttachmentBuilder(f.buffer, { name: `devlog_${i}.${f.name.split('.').pop().toLowerCase()}` })
); );
const mainEmbed = new EmbedBuilder() const mainEmbed = new EmbedBuilder()
.setColor(BRAND_YELLOW) .setColor(brandColor())
.setTitle(`📔 Devlog — ${dateStr}`) .setTitle(`📔 Devlog — ${dateStr}`)
.setURL(groupUrl) .setURL(groupUrl)
.setDescription(prose) .setDescription(prose)
.setFooter({ .setFooter({
text: `D4RKST3R // DEVLOG${commitCount ? `${commitCount} Commits` : ''}`, text: `${brandFooter('DEVLOG')}${commitCount ? `${commitCount} Commits` : ''}`,
}) })
.setTimestamp(); .setTimestamp();
if (project) { if (project) {
@@ -180,7 +179,7 @@ export function registerDevlogEndpoint(app, client) {
message_id: message.id, message_id: message.id,
channel_id: message.channelId, channel_id: message.channelId,
content: `${title}\n\n${description}`, content: `${title}\n\n${description}`,
author_name: 'D4RKST3R // DEVLOG', author_name: brandFooter('DEVLOG'),
posted_at: message.createdAt.toISOString(), posted_at: message.createdAt.toISOString(),
images: JSON.stringify(imageFiles), images: JSON.stringify(imageFiles),
}); });
+3 -2
View File
@@ -3,6 +3,7 @@
// Zeiten sind lokale Serverzeit (TZ im Compose = Europe/Berlin), gespeichert als UTC. // Zeiten sind lokale Serverzeit (TZ im Compose = Europe/Berlin), gespeichert als UTC.
import { EmbedBuilder } from 'discord.js'; import { EmbedBuilder } from 'discord.js';
import { dueScheduledPosts, markScheduledRun } from '../db.js'; import { dueScheduledPosts, markScheduledRun } from '../db.js';
import { brandColor, brandName } from '../runtime-settings.js';
const CHECK_INTERVAL_MS = 60 * 1000; const CHECK_INTERVAL_MS = 60 * 1000;
@@ -47,10 +48,10 @@ export function buildScheduledPayload(post) {
return { return {
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setColor(0xf5c518) .setColor(brandColor())
.setTitle(post.embed_title.slice(0, 200)) .setTitle(post.embed_title.slice(0, 200))
.setDescription(post.content.slice(0, 4000)) .setDescription(post.content.slice(0, 4000))
.setFooter({ text: 'D4RKST3R' }) .setFooter({ text: brandName() })
.setTimestamp(), .setTimestamp(),
], ],
}; };