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:
+110
-3
@@ -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))
|
||||
);
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -361,7 +361,26 @@ db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS temp_voice (
|
||||
channel_id TEXT PRIMARY KEY
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS message_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
// Migration: Temp-Voice-Besitzer (fürs Control-Panel)
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(temp_voice)').all().map((c) => c.name);
|
||||
if (!cols.includes('owner_id')) db.exec(`ALTER TABLE temp_voice ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''`);
|
||||
}
|
||||
const upsertTemplate = db.prepare(`
|
||||
INSERT INTO message_templates (name, payload) VALUES (?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET payload = excluded.payload
|
||||
`);
|
||||
const listTemplatesStmt = db.prepare('SELECT id, name, payload FROM message_templates ORDER BY name');
|
||||
const deleteTemplateStmt = db.prepare('DELETE FROM message_templates WHERE name = ?');
|
||||
export const saveTemplate = (name, payload) => upsertTemplate.run(name, JSON.stringify(payload));
|
||||
export const listTemplates = () => listTemplatesStmt.all().map((t) => ({ ...t, payload: JSON.parse(t.payload) }));
|
||||
export const deleteTemplate = (name) => deleteTemplateStmt.run(name).changes > 0;
|
||||
const upsertTrigger = db.prepare(`
|
||||
INSERT INTO triggers (keyword, reply) VALUES (?, ?)
|
||||
ON CONFLICT(keyword) DO UPDATE SET reply = excluded.reply
|
||||
@@ -395,10 +414,12 @@ const activityRangeStmt = db.prepare(
|
||||
);
|
||||
export const activityRange = (days = 30) => activityRangeStmt.all(`-${days} days`);
|
||||
|
||||
const insertTempVoice = db.prepare('INSERT OR IGNORE INTO temp_voice (channel_id) VALUES (?)');
|
||||
const insertTempVoice = db.prepare('INSERT OR IGNORE INTO temp_voice (channel_id, owner_id) VALUES (?, ?)');
|
||||
const tempVoiceOwnerStmt = db.prepare('SELECT owner_id FROM temp_voice WHERE channel_id = ?');
|
||||
export const tempVoiceOwner = (id) => tempVoiceOwnerStmt.get(id)?.owner_id ?? null;
|
||||
const deleteTempVoiceStmt = db.prepare('DELETE FROM temp_voice WHERE channel_id = ?');
|
||||
const listTempVoiceStmt = db.prepare('SELECT channel_id FROM temp_voice');
|
||||
export const trackTempVoice = (id) => insertTempVoice.run(id);
|
||||
export const trackTempVoice = (id, ownerId = '') => insertTempVoice.run(id, ownerId);
|
||||
export const untrackTempVoice = (id) => deleteTempVoiceStmt.run(id);
|
||||
export const listTempVoice = () => listTempVoiceStmt.all().map((r) => r.channel_id);
|
||||
const isTempVoiceStmt = db.prepare('SELECT 1 FROM temp_voice WHERE channel_id = ?');
|
||||
@@ -453,11 +474,21 @@ db.exec(`
|
||||
db.prepare(`DELETE FROM settings WHERE key = 'gameservers'`).run();
|
||||
}
|
||||
}
|
||||
// Migration v3: host/port (gamedig) + connect_url (Connect-Link im Embed)
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(gameservers)').all().map((c) => c.name);
|
||||
if (!cols.includes('host')) db.exec(`ALTER TABLE gameservers ADD COLUMN host TEXT NOT NULL DEFAULT ''`);
|
||||
if (!cols.includes('port')) db.exec('ALTER TABLE gameservers ADD COLUMN port INTEGER');
|
||||
if (!cols.includes('connect_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN connect_url TEXT NOT NULL DEFAULT ''`);
|
||||
}
|
||||
const insertGameserver = db.prepare(`
|
||||
INSERT INTO gameservers (name, type, query_url, address) VALUES (@name, @type, @query_url, @address)
|
||||
INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url)
|
||||
VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url)
|
||||
`);
|
||||
const updateGameserverStmt = db.prepare(`
|
||||
UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address WHERE id = @id
|
||||
UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address,
|
||||
host = @host, port = @port, connect_url = @connect_url
|
||||
WHERE id = @id
|
||||
`);
|
||||
const setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?');
|
||||
const listGameserversStmt = db.prepare('SELECT * FROM gameservers ORDER BY id');
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Zentrale Embed-Factory: einheitlicher Brand-Look für alle Bot-Embeds.
|
||||
// Footer mit Bot-Avatar, Brand-Farbe und Timestamp kommen automatisch.
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { brandColor, brandColor2, brandFooter } from './runtime-settings.js';
|
||||
|
||||
/**
|
||||
* Gebrandetes Embed erzeugen.
|
||||
* @param {import('discord.js').Client|null} client — für das Footer-Icon (Bot-Avatar)
|
||||
* @param {string} tag — Footer-Tag, z. B. 'DEVLOG' → "D4RKST3R // DEVLOG"
|
||||
* @param {{ secondary?: boolean, footerSuffix?: string, timestamp?: boolean }} [opts]
|
||||
*/
|
||||
export function brandEmbed(client, tag, opts = {}) {
|
||||
const embed = new EmbedBuilder().setColor(opts.secondary ? brandColor2() : brandColor());
|
||||
embed.setFooter({
|
||||
text: brandFooter(tag) + (opts.footerSuffix ? ` • ${opts.footerSuffix}` : ''),
|
||||
iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) ?? undefined,
|
||||
});
|
||||
if (opts.timestamp !== false) embed.setTimestamp();
|
||||
return embed;
|
||||
}
|
||||
+6
-1
@@ -20,12 +20,17 @@ function requireScope(request, reply, scope) {
|
||||
}
|
||||
|
||||
/** Nur erlaubte Embed-Felder durchreichen (kein Blind-Passthrough) */
|
||||
function sanitizeEmbed(raw) {
|
||||
export function sanitizeEmbed(raw) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const embed = {};
|
||||
for (const key of ['title', 'description', 'url', 'color', 'timestamp']) {
|
||||
if (raw[key] !== undefined) embed[key] = raw[key];
|
||||
}
|
||||
if (raw.author?.name) {
|
||||
embed.author = { name: String(raw.author.name).slice(0, 256) };
|
||||
if (raw.author.icon_url) embed.author.icon_url = String(raw.author.icon_url);
|
||||
if (raw.author.url) embed.author.url = String(raw.author.url);
|
||||
}
|
||||
if (raw.footer?.text) embed.footer = { text: String(raw.footer.text) };
|
||||
if (raw.image?.url) embed.image = { url: String(raw.image.url) };
|
||||
if (raw.thumbnail?.url) embed.thumbnail = { url: String(raw.thumbnail.url) };
|
||||
|
||||
+65
-13
@@ -12,8 +12,10 @@ import {
|
||||
saveTrigger, listTriggers, deleteTrigger, activityRange,
|
||||
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
|
||||
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
||||
saveTemplate, listTemplates, deleteTemplate,
|
||||
} from '../db.js';
|
||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||
import { sanitizeEmbed } from './api-v1.js';
|
||||
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
|
||||
import { computeNextRun } from './scheduled-posts.js';
|
||||
import { config } from '../config.js';
|
||||
@@ -299,6 +301,7 @@ ${rssItems}
|
||||
twitch_channel: getSetting('twitch_channel') ?? '',
|
||||
twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')),
|
||||
tempvoice_channel_id: getSetting('tempvoice_channel_id') ?? '',
|
||||
server_alert_channel_id: getSetting('server_alert_channel_id') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -344,7 +347,7 @@ ${rssItems}
|
||||
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
|
||||
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
|
||||
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_channel_id',
|
||||
'events_announce_channel_id', 'social_announce_channel_id',
|
||||
'events_announce_channel_id', 'social_announce_channel_id', 'server_alert_channel_id',
|
||||
];
|
||||
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
|
||||
if (body[key] === undefined) continue;
|
||||
@@ -738,10 +741,23 @@ ${rssItems}
|
||||
function parseServerBody(request, reply) {
|
||||
const body = request.body ?? {};
|
||||
const name = String(body.name ?? '').trim();
|
||||
const type = String(body.type ?? 'fivem').trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const queryUrl = String(body.query_url ?? '').trim().replace(/\/$/, '');
|
||||
const type = ['fivem', 'http'].includes(body.type) ? body.type : 'fivem';
|
||||
if (!name || !/^https?:\/\/.+/.test(queryUrl)) {
|
||||
reply.code(400).send({ error: 'name und query_url (http/https) nötig' });
|
||||
const host = String(body.host ?? '').trim();
|
||||
const port = Number(body.port) || null;
|
||||
|
||||
if (!name || !type) {
|
||||
reply.code(400).send({ error: 'name und type nötig' });
|
||||
return null;
|
||||
}
|
||||
if (['fivem', 'http'].includes(type)) {
|
||||
if (!/^https?:\/\/.+/.test(queryUrl)) {
|
||||
reply.code(400).send({ error: 'query_url (http/https) nötig' });
|
||||
return null;
|
||||
}
|
||||
} else if (!host) {
|
||||
// gamedig-Typ: Host (+ optionaler Port) statt URL
|
||||
reply.code(400).send({ error: 'host nötig (gamedig-Query)' });
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -749,6 +765,9 @@ ${rssItems}
|
||||
type,
|
||||
query_url: queryUrl,
|
||||
address: String(body.address ?? '').trim().slice(0, 120),
|
||||
host: host.slice(0, 120),
|
||||
port,
|
||||
connect_url: String(body.connect_url ?? '').trim().slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -885,19 +904,23 @@ ${rssItems}
|
||||
app.post('/api/compose', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
|
||||
const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle } = request.body ?? {};
|
||||
const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle, embed } = request.body ?? {};
|
||||
const text = String(content ?? '').trim();
|
||||
if (!channelId || !text) {
|
||||
return reply.code(400).send({ error: 'channel_id und content nötig' });
|
||||
const cleanEmbed = embed ? sanitizeEmbed(embed) : null;
|
||||
if (!channelId || (!text && !cleanEmbed)) {
|
||||
return reply.code(400).send({ error: 'channel_id und content oder embed nötig' });
|
||||
}
|
||||
const channel = await client.channels.fetch(String(channelId)).catch(() => null);
|
||||
if (!channel?.isTextBased()) {
|
||||
return reply.code(404).send({ error: 'Kanal nicht gefunden' });
|
||||
}
|
||||
|
||||
// Mit Embed-Titel → gebrandetes Embed, sonst Plaintext (Discord-Markdown)
|
||||
const payload = embedTitle?.trim()
|
||||
? {
|
||||
// Drei Modi: volles Embed (Composer v2) > Embed-Titel-Kurzform > Plaintext
|
||||
let payload;
|
||||
if (cleanEmbed) {
|
||||
payload = { content: text ? text.slice(0, 2000) : null, embeds: [cleanEmbed] };
|
||||
} else if (embedTitle?.trim()) {
|
||||
payload = {
|
||||
content: null,
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
@@ -907,8 +930,10 @@ ${rssItems}
|
||||
.setFooter({ text: brandName() })
|
||||
.setTimestamp(),
|
||||
],
|
||||
}
|
||||
: { content: text.slice(0, 2000), embeds: [] };
|
||||
};
|
||||
} else {
|
||||
payload = { content: text.slice(0, 2000), embeds: [] };
|
||||
}
|
||||
|
||||
if (messageId) {
|
||||
const message = await channel.messages.fetch(String(messageId)).catch(() => null);
|
||||
@@ -954,6 +979,33 @@ ${rssItems}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Composer-Vorlagen (Scope: content) ---
|
||||
|
||||
app.get('/api/templates', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
return { templates: listTemplates() };
|
||||
});
|
||||
|
||||
app.put('/api/templates/:name', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
const name = String(request.params.name).trim().slice(0, 60);
|
||||
if (!name) return reply.code(400).send({ error: 'name nötig' });
|
||||
const payload = {
|
||||
content: String(request.body?.content ?? '').slice(0, 2000),
|
||||
embed: request.body?.embed ? sanitizeEmbed(request.body.embed) : null,
|
||||
};
|
||||
if (!payload.content && !payload.embed) {
|
||||
return reply.code(400).send({ error: 'Vorlage ist leer' });
|
||||
}
|
||||
saveTemplate(name, payload);
|
||||
return { ok: true, templates: listTemplates() };
|
||||
});
|
||||
|
||||
app.delete('/api/templates/:name', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'content')) return;
|
||||
return { deleted: deleteTemplate(String(request.params.name)), templates: listTemplates() };
|
||||
});
|
||||
|
||||
// --- Team-Verwaltung (nur Owner) ---
|
||||
|
||||
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs'];
|
||||
@@ -1068,7 +1120,7 @@ ${rssItems}
|
||||
.setDescription(
|
||||
`Test-Nachricht für den **${{ commit: 'Commit', devlog: 'Devlog', release: 'Release' }[target]}-Kanal** — von der Settings-Seite ausgelöst.`
|
||||
)
|
||||
.setFooter({ text: brandFooter('SETUP') }),
|
||||
.setFooter({ text: brandFooter('SETUP'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
|
||||
],
|
||||
});
|
||||
return { ok: true };
|
||||
|
||||
@@ -103,6 +103,7 @@ export function registerDevlogEndpoint(app, client) {
|
||||
.setDescription(prose)
|
||||
.setFooter({
|
||||
text: `${brandFooter('DEVLOG')}${commitCount ? ` • ${commitCount} Commits` : ''}`,
|
||||
iconURL: client?.user?.displayAvatarURL?.({ size: 64 }),
|
||||
})
|
||||
.setTimestamp();
|
||||
if (project) {
|
||||
|
||||
Reference in New Issue
Block a user