From eec170ff41759b08b335dac0581b71c296c985cc Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sat, 8 Aug 2026 22:52:08 +0200 Subject: [PATCH] Connect-Links im Discord anklickbar, und Mods bekommen einen eigenen Knopf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Im Kanal stand woertlich das da: [https://cfx.re/join/4odv68](https://cfx.re/join/4odv68) Der Link war sein eigener Linktext. Discord macht aus der nackten URL zwischen den eckigen Klammern zuerst selbst einen Link und kommt mit der Masked-Link-Schreibweise dann nicht mehr durch — heraus kommen die Klammern im Klartext. Der Kommentar an der Stelle behauptete ausserdem, so seien steam:// und Konsorten klickbar. Waren sie nie. Jetzt sind es Link-Knoepfe unter dem Embed. Die Frage nach der Schreibweise stellt sich damit gar nicht mehr, und ein Knopf sieht auch nach einem aus. Discord nimmt dort nur http(s) an; fivem:// und steam:// stehen deshalb weiter im Embed, aber als Codeblock zum Kopieren statt als toter Link. Dazu ein eigenes Feld fuer den Mod-Download. Vorher landete der beim Connect-Link, weil es kein anderes gab — und dann fuehrte "Verbinden" zu einer ZIP-Datei. Farming Simulator liefert die Modliste ohnehin mit, also steht die Anzahl jetzt auch im Embed und als Chip auf der Seite. Beim Testen aufgefallen: eine neue Spalte hat jeden Aufrufer umgebracht, der sie noch nicht kannte — better-sqlite3 wirft bei einem fehlenden benannten Parameter. createGameserver und updateGameserver fuellen die Felder jetzt einmal auf, statt das an jeder Aufrufstelle nachzuziehen. Geprueft: was in einen Knopf darf und was nicht (https, http, fivem://, steam://, leer, null, Leerzeichen, Satz mit Link drin), beide Knoepfe in einer Reihe, nur einer, gar keiner — eine leere Reihe weist Discord ab —, https landet im Knopf und nicht doppelt auch im Embed, fivem:// umgekehrt, Mods bei 47/0/null, und der Aufruf ohne die neue Spalte. Seite im laufenden Frontend angesehen. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- frontend/src/locales/de.js | 2 ++ frontend/src/locales/en.js | 2 ++ frontend/src/pages/Server.jsx | 12 ++++++-- frontend/src/pages/Settings.jsx | 10 ++++--- src/bot/server-monitor.js | 52 +++++++++++++++++++++++++++++---- src/db.js | 22 ++++++++++---- src/web/api.js | 7 +++++ 8 files changed, 91 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 96fba0e..66642ba 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | Funktion | Beschreibung | |---|---| -| 🎮 **Game-Server-Monitor** | DiscordGSM-Stil: pro Server ein Live-Embed (🟢/🔴, Spieler-Balken, Map, Version, Spieler-Liste, Connect-Link, Ping); **alle Spiele, die gamedig kennt** — die Auswahl im Panel kommt aus der Bibliothek selbst, inklusive Standard-Port und Hinweis auf nötige Zugangs-Codes (Farming Simulator, Terraria) — plus FiveM + HTTP-Check; Down/Up-Alerts mit Ausfalldauer | +| 🎮 **Game-Server-Monitor** | DiscordGSM-Stil: pro Server ein Live-Embed (🟢/🔴, Spieler-Balken, Map, Version, Mod-Anzahl, Spieler-Liste, Knöpfe für Verbinden und Mod-Download, Ping); **alle Spiele, die gamedig kennt** — die Auswahl im Panel kommt aus der Bibliothek selbst, inklusive Standard-Port und Hinweis auf nötige Zugangs-Codes (Farming Simulator, Terraria) — plus FiveM + HTTP-Check; Down/Up-Alerts mit Ausfalldauer | | 🚨 **Wächter** | Prüft eigene Dienste in einstellbarem Takt, DM-Alarm bei Ausfall + Entwarnung; speist die öffentliche Statusseite | | 💓 **Herzschlag** | Der Bot zeichnet sich selbst auf — im Dashboard steht neben der Laufzeit ein Erreichbarkeits-Balken über 7 Tage. Fehlende Stunden sind rot: dass nichts dasteht, *ist* die Aussage | | 🔊 **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 | diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index ef09a3a..c34da50 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -190,6 +190,8 @@ export default { 'server.agoDays': 'vor %s Tagen', 'server.neverOnline': 'Seit Beginn der Messung nicht erreichbar.', 'server.version': 'Version', + 'server.mods': '%s Mods', + 'server.modsDownload': 'Mods laden', // ── Galerie ─────────────────────────────────────── 'gallery.tag': '// Community', diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index 00566cc..b0c103f 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -189,6 +189,8 @@ export default { 'server.agoDays': '%s days ago', 'server.neverOnline': 'Not reachable since measuring began.', 'server.version': 'Version', + 'server.mods': '%s mods', + 'server.modsDownload': 'Get mods', // ── Gallery ─────────────────────────────────────── 'gallery.tag': '// Community', diff --git a/frontend/src/pages/Server.jsx b/frontend/src/pages/Server.jsx index ae4321c..005bb95 100644 --- a/frontend/src/pages/Server.jsx +++ b/frontend/src/pages/Server.jsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { apiGet } from '../api.js'; import { SkeletonCards } from '../components/Skeleton.jsx'; -import { IconGame, IconMapPin, IconClock, IconCheck, IconTeam, IconPackage, IconZap } from '../icons.jsx'; +import { IconGame, IconMapPin, IconClock, IconCheck, IconTeam, IconPackage, IconZap, IconDownload, IconArchive } from '../icons.jsx'; import { useT, useFmt } from '../i18n.jsx'; import UptimeTrack from '../components/UptimeTrack.jsx'; @@ -130,6 +130,9 @@ function ServerCard({ s }) {
{s.map && {s.map}} {s.version && {s.version}} + {/* Eigenes Zeichen — mit dem Paket-Icon der Version daneben + sähen die beiden Chips wie zusammengehörig aus. */} + {s.mods > 0 && {t('server.mods', s.mods)}} {s.ping != null && {s.ping} ms}
)} @@ -177,13 +180,18 @@ function ServerCard({ s }) { - {(s.connect_url || s.address) && ( + {(s.connect_url || s.mods_url || s.address) && (
{s.connect_url && ( {t('server.connect')} )} + {s.mods_url && ( + + {t('server.modsDownload')} + + )} {s.address && (
@@ -3396,11 +3396,13 @@ export default function Settings({ me }) { )}
- +
- setServerDraft({ ...serverDraft, connect_url: e.target.value })} /> + setServerDraft({ ...serverDraft, connect_url: e.target.value })} /> setServerDraft({ ...serverDraft, address: e.target.value })} />
+ + setServerDraft({ ...serverDraft, mods_url: e.target.value })} /> setServerDraft({ ...serverDraft, image_url: e.target.value })} />
diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js index ecba9af..059ab5a 100644 --- a/src/bot/server-monitor.js +++ b/src/bot/server-monitor.js @@ -1,7 +1,7 @@ // 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 { ActionRowBuilder, ActivityType, ButtonBuilder, ButtonStyle, EmbedBuilder, Events } from 'discord.js'; import { GameDig, games as gamedigGames } from 'gamedig'; import { getSetting, listGameservers, setGameserverMessage, recordPlayerSample, prunePlayerHistory } from '../db.js'; import { brandFooter, botStatusText } from '../runtime-settings.js'; @@ -75,7 +75,7 @@ export const lastResults = new Map(); // id → Query-Ergebnis + checkedAt export async function queryServer(server) { const base = { ...server, online: false, players: null, max: null, map: null, - playerNames: [], ping: null, version: null, + playerNames: [], ping: null, version: null, mods: null, }; const started = Date.now(); try { @@ -118,6 +118,9 @@ export async function queryServer(server) { max: state.maxplayers ?? null, map: state.map || null, version: state.version || null, + // Nur Farming Simulator liefert die Modliste mit; anderswo bleibt + // das Feld leer statt auf 0 zu stehen und eine Aussage zu machen. + mods: Array.isArray(state.raw?.mods) ? state.raw.mods.length : null, playerNames: (state.players ?? []).map((p) => p?.name).filter(Boolean).slice(0, 15), }; } catch { @@ -125,6 +128,38 @@ export async function queryServer(server) { } } +/** Nur http(s) darf in einen Link-Knopf — Discord weist alles andere ab */ +export function istWebLink(url) { + return /^https?:\/\/\S+$/i.test(String(url ?? '').trim()); +} + +/** + * Die klickbaren Links unter dem Embed. + * + * Vorher stand der Connect-Link als `[url](url)` im Embed-Feld. Discord macht + * aus der nackten URL zwischen den Klammern selbst einen Link und kommt mit + * der Masked-Link-Schreibweise dann nicht mehr durch — im Kanal standen die + * eckigen Klammern wörtlich da. Ein Knopf lässt diese Frage gar nicht erst + * aufkommen und ist obendrein als Knopf erkennbar. + */ +export function serverKnoepfe(r) { + const knoepfe = [ + ['🎮 Verbinden', r.connect_url], + ['📦 Mods laden', r.mods_url], + ] + .filter(([, url]) => istWebLink(url)) + .map(([label, url]) => new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel(label) + .setURL(url.trim())); + return knoepfe.length > 0 ? [new ActionRowBuilder().addComponents(knoepfe)] : []; +} + +/** Embed + Knöpfe — das, was der Monitor in den Kanal schreibt */ +export function buildServerNachricht(r) { + return { embeds: [buildServerEmbed(r)], components: serverKnoepfe(r) }; +} + /** GSM-Style-Embed für ein Query-Ergebnis */ export function buildServerEmbed(r) { const icon = GAME_ICONS[r.type] ?? '🎮'; @@ -152,9 +187,14 @@ export function buildServerEmbed(r) { } if (r.map) embed.addFields({ name: 'Map', value: r.map, inline: true }); if (r.version) embed.addFields({ name: 'Version', value: String(r.version).slice(0, 60), 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.mods != null) embed.addFields({ name: 'Mods', value: String(r.mods), inline: true }); + // Connect und Mods stehen als Knopf darunter, wenn es https ist. Alles + // andere (fivem://, steam://) macht Discord nicht klickbar — dann lieber + // als Codeblock zum Kopieren, statt einen toten Link vorzugaukeln. + for (const [titel, url] of [['Connect', r.connect_url], ['Mods', r.mods_url]]) { + if (url && !istWebLink(url)) { + embed.addFields({ name: titel, value: `\`\`\`\n${url}\n\`\`\`` }); + } } if (r.address) embed.addFields({ name: 'Adresse', value: `\`\`\`\n${r.address}\n\`\`\`` }); if (r.online && r.playerNames.length > 0) { @@ -241,7 +281,7 @@ export async function monitorTick(client) { if (!channel?.isTextBased()) return; for (const r of results) { - const payload = { embeds: [buildServerEmbed(r)] }; + const payload = buildServerNachricht(r); try { if (r.message_id) { const existing = await channel.messages.fetch(r.message_id).catch(() => null); diff --git a/src/db.js b/src/db.js index 66ae9ed..5bcbc14 100644 --- a/src/db.js +++ b/src/db.js @@ -924,6 +924,9 @@ db.exec(` if (!cols.includes('image_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`); // Zugangs-Code für Spiele, die ohne nicht antworten (Farming Simulator, Terraria) if (!cols.includes('token')) db.exec(`ALTER TABLE gameservers ADD COLUMN token TEXT NOT NULL DEFAULT ''`); + // Eigener Link für den Mod-Download. Vorher landete der beim Connect-Link, + // weil es kein anderes Feld gab — und dann führte "Connect" zu einer ZIP. + if (!cols.includes('mods_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN mods_url TEXT NOT NULL DEFAULT ''`); } // Die Auswahl im Panel bot mal Spiel-IDs an, die es bei gamedig gar nicht gibt. // Wer eine davon gewählt hat, hatte einen Server, der dauerhaft offline stand, @@ -937,20 +940,29 @@ db.exec(` for (const [alt, neu] of Object.entries(echteIds)) um.run(neu, alt); } const insertGameserver = db.prepare(` - INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url, token) - VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url, @token) + INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url, token, mods_url) + VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url, @token, @mods_url) `); const updateGameserverStmt = db.prepare(` UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address, - host = @host, port = @port, connect_url = @connect_url, image_url = @image_url, token = @token + host = @host, port = @port, connect_url = @connect_url, image_url = @image_url, token = @token, + mods_url = @mods_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'); const getGameserverStmt = db.prepare('SELECT * FROM gameservers WHERE id = ?'); const deleteGameserverStmt = db.prepare('DELETE FROM gameservers WHERE id = ?'); -export const createGameserver = (s) => insertGameserver.run(s).lastInsertRowid; -export const updateGameserver = (s) => updateGameserverStmt.run(s).changes > 0; +// Jede neue Spalte bricht sonst jeden Aufrufer, der sie noch nicht kennt — +// better-sqlite3 wirft bei einem fehlenden benannten Parameter. Hier einmal +// auffüllen ist billiger, als das an jeder Aufrufstelle nachzuziehen. +const gameserverFelder = { + name: '', type: 'fivem', query_url: '', address: '', host: '', port: null, + connect_url: '', image_url: '', token: '', mods_url: '', +}; +const vollstaendig = (s) => ({ ...gameserverFelder, ...s }); +export const createGameserver = (s) => insertGameserver.run(vollstaendig(s)).lastInsertRowid; +export const updateGameserver = (s) => updateGameserverStmt.run(vollstaendig(s)).changes > 0; export const setGameserverMessage = (id, messageId) => setGameserverMessageStmt.run(messageId, id); export const listGameservers = () => listGameserversStmt.all(); export const getGameserver = (id) => getGameserverStmt.get(id) ?? null; diff --git a/src/web/api.js b/src/web/api.js index cbba81c..b4d09b8 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -225,11 +225,13 @@ export function registerApiRoutes(app, client) { image_url: s.image_url || null, address: s.address || null, connect_url: s.connect_url || null, + mods_url: s.mods_url || null, online: r?.online ?? null, // null = noch nie gecheckt players: r?.players ?? null, max: r?.max ?? null, map: r?.map ?? null, version: r?.version ?? null, + mods: r?.mods ?? null, ping: r?.ping ?? null, // Namen im Spiel, so wie sie im Discord-Embed auch stehen spielerNamen: r?.online ? (r.playerNames ?? []) : [], @@ -1576,6 +1578,11 @@ ${rssItems} ? String(body.image_url).trim().slice(0, 300) : '', token: String(body.token ?? '').trim().slice(0, 200), + // Nur http(s) — daraus wird ein Link-Knopf, und Discord weist + // alles andere ab. Unbrauchbares lieber hier verwerfen. + mods_url: /^https?:\/\/.+/i.test(String(body.mods_url ?? '').trim()) + ? String(body.mods_url).trim().slice(0, 300) + : '', }; }