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
+10 -4
View File
@@ -40,11 +40,11 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
| 💬 **Triggers** | Auto-Antworten auf Schlüsselwörter (30s-Cooldown), verwaltet im Composer-Tab | | 💬 **Triggers** | Auto-Antworten auf Schlüsselwörter (30s-Cooldown), verwaltet im Composer-Tab |
| ⏰ **/remind** | Erinnerungen per DM (`/remind dauer:2h text:…`) | | ⏰ **/remind** | Erinnerungen per DM (`/remind dauer:2h text:…`) |
| 📣 **Twitch/YouTube** | 🔴-Live- und ▶️-Neues-Video-Announcements (YouTube ohne Key via RSS, Twitch mit App-Credentials) | | 📣 **Twitch/YouTube** | 🔴-Live- und ▶️-Neues-Video-Announcements (YouTube ohne Key via RSS, Twitch mit App-Credentials) |
| 🔊 **Temp-Voice** | „Join to Create": Hub-Kanal betreten → eigener Voice-Kanal, löscht sich wenn leer | | 🔊 **Temp-Voice** | „Join to Create": Hub-Kanal betreten → eigener Voice-Kanal mit Control-Panel (Umbenennen, Sperren, Limit, Löschen — nur für den Besitzer), löscht sich wenn leer |
| 📊 **Server-Stats** | Nachrichten/Joins/Leaves pro Tag → Aktivitäts-Chart auf der Level-Seite | | 📊 **Server-Stats** | Nachrichten/Joins/Leaves pro Tag → Aktivitäts-Chart auf der Level-Seite |
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) | | 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal | | 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
| 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken, Map, Copy-Paste-Adresse); Typen FiveM + HTTP-Check; Spielerzahl in der Presence | | 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken mit %, Map, Spieler-Liste, klickbarer Connect-Link, Ping); **300+ Spiele via gamedig** (Minecraft, Rust, CS2, Valheim, ARK …) + FiveM + HTTP-Check; Down/Up-Alerts (🚨 nach 2 Fehlversuchen, ✅ mit Downtime bei Recovery) in eigenen Alert-Kanal; Spielerzahl in der Presence |
| 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only | | 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only |
| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite | | 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf | | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf |
@@ -82,7 +82,10 @@ SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig:
- **Community:** Playtester-Rolle (+ Liste), Starboard-Kanal + ⭐-Schwellwert, - **Community:** Playtester-Rolle (+ Liste), Starboard-Kanal + ⭐-Schwellwert,
Screenshot-Kanal, Voting-Kanal Screenshot-Kanal, Voting-Kanal
- **Moderation & Kontakt:** Modmail-, Willkommens-, Mod-Log-Kanal - **Moderation & Kontakt:** Modmail-, Willkommens-, Mod-Log-Kanal
- **Game-Server:** Status-Kanal + Server-Liste (`Name=URL`, FiveM-kompatibel) - **Game-Server:** eigener Tab — Spiel-Auswahl (19 Presets + freie gamedig-ID), Host/Port
oder Query-URL, Connect-URL, Status- & Alert-Kanal
- **Composer:** visueller **Embed-Builder mit Live-Discord-Preview** (Autor, Felder,
Bilder, Farbe, Footer), Nachrichten senden & nachträglich bearbeiten, speicherbare Vorlagen
- **Bug-Reports / Roadmap:** Ziel-Repos - **Bug-Reports / Roadmap:** Ziel-Repos
- **Backups:** an/aus, Upload-Kanal, „Backup jetzt" - **Backups:** an/aus, Upload-Kanal, „Backup jetzt"
- **Watchdog:** überwachte URLs - **Watchdog:** überwachte URLs
@@ -258,6 +261,7 @@ urllib.request.urlopen(req)
| `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben | | `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben |
| `GET/POST/DELETE /api/apikeys` | Admin | API-Key-Verwaltung | | `GET/POST/DELETE /api/apikeys` | Admin | API-Key-Verwaltung |
| `GET/POST/PUT/DELETE /api/rolemenus`, `POST /api/rolemenus/:id/publish` | Admin | Rollen-Menüs | | `GET/POST/PUT/DELETE /api/rolemenus`, `POST /api/rolemenus/:id/publish` | Admin | Rollen-Menüs |
| `POST /api/compose` · `GET/PUT/DELETE /api/templates` | Team (content) | Embed-Composer senden/bearbeiten · Vorlagen |
| `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite | | `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite |
| `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen | | `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen |
| `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 | | `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 |
@@ -278,6 +282,7 @@ d4rkbot/
│ ├── runtime-settings.js # Effektive Settings (DB vor Env) │ ├── runtime-settings.js # Effektive Settings (DB vor Env)
│ ├── gitea-api.js # Gitea-REST (Issues + Assets für /bug) │ ├── gitea-api.js # Gitea-REST (Issues + Assets für /bug)
│ ├── backup.js # Nächtliche DB-Backups (gzip, Rotation, Upload) │ ├── backup.js # Nächtliche DB-Backups (gzip, Rotation, Upload)
│ ├── embeds.js # brandEmbed-Factory (Farbe, Footer, Bot-Icon)
│ ├── bot/ │ ├── bot/
│ │ ├── client.js # Discord-Client, Commands, Listener, Button-Handler │ │ ├── client.js # Discord-Client, Commands, Listener, Button-Handler
│ │ ├── commit-feed.js # Push → Embed │ │ ├── commit-feed.js # Push → Embed
@@ -285,7 +290,7 @@ d4rkbot/
│ │ ├── devlog-archive.js # Nachricht → SQLite (+ Bild-Download) │ │ ├── devlog-archive.js # Nachricht → SQLite (+ Bild-Download)
│ │ ├── community.js # Starboard + Screenshot-Galerie │ │ ├── community.js # Starboard + Screenshot-Galerie
│ │ ├── mod-tools.js # Modmail, Willkommens-Embed, Mod-Log │ │ ├── mod-tools.js # Modmail, Willkommens-Embed, Mod-Log
│ │ ├── server-monitor.js # Game-Server-Status (FiveM-kompatibel) │ │ ├── server-monitor.js # Game-Server-Status (gamedig 300+ Spiele, Alerts)
│ │ ├── giveaways.js # Giveaway-Ziehung (Scheduler) │ │ ├── giveaways.js # Giveaway-Ziehung (Scheduler)
│ │ ├── weekly-recap.js # Sonntags-Zusammenfassung + Scheduler │ │ ├── weekly-recap.js # Sonntags-Zusammenfassung + Scheduler
│ │ ├── watchdog.js # URL-Checks + DM-Alarm │ │ ├── watchdog.js # URL-Checks + DM-Alarm
@@ -300,6 +305,7 @@ d4rkbot/
│ └── src/ │ └── src/
│ ├── App.jsx # Layout, Router, Nav, Login-Status │ ├── App.jsx # Layout, Router, Nav, Login-Status
│ ├── markdown.jsx # Mini-Markdown (fett/kursiv/Code/Links/Listen/---/##) │ ├── markdown.jsx # Mini-Markdown (fett/kursiv/Code/Links/Listen/---/##)
│ ├── components/ # EmbedComposer (Builder + Discord-Preview)
│ └── pages/ # Devlogs, Roadmap, Galerie, Changelog, Commits, Settings │ └── pages/ # Devlogs, Roadmap, Galerie, Changelog, Commits, Settings
├── tools/test-webhook.mjs # Lokaler Commit-Feed-Test ├── tools/test-webhook.mjs # Lokaler Commit-Feed-Test
├── .env.example # Vorlage für alle Env-Variablen ├── .env.example # Vorlage für alle Env-Variablen
+262
View File
@@ -0,0 +1,262 @@
// Composer v2 (Vorbild: message.style / embed-generator): voller Embed-Builder
// mit Live-Vorschau im Discord-Look + speicherbaren Vorlagen.
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { Markdown } from '../markdown.jsx';
const EMPTY_EMBED = {
author_name: '', author_icon: '', title: '', url: '', description: '',
color: '#f5c518', thumbnail: '', image: '', footer: '', timestamp: true,
fields: [],
};
/** Builder-State → Discord-Embed-Objekt (nur gesetzte Felder) */
function toApiEmbed(e) {
const out = {};
if (e.author_name.trim()) {
out.author = { name: e.author_name.trim() };
if (e.author_icon.trim()) out.author.icon_url = e.author_icon.trim();
}
if (e.title.trim()) out.title = e.title.trim();
if (e.url.trim()) out.url = e.url.trim();
if (e.description.trim()) out.description = e.description;
out.color = parseInt(e.color.replace('#', ''), 16) || 0xf5c518;
if (e.thumbnail.trim()) out.thumbnail = { url: e.thumbnail.trim() };
if (e.image.trim()) out.image = { url: e.image.trim() };
if (e.footer.trim()) out.footer = { text: e.footer.trim() };
if (e.timestamp) out.timestamp = new Date().toISOString();
const fields = e.fields.filter((f) => f.name.trim() && f.value.trim());
if (fields.length) out.fields = fields;
return out;
}
/** Discord-Embed-Objekt (aus Vorlage) → Builder-State */
function fromApiEmbed(embed) {
if (!embed) return { ...EMPTY_EMBED, fields: [] };
return {
author_name: embed.author?.name ?? '',
author_icon: embed.author?.icon_url ?? '',
title: embed.title ?? '',
url: embed.url ?? '',
description: embed.description ?? '',
color: `#${Number(embed.color ?? 0xf5c518).toString(16).padStart(6, '0')}`,
thumbnail: embed.thumbnail?.url ?? '',
image: embed.image?.url ?? '',
footer: embed.footer?.text ?? '',
timestamp: Boolean(embed.timestamp),
fields: (embed.fields ?? []).map((f) => ({ name: f.name, value: f.value, inline: Boolean(f.inline) })),
};
}
/** Live-Vorschau im Discord-Look */
function DiscordPreview({ content, embed, useEmbed }) {
const hasEmbed = useEmbed && (embed.title || embed.description || embed.author_name || embed.fields.some((f) => f.name));
return (
<div className="dpv">
<div className="dpv-msg">
<div className="dpv-avatar">🤖</div>
<div className="dpv-body">
<div className="dpv-name">d4rkbot <span className="dpv-tag">APP</span> <span className="dpv-time">heute um {new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr</span></div>
{content.trim() && <div className="dpv-content"><Markdown text={content} /></div>}
{hasEmbed && (
<div className="dpv-embed" style={{ borderLeftColor: embed.color }}>
<div className="dpv-embed-main">
{embed.author_name && (
<div className="dpv-author">
{embed.author_icon && <img src={embed.author_icon} alt="" />}
{embed.author_name}
</div>
)}
{embed.title && <div className="dpv-title">{embed.title}</div>}
{embed.description && <div className="dpv-desc"><Markdown text={embed.description} /></div>}
{embed.fields.filter((f) => f.name && f.value).length > 0 && (
<div className="dpv-fields">
{embed.fields.filter((f) => f.name && f.value).map((f, i) => (
<div className={`dpv-field ${f.inline ? 'inline' : ''}`} key={i}>
<div className="dpv-field-name">{f.name}</div>
<div className="dpv-field-value"><Markdown text={f.value} /></div>
</div>
))}
</div>
)}
{embed.image && <img className="dpv-image" src={embed.image} alt="" />}
{(embed.footer || embed.timestamp) && (
<div className="dpv-footer">
{embed.footer}{embed.footer && embed.timestamp ? ' • ' : ''}{embed.timestamp ? 'heute' : ''}
</div>
)}
</div>
{embed.thumbnail && <img className="dpv-thumb" src={embed.thumbnail} alt="" />}
</div>
)}
</div>
</div>
</div>
);
}
export default function EmbedComposer({ channelOptions, flash }) {
const [channel, setChannel] = useState('');
const [messageId, setMessageId] = useState('');
const [content, setContent] = useState('');
const [useEmbed, setUseEmbed] = useState(true);
const [embed, setEmbed] = useState({ ...EMPTY_EMBED, fields: [] });
const [templates, setTemplates] = useState([]);
const [templateName, setTemplateName] = useState('');
useEffect(() => {
apiGet('/api/templates').then((d) => setTemplates(d.templates)).catch(() => {});
}, []);
const setE = (patch) => setEmbed({ ...embed, ...patch });
const setField = (i, patch) =>
setE({ fields: embed.fields.map((f, j) => (j === i ? { ...f, ...patch } : f)) });
async function send() {
if (!channel) return flash('✗ Kanal wählen');
const apiEmbed = useEmbed ? toApiEmbed(embed) : undefined;
if (!content.trim() && !useEmbed) return flash('✗ Text oder Embed nötig');
try {
const res = await apiPost('/api/compose', {
channel_id: channel,
message_id: messageId.trim() || undefined,
content,
embed: apiEmbed,
});
flash(res.edited ? '✓ Aktualisiert' : `✓ Gesendet (ID ${res.message_id})`);
if (!res.edited) setMessageId(res.message_id);
} catch {
flash('✗ Senden fehlgeschlagen');
}
}
async function saveTemplate() {
if (!templateName.trim()) return flash('✗ Vorlagen-Name nötig');
try {
const res = await apiPut(`/api/templates/${encodeURIComponent(templateName.trim())}`, {
content,
embed: useEmbed ? toApiEmbed(embed) : null,
});
setTemplates(res.templates);
flash('✓ Vorlage gespeichert');
} catch {
flash('✗ Vorlage fehlgeschlagen');
}
}
function loadTemplate(name) {
const t = templates.find((x) => x.name === name);
if (!t) return;
setContent(t.payload.content ?? '');
setUseEmbed(Boolean(t.payload.embed));
setEmbed(fromApiEmbed(t.payload.embed));
setTemplateName(name);
}
async function removeTemplate() {
if (!templateName.trim() || !window.confirm(`Vorlage „${templateName}" löschen?`)) return;
const res = await apiDelete(`/api/templates/${encodeURIComponent(templateName.trim())}`).catch(() => null);
if (res) { setTemplates(res.templates); setTemplateName(''); flash('✓ Vorlage gelöscht'); }
}
return (
<div className="settings-section">
<h2 className="settings-title">// Post verfassen</h2>
<div className="field">
<div className="field-row">
<select value={channel} onChange={(e) => setChannel(e.target.value)}>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
<input type="text" style={{ flex: 'none', width: '13rem' }} placeholder="Message-ID (= bearbeiten)" value={messageId} onChange={(e) => setMessageId(e.target.value)} />
</div>
</div>
<div className="field">
<div className="field-row">
<select style={{ flex: 1 }} value={templateName} onChange={(e) => loadTemplate(e.target.value)}>
<option value=""> Vorlage laden </option>
{templates.map((t) => <option key={t.name} value={t.name}>{t.name}</option>)}
</select>
<input type="text" style={{ flex: 1 }} placeholder="Als Vorlage speichern unter…" value={templateName} onChange={(e) => setTemplateName(e.target.value)} />
<button className="btn" onClick={saveTemplate}>💾</button>
<button className="card-delete" title="Vorlage löschen" onClick={removeTemplate}></button>
</div>
</div>
<div className="composer-grid">
<div className="composer-form">
<div className="field">
<label>Text über dem Embed <span className="field-hint">Discord-Markdown</span></label>
<textarea rows={3} value={content} onChange={(e) => setContent(e.target.value)} />
</div>
<div className="field">
<label className="toggle">
<input type="checkbox" checked={useEmbed} onChange={(e) => setUseEmbed(e.target.checked)} />
<span>Embed anhängen</span>
</label>
</div>
{useEmbed && (
<>
<div className="field">
<div className="field-row">
<input type="color" className="color-pick" title="Embed-Farbe" value={embed.color} onChange={(e) => setE({ color: e.target.value })} />
<input type="text" placeholder="Author-Name" value={embed.author_name} onChange={(e) => setE({ author_name: e.target.value })} />
<input type="text" placeholder="Author-Icon-URL" value={embed.author_icon} onChange={(e) => setE({ author_icon: e.target.value })} />
</div>
</div>
<div className="field">
<div className="field-row">
<input type="text" placeholder="Titel" value={embed.title} onChange={(e) => setE({ title: e.target.value })} />
<input type="text" placeholder="Titel-URL" value={embed.url} onChange={(e) => setE({ url: e.target.value })} />
</div>
</div>
<div className="field">
<textarea rows={5} placeholder="Beschreibung (Markdown: **fett**, Listen, ## Überschriften …)" value={embed.description} onChange={(e) => setE({ description: e.target.value })} />
</div>
<div className="field">
<div className="field-row">
<input type="text" placeholder="Thumbnail-URL (klein, rechts)" value={embed.thumbnail} onChange={(e) => setE({ thumbnail: e.target.value })} />
<input type="text" placeholder="Bild-URL (groß, unten)" value={embed.image} onChange={(e) => setE({ image: e.target.value })} />
</div>
</div>
<div className="field">
<label>Felder</label>
{embed.fields.map((f, i) => (
<div className="rr-row" key={i}>
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Feld-Name" value={f.name} onChange={(e) => setField(i, { name: e.target.value })} />
<input type="text" placeholder="Feld-Wert" value={f.value} onChange={(e) => setField(i, { value: e.target.value })} />
<label className="scope-pick" title="Nebeneinander">
<input type="checkbox" checked={f.inline} onChange={(e) => setField(i, { inline: e.target.checked })} />
inline
</label>
<button className="card-delete" onClick={() => setE({ fields: embed.fields.filter((_, j) => j !== i) })}></button>
</div>
))}
<button className="btn btn-mini" style={{ marginTop: '.4rem' }} disabled={embed.fields.length >= 25}
onClick={() => setE({ fields: [...embed.fields, { name: '', value: '', inline: true }] })}>+ Feld</button>
</div>
<div className="field">
<div className="field-row">
<input type="text" placeholder="Footer-Text" value={embed.footer} onChange={(e) => setE({ footer: e.target.value })} />
<label className="scope-pick"><input type="checkbox" checked={embed.timestamp} onChange={(e) => setE({ timestamp: e.target.checked })} /> Zeitstempel</label>
</div>
</div>
</>
)}
<div className="settings-actions">
<button className="btn btn-save" onClick={send}>{messageId.trim() ? 'Aktualisieren' : 'Senden'}</button>
{messageId.trim() && <button className="btn btn-ghost" onClick={() => setMessageId('')}>Neu statt bearbeiten</button>}
</div>
</div>
<div className="composer-preview">
<label>Vorschau</label>
<DiscordPreview content={content} embed={embed} useEmbed={useEmbed} />
</div>
</div>
</div>
);
}
+53 -58
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { apiGet, apiPut, apiPost, apiDelete } from '../api.js'; import { apiGet, apiPut, apiPost, apiDelete } from '../api.js';
import EmbedComposer from '../components/EmbedComposer.jsx';
const API_SCOPES = [ const API_SCOPES = [
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' }, { id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
@@ -77,8 +78,6 @@ export default function Settings({ me }) {
}; };
const [menus, setMenus] = useState([]); const [menus, setMenus] = useState([]);
const [menuDraft, setMenuDraft] = useState(emptyMenu); const [menuDraft, setMenuDraft] = useState(emptyMenu);
// Composer
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
// Branding // Branding
const [giteaToken, setGiteaToken] = useState(''); const [giteaToken, setGiteaToken] = useState('');
const [botName, setBotName] = useState(''); const [botName, setBotName] = useState('');
@@ -97,7 +96,17 @@ export default function Settings({ me }) {
const [triggers, setTriggers] = useState([]); const [triggers, setTriggers] = useState([]);
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' }); const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
// Game-Server // Game-Server
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '' }; const GAME_PRESETS = [
['fivem', '🚗 FiveM'], ['http', '🌐 HTTP-Check'], ['minecraft', '⛏️ Minecraft (Java)'],
['minecraftbe', '⛏️ Minecraft (Bedrock)'], ['rust', '🪓 Rust'], ['valheim', '🛡️ Valheim'],
['palworld', '🐑 Palworld'], ['arkse', '🦖 ARK: Survival Evolved'], ['dayz', '🧟 DayZ'],
['projectzomboid', '🧟 Project Zomboid'], ['sevendaystodie', '🧟 7 Days to Die'],
['teamfortress2', '🎩 Team Fortress 2'], ['counterstrike2', '🔫 Counter-Strike 2'],
['garrysmod', '🔧 Garrys Mod'], ['satisfactory', '🏭 Satisfactory'],
['terraria', '🌳 Terraria'], ['arma3', '🪖 Arma 3'], ['squad', '🪖 Squad'],
['unturned', '🏝️ Unturned'],
];
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '', host: '', port: '', connect_url: '' };
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
const [serverDraft, setServerDraft] = useState(emptyServer); const [serverDraft, setServerDraft] = useState(emptyServer);
// Bot-Beschreibung + Team // Bot-Beschreibung + Team
@@ -177,9 +186,10 @@ export default function Settings({ me }) {
if (res) setTeam(res.admins); if (res) setTeam(res.admins);
} }
const serverNeedsUrl = ['fivem', 'http'].includes(serverDraft.type);
async function saveServer() { async function saveServer() {
if (!serverDraft.name.trim() || !serverDraft.query_url.trim()) { if (!serverDraft.name.trim() || (serverNeedsUrl ? !serverDraft.query_url.trim() : !serverDraft.host.trim())) {
flash('✗ Name und Query-URL nötig'); flash(serverNeedsUrl ? '✗ Name und Query-URL nötig' : '✗ Name und Host nötig');
return; return;
} }
try { try {
@@ -388,25 +398,6 @@ export default function Settings({ me }) {
if (createdKey?.id === id) setCreatedKey(null); if (createdKey?.id === id) setCreatedKey(null);
} }
async function sendCompose() {
if (!composer.channel || !composer.text.trim()) {
flash('✗ Kanal und Text nötig');
return;
}
try {
const res = await apiPost('/api/compose', {
channel_id: composer.channel,
message_id: composer.messageId.trim() || undefined,
embed_title: composer.title.trim() || undefined,
content: composer.text,
});
flash(res.edited ? '✓ Aktualisiert' : `✓ Gesendet (ID ${res.message_id})`);
if (!res.edited) setComposer({ ...composer, messageId: res.message_id });
} catch {
flash('✗ Senden fehlgeschlagen');
}
}
const reloadMenus = async () => setMenus((await apiGet('/api/rolemenus')).menus); const reloadMenus = async () => setMenus((await apiGet('/api/rolemenus')).menus);
async function saveMenu(publish = false) { async function saveMenu(publish = false) {
@@ -1024,33 +1015,7 @@ export default function Settings({ me }) {
const tabComposer = ( const tabComposer = (
<> <>
<div className="settings-section"> <EmbedComposer channelOptions={channelOptions} flash={flash} />
<h2 className="settings-title">// Post verfassen</h2>
<div className="field">
<label>Kanal</label>
<select value={composer.channel} onChange={(e) => setComposer({ ...composer, channel: e.target.value })}>
<option value="" disabled> Kanal wählen </option>
{channelOptions}
</select>
</div>
<div className="field">
<label>Embed-Titel <span className="field-hint">leer = normale Nachricht statt Embed</span></label>
<input type="text" placeholder="z. B. 📣 Ankündigung" value={composer.title} onChange={(e) => setComposer({ ...composer, title: e.target.value })} />
</div>
<div className="field">
<label>Text <span className="field-hint">Discord-Markdown (##, **, Listen )</span></label>
<textarea rows={8} value={composer.text} onChange={(e) => setComposer({ ...composer, text: e.target.value })} />
</div>
<div className="field">
<label>Message-ID <span className="field-hint">optional vorhandenen Bot-Post bearbeiten</span></label>
<div className="field-row">
<input type="text" placeholder="leer = neue Nachricht" value={composer.messageId} onChange={(e) => setComposer({ ...composer, messageId: e.target.value })} />
<button className="btn btn-save" onClick={sendCompose}>
{composer.messageId.trim() ? 'Aktualisieren' : 'Senden'}
</button>
</div>
</div>
</div>
<div className="settings-section"> <div className="settings-section">
<h2 className="settings-title">// Geplante Posts</h2> <h2 className="settings-title">// Geplante Posts</h2>
@@ -1311,14 +1276,25 @@ export default function Settings({ me }) {
Spieler-Balken, Map, Adresse) aktualisiert alle 2 Minuten, DiscordGSM-Style. Spieler-Balken, Map, Adresse) aktualisiert alle 2 Minuten, DiscordGSM-Style.
</p> </p>
<div className="field"> <div className="field">
<div className="field-row">
<div style={{ flex: 1 }}>
<label>Status-Kanal</label> <label>Status-Kanal</label>
<select value={form.status_channel_id ?? ''} onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}> <select value={form.status_channel_id ?? ''} onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}>
<option value=""> deaktiviert </option> <option value=""> deaktiviert </option>
{channelOptions} {channelOptions}
</select> </select>
</div> </div>
<div style={{ flex: 1 }}>
<label>Alert-Kanal <span className="field-hint">🚨 down / online</span></label>
<select value={form.server_alert_channel_id ?? ''} onChange={(e) => setForm({ ...form, server_alert_channel_id: e.target.value })}>
<option value=""> keine Alerts </option>
{channelOptions}
</select>
</div>
</div>
</div>
<div className="settings-actions" style={{ marginBottom: '1.4rem' }}> <div className="settings-actions" style={{ marginBottom: '1.4rem' }}>
<button className="btn btn-save" onClick={save}>Kanal speichern</button> <button className="btn btn-save" onClick={save}>Kanäle speichern</button>
</div> </div>
{servers.length > 0 && ( {servers.length > 0 && (
@@ -1329,7 +1305,8 @@ export default function Settings({ me }) {
<span className="key-scopes">{s.type}</span> <span className="key-scopes">{s.type}</span>
<span className="key-used" title={s.query_url}>{s.query_url}</span> <span className="key-used" title={s.query_url}>{s.query_url}</span>
<button className="btn btn-mini" onClick={() => setServerDraft({ <button className="btn btn-mini" onClick={() => setServerDraft({
id: s.id, name: s.name, type: s.type, query_url: s.query_url, address: s.address ?? '', id: s.id, name: s.name, type: s.type, query_url: s.query_url ?? '',
address: s.address ?? '', host: s.host ?? '', port: s.port ?? '', connect_url: s.connect_url ?? '',
})}>Bearbeiten</button> })}>Bearbeiten</button>
<button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}></button> <button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}></button>
</div> </div>
@@ -1341,17 +1318,35 @@ export default function Settings({ me }) {
<label>{serverDraft.id ? `Server #${serverDraft.id} bearbeiten` : 'Neuer Server'}</label> <label>{serverDraft.id ? `Server #${serverDraft.id} bearbeiten` : 'Neuer Server'}</label>
<div className="field-row"> <div className="field-row">
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Name, z. B. FiveM" value={serverDraft.name} onChange={(e) => setServerDraft({ ...serverDraft, name: e.target.value })} /> <input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Name, z. B. FiveM" value={serverDraft.name} onChange={(e) => setServerDraft({ ...serverDraft, name: e.target.value })} />
<select style={{ flex: 'none', width: '8rem' }} value={serverDraft.type} onChange={(e) => setServerDraft({ ...serverDraft, type: e.target.value })}> <input
<option value="fivem">FiveM</option> type="text" list="game-presets" style={{ flex: 'none', width: '13rem' }}
<option value="http">HTTP-Check</option> placeholder="Spiel (gamedig-ID)" title="Vorschläge oder beliebige gamedig-Spiel-ID"
</select> value={serverDraft.type}
onChange={(e) => setServerDraft({ ...serverDraft, type: e.target.value })}
/>
<datalist id="game-presets">
{GAME_PRESETS.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
</datalist>
{serverNeedsUrl ? (
<input type="text" placeholder={serverDraft.type === 'fivem' ? 'http://ip:30120' : 'https://dienst.example/health'} value={serverDraft.query_url} onChange={(e) => setServerDraft({ ...serverDraft, query_url: e.target.value })} /> <input type="text" placeholder={serverDraft.type === 'fivem' ? 'http://ip:30120' : 'https://dienst.example/health'} value={serverDraft.query_url} onChange={(e) => setServerDraft({ ...serverDraft, query_url: e.target.value })} />
) : (
<>
<input type="text" placeholder="Host / IP" value={serverDraft.host} onChange={(e) => setServerDraft({ ...serverDraft, host: e.target.value })} />
<input type="text" style={{ flex: 'none', width: '6rem' }} placeholder="Port" value={serverDraft.port} onChange={(e) => setServerDraft({ ...serverDraft, port: e.target.value })} />
</>
)}
</div> </div>
<p className="field-hint" style={{ marginTop: '.3rem' }}>
Neben FiveM/HTTP funktioniert jede <a href="https://github.com/gamedig/node-gamedig#games-list" target="_blank" rel="noreferrer" style={{ textDecoration: 'underline' }}>gamedig-Spiel-ID</a> (300+ Spiele) Host + Query-Port eintragen.
</p>
</div> </div>
<div className="field"> <div className="field">
<label>Anzeige-Adresse <span className="field-hint">optional steht als Copy-Paste-Block im Embed (z. B. connect play.d4rkst3r.de)</span></label> <label>Connect-Link + Anzeige-Adresse <span className="field-hint">Link ist im Embed klickbar (auch steam:// / fivem://); Adresse als Copy-Paste-Block</span></label>
<div className="field-row"> <div className="field-row">
<input type="text" placeholder="fivem://connect/play.d4rkst3r.de" value={serverDraft.connect_url} onChange={(e) => setServerDraft({ ...serverDraft, connect_url: e.target.value })} />
<input type="text" placeholder="connect play.d4rkst3r.de" value={serverDraft.address} onChange={(e) => setServerDraft({ ...serverDraft, address: e.target.value })} /> <input type="text" placeholder="connect play.d4rkst3r.de" value={serverDraft.address} onChange={(e) => setServerDraft({ ...serverDraft, address: e.target.value })} />
</div>
<div className="field-row" style={{ marginTop: '.6rem' }}>
<button className="btn btn-save" onClick={saveServer}>{serverDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button> <button className="btn btn-save" onClick={saveServer}>{serverDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
{serverDraft.id && <button className="btn btn-ghost" onClick={() => setServerDraft(emptyServer)}>Abbrechen</button>} {serverDraft.id && <button className="btn btn-ghost" onClick={() => setServerDraft(emptyServer)}>Abbrechen</button>}
</div> </div>
+82
View File
@@ -1009,3 +1009,85 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
transition: opacity .15s; transition: opacity .15s;
} }
.act-bar:hover { opacity: .7; } .act-bar:hover { opacity: .7; }
/* ── COMPOSER v2: Builder + Discord-Preview ────────── */
.composer-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 900px) { .composer-grid { grid-template-columns: 1fr; } }
.composer-preview > label {
display: block;
font-family: var(--mono);
font-size: .68rem; letter-spacing: .15em; text-transform: uppercase;
color: var(--muted);
margin-bottom: .4rem;
}
/* Discord-Look (bewusst Discord-Farben, nicht Brand) */
.dpv {
background: #313338;
border-radius: 10px;
padding: 1rem;
font-family: 'gg sans', 'Noto Sans', sans-serif;
font-size: .95rem;
line-height: 1.35;
color: #dbdee1;
position: sticky;
top: 5rem;
}
.dpv-msg { display: flex; gap: .8rem; }
.dpv-avatar {
width: 40px; height: 40px; flex-shrink: 0;
border-radius: 50%;
background: #f5c518;
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem;
}
.dpv-body { min-width: 0; flex: 1; }
.dpv-name { font-weight: 600; color: #f2f3f5; margin-bottom: .15rem; }
.dpv-tag {
background: #5865f2; color: #fff;
font-size: .6rem; font-weight: 700;
border-radius: 4px; padding: .05rem .3rem;
vertical-align: middle;
}
.dpv-time { font-size: .7rem; color: #949ba4; font-weight: 400; }
.dpv-content { white-space: pre-wrap; word-break: break-word; }
.dpv-content p, .dpv-desc p { margin: .15rem 0; }
.dpv-content .md-h, .dpv-desc .md-h {
font-family: inherit; text-transform: none; letter-spacing: 0;
font-size: 1.15rem; font-weight: 700; color: #f2f3f5; margin: .4rem 0 .1rem;
}
.dpv .md-list li::before { color: #dbdee1; }
.dpv .md-hr { background: #3f4147; }
.dpv code { background: #1e1f22; border: none; color: #dbdee1; }
.dpv-embed {
display: flex; gap: 1rem;
background: #2b2d31;
border-left: 4px solid #f5c518;
border-radius: 4px;
padding: .7rem .9rem .8rem;
margin-top: .35rem;
max-width: 520px;
}
.dpv-embed-main { min-width: 0; flex: 1; }
.dpv-author {
display: flex; align-items: center; gap: .5rem;
font-size: .82rem; font-weight: 600; color: #f2f3f5;
margin-bottom: .3rem;
}
.dpv-author img { width: 22px; height: 22px; border-radius: 50%; }
.dpv-title { font-weight: 700; color: #f2f3f5; margin-bottom: .3rem; }
.dpv-desc { font-size: .9rem; word-break: break-word; }
.dpv-fields { display: flex; flex-wrap: wrap; gap: .5rem 1rem; margin-top: .5rem; }
.dpv-field { flex: 0 0 100%; }
.dpv-field.inline { flex: 1 0 30%; min-width: 8rem; }
.dpv-field-name { font-size: .8rem; font-weight: 700; color: #f2f3f5; margin-bottom: .1rem; }
.dpv-field-value { font-size: .85rem; }
.dpv-image { max-width: 100%; border-radius: 6px; margin-top: .6rem; display: block; }
.dpv-thumb { width: 76px; height: 76px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.dpv-footer { font-size: .72rem; color: #949ba4; margin-top: .55rem; }
+612 -1
View File
@@ -15,7 +15,8 @@
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"discord.js": "^14.16.3", "discord.js": "^14.16.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"fastify": "^5.10.0" "fastify": "^5.10.0",
"gamedig": "^5.3.3"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -413,6 +414,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/@nodable/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -452,6 +465,36 @@
"npm": ">=7.0.0" "npm": ">=7.0.0"
} }
}, },
"node_modules/@sindresorhus/is": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz",
"integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
"license": "MIT",
"dependencies": {
"defer-to-connect": "^2.0.1"
},
"engines": {
"node": ">=14.16"
}
},
"node_modules/@types/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.1.1", "version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
@@ -519,6 +562,24 @@
} }
} }
}, },
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"license": "MIT"
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/atomic-sleep": { "node_modules/atomic-sleep": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -557,6 +618,33 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/barse": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/barse/-/barse-0.4.3.tgz",
"integrity": "sha512-UEpvriJqAn8zuVinYICuKoPttZy3XxXEoqX/V2uYAL4zzJRuNzCK3+20nAu3YUIa2U7G53kf90wfBIp9/A+Odw==",
"license": "MIT",
"dependencies": {
"readable-stream": "~1.0.2"
}
},
"node_modules/barse/node_modules/readable-stream": {
"version": "1.0.34",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
"integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
"isarray": "0.0.1",
"string_decoder": "~0.10.x"
}
},
"node_modules/barse/node_modules/string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT"
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -647,12 +735,60 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
"integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
"license": "MIT",
"engines": {
"node": ">=14.16"
}
},
"node_modules/cacheable-request": {
"version": "10.2.14",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz",
"integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==",
"license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "^4.0.2",
"get-stream": "^6.0.1",
"http-cache-semantics": "^4.1.1",
"keyv": "^4.5.3",
"mimic-response": "^4.0.0",
"normalize-url": "^8.0.0",
"responselike": "^3.0.0"
},
"engines": {
"node": ">=14.16"
}
},
"node_modules/cacheable-request/node_modules/mimic-response": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
"integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/commander": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
"integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
@@ -679,6 +815,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/decompress-response": { "node_modules/decompress-response": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -703,6 +845,15 @@
"node": ">=4.0.0" "node": ">=4.0.0"
} }
}, },
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -778,6 +929,14 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/emitter-component": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.2.tgz",
"integrity": "sha512-QdXO3nXOzZB4pAjM0n6ZE+R9/+kPpECA/XSELIcc54NeYVnBqIk+4DFiBgK+8QbV3mdvTG6nedl7dTYgO+5wDw==",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -793,6 +952,13 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/event-to-promise": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/event-to-promise/-/event-to-promise-0.7.0.tgz",
"integrity": "sha512-VOBBfyaADfe378ZzG0tgkzmsvzUyeU5arehrFzNRt5yaASUDshgctTwSrPI17ocAwR3+YftsxRClHF+GBKFByQ==",
"deprecated": "Use promise-toolbox/fromEvent instead",
"license": "MIT"
},
"node_modules/expand-template": { "node_modules/expand-template": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -879,6 +1045,59 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fast-xml-builder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
"integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-builder/node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz",
"integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.2.0",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.3.0",
"xml-naming": "^0.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastify": { "node_modules/fastify": {
"version": "5.10.0", "version": "5.10.0",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz",
@@ -957,12 +1176,71 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/form-data-encoder": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz",
"integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==",
"license": "MIT",
"engines": {
"node": ">= 14.17"
}
},
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/gamedig": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/gamedig/-/gamedig-5.3.3.tgz",
"integrity": "sha512-35z+ijImna5Ty84efoVXDHBYwH0VmvcKQzwvZarCrcpqWe6nn8X3rbKlLAtmt1HC7EvdPvBE7VjXJmwn/e1K9A==",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "5.8.0",
"gbxremote": "0.2.1",
"got": "13.0.0",
"iconv-lite": "0.7.2",
"long": "5.3.2",
"minimist": "1.2.8",
"seek-bzip": "2.0.0",
"telnet-client": "2.2.13",
"varint": "6.0.0"
},
"bin": {
"gamedig": "bin/gamedig.js"
},
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/gbxremote": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/gbxremote/-/gbxremote-0.2.1.tgz",
"integrity": "sha512-SMehu6Y6ndq2Qgp9VxAb8Np3f+UUD+RWoW2SAMaxzGS96rWXyr4T1GGkecO0HHtxeH1m7pEh4FJWB8a/6aM2XQ==",
"dependencies": {
"any-promise": "^1.1.0",
"barse": "~0.4.2",
"event-to-promise": "^0.7.0",
"string-to-stream": "^1.0.1",
"xmlrpc": "^1.3.1"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/github-from-package": { "node_modules/github-from-package": {
"version": "0.0.0", "version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
@@ -986,6 +1264,37 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/got": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz",
"integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==",
"license": "MIT",
"dependencies": {
"@sindresorhus/is": "^5.2.0",
"@szmarczak/http-timer": "^5.0.1",
"cacheable-lookup": "^7.0.0",
"cacheable-request": "^10.2.8",
"decompress-response": "^6.0.0",
"form-data-encoder": "^2.1.2",
"get-stream": "^6.0.1",
"http2-wrapper": "^2.1.10",
"lowercase-keys": "^3.0.0",
"p-cancelable": "^3.0.0",
"responselike": "^3.0.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"license": "BSD-2-Clause"
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1006,6 +1315,35 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/http2-wrapper": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
"integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
"license": "MIT",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.2.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -1047,6 +1385,18 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
"license": "MIT"
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
},
"node_modules/json-schema-ref-resolver": { "node_modules/json-schema-ref-resolver": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
@@ -1072,6 +1422,15 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/light-my-request": { "node_modules/light-my-request": {
"version": "6.6.0", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -1121,6 +1480,24 @@
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lowercase-keys": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.5.2", "version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
@@ -1205,6 +1582,12 @@
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/net": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/net/-/net-1.0.2.tgz",
"integrity": "sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==",
"license": "MIT"
},
"node_modules/node-abi": { "node_modules/node-abi": {
"version": "3.94.0", "version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
@@ -1217,6 +1600,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/normalize-url": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz",
"integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/on-exit-leak-free": { "node_modules/on-exit-leak-free": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -1235,6 +1630,30 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/p-cancelable": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
"integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-scurry": { "node_modules/path-scurry": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
@@ -1315,6 +1734,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/process-warning": { "node_modules/process-warning": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@@ -1347,6 +1772,18 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/rc": { "node_modules/rc": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -1394,6 +1831,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
"license": "MIT"
},
"node_modules/responselike": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz",
"integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==",
"license": "MIT",
"dependencies": {
"lowercase-keys": "^3.0.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ret": { "node_modules/ret": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
@@ -1470,6 +1928,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"license": "ISC"
},
"node_modules/secure-json-parse": { "node_modules/secure-json-parse": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -1486,6 +1956,19 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/seek-bzip": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz",
"integrity": "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==",
"license": "MIT",
"dependencies": {
"commander": "^6.0.0"
},
"bin": {
"seek-bunzip": "bin/seek-bunzip",
"seek-table": "bin/seek-bzip-table"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -1582,6 +2065,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/stream": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz",
"integrity": "sha512-gCq3NDI2P35B2n6t76YJuOp7d6cN/C7Rt0577l91wllh0sY9ZBuw9KaSGqH/b0hzn3CWWJbpbW0W0WvQ1H/Q7g==",
"license": "MIT",
"dependencies": {
"emitter-component": "^1.1.1"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -1591,6 +2083,52 @@
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
}, },
"node_modules/string-to-stream": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-1.1.1.tgz",
"integrity": "sha512-QySF2+3Rwq0SdO3s7BAp4x+c3qsClpPQ6abAmb0DGViiSBAkT5kL6JT2iyzEVP+T1SmzHrQD1TwlP9QAHCc+Sw==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.1",
"readable-stream": "^2.1.0"
}
},
"node_modules/string-to-stream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/string-to-stream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/string-to-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-to-stream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -1600,6 +2138,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/strnum": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
@@ -1628,6 +2181,20 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/telnet-client": {
"version": "2.2.13",
"resolved": "https://registry.npmjs.org/telnet-client/-/telnet-client-2.2.13.tgz",
"integrity": "sha512-pXAGggyeE7/STEnXhN8oe0V1hRzT6oNiGvs4CU4ilppledV40dok+70yl/Xb2newAXXdM9C5rW8otOKmRvkZxA==",
"license": "MIT",
"dependencies": {
"net": "^1.0.2",
"stream": "^0.0.2"
},
"funding": {
"type": "paypal",
"url": "https://paypal.me/kozjak"
}
},
"node_modules/thread-stream": { "node_modules/thread-stream": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@@ -1709,6 +2276,12 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"license": "MIT"
},
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1735,6 +2308,44 @@
"optional": true "optional": true
} }
} }
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz",
"integrity": "sha512-eKRAFz04jghooy8muekqzo8uCSVNeyRedbuJrp0fovbLIi7wlsYtdUn3vBAAPq2Y3/0xMz2WMEUQ8yhVVO9Stw==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlrpc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/xmlrpc/-/xmlrpc-1.3.2.tgz",
"integrity": "sha512-jQf5gbrP6wvzN71fgkcPPkF4bF/Wyovd7Xdff8d6/ihxYmgETQYSuTc+Hl+tsh/jmgPLro/Aro48LMFlIyEKKQ==",
"license": "MIT",
"dependencies": {
"sax": "1.2.x",
"xmlbuilder": "8.2.x"
},
"engines": {
"node": ">=0.8",
"npm": ">=1.0.0"
}
} }
} }
} }
+2 -1
View File
@@ -20,6 +20,7 @@
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"discord.js": "^14.16.3", "discord.js": "^14.16.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"fastify": "^5.10.0" "fastify": "^5.10.0",
"gamedig": "^5.3.3"
} }
} }
+110 -3
View File
@@ -1,9 +1,12 @@
// Kleinkram-Paket: Triggers (Auto-Antworten), Reminder-Scheduler, Temp-Voice // Kleinkram-Paket: Triggers (Auto-Antworten), Reminder-Scheduler, Temp-Voice
// (Join to Create), Event-Ankündigungen und Aktivitäts-Zählung. // (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 { import {
listTriggers, dueReminders, markReminderSent, countActivity, listTriggers, dueReminders, markReminderSent, countActivity,
trackTempVoice, untrackTempVoice, isTempVoice, listTempVoice, trackTempVoice, untrackTempVoice, isTempVoice, listTempVoice, tempVoiceOwner,
} from '../db.js'; } from '../db.js';
import { import {
tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter, tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter,
@@ -40,8 +43,30 @@ async function handleVoiceState(oldState, newState) {
type: ChannelType.GuildVoice, type: ChannelType.GuildVoice,
parent: hub.parentId ?? undefined, parent: hub.parentId ?? undefined,
}); });
trackTempVoice(channel.id); trackTempVoice(channel.id, newState.member.id);
await newState.member.voice.setChannel(channel).catch(() => {}); 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 // Temp-Kanal leer → löschen
@@ -99,6 +124,77 @@ async function announceEvent(event) {
await channel.send({ embeds: [embed] }).catch(() => {}); 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 ─────────────────────────────────── */ /* ── Registrierung ─────────────────────────────────── */
export function registerExtras(client) { export function registerExtras(client) {
@@ -117,6 +213,17 @@ export function registerExtras(client) {
client.on(Events.VoiceStateUpdate, (oldState, newState) => client.on(Events.VoiceStateUpdate, (oldState, newState) =>
handleVoiceState(oldState, newState).catch((e) => console.error('[tempvoice]', e)) 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) => client.on(Events.GuildScheduledEventCreate, (event) =>
announceEvent(event).catch((e) => console.error('[events]', e)) 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` ? `🏆 Gewonnen: ${mentions}\n👥 ${entries.length} Teilnahmen`
: 'Keine Teilnahmen — niemand hat gewonnen. 😢' : 'Keine Teilnahmen — niemand hat gewonnen. 😢'
) )
.setFooter({ text: brandFooter('GIVEAWAY') }), .setFooter({ text: brandFooter('GIVEAWAY'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
], ],
components: [], components: [],
}).catch(() => {}); }).catch(() => {});
+2 -2
View File
@@ -36,7 +36,7 @@ async function ensureModmailThread(client, user) {
`Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` + `Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` +
'Antworten in diesem Thread gehen als DM an den User.' 'Antworten in diesem Thread gehen als DM an den User.'
) )
.setFooter({ text: brandFooter('MODMAIL') }), .setFooter({ text: brandFooter('MODMAIL'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) }),
], ],
}); });
return thread; return thread;
@@ -93,7 +93,7 @@ async function handleMemberAdd(member) {
'🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' + '🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' +
'🐛 Bug gefunden? Einfach `/bug` benutzen.' '🐛 Bug gefunden? Einfach `/bug` benutzen.'
) )
.setFooter({ text: brandFooter('COMMUNITY') }) .setFooter({ text: brandFooter('COMMUNITY'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp(); .setTimestamp();
await channel.send({ content: `<@${member.id}>`, embeds: [embed] }); 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.*') .setDescription(body || '*Keine Release-Notes.*')
.addFields({ name: 'Version', value: `\`${release.tag}\``, inline: true }, .addFields({ name: 'Version', value: `\`${release.tag}\``, inline: true },
{ name: 'Projekt', value: release.repo, inline: true }) { name: 'Projekt', value: release.repo, inline: true })
.setFooter({ text: brandFooter('RELEASE') }) .setFooter({ text: brandFooter('RELEASE'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp(); .setTimestamp();
const buttons = new ActionRowBuilder().addComponents( const buttons = new ActionRowBuilder().addComponents(
+107 -17
View File
@@ -1,65 +1,153 @@
// Game-Server-Monitor (DiscordGSM-Stil): pro Server ein eigenes Status-Embed // Game-Server-Monitor v3 (DiscordGSM-Stil): gamedig-Queries für 300+ Spiele,
// im Status-Kanal (edit in place, grün/rot), alle 2 Minuten aktualisiert. // pro Server ein Live-Embed (Spieler-Balken, Map, Player-Liste, Connect-Link),
// Server werden im Setup-Tab "Server" verwaltet (Typ: fivem | http). // Down/Up-Alerts mit 2-Fails-Schwelle. Verwaltet im Setup-Tab "Server".
import { ActivityType, EmbedBuilder, Events } from 'discord.js'; import { ActivityType, EmbedBuilder, Events } from 'discord.js';
import { GameDig } from 'gamedig';
import { getSetting, listGameservers, setGameserverMessage } from '../db.js'; import { getSetting, listGameservers, setGameserverMessage } from '../db.js';
import { brandFooter, botStatusText } from '../runtime-settings.js'; import { brandFooter, botStatusText } from '../runtime-settings.js';
import { brandEmbed } from '../embeds.js';
const CHECK_INTERVAL_MS = 2 * 60 * 1000; const CHECK_INTERVAL_MS = 2 * 60 * 1000;
const TIMEOUT_MS = 8000; const TIMEOUT_MS = 8000;
const FAILS_BEFORE_ALERT = 2;
const GREEN = 0x23a55a; const GREEN = 0x23a55a;
const RED = 0xf23f43; 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) { 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 { try {
if (server.type === 'fivem') { if (server.type === 'fivem') {
const res = await fetch(`${server.query_url.replace(/\/$/, '')}/dynamic.json`, { const res = await fetch(`${server.query_url.replace(/\/$/, '')}/dynamic.json`, {
signal: AbortSignal.timeout(TIMEOUT_MS), 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}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
return { return {
...base, ...base, online: true, ping: Date.now() - started,
online: true,
players: Number(data.clients) || 0, players: Number(data.clients) || 0,
max: Number(data.sv_maxclients) || 0, max: Number(data.sv_maxclients) || 0,
map: data.mapname ?? null, map: data.mapname ?? null,
}; };
} }
// Typ 'http': generischer Healthcheck (2xx/3xx/4xx = erreichbar) if (server.type === 'http') {
const res = await fetch(server.query_url, { const res = await fetch(server.query_url, {
signal: AbortSignal.timeout(TIMEOUT_MS), signal: AbortSignal.timeout(TIMEOUT_MS),
redirect: 'follow', redirect: 'follow',
headers: { 'User-Agent': 'd4rkbot-monitor/2.0' }, headers: { 'User-Agent': 'd4rkbot-monitor/3.0' },
}); });
return { ...base, online: res.status < 500 }; 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: 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 { } catch {
return base; return base;
} }
} }
/** Status-Embed für einen Server bauen (DiscordGSM-Look) */ /** GSM-Style-Embed für ein Query-Ergebnis */
export function buildServerEmbed(r) { export function buildServerEmbed(r) {
const icon = GAME_ICONS[r.type] ?? '🎮';
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(r.online ? GREEN : RED) .setColor(r.online ? GREEN : RED)
.setTitle(`${r.online ? '🟢' : '🔴'} ${r.name}`) .setTitle(`${icon} ${r.name}`)
.addFields({ name: 'Status', value: r.online ? '**Online**' : '**Offline**', inline: true }) .addFields({ name: 'Status', value: r.online ? '🟢 **Online**' : '🔴 **Offline**', inline: true })
.setFooter({ text: `${brandFooter('STATUS')} • alle 2 min` }) .setFooter({ text: `${brandFooter('STATUS')}${r.ping != null ? `${r.ping}ms` : ''} • alle 2 min` })
.setTimestamp(); .setTimestamp();
if (r.online && r.players != null && r.max) { if (r.online && r.players != null && r.max) {
const pct = Math.min(1, r.players / r.max); const pct = Math.min(1, r.players / r.max);
const bar = '█'.repeat(Math.round(pct * 10)).padEnd(10, '░'); 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.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.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; 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 */ /** Ein Poll-Durchlauf — exportiert für Tests und den Timer */
export async function monitorTick(client) { export async function monitorTick(client) {
const servers = listGameservers(); const servers = listGameservers();
@@ -79,7 +167,9 @@ export async function monitorTick(client) {
} catch { /* Presence ist nice-to-have */ } } 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'); const channelId = getSetting('status_channel_id');
if (!channelId) return; if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null); 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 }, { name: 'Devlogs', value: String(stats.devlogs), inline: true },
...(repoLine ? [{ name: 'Aktive Projekte', value: repoLine, inline: false }] : []) ...(repoLine ? [{ name: 'Aktive Projekte', value: repoLine, inline: false }] : [])
) )
.setFooter({ text: brandFooter('WEEKLY') }) .setFooter({ text: brandFooter('WEEKLY'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp(); .setTimestamp();
await channel.send({ embeds: [embed] }); await channel.send({ embeds: [embed] });
+35 -4
View File
@@ -361,7 +361,26 @@ db.exec(`
CREATE TABLE IF NOT EXISTS temp_voice ( CREATE TABLE IF NOT EXISTS temp_voice (
channel_id TEXT PRIMARY KEY 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(` const upsertTrigger = db.prepare(`
INSERT INTO triggers (keyword, reply) VALUES (?, ?) INSERT INTO triggers (keyword, reply) VALUES (?, ?)
ON CONFLICT(keyword) DO UPDATE SET reply = excluded.reply 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`); 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 deleteTempVoiceStmt = db.prepare('DELETE FROM temp_voice WHERE channel_id = ?');
const listTempVoiceStmt = db.prepare('SELECT channel_id FROM temp_voice'); 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 untrackTempVoice = (id) => deleteTempVoiceStmt.run(id);
export const listTempVoice = () => listTempVoiceStmt.all().map((r) => r.channel_id); export const listTempVoice = () => listTempVoiceStmt.all().map((r) => r.channel_id);
const isTempVoiceStmt = db.prepare('SELECT 1 FROM temp_voice WHERE 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(); 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(` 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(` 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 setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?');
const listGameserversStmt = db.prepare('SELECT * FROM gameservers ORDER BY id'); const listGameserversStmt = db.prepare('SELECT * FROM gameservers ORDER BY id');
+20
View File
@@ -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
View File
@@ -20,12 +20,17 @@ function requireScope(request, reply, scope) {
} }
/** Nur erlaubte Embed-Felder durchreichen (kein Blind-Passthrough) */ /** Nur erlaubte Embed-Felder durchreichen (kein Blind-Passthrough) */
function sanitizeEmbed(raw) { export function sanitizeEmbed(raw) {
if (!raw || typeof raw !== 'object') return null; if (!raw || typeof raw !== 'object') return null;
const embed = {}; const embed = {};
for (const key of ['title', 'description', 'url', 'color', 'timestamp']) { for (const key of ['title', 'description', 'url', 'color', 'timestamp']) {
if (raw[key] !== undefined) embed[key] = raw[key]; 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.footer?.text) embed.footer = { text: String(raw.footer.text) };
if (raw.image?.url) embed.image = { url: String(raw.image.url) }; if (raw.image?.url) embed.image = { url: String(raw.image.url) };
if (raw.thumbnail?.url) embed.thumbnail = { url: String(raw.thumbnail.url) }; if (raw.thumbnail?.url) embed.thumbnail = { url: String(raw.thumbnail.url) };
+64 -12
View File
@@ -12,8 +12,10 @@ import {
saveTrigger, listTriggers, deleteTrigger, activityRange, saveTrigger, listTriggers, deleteTrigger, activityRange,
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver, createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin, saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate,
} from '../db.js'; } from '../db.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.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 { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
import { computeNextRun } from './scheduled-posts.js'; import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js'; import { config } from '../config.js';
@@ -299,6 +301,7 @@ ${rssItems}
twitch_channel: getSetting('twitch_channel') ?? '', twitch_channel: getSetting('twitch_channel') ?? '',
twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')), twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')),
tempvoice_channel_id: getSetting('tempvoice_channel_id') ?? '', 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', 'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id', 'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_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]) { for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
if (body[key] === undefined) continue; if (body[key] === undefined) continue;
@@ -738,10 +741,23 @@ ${rssItems}
function parseServerBody(request, reply) { function parseServerBody(request, reply) {
const body = request.body ?? {}; const body = request.body ?? {};
const name = String(body.name ?? '').trim(); 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 queryUrl = String(body.query_url ?? '').trim().replace(/\/$/, '');
const type = ['fivem', 'http'].includes(body.type) ? body.type : 'fivem'; const host = String(body.host ?? '').trim();
if (!name || !/^https?:\/\/.+/.test(queryUrl)) { const port = Number(body.port) || null;
reply.code(400).send({ error: 'name und query_url (http/https) nötig' });
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 null;
} }
return { return {
@@ -749,6 +765,9 @@ ${rssItems}
type, type,
query_url: queryUrl, query_url: queryUrl,
address: String(body.address ?? '').trim().slice(0, 120), 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) => { app.post('/api/compose', async (request, reply) => {
if (requireScope(request, reply, 'content')) return; 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(); const text = String(content ?? '').trim();
if (!channelId || !text) { const cleanEmbed = embed ? sanitizeEmbed(embed) : null;
return reply.code(400).send({ error: 'channel_id und content nötig' }); 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); const channel = await client.channels.fetch(String(channelId)).catch(() => null);
if (!channel?.isTextBased()) { if (!channel?.isTextBased()) {
return reply.code(404).send({ error: 'Kanal nicht gefunden' }); return reply.code(404).send({ error: 'Kanal nicht gefunden' });
} }
// Mit Embed-Titel → gebrandetes Embed, sonst Plaintext (Discord-Markdown) // Drei Modi: volles Embed (Composer v2) > Embed-Titel-Kurzform > Plaintext
const payload = embedTitle?.trim() let payload;
? { if (cleanEmbed) {
payload = { content: text ? text.slice(0, 2000) : null, embeds: [cleanEmbed] };
} else if (embedTitle?.trim()) {
payload = {
content: null, content: null,
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
@@ -907,8 +930,10 @@ ${rssItems}
.setFooter({ text: brandName() }) .setFooter({ text: brandName() })
.setTimestamp(), .setTimestamp(),
], ],
};
} else {
payload = { content: text.slice(0, 2000), embeds: [] };
} }
: { content: text.slice(0, 2000), embeds: [] };
if (messageId) { if (messageId) {
const message = await channel.messages.fetch(String(messageId)).catch(() => null); 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) --- // --- Team-Verwaltung (nur Owner) ---
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs']; const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs'];
@@ -1068,7 +1120,7 @@ ${rssItems}
.setDescription( .setDescription(
`Test-Nachricht für den **${{ commit: 'Commit', devlog: 'Devlog', release: 'Release' }[target]}-Kanal** — von der Settings-Seite ausgelöst.` `Test-Nachricht für den **${{ commit: 'Commit', devlog: 'Devlog', release: 'Release' }[target]}-Kanal** — von der Settings-Seite ausgelöst.`
) )
.setFooter({ text: brandFooter('SETUP') }), .setFooter({ text: brandFooter('SETUP'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
], ],
}); });
return { ok: true }; return { ok: true };
+1
View File
@@ -103,6 +103,7 @@ export function registerDevlogEndpoint(app, client) {
.setDescription(prose) .setDescription(prose)
.setFooter({ .setFooter({
text: `${brandFooter('DEVLOG')}${commitCount ? `${commitCount} Commits` : ''}`, text: `${brandFooter('DEVLOG')}${commitCount ? `${commitCount} Commits` : ''}`,
iconURL: client?.user?.displayAvatarURL?.({ size: 64 }),
}) })
.setTimestamp(); .setTimestamp();
if (project) { if (project) {