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:
@@ -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` |
|
||||
| 🏷️ **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) |
|
||||
| 🎨 **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) |
|
||||
| 📋 **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 |
|
||||
@@ -58,8 +59,8 @@ Design: D4RKST3R-Brand (Neon-Gelb/Orange auf Schwarz, Bebas Neue + Barlow Conden
|
||||
Share Tech Mono, selbst gehostet).
|
||||
|
||||
### Setup-Seite (`/settings`)
|
||||
Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Feeds · Community · Rollen ·
|
||||
Support · Composer · System · API). Alles zur Laufzeit änderbar — gespeichert in
|
||||
Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community ·
|
||||
Rollen · Support · Composer · System · API). Alles zur Laufzeit änderbar — gespeichert in
|
||||
SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig:
|
||||
|
||||
- **Allgemein:** Öffentliche URL, Gitea-URL
|
||||
@@ -116,7 +117,7 @@ in Portainer als Stack-Environment-Variables.
|
||||
| `GITEA_WEBHOOK_SECRET` | ✅ | HMAC-Prüfung der Gitea-Webhooks¹ |
|
||||
| `DEVLOG_POST_SECRET` | ✅ | Secret im Devlog-Endpoint-Pfad¹ |
|
||||
| `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` |
|
||||
| `PUBLIC_URL` | optional | Default `https://bot.d4rkst3r.de` |
|
||||
| `COMMIT_CHANNEL_ID` / `DEVLOG_CHANNEL_ID` | optional | Fallbacks — Kanäle kommen normal von der Setup-Seite |
|
||||
|
||||
@@ -10,6 +10,7 @@ const API_SCOPES = [
|
||||
|
||||
const TABS = [
|
||||
{ id: 'status', icon: '📊', label: 'Status' },
|
||||
{ id: 'brand', icon: '🎨', label: 'Brand' },
|
||||
{ id: 'feeds', icon: '📔', label: 'Feeds' },
|
||||
{ id: 'community', icon: '🙌', label: 'Community' },
|
||||
{ id: 'rollen', icon: '🎭', label: 'Rollen' },
|
||||
@@ -43,12 +44,14 @@ export default function Settings({ me }) {
|
||||
// Rollen-Menüs
|
||||
const emptyMenu = {
|
||||
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 [menuDraft, setMenuDraft] = useState(emptyMenu);
|
||||
// Composer
|
||||
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
|
||||
// Branding
|
||||
const [giteaToken, setGiteaToken] = useState('');
|
||||
// Tags + geplante Posts
|
||||
const [tags, setTags] = useState([]);
|
||||
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
|
||||
@@ -78,6 +81,33 @@ export default function Settings({ me }) {
|
||||
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
|
||||
}, [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() {
|
||||
if (!tagDraft.name.trim() || !tagDraft.content.trim()) {
|
||||
flash('✗ Name und Inhalt nötig');
|
||||
@@ -223,7 +253,9 @@ export default function Settings({ me }) {
|
||||
setMenuDraft({
|
||||
id: m.id, title: m.title, description: m.description,
|
||||
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) =>
|
||||
@@ -291,6 +323,73 @@ export default function Settings({ me }) {
|
||||
</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 & 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 & anwenden</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const tabFeeds = (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
@@ -533,6 +632,17 @@ export default function Settings({ me }) {
|
||||
<option value="" disabled>— Rolle —</option>
|
||||
{roleOptions}
|
||||
</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
|
||||
className="card-delete"
|
||||
title="Zeile entfernen"
|
||||
@@ -822,6 +932,7 @@ export default function Settings({ me }) {
|
||||
|
||||
const content = {
|
||||
status: tabStatus,
|
||||
brand: tabBrand,
|
||||
feeds: tabFeeds,
|
||||
community: tabCommunity,
|
||||
rollen: tabRollen,
|
||||
|
||||
@@ -920,3 +920,38 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
border-color: var(--neon);
|
||||
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
@@ -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'})`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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}**`
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>' }),
|
||||
|
||||
@@ -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('👍');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
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;
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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,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(),
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user