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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:17:30 +02:00
co-authored by Claude Fable 5
parent 332e89dcbd
commit 00249f9d8b
17 changed files with 1379 additions and 116 deletions
+110 -3
View File
@@ -1,9 +1,12 @@
// Kleinkram-Paket: Triggers (Auto-Antworten), Reminder-Scheduler, Temp-Voice
// (Join to Create), Event-Ankündigungen und Aktivitäts-Zählung.
import { ChannelType, EmbedBuilder, Events } from 'discord.js';
import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, EmbedBuilder, Events,
MessageFlags, ModalBuilder, TextInputBuilder, TextInputStyle,
} from 'discord.js';
import {
listTriggers, dueReminders, markReminderSent, countActivity,
trackTempVoice, untrackTempVoice, isTempVoice, listTempVoice,
trackTempVoice, untrackTempVoice, isTempVoice, listTempVoice, tempVoiceOwner,
} from '../db.js';
import {
tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter,
@@ -40,8 +43,30 @@ async function handleVoiceState(oldState, newState) {
type: ChannelType.GuildVoice,
parent: hub.parentId ?? undefined,
});
trackTempVoice(channel.id);
trackTempVoice(channel.id, newState.member.id);
await newState.member.voice.setChannel(channel).catch(() => {});
// Control-Panel in den Kanal-Textchat (nur der Besitzer darf bedienen)
await channel.send({
embeds: [
new EmbedBuilder()
.setColor(brandColor())
.setTitle('🔊 Dein Kanal')
.setDescription(
`<@${newState.member.id}>, das hier ist dein temporärer Voice-Kanal — ` +
'er verschwindet automatisch, wenn alle raus sind. Steuern kannst du ihn hier:'
)
.setFooter({ text: brandFooter('VOICE') }),
],
components: [
new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId('tv:rename').setStyle(ButtonStyle.Secondary).setLabel('Umbenennen').setEmoji('✏️'),
new ButtonBuilder().setCustomId('tv:lock').setStyle(ButtonStyle.Secondary).setLabel('Sperren/Öffnen').setEmoji('🔒'),
new ButtonBuilder().setCustomId('tv:limit').setStyle(ButtonStyle.Secondary).setLabel('Limit').setEmoji('👥'),
new ButtonBuilder().setCustomId('tv:delete').setStyle(ButtonStyle.Danger).setLabel('Löschen').setEmoji('🗑️')
),
],
}).catch(() => {});
}
// Temp-Kanal leer → löschen
@@ -99,6 +124,77 @@ async function announceEvent(event) {
await channel.send({ embeds: [embed] }).catch(() => {});
}
/* ── Temp-Voice-Control-Panel (Buttons + Modals) ───── */
async function handleTempVoiceInteraction(interaction) {
const channel = interaction.channel;
const owner = tempVoiceOwner(channel?.id);
if (!owner) {
await interaction.reply({ content: '❌ Das ist kein Temp-Voice-Kanal (mehr).', flags: MessageFlags.Ephemeral });
return;
}
if (interaction.user.id !== owner) {
await interaction.reply({ content: '❌ Nur der Kanal-Besitzer darf das.', flags: MessageFlags.Ephemeral });
return;
}
const action = interaction.customId.split(':')[1];
if (action === 'rename') {
const modal = new ModalBuilder().setCustomId('tvmodal:rename').setTitle('Kanal umbenennen');
modal.addComponents(new ActionRowBuilder().addComponents(
new TextInputBuilder().setCustomId('value').setLabel('Neuer Name').setStyle(TextInputStyle.Short).setMaxLength(90).setRequired(true)
));
await interaction.showModal(modal);
return;
}
if (action === 'limit') {
const modal = new ModalBuilder().setCustomId('tvmodal:limit').setTitle('User-Limit setzen');
modal.addComponents(new ActionRowBuilder().addComponents(
new TextInputBuilder().setCustomId('value').setLabel('Limit (0 = unbegrenzt, max 99)').setStyle(TextInputStyle.Short).setMaxLength(2).setRequired(true)
));
await interaction.showModal(modal);
return;
}
if (action === 'lock') {
const everyone = channel.guild.roles.everyone;
const current = channel.permissionOverwrites.cache.get(everyone.id);
const locked = current?.deny?.has?.('Connect') ?? false;
await channel.permissionOverwrites.edit(everyone, { Connect: locked ? null : false });
await interaction.reply({
content: locked ? '🔓 Kanal ist wieder offen.' : '🔒 Kanal gesperrt — nur wer drin ist, bleibt drin.',
flags: MessageFlags.Ephemeral,
});
return;
}
if (action === 'delete') {
await interaction.reply({ content: '🗑️ Kanal wird gelöscht …', flags: MessageFlags.Ephemeral });
untrackTempVoice(channel.id);
await channel.delete().catch(() => {});
}
}
async function handleTempVoiceModal(interaction) {
const channel = interaction.channel;
if (tempVoiceOwner(channel?.id) !== interaction.user.id) {
await interaction.reply({ content: '❌ Nur der Kanal-Besitzer darf das.', flags: MessageFlags.Ephemeral });
return;
}
const kind = interaction.customId.split(':')[1];
const value = interaction.fields.getTextInputValue('value').trim();
if (kind === 'rename') {
await channel.setName(`🔊 ${value}`.slice(0, 90)).catch(() => {});
await interaction.reply({ content: `✏️ Kanal heißt jetzt **🔊 ${value}**.`, flags: MessageFlags.Ephemeral });
} else if (kind === 'limit') {
const n = Math.max(0, Math.min(99, Number(value) || 0));
await channel.setUserLimit(n).catch(() => {});
await interaction.reply({
content: n === 0 ? '👥 Limit entfernt.' : `👥 Limit: **${n}** Plätze.`,
flags: MessageFlags.Ephemeral,
});
}
}
/* ── Registrierung ─────────────────────────────────── */
export function registerExtras(client) {
@@ -117,6 +213,17 @@ export function registerExtras(client) {
client.on(Events.VoiceStateUpdate, (oldState, newState) =>
handleVoiceState(oldState, newState).catch((e) => console.error('[tempvoice]', e))
);
client.on(Events.InteractionCreate, async (interaction) => {
try {
if (interaction.isButton() && interaction.customId.startsWith('tv:')) {
await handleTempVoiceInteraction(interaction);
} else if (interaction.isModalSubmit() && interaction.customId.startsWith('tvmodal:')) {
await handleTempVoiceModal(interaction);
}
} catch (error) {
console.error('[tempvoice] Panel-Fehler:', error);
}
});
client.on(Events.GuildScheduledEventCreate, (event) =>
announceEvent(event).catch((e) => console.error('[events]', e))
);
+1 -1
View File
@@ -40,7 +40,7 @@ export async function giveawayTick(client) {
? `🏆 Gewonnen: ${mentions}\n👥 ${entries.length} Teilnahmen`
: 'Keine Teilnahmen — niemand hat gewonnen. 😢'
)
.setFooter({ text: brandFooter('GIVEAWAY') }),
.setFooter({ text: brandFooter('GIVEAWAY'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
],
components: [],
}).catch(() => {});
+2 -2
View File
@@ -36,7 +36,7 @@ async function ensureModmailThread(client, user) {
`Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` +
'Antworten in diesem Thread gehen als DM an den User.'
)
.setFooter({ text: brandFooter('MODMAIL') }),
.setFooter({ text: brandFooter('MODMAIL'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) }),
],
});
return thread;
@@ -93,7 +93,7 @@ async function handleMemberAdd(member) {
'🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' +
'🐛 Bug gefunden? Einfach `/bug` benutzen.'
)
.setFooter({ text: brandFooter('COMMUNITY') })
.setFooter({ text: brandFooter('COMMUNITY'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp();
await channel.send({ content: `<@${member.id}>`, embeds: [embed] });
+1 -1
View File
@@ -30,7 +30,7 @@ export async function postReleaseEmbed(client, release) {
.setDescription(body || '*Keine Release-Notes.*')
.addFields({ name: 'Version', value: `\`${release.tag}\``, inline: true },
{ name: 'Projekt', value: release.repo, inline: true })
.setFooter({ text: brandFooter('RELEASE') })
.setFooter({ text: brandFooter('RELEASE'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp();
const buttons = new ActionRowBuilder().addComponents(
+110 -20
View File
@@ -1,65 +1,153 @@
// Game-Server-Monitor (DiscordGSM-Stil): pro Server ein eigenes Status-Embed
// im Status-Kanal (edit in place, grün/rot), alle 2 Minuten aktualisiert.
// Server werden im Setup-Tab "Server" verwaltet (Typ: fivem | http).
// Game-Server-Monitor v3 (DiscordGSM-Stil): gamedig-Queries für 300+ Spiele,
// pro Server ein Live-Embed (Spieler-Balken, Map, Player-Liste, Connect-Link),
// Down/Up-Alerts mit 2-Fails-Schwelle. Verwaltet im Setup-Tab "Server".
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
import { GameDig } from 'gamedig';
import { getSetting, listGameservers, setGameserverMessage } from '../db.js';
import { brandFooter, botStatusText } from '../runtime-settings.js';
import { brandEmbed } from '../embeds.js';
const CHECK_INTERVAL_MS = 2 * 60 * 1000;
const TIMEOUT_MS = 8000;
const FAILS_BEFORE_ALERT = 2;
const GREEN = 0x23a55a;
const RED = 0xf23f43;
/** Einen Server abfragen — je nach Typ */
// Emoji pro Spieltyp (Fallback 🎮)
export const GAME_ICONS = {
fivem: '🚗', http: '🌐', minecraft: '⛏️', minecraftbe: '⛏️',
teamfortress2: '🎩', counterstrike2: '🔫', csgo: '🔫', garrysmod: '🔧',
rust: '🪓', valheim: '🛡️', palworld: '🐑', ark: '🦖', arkse: '🦖',
dayz: '🧟', projectzomboid: '🧟', sevendaystodie: '🧟',
satisfactory: '🏭', terraria: '🌳', arma3: '🪖', squad: '🪖', unturned: '🏝️',
};
// Alert-Zustand pro Server (in-memory)
const alertState = new Map(); // id → { fails, down, since }
/** Einen Server abfragen — fivem/http direkt, alles andere über gamedig */
export async function queryServer(server) {
const base = { ...server, online: false, players: null, max: null, map: null };
const base = {
...server, online: false, players: null, max: null, map: null,
playerNames: [], ping: null,
};
const started = Date.now();
try {
if (server.type === 'fivem') {
const res = await fetch(`${server.query_url.replace(/\/$/, '')}/dynamic.json`, {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'User-Agent': 'd4rkbot-monitor/2.0' },
headers: { 'User-Agent': 'd4rkbot-monitor/3.0' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return {
...base,
online: true,
...base, online: true, ping: Date.now() - started,
players: Number(data.clients) || 0,
max: Number(data.sv_maxclients) || 0,
map: data.mapname ?? null,
};
}
// Typ 'http': generischer Healthcheck (2xx/3xx/4xx = erreichbar)
const res = await fetch(server.query_url, {
signal: AbortSignal.timeout(TIMEOUT_MS),
redirect: 'follow',
headers: { 'User-Agent': 'd4rkbot-monitor/2.0' },
if (server.type === 'http') {
const res = await fetch(server.query_url, {
signal: AbortSignal.timeout(TIMEOUT_MS),
redirect: 'follow',
headers: { 'User-Agent': 'd4rkbot-monitor/3.0' },
});
return { ...base, online: res.status < 500, ping: Date.now() - started };
}
// gamedig: Typ = Spiel-ID (minecraft, teamfortress2, rust, …)
const state = await GameDig.query({
type: server.type,
host: server.host,
port: server.port || undefined,
maxRetries: 1,
socketTimeout: TIMEOUT_MS,
});
return { ...base, online: res.status < 500 };
return {
...base, online: true, ping: state.ping ?? Date.now() - started,
players: state.players?.length ?? state.numplayers ?? 0,
max: state.maxplayers ?? null,
map: state.map || null,
playerNames: (state.players ?? []).map((p) => p?.name).filter(Boolean).slice(0, 15),
};
} catch {
return base;
}
}
/** Status-Embed für einen Server bauen (DiscordGSM-Look) */
/** GSM-Style-Embed für ein Query-Ergebnis */
export function buildServerEmbed(r) {
const icon = GAME_ICONS[r.type] ?? '🎮';
const embed = new EmbedBuilder()
.setColor(r.online ? GREEN : RED)
.setTitle(`${r.online ? '🟢' : '🔴'} ${r.name}`)
.addFields({ name: 'Status', value: r.online ? '**Online**' : '**Offline**', inline: true })
.setFooter({ text: `${brandFooter('STATUS')} • alle 2 min` })
.setTitle(`${icon} ${r.name}`)
.addFields({ name: 'Status', value: r.online ? '🟢 **Online**' : '🔴 **Offline**', inline: true })
.setFooter({ text: `${brandFooter('STATUS')}${r.ping != null ? `${r.ping}ms` : ''} • alle 2 min` })
.setTimestamp();
if (r.online && r.players != null && r.max) {
const pct = Math.min(1, r.players / r.max);
const bar = '█'.repeat(Math.round(pct * 10)).padEnd(10, '░');
embed.addFields({ name: 'Spieler', value: `\`${bar}\` ${r.players}/${r.max}`, inline: true });
embed.addFields({
name: 'Spieler',
value: `\`${bar}\` ${r.players}/${r.max} (${Math.round(pct * 100)}%)`,
inline: true,
});
}
if (r.map) embed.addFields({ name: 'Map', value: r.map, inline: true });
if (r.connect_url) {
// steam:// & Co. sind in Embeds klickbar (Link-Buttons erlauben nur https)
embed.addFields({ name: 'Connect', value: `[${r.connect_url}](${r.connect_url})`, inline: false });
}
if (r.address) embed.addFields({ name: 'Adresse', value: `\`\`\`\n${r.address}\n\`\`\`` });
if (r.online && r.playerNames.length > 0) {
embed.addFields({
name: `Spieler-Liste (${r.playerNames.length})`,
value: r.playerNames.map((n) => `\`${n}\``).join(' ').slice(0, 1000),
});
}
return embed;
}
/** Down/Up-Alerts in den Alert-Kanal (2 Fails in Folge = down) */
async function handleAlerts(client, r) {
const channelId = getSetting('server_alert_channel_id');
if (!channelId) return;
const s = alertState.get(r.id) ?? { fails: 0, down: false, since: null };
if (r.online) {
if (s.down) {
const minutes = Math.max(1, Math.round((Date.now() - s.since) / 60000));
const channel = await client.channels.fetch(channelId).catch(() => null);
await channel?.send({
embeds: [
brandEmbed(client, 'ALERT')
.setColor(GREEN)
.setTitle(`${r.name} ist wieder online!`)
.setDescription(`Downtime: ~${minutes} min`),
],
}).catch(() => {});
}
alertState.set(r.id, { fails: 0, down: false, since: null });
return;
}
const fails = s.fails + 1;
if (!s.down && fails >= FAILS_BEFORE_ALERT) {
const channel = await client.channels.fetch(channelId).catch(() => null);
await channel?.send({
embeds: [
brandEmbed(client, 'ALERT')
.setColor(RED)
.setTitle(`🚨 ${r.name} scheint down zu sein!`)
.setDescription(r.address ? `\`${r.address}\`` : null),
],
}).catch(() => {});
alertState.set(r.id, { fails, down: true, since: Date.now() });
} else {
alertState.set(r.id, { ...s, fails });
}
}
/** Ein Poll-Durchlauf — exportiert für Tests und den Timer */
export async function monitorTick(client) {
const servers = listGameservers();
@@ -79,7 +167,9 @@ export async function monitorTick(client) {
} catch { /* Presence ist nice-to-have */ }
}
// Pro Server ein Embed pflegen
for (const r of results) await handleAlerts(client, r).catch(() => {});
// Pro Server ein Embed pflegen (edit in place)
const channelId = getSetting('status_channel_id');
if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null);
+1 -1
View File
@@ -56,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: brandFooter('WEEKLY') })
.setFooter({ text: brandFooter('WEEKLY'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp();
await channel.send({ embeds: [embed] });