diff --git a/README.md b/README.md
index 45e74ad..27f00c7 100644
--- a/README.md
+++ b/README.md
@@ -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 |
| ⏰ **/remind** | Erinnerungen per DM (`/remind dauer:2h text:…`) |
| 📣 **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 |
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
-| 🎮 **Server-Monitor** | 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 |
| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
| 🎉 **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,
Screenshot-Kanal, Voting-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
- **Backups:** an/aus, Upload-Kanal, „Backup jetzt"
- **Watchdog:** überwachte URLs
@@ -258,6 +261,7 @@ urllib.request.urlopen(req)
| `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben |
| `GET/POST/DELETE /api/apikeys` | Admin | API-Key-Verwaltung |
| `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 /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen |
| `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 |
@@ -278,6 +282,7 @@ d4rkbot/
│ ├── runtime-settings.js # Effektive Settings (DB vor Env)
│ ├── gitea-api.js # Gitea-REST (Issues + Assets für /bug)
│ ├── backup.js # Nächtliche DB-Backups (gzip, Rotation, Upload)
+│ ├── embeds.js # brandEmbed-Factory (Farbe, Footer, Bot-Icon)
│ ├── bot/
│ │ ├── client.js # Discord-Client, Commands, Listener, Button-Handler
│ │ ├── commit-feed.js # Push → Embed
@@ -285,7 +290,7 @@ d4rkbot/
│ │ ├── devlog-archive.js # Nachricht → SQLite (+ Bild-Download)
│ │ ├── community.js # Starboard + Screenshot-Galerie
│ │ ├── 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)
│ │ ├── weekly-recap.js # Sonntags-Zusammenfassung + Scheduler
│ │ ├── watchdog.js # URL-Checks + DM-Alarm
@@ -300,6 +305,7 @@ d4rkbot/
│ └── src/
│ ├── App.jsx # Layout, Router, Nav, Login-Status
│ ├── markdown.jsx # Mini-Markdown (fett/kursiv/Code/Links/Listen/---/##)
+│ ├── components/ # EmbedComposer (Builder + Discord-Preview)
│ └── pages/ # Devlogs, Roadmap, Galerie, Changelog, Commits, Settings
├── tools/test-webhook.mjs # Lokaler Commit-Feed-Test
├── .env.example # Vorlage für alle Env-Variablen
diff --git a/frontend/src/components/EmbedComposer.jsx b/frontend/src/components/EmbedComposer.jsx
new file mode 100644
index 0000000..f1696dd
--- /dev/null
+++ b/frontend/src/components/EmbedComposer.jsx
@@ -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 (
+
-
+
+ setServerDraft({ ...serverDraft, connect_url: e.target.value })} />
setServerDraft({ ...serverDraft, address: e.target.value })} />
+
+
{serverDraft.id && }
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 8637d89..76afeab 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1009,3 +1009,85 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
transition: opacity .15s;
}
.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; }
diff --git a/package-lock.json b/package-lock.json
index 02e7419..478af3d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,8 @@
"better-sqlite3": "^12.11.1",
"discord.js": "^14.16.3",
"dotenv": "^16.4.7",
- "fastify": "^5.10.0"
+ "fastify": "^5.10.0",
+ "gamedig": "^5.3.3"
},
"engines": {
"node": ">=20"
@@ -413,6 +414,18 @@
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -452,6 +465,36 @@
"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": {
"version": "26.1.1",
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -557,6 +618,33 @@
"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": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -647,12 +735,60 @@
"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": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
@@ -679,6 +815,12 @@
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -703,6 +845,15 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -778,6 +929,14 @@
"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": {
"version": "1.4.5",
"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==",
"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": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -879,6 +1045,59 @@
],
"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": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz",
@@ -957,12 +1176,71 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"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": {
"version": "0.0.0",
"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"
}
},
+ "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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1006,6 +1315,35 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -1047,6 +1385,18 @@
"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": {
"version": "3.0.0",
"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==",
"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": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -1121,6 +1480,24 @@
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"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": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
@@ -1205,6 +1582,12 @@
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"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": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
@@ -1217,6 +1600,18 @@
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -1235,6 +1630,30 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
@@ -1315,6 +1734,12 @@
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@@ -1347,6 +1772,18 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"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": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -1394,6 +1831,27 @@
"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": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
@@ -1470,6 +1928,18 @@
"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": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -1486,6 +1956,19 @@
],
"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": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -1582,6 +2065,15 @@
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -1591,6 +2083,52 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -1600,6 +2138,21 @@
"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": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
@@ -1628,6 +2181,20 @@
"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": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@@ -1709,6 +2276,12 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1735,6 +2308,44 @@
"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"
+ }
}
}
}
diff --git a/package.json b/package.json
index c468780..f7c04a9 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"better-sqlite3": "^12.11.1",
"discord.js": "^14.16.3",
"dotenv": "^16.4.7",
- "fastify": "^5.10.0"
+ "fastify": "^5.10.0",
+ "gamedig": "^5.3.3"
}
}
diff --git a/src/bot/extras.js b/src/bot/extras.js
index 2316206..f31051a 100644
--- a/src/bot/extras.js
+++ b/src/bot/extras.js
@@ -1,9 +1,12 @@
// Kleinkram-Paket: Triggers (Auto-Antworten), Reminder-Scheduler, Temp-Voice
// (Join to Create), Event-Ankündigungen und Aktivitäts-Zählung.
-import { ChannelType, EmbedBuilder, Events } from 'discord.js';
+import {
+ ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, EmbedBuilder, Events,
+ MessageFlags, ModalBuilder, TextInputBuilder, TextInputStyle,
+} from 'discord.js';
import {
listTriggers, dueReminders, markReminderSent, countActivity,
- trackTempVoice, untrackTempVoice, isTempVoice, listTempVoice,
+ trackTempVoice, untrackTempVoice, isTempVoice, listTempVoice, tempVoiceOwner,
} from '../db.js';
import {
tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter,
@@ -40,8 +43,30 @@ async function handleVoiceState(oldState, newState) {
type: ChannelType.GuildVoice,
parent: hub.parentId ?? undefined,
});
- trackTempVoice(channel.id);
+ trackTempVoice(channel.id, newState.member.id);
await newState.member.voice.setChannel(channel).catch(() => {});
+
+ // Control-Panel in den Kanal-Textchat (nur der Besitzer darf bedienen)
+ await channel.send({
+ embeds: [
+ new EmbedBuilder()
+ .setColor(brandColor())
+ .setTitle('🔊 Dein Kanal')
+ .setDescription(
+ `<@${newState.member.id}>, das hier ist dein temporärer Voice-Kanal — ` +
+ 'er verschwindet automatisch, wenn alle raus sind. Steuern kannst du ihn hier:'
+ )
+ .setFooter({ text: brandFooter('VOICE') }),
+ ],
+ components: [
+ new ActionRowBuilder().addComponents(
+ new ButtonBuilder().setCustomId('tv:rename').setStyle(ButtonStyle.Secondary).setLabel('Umbenennen').setEmoji('✏️'),
+ new ButtonBuilder().setCustomId('tv:lock').setStyle(ButtonStyle.Secondary).setLabel('Sperren/Öffnen').setEmoji('🔒'),
+ new ButtonBuilder().setCustomId('tv:limit').setStyle(ButtonStyle.Secondary).setLabel('Limit').setEmoji('👥'),
+ new ButtonBuilder().setCustomId('tv:delete').setStyle(ButtonStyle.Danger).setLabel('Löschen').setEmoji('🗑️')
+ ),
+ ],
+ }).catch(() => {});
}
// Temp-Kanal leer → löschen
@@ -99,6 +124,77 @@ async function announceEvent(event) {
await channel.send({ embeds: [embed] }).catch(() => {});
}
+/* ── Temp-Voice-Control-Panel (Buttons + Modals) ───── */
+
+async function handleTempVoiceInteraction(interaction) {
+ const channel = interaction.channel;
+ const owner = tempVoiceOwner(channel?.id);
+ if (!owner) {
+ await interaction.reply({ content: '❌ Das ist kein Temp-Voice-Kanal (mehr).', flags: MessageFlags.Ephemeral });
+ return;
+ }
+ if (interaction.user.id !== owner) {
+ await interaction.reply({ content: '❌ Nur der Kanal-Besitzer darf das.', flags: MessageFlags.Ephemeral });
+ return;
+ }
+ const action = interaction.customId.split(':')[1];
+
+ if (action === 'rename') {
+ const modal = new ModalBuilder().setCustomId('tvmodal:rename').setTitle('Kanal umbenennen');
+ modal.addComponents(new ActionRowBuilder().addComponents(
+ new TextInputBuilder().setCustomId('value').setLabel('Neuer Name').setStyle(TextInputStyle.Short).setMaxLength(90).setRequired(true)
+ ));
+ await interaction.showModal(modal);
+ return;
+ }
+ if (action === 'limit') {
+ const modal = new ModalBuilder().setCustomId('tvmodal:limit').setTitle('User-Limit setzen');
+ modal.addComponents(new ActionRowBuilder().addComponents(
+ new TextInputBuilder().setCustomId('value').setLabel('Limit (0 = unbegrenzt, max 99)').setStyle(TextInputStyle.Short).setMaxLength(2).setRequired(true)
+ ));
+ await interaction.showModal(modal);
+ return;
+ }
+ if (action === 'lock') {
+ const everyone = channel.guild.roles.everyone;
+ const current = channel.permissionOverwrites.cache.get(everyone.id);
+ const locked = current?.deny?.has?.('Connect') ?? false;
+ await channel.permissionOverwrites.edit(everyone, { Connect: locked ? null : false });
+ await interaction.reply({
+ content: locked ? '🔓 Kanal ist wieder offen.' : '🔒 Kanal gesperrt — nur wer drin ist, bleibt drin.',
+ flags: MessageFlags.Ephemeral,
+ });
+ return;
+ }
+ if (action === 'delete') {
+ await interaction.reply({ content: '🗑️ Kanal wird gelöscht …', flags: MessageFlags.Ephemeral });
+ untrackTempVoice(channel.id);
+ await channel.delete().catch(() => {});
+ }
+}
+
+async function handleTempVoiceModal(interaction) {
+ const channel = interaction.channel;
+ if (tempVoiceOwner(channel?.id) !== interaction.user.id) {
+ await interaction.reply({ content: '❌ Nur der Kanal-Besitzer darf das.', flags: MessageFlags.Ephemeral });
+ return;
+ }
+ const kind = interaction.customId.split(':')[1];
+ const value = interaction.fields.getTextInputValue('value').trim();
+
+ if (kind === 'rename') {
+ await channel.setName(`🔊 ${value}`.slice(0, 90)).catch(() => {});
+ await interaction.reply({ content: `✏️ Kanal heißt jetzt **🔊 ${value}**.`, flags: MessageFlags.Ephemeral });
+ } else if (kind === 'limit') {
+ const n = Math.max(0, Math.min(99, Number(value) || 0));
+ await channel.setUserLimit(n).catch(() => {});
+ await interaction.reply({
+ content: n === 0 ? '👥 Limit entfernt.' : `👥 Limit: **${n}** Plätze.`,
+ flags: MessageFlags.Ephemeral,
+ });
+ }
+}
+
/* ── Registrierung ─────────────────────────────────── */
export function registerExtras(client) {
@@ -117,6 +213,17 @@ export function registerExtras(client) {
client.on(Events.VoiceStateUpdate, (oldState, newState) =>
handleVoiceState(oldState, newState).catch((e) => console.error('[tempvoice]', e))
);
+ client.on(Events.InteractionCreate, async (interaction) => {
+ try {
+ if (interaction.isButton() && interaction.customId.startsWith('tv:')) {
+ await handleTempVoiceInteraction(interaction);
+ } else if (interaction.isModalSubmit() && interaction.customId.startsWith('tvmodal:')) {
+ await handleTempVoiceModal(interaction);
+ }
+ } catch (error) {
+ console.error('[tempvoice] Panel-Fehler:', error);
+ }
+ });
client.on(Events.GuildScheduledEventCreate, (event) =>
announceEvent(event).catch((e) => console.error('[events]', e))
);
diff --git a/src/bot/giveaways.js b/src/bot/giveaways.js
index 5338705..eaaf39f 100644
--- a/src/bot/giveaways.js
+++ b/src/bot/giveaways.js
@@ -40,7 +40,7 @@ export async function giveawayTick(client) {
? `🏆 Gewonnen: ${mentions}\n👥 ${entries.length} Teilnahmen`
: 'Keine Teilnahmen — niemand hat gewonnen. 😢'
)
- .setFooter({ text: brandFooter('GIVEAWAY') }),
+ .setFooter({ text: brandFooter('GIVEAWAY'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
],
components: [],
}).catch(() => {});
diff --git a/src/bot/mod-tools.js b/src/bot/mod-tools.js
index 5a165ee..6db50ff 100644
--- a/src/bot/mod-tools.js
+++ b/src/bot/mod-tools.js
@@ -36,7 +36,7 @@ async function ensureModmailThread(client, user) {
`Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` +
'Antworten in diesem Thread gehen als DM an den User.'
)
- .setFooter({ text: brandFooter('MODMAIL') }),
+ .setFooter({ text: brandFooter('MODMAIL'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) }),
],
});
return thread;
@@ -93,7 +93,7 @@ async function handleMemberAdd(member) {
'🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' +
'🐛 Bug gefunden? Einfach `/bug` benutzen.'
)
- .setFooter({ text: brandFooter('COMMUNITY') })
+ .setFooter({ text: brandFooter('COMMUNITY'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp();
await channel.send({ content: `<@${member.id}>`, embeds: [embed] });
diff --git a/src/bot/release-feed.js b/src/bot/release-feed.js
index 962937a..dcd24a9 100644
--- a/src/bot/release-feed.js
+++ b/src/bot/release-feed.js
@@ -30,7 +30,7 @@ export async function postReleaseEmbed(client, release) {
.setDescription(body || '*Keine Release-Notes.*')
.addFields({ name: 'Version', value: `\`${release.tag}\``, inline: true },
{ name: 'Projekt', value: release.repo, inline: true })
- .setFooter({ text: brandFooter('RELEASE') })
+ .setFooter({ text: brandFooter('RELEASE'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp();
const buttons = new ActionRowBuilder().addComponents(
diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js
index 37967e6..5f3c363 100644
--- a/src/bot/server-monitor.js
+++ b/src/bot/server-monitor.js
@@ -1,65 +1,153 @@
-// Game-Server-Monitor (DiscordGSM-Stil): pro Server ein eigenes Status-Embed
-// im Status-Kanal (edit in place, grün/rot), alle 2 Minuten aktualisiert.
-// Server werden im Setup-Tab "Server" verwaltet (Typ: fivem | http).
+// Game-Server-Monitor v3 (DiscordGSM-Stil): gamedig-Queries für 300+ Spiele,
+// pro Server ein Live-Embed (Spieler-Balken, Map, Player-Liste, Connect-Link),
+// Down/Up-Alerts mit 2-Fails-Schwelle. Verwaltet im Setup-Tab "Server".
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
+import { GameDig } from 'gamedig';
import { getSetting, listGameservers, setGameserverMessage } from '../db.js';
import { brandFooter, botStatusText } from '../runtime-settings.js';
+import { brandEmbed } from '../embeds.js';
const CHECK_INTERVAL_MS = 2 * 60 * 1000;
const TIMEOUT_MS = 8000;
+const FAILS_BEFORE_ALERT = 2;
const GREEN = 0x23a55a;
const RED = 0xf23f43;
-/** Einen Server abfragen — je nach Typ */
+// Emoji pro Spieltyp (Fallback 🎮)
+export const GAME_ICONS = {
+ fivem: '🚗', http: '🌐', minecraft: '⛏️', minecraftbe: '⛏️',
+ teamfortress2: '🎩', counterstrike2: '🔫', csgo: '🔫', garrysmod: '🔧',
+ rust: '🪓', valheim: '🛡️', palworld: '🐑', ark: '🦖', arkse: '🦖',
+ dayz: '🧟', projectzomboid: '🧟', sevendaystodie: '🧟',
+ satisfactory: '🏭', terraria: '🌳', arma3: '🪖', squad: '🪖', unturned: '🏝️',
+};
+
+// Alert-Zustand pro Server (in-memory)
+const alertState = new Map(); // id → { fails, down, since }
+
+/** Einen Server abfragen — fivem/http direkt, alles andere über gamedig */
export async function queryServer(server) {
- const base = { ...server, online: false, players: null, max: null, map: null };
+ const base = {
+ ...server, online: false, players: null, max: null, map: null,
+ playerNames: [], ping: null,
+ };
+ const started = Date.now();
try {
if (server.type === 'fivem') {
const res = await fetch(`${server.query_url.replace(/\/$/, '')}/dynamic.json`, {
signal: AbortSignal.timeout(TIMEOUT_MS),
- headers: { 'User-Agent': 'd4rkbot-monitor/2.0' },
+ headers: { 'User-Agent': 'd4rkbot-monitor/3.0' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return {
- ...base,
- online: true,
+ ...base, online: true, ping: Date.now() - started,
players: Number(data.clients) || 0,
max: Number(data.sv_maxclients) || 0,
map: data.mapname ?? null,
};
}
- // Typ 'http': generischer Healthcheck (2xx/3xx/4xx = erreichbar)
- const res = await fetch(server.query_url, {
- signal: AbortSignal.timeout(TIMEOUT_MS),
- redirect: 'follow',
- headers: { 'User-Agent': 'd4rkbot-monitor/2.0' },
+ if (server.type === 'http') {
+ const res = await fetch(server.query_url, {
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ redirect: 'follow',
+ headers: { 'User-Agent': 'd4rkbot-monitor/3.0' },
+ });
+ return { ...base, online: res.status < 500, ping: Date.now() - started };
+ }
+ // gamedig: Typ = Spiel-ID (minecraft, teamfortress2, rust, …)
+ const state = await GameDig.query({
+ type: server.type,
+ host: server.host,
+ port: server.port || undefined,
+ maxRetries: 1,
+ socketTimeout: TIMEOUT_MS,
});
- return { ...base, online: res.status < 500 };
+ return {
+ ...base, online: true, ping: state.ping ?? Date.now() - started,
+ players: state.players?.length ?? state.numplayers ?? 0,
+ max: state.maxplayers ?? null,
+ map: state.map || null,
+ playerNames: (state.players ?? []).map((p) => p?.name).filter(Boolean).slice(0, 15),
+ };
} catch {
return base;
}
}
-/** Status-Embed für einen Server bauen (DiscordGSM-Look) */
+/** GSM-Style-Embed für ein Query-Ergebnis */
export function buildServerEmbed(r) {
+ const icon = GAME_ICONS[r.type] ?? '🎮';
const embed = new EmbedBuilder()
.setColor(r.online ? GREEN : RED)
- .setTitle(`${r.online ? '🟢' : '🔴'} ${r.name}`)
- .addFields({ name: 'Status', value: r.online ? '**Online**' : '**Offline**', inline: true })
- .setFooter({ text: `${brandFooter('STATUS')} • alle 2 min` })
+ .setTitle(`${icon} ${r.name}`)
+ .addFields({ name: 'Status', value: r.online ? '🟢 **Online**' : '🔴 **Offline**', inline: true })
+ .setFooter({ text: `${brandFooter('STATUS')}${r.ping != null ? ` • ${r.ping}ms` : ''} • alle 2 min` })
.setTimestamp();
if (r.online && r.players != null && r.max) {
const pct = Math.min(1, r.players / r.max);
const bar = '█'.repeat(Math.round(pct * 10)).padEnd(10, '░');
- embed.addFields({ name: 'Spieler', value: `\`${bar}\` ${r.players}/${r.max}`, inline: true });
+ embed.addFields({
+ name: 'Spieler',
+ value: `\`${bar}\` ${r.players}/${r.max} (${Math.round(pct * 100)}%)`,
+ inline: true,
+ });
}
if (r.map) embed.addFields({ name: 'Map', value: r.map, inline: true });
+ if (r.connect_url) {
+ // steam:// & Co. sind in Embeds klickbar (Link-Buttons erlauben nur https)
+ embed.addFields({ name: 'Connect', value: `[${r.connect_url}](${r.connect_url})`, inline: false });
+ }
if (r.address) embed.addFields({ name: 'Adresse', value: `\`\`\`\n${r.address}\n\`\`\`` });
+ if (r.online && r.playerNames.length > 0) {
+ embed.addFields({
+ name: `Spieler-Liste (${r.playerNames.length})`,
+ value: r.playerNames.map((n) => `\`${n}\``).join(' ').slice(0, 1000),
+ });
+ }
return embed;
}
+/** Down/Up-Alerts in den Alert-Kanal (2 Fails in Folge = down) */
+async function handleAlerts(client, r) {
+ const channelId = getSetting('server_alert_channel_id');
+ if (!channelId) return;
+ const s = alertState.get(r.id) ?? { fails: 0, down: false, since: null };
+
+ if (r.online) {
+ if (s.down) {
+ const minutes = Math.max(1, Math.round((Date.now() - s.since) / 60000));
+ const channel = await client.channels.fetch(channelId).catch(() => null);
+ await channel?.send({
+ embeds: [
+ brandEmbed(client, 'ALERT')
+ .setColor(GREEN)
+ .setTitle(`✅ ${r.name} ist wieder online!`)
+ .setDescription(`Downtime: ~${minutes} min`),
+ ],
+ }).catch(() => {});
+ }
+ alertState.set(r.id, { fails: 0, down: false, since: null });
+ return;
+ }
+ const fails = s.fails + 1;
+ if (!s.down && fails >= FAILS_BEFORE_ALERT) {
+ const channel = await client.channels.fetch(channelId).catch(() => null);
+ await channel?.send({
+ embeds: [
+ brandEmbed(client, 'ALERT')
+ .setColor(RED)
+ .setTitle(`🚨 ${r.name} scheint down zu sein!`)
+ .setDescription(r.address ? `\`${r.address}\`` : null),
+ ],
+ }).catch(() => {});
+ alertState.set(r.id, { fails, down: true, since: Date.now() });
+ } else {
+ alertState.set(r.id, { ...s, fails });
+ }
+}
+
/** Ein Poll-Durchlauf — exportiert für Tests und den Timer */
export async function monitorTick(client) {
const servers = listGameservers();
@@ -79,7 +167,9 @@ export async function monitorTick(client) {
} catch { /* Presence ist nice-to-have */ }
}
- // Pro Server ein Embed pflegen
+ for (const r of results) await handleAlerts(client, r).catch(() => {});
+
+ // Pro Server ein Embed pflegen (edit in place)
const channelId = getSetting('status_channel_id');
if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null);
diff --git a/src/bot/weekly-recap.js b/src/bot/weekly-recap.js
index 2eacccd..de26070 100644
--- a/src/bot/weekly-recap.js
+++ b/src/bot/weekly-recap.js
@@ -56,7 +56,7 @@ export async function postWeeklyRecap(client) {
{ name: 'Devlogs', value: String(stats.devlogs), inline: true },
...(repoLine ? [{ name: 'Aktive Projekte', value: repoLine, inline: false }] : [])
)
- .setFooter({ text: brandFooter('WEEKLY') })
+ .setFooter({ text: brandFooter('WEEKLY'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp();
await channel.send({ embeds: [embed] });
diff --git a/src/db.js b/src/db.js
index 557c92d..f3ce04e 100644
--- a/src/db.js
+++ b/src/db.js
@@ -361,7 +361,26 @@ db.exec(`
CREATE TABLE IF NOT EXISTS temp_voice (
channel_id TEXT PRIMARY KEY
);
+ CREATE TABLE IF NOT EXISTS message_templates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ payload TEXT NOT NULL
+ );
`);
+// Migration: Temp-Voice-Besitzer (fürs Control-Panel)
+{
+ const cols = db.prepare('PRAGMA table_info(temp_voice)').all().map((c) => c.name);
+ if (!cols.includes('owner_id')) db.exec(`ALTER TABLE temp_voice ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''`);
+}
+const upsertTemplate = db.prepare(`
+ INSERT INTO message_templates (name, payload) VALUES (?, ?)
+ ON CONFLICT(name) DO UPDATE SET payload = excluded.payload
+`);
+const listTemplatesStmt = db.prepare('SELECT id, name, payload FROM message_templates ORDER BY name');
+const deleteTemplateStmt = db.prepare('DELETE FROM message_templates WHERE name = ?');
+export const saveTemplate = (name, payload) => upsertTemplate.run(name, JSON.stringify(payload));
+export const listTemplates = () => listTemplatesStmt.all().map((t) => ({ ...t, payload: JSON.parse(t.payload) }));
+export const deleteTemplate = (name) => deleteTemplateStmt.run(name).changes > 0;
const upsertTrigger = db.prepare(`
INSERT INTO triggers (keyword, reply) VALUES (?, ?)
ON CONFLICT(keyword) DO UPDATE SET reply = excluded.reply
@@ -395,10 +414,12 @@ const activityRangeStmt = db.prepare(
);
export const activityRange = (days = 30) => activityRangeStmt.all(`-${days} days`);
-const insertTempVoice = db.prepare('INSERT OR IGNORE INTO temp_voice (channel_id) VALUES (?)');
+const insertTempVoice = db.prepare('INSERT OR IGNORE INTO temp_voice (channel_id, owner_id) VALUES (?, ?)');
+const tempVoiceOwnerStmt = db.prepare('SELECT owner_id FROM temp_voice WHERE channel_id = ?');
+export const tempVoiceOwner = (id) => tempVoiceOwnerStmt.get(id)?.owner_id ?? null;
const deleteTempVoiceStmt = db.prepare('DELETE FROM temp_voice WHERE channel_id = ?');
const listTempVoiceStmt = db.prepare('SELECT channel_id FROM temp_voice');
-export const trackTempVoice = (id) => insertTempVoice.run(id);
+export const trackTempVoice = (id, ownerId = '') => insertTempVoice.run(id, ownerId);
export const untrackTempVoice = (id) => deleteTempVoiceStmt.run(id);
export const listTempVoice = () => listTempVoiceStmt.all().map((r) => r.channel_id);
const isTempVoiceStmt = db.prepare('SELECT 1 FROM temp_voice WHERE channel_id = ?');
@@ -453,11 +474,21 @@ db.exec(`
db.prepare(`DELETE FROM settings WHERE key = 'gameservers'`).run();
}
}
+// Migration v3: host/port (gamedig) + connect_url (Connect-Link im Embed)
+{
+ const cols = db.prepare('PRAGMA table_info(gameservers)').all().map((c) => c.name);
+ if (!cols.includes('host')) db.exec(`ALTER TABLE gameservers ADD COLUMN host TEXT NOT NULL DEFAULT ''`);
+ if (!cols.includes('port')) db.exec('ALTER TABLE gameservers ADD COLUMN port INTEGER');
+ if (!cols.includes('connect_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN connect_url TEXT NOT NULL DEFAULT ''`);
+}
const insertGameserver = db.prepare(`
- INSERT INTO gameservers (name, type, query_url, address) VALUES (@name, @type, @query_url, @address)
+ INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url)
+ VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url)
`);
const updateGameserverStmt = db.prepare(`
- UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address WHERE id = @id
+ UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address,
+ host = @host, port = @port, connect_url = @connect_url
+ WHERE id = @id
`);
const setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?');
const listGameserversStmt = db.prepare('SELECT * FROM gameservers ORDER BY id');
diff --git a/src/embeds.js b/src/embeds.js
new file mode 100644
index 0000000..bca868e
--- /dev/null
+++ b/src/embeds.js
@@ -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;
+}
diff --git a/src/web/api-v1.js b/src/web/api-v1.js
index 418665d..1f0d926 100644
--- a/src/web/api-v1.js
+++ b/src/web/api-v1.js
@@ -20,12 +20,17 @@ function requireScope(request, reply, scope) {
}
/** Nur erlaubte Embed-Felder durchreichen (kein Blind-Passthrough) */
-function sanitizeEmbed(raw) {
+export function sanitizeEmbed(raw) {
if (!raw || typeof raw !== 'object') return null;
const embed = {};
for (const key of ['title', 'description', 'url', 'color', 'timestamp']) {
if (raw[key] !== undefined) embed[key] = raw[key];
}
+ if (raw.author?.name) {
+ embed.author = { name: String(raw.author.name).slice(0, 256) };
+ if (raw.author.icon_url) embed.author.icon_url = String(raw.author.icon_url);
+ if (raw.author.url) embed.author.url = String(raw.author.url);
+ }
if (raw.footer?.text) embed.footer = { text: String(raw.footer.text) };
if (raw.image?.url) embed.image = { url: String(raw.image.url) };
if (raw.thumbnail?.url) embed.thumbnail = { url: String(raw.thumbnail.url) };
diff --git a/src/web/api.js b/src/web/api.js
index 823639e..3d19441 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -12,8 +12,10 @@ import {
saveTrigger, listTriggers, deleteTrigger, activityRange,
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
+ saveTemplate, listTemplates, deleteTemplate,
} from '../db.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
+import { sanitizeEmbed } from './api-v1.js';
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js';
@@ -299,6 +301,7 @@ ${rssItems}
twitch_channel: getSetting('twitch_channel') ?? '',
twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')),
tempvoice_channel_id: getSetting('tempvoice_channel_id') ?? '',
+ server_alert_channel_id: getSetting('server_alert_channel_id') ?? '',
};
}
@@ -344,7 +347,7 @@ ${rssItems}
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_channel_id',
- 'events_announce_channel_id', 'social_announce_channel_id',
+ 'events_announce_channel_id', 'social_announce_channel_id', 'server_alert_channel_id',
];
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
if (body[key] === undefined) continue;
@@ -738,10 +741,23 @@ ${rssItems}
function parseServerBody(request, reply) {
const body = request.body ?? {};
const name = String(body.name ?? '').trim();
+ const type = String(body.type ?? 'fivem').trim().toLowerCase().replace(/[^a-z0-9]/g, '');
const queryUrl = String(body.query_url ?? '').trim().replace(/\/$/, '');
- const type = ['fivem', 'http'].includes(body.type) ? body.type : 'fivem';
- if (!name || !/^https?:\/\/.+/.test(queryUrl)) {
- reply.code(400).send({ error: 'name und query_url (http/https) nötig' });
+ const host = String(body.host ?? '').trim();
+ const port = Number(body.port) || null;
+
+ if (!name || !type) {
+ reply.code(400).send({ error: 'name und type nötig' });
+ return null;
+ }
+ if (['fivem', 'http'].includes(type)) {
+ if (!/^https?:\/\/.+/.test(queryUrl)) {
+ reply.code(400).send({ error: 'query_url (http/https) nötig' });
+ return null;
+ }
+ } else if (!host) {
+ // gamedig-Typ: Host (+ optionaler Port) statt URL
+ reply.code(400).send({ error: 'host nötig (gamedig-Query)' });
return null;
}
return {
@@ -749,6 +765,9 @@ ${rssItems}
type,
query_url: queryUrl,
address: String(body.address ?? '').trim().slice(0, 120),
+ host: host.slice(0, 120),
+ port,
+ connect_url: String(body.connect_url ?? '').trim().slice(0, 200),
};
}
@@ -885,19 +904,23 @@ ${rssItems}
app.post('/api/compose', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
- const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle } = request.body ?? {};
+ const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle, embed } = request.body ?? {};
const text = String(content ?? '').trim();
- if (!channelId || !text) {
- return reply.code(400).send({ error: 'channel_id und content nötig' });
+ const cleanEmbed = embed ? sanitizeEmbed(embed) : null;
+ if (!channelId || (!text && !cleanEmbed)) {
+ return reply.code(400).send({ error: 'channel_id und content oder embed nötig' });
}
const channel = await client.channels.fetch(String(channelId)).catch(() => null);
if (!channel?.isTextBased()) {
return reply.code(404).send({ error: 'Kanal nicht gefunden' });
}
- // Mit Embed-Titel → gebrandetes Embed, sonst Plaintext (Discord-Markdown)
- const payload = embedTitle?.trim()
- ? {
+ // Drei Modi: volles Embed (Composer v2) > Embed-Titel-Kurzform > Plaintext
+ let payload;
+ if (cleanEmbed) {
+ payload = { content: text ? text.slice(0, 2000) : null, embeds: [cleanEmbed] };
+ } else if (embedTitle?.trim()) {
+ payload = {
content: null,
embeds: [
new EmbedBuilder()
@@ -907,8 +930,10 @@ ${rssItems}
.setFooter({ text: brandName() })
.setTimestamp(),
],
- }
- : { content: text.slice(0, 2000), embeds: [] };
+ };
+ } else {
+ payload = { content: text.slice(0, 2000), embeds: [] };
+ }
if (messageId) {
const message = await channel.messages.fetch(String(messageId)).catch(() => null);
@@ -954,6 +979,33 @@ ${rssItems}
}
});
+ // --- Composer-Vorlagen (Scope: content) ---
+
+ app.get('/api/templates', async (request, reply) => {
+ if (requireScope(request, reply, 'content')) return;
+ return { templates: listTemplates() };
+ });
+
+ app.put('/api/templates/:name', async (request, reply) => {
+ if (requireScope(request, reply, 'content')) return;
+ const name = String(request.params.name).trim().slice(0, 60);
+ if (!name) return reply.code(400).send({ error: 'name nötig' });
+ const payload = {
+ content: String(request.body?.content ?? '').slice(0, 2000),
+ embed: request.body?.embed ? sanitizeEmbed(request.body.embed) : null,
+ };
+ if (!payload.content && !payload.embed) {
+ return reply.code(400).send({ error: 'Vorlage ist leer' });
+ }
+ saveTemplate(name, payload);
+ return { ok: true, templates: listTemplates() };
+ });
+
+ app.delete('/api/templates/:name', async (request, reply) => {
+ if (requireScope(request, reply, 'content')) return;
+ return { deleted: deleteTemplate(String(request.params.name)), templates: listTemplates() };
+ });
+
// --- Team-Verwaltung (nur Owner) ---
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs'];
@@ -1068,7 +1120,7 @@ ${rssItems}
.setDescription(
`Test-Nachricht für den **${{ commit: 'Commit', devlog: 'Devlog', release: 'Release' }[target]}-Kanal** — von der Settings-Seite ausgelöst.`
)
- .setFooter({ text: brandFooter('SETUP') }),
+ .setFooter({ text: brandFooter('SETUP'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
],
});
return { ok: true };
diff --git a/src/web/devlog-endpoint.js b/src/web/devlog-endpoint.js
index 60ea55f..1e4c96d 100644
--- a/src/web/devlog-endpoint.js
+++ b/src/web/devlog-endpoint.js
@@ -103,6 +103,7 @@ export function registerDevlogEndpoint(app, client) {
.setDescription(prose)
.setFooter({
text: `${brandFooter('DEVLOG')}${commitCount ? ` • ${commitCount} Commits` : ''}`,
+ iconURL: client?.user?.displayAvatarURL?.({ size: 64 }),
})
.setTimestamp();
if (project) {