Modliste: melden was sich geaendert hat, einzeln laden
Bisher gab es fuer Mods nur einen Link auf das Sammelpaket des Hosters — vier Gigabyte, auch wenn sich zwei Dateien geaendert haben. Jetzt liest der Bot die Modliste aus der Statusabfrage, die er ohnehin holt, und meldet im Kanal, was neu, aktualisiert oder entfernt wurde. Beim ersten Durchlauf bleibt es still, sonst kaeme eine Meldung ueber 110 "neue" Mods. Geaendert heisst: andere Version oder anderer Hash. Beides einzeln reicht nicht — manche Modder bessern nach, ohne die Version zu erhoehen. Dazu die Seite /mods/<server>: jeder Mod mit Version und Groesse, Suche ueber Titel, Dateiname und Autor, und ein Download je Zeile. Der laeuft durch den Bot, damit der Zugangs-Code des Spielservers nicht in einem Link landet — mit dem Code liesse sich auch der ganze Spielstand lesen. Angefragt wird nur, was wirklich in der Modliste steht; ueber den Dateinamen kommt man an nichts anderes heran. Groessen lernt der Bot beim Weiterleiten. Der Spielserver beantwortet kein HEAD (501) und ignoriert Range, es gibt also keinen billigen Weg, sie vorher zu erfahren — und 4 GB nur fuers Anzeigen zu holen waere keiner. Nebenbei gefunden und behoben: 'server.mods' und 'commands.tag' gab es je zweimal in den Sprachdateien. Der spaetere Eintrag gewinnt still, deshalb stand auf dem neuen Knopf woertlich "%s Mods (110)" und auf der Befehlsseite die Beschreibung von /tag statt der Kopfzeile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+149
-4
@@ -12,10 +12,10 @@
|
||||
import { AttachmentBuilder, Events } from 'discord.js';
|
||||
import sharp from 'sharp';
|
||||
import { brandEmbed } from '../embeds.js';
|
||||
import { brandFooter } from '../runtime-settings.js';
|
||||
import { brandFooter, hubUrl } from '../runtime-settings.js';
|
||||
import { moduleEnabled } from '../modules.js';
|
||||
import { tuning, tuningMs, everyTuned } from '../tuning.js';
|
||||
import { getSetting, setSetting, listGameservers, lsState, setLsMessage, setLsTageszeit, setLsHofMessages } from '../db.js';
|
||||
import { getSetting, setSetting, listGameservers, lsState, setLsMessage, setLsTageszeit, setLsHofMessages, setLsMods } from '../db.js';
|
||||
|
||||
/** Alle Attribute eines XML-Tags als Objekt */
|
||||
const attrsVon = (roh) =>
|
||||
@@ -111,6 +111,131 @@ export function hofUebersicht(parzellen) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mods ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Die Modliste aus der Statusabfrage.
|
||||
*
|
||||
* Steht ganz oben im XML, noch vor den Parzellen. Je Mod gibt es `name`
|
||||
* (= Dateiname ohne .zip), `author`, `version` und `hash` — GIANTS' eigenen
|
||||
* Fingerabdruck. Was der genau ist, weiss ich nicht: gemessen ist er weder
|
||||
* die MD5 der ausgelieferten Zip noch die der modDesc.xml. Für den Abgleich
|
||||
* reicht, dass er sich mit dem Mod ändert.
|
||||
*/
|
||||
export function modsLesen(xml) {
|
||||
const block = String(xml ?? '').match(/<Mods>([\s\S]*?)<\/Mods>/);
|
||||
if (!block) return [];
|
||||
return [...block[1].matchAll(/<Mod ([^>]*)>([^<]*)<\/Mod>/g)].map(([, kopf, titel]) => {
|
||||
const a = attrsVon(kopf);
|
||||
return {
|
||||
name: a.name ?? '',
|
||||
titel: entschaerft(titel.trim()) || a.name || '?',
|
||||
autor: entschaerft(a.author ?? ''),
|
||||
version: a.version ?? '',
|
||||
hash: a.hash ?? '',
|
||||
};
|
||||
}).filter((m) => m.name);
|
||||
}
|
||||
|
||||
/** &#93; und Konsorten aus dem Feed zurückübersetzen */
|
||||
function entschaerft(text) {
|
||||
return String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
||||
.replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Was hat sich an der Modliste geändert?
|
||||
*
|
||||
* Beim ersten Durchlauf gibt es nichts zu melden — sonst käme beim Botstart
|
||||
* eine Meldung über 110 „neue" Mods.
|
||||
*
|
||||
* Geändert heisst: andere Version **oder** anderer Hash. Der Hash allein
|
||||
* reicht nicht, weil manche Modder nachbessern, ohne die Version zu erhöhen —
|
||||
* und die Version allein reicht auch nicht, aus demselben Grund.
|
||||
*/
|
||||
export function modsDiff(vorher, jetzt) {
|
||||
if (!Array.isArray(vorher) || vorher.length === 0) return null;
|
||||
const alt = new Map(vorher.map((m) => [m.name, m]));
|
||||
const neu = new Map(jetzt.map((m) => [m.name, m]));
|
||||
const geaendert = [];
|
||||
for (const m of jetzt) {
|
||||
const v = alt.get(m.name);
|
||||
if (v && (v.version !== m.version || v.hash !== m.hash)) {
|
||||
geaendert.push({ ...m, vorher: v.version });
|
||||
}
|
||||
}
|
||||
const diff = {
|
||||
neu: jetzt.filter((m) => !alt.has(m.name)),
|
||||
geaendert,
|
||||
entfernt: vorher.filter((m) => !neu.has(m.name)),
|
||||
};
|
||||
return diff.neu.length + diff.geaendert.length + diff.entfernt.length > 0 ? diff : null;
|
||||
}
|
||||
|
||||
/** Eine Zeile je Mod, aber nie mehr als in ein Discord-Feld passt */
|
||||
function modZeilen(mods, zeile, grenze = 1000) {
|
||||
const alle = mods.map(zeile);
|
||||
const raus = [];
|
||||
let laenge = 0;
|
||||
for (const z of alle) {
|
||||
// Der Hinweis auf den Rest muss selbst noch hineinpassen
|
||||
if (laenge + z.length + 1 > grenze - 30) {
|
||||
raus.push(`… und ${alle.length - raus.length} weitere`);
|
||||
break;
|
||||
}
|
||||
raus.push(z);
|
||||
laenge += z.length + 1;
|
||||
}
|
||||
return raus.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Was sich an den Mods getan hat — als Meldung im Kanal.
|
||||
*
|
||||
* Bewusst nur die Änderung und nicht die ganze Liste: wer alle 110 sehen
|
||||
* will, geht auf die Seite. Hier steht, was man nachladen muss.
|
||||
*/
|
||||
export function modsEmbed(client, diff, server) {
|
||||
const seite = `${hubUrl()}/mods/${server.id}`;
|
||||
const teile = [];
|
||||
if (diff.neu.length) teile.push(`${diff.neu.length} neu`);
|
||||
if (diff.geaendert.length) teile.push(`${diff.geaendert.length} aktualisiert`);
|
||||
if (diff.entfernt.length) teile.push(`${diff.entfernt.length} entfernt`);
|
||||
|
||||
const embed = brandEmbed(client, 'MOD')
|
||||
.setTitle('🧩 Modliste geändert')
|
||||
.setURL(seite)
|
||||
.setDescription(`**${server.name}** — ${teile.join(', ')}.\nAlle Mods und Downloads: [auf der Seite](${seite})`)
|
||||
.setFooter({ text: brandFooter('MOD') })
|
||||
.setTimestamp();
|
||||
|
||||
if (diff.geaendert.length) {
|
||||
embed.addFields({
|
||||
name: `⬆️ Aktualisiert (${diff.geaendert.length})`,
|
||||
// Gleiche Version, anderer Hash: der Modder hat nachgebessert,
|
||||
// ohne die Version zu erhöhen. „1.3.0.0 → 1.3.0.0" wäre Unsinn.
|
||||
value: modZeilen(diff.geaendert, (m) => (m.vorher === m.version
|
||||
? `**${m.titel}** ${m.version} · neu gepackt`
|
||||
: `**${m.titel}** ${m.vorher} → ${m.version}`)),
|
||||
});
|
||||
}
|
||||
if (diff.neu.length) {
|
||||
embed.addFields({
|
||||
name: `➕ Neu (${diff.neu.length})`,
|
||||
value: modZeilen(diff.neu, (m) => `**${m.titel}** ${m.version}`),
|
||||
});
|
||||
}
|
||||
if (diff.entfernt.length) {
|
||||
embed.addFields({
|
||||
name: `➖ Entfernt (${diff.entfernt.length})`,
|
||||
value: modZeilen(diff.entfernt, (m) => `~~${m.titel}~~`),
|
||||
});
|
||||
}
|
||||
return embed;
|
||||
}
|
||||
|
||||
// ── Fuhrpark ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -621,6 +746,17 @@ export function feedBasis(server) {
|
||||
return `http://${server.host}:${port}/feed`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wo die Mod-Dateien liegen.
|
||||
*
|
||||
* Nicht unter `/feed` — die Zips hängen eine Ebene höher direkt am Webserver
|
||||
* des Spiels: `http://host:port/mods/<name>.zip?code=…`. Deshalb wird das
|
||||
* `/feed` vom Ende abgeschnitten statt eine zweite Einstellung zu bauen.
|
||||
*/
|
||||
export function modBasis(server) {
|
||||
return feedBasis(server).replace(/\/feed\/?$/i, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist das ein LS-Server, den wir abfragen können?
|
||||
*
|
||||
@@ -651,6 +787,7 @@ export async function lsAbfragen(server, { timeoutMs = 15000, kartenBreite = 102
|
||||
const code = encodeURIComponent(server.token);
|
||||
const stats = await holen(`${basis}/dedicated-server-stats.xml?code=${code}`, timeoutMs);
|
||||
const stand = statsLesen(stats);
|
||||
const mods = modsLesen(stats);
|
||||
|
||||
// Karte, Preise und Fuhrpark sind Beiwerk: fehlt eins, bleibt der Rest
|
||||
// brauchbar. Deshalb einzeln abgefangen statt einmal um alles herum.
|
||||
@@ -665,7 +802,7 @@ export async function lsAbfragen(server, { timeoutMs = 15000, kartenBreite = 102
|
||||
holen(`${basis}/dedicated-server-savegame.html?file=vehicles&code=${code}`, timeoutMs)
|
||||
.then(fahrzeugeLesen).catch(() => null),
|
||||
]);
|
||||
return { stand, bild, preise, fahrzeuge };
|
||||
return { stand, mods, bild, preise, fahrzeuge };
|
||||
}
|
||||
|
||||
|
||||
@@ -706,7 +843,7 @@ export async function lsTick(client) {
|
||||
|
||||
for (const s of server) {
|
||||
try {
|
||||
const { stand, bild, preise, fahrzeuge } = await lsAbfragen(s, {
|
||||
const { stand, mods, bild, preise, fahrzeuge } = await lsAbfragen(s, {
|
||||
timeoutMs: tuningMs.seconds('monitor_timeout'),
|
||||
kartenBreite: breite,
|
||||
});
|
||||
@@ -726,8 +863,16 @@ export async function lsTick(client) {
|
||||
// bekannt", und der echte Mitternachtssprung geht verloren.
|
||||
if (stand.tageszeit != null) setLsTageszeit(s.id, stand.tageszeit);
|
||||
|
||||
// Modliste: erst vergleichen, dann merken. Die Meldung ist die
|
||||
// einzige, die noch von sich aus schreibt — sie kommt selten und
|
||||
// sagt etwas, das man wissen muss, bevor man verbindet.
|
||||
const modWechsel = mods.length > 0 ? modsDiff(alt?.mods, mods) : null;
|
||||
if (mods.length > 0) setLsMods(s.id, mods);
|
||||
|
||||
if (!kanal?.isTextBased()) continue;
|
||||
|
||||
if (modWechsel) await kanal.send({ embeds: [modsEmbed(client, modWechsel, s)] });
|
||||
|
||||
// 1) Karte und Preise
|
||||
const payload = await hofNachricht(client, stand, bild, namen, breite);
|
||||
if (preise.length > 0) {
|
||||
|
||||
@@ -1052,6 +1052,37 @@ const lsHofStmt = db.prepare(`
|
||||
ON CONFLICT(server_id) DO UPDATE SET hof_messages = excluded.hof_messages
|
||||
`);
|
||||
export const setLsHofMessages = (serverId, map) => lsHofStmt.run(serverId, JSON.stringify(map));
|
||||
// Die zuletzt gesehene Modliste — daraus entsteht die Änderungsmeldung.
|
||||
// Dazu je Mod die Dateigrösse, sobald sie einmal bekannt ist: der Server
|
||||
// beantwortet kein HEAD, also lernt der Bot sie beim ersten Weiterleiten
|
||||
// eines Downloads statt 4 GB nur fürs Anzeigen zu holen.
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(ls_state)').all().map((c) => c.name);
|
||||
if (!cols.includes('mods')) db.exec('ALTER TABLE ls_state ADD COLUMN mods TEXT');
|
||||
}
|
||||
const lsModsStmt = db.prepare(`
|
||||
INSERT INTO ls_state (server_id, mods) VALUES (?, ?)
|
||||
ON CONFLICT(server_id) DO UPDATE SET mods = excluded.mods
|
||||
`);
|
||||
export const setLsMods = (serverId, mods) => lsModsStmt.run(serverId, JSON.stringify(mods));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ls_mod_sizes (
|
||||
server_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
bytes INTEGER NOT NULL,
|
||||
gesehen TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (server_id, name)
|
||||
);
|
||||
`);
|
||||
const lsModSizeStmt = db.prepare(`
|
||||
INSERT INTO ls_mod_sizes (server_id, name, bytes, gesehen) VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(server_id, name) DO UPDATE SET bytes = excluded.bytes, gesehen = datetime('now')
|
||||
`);
|
||||
export const setLsModSize = (serverId, name, bytes) => lsModSizeStmt.run(serverId, name, bytes);
|
||||
const lsModSizesStmt = db.prepare('SELECT name, bytes FROM ls_mod_sizes WHERE server_id = ?');
|
||||
export const lsModSizes = (serverId) =>
|
||||
Object.fromEntries(lsModSizesStmt.all(serverId).map((r) => [r.name, r.bytes]));
|
||||
|
||||
const lsVergessenStmt = db.prepare('DELETE FROM ls_state WHERE server_id = ?');
|
||||
export const lsState = (serverId) => {
|
||||
const r = lsStateStmt.get(serverId);
|
||||
@@ -1063,7 +1094,12 @@ export const lsState = (serverId) => {
|
||||
const roh = JSON.parse(r.hof_messages || '{}');
|
||||
if (roh && typeof roh === 'object' && !Array.isArray(roh)) hofMessages = roh;
|
||||
} catch { /* kaputt gespeichert — dann werden die Embeds neu gepostet */ }
|
||||
return { ...r, hofMessages };
|
||||
let mods = null;
|
||||
try {
|
||||
const roh = r.mods ? JSON.parse(r.mods) : null;
|
||||
if (Array.isArray(roh)) mods = roh;
|
||||
} catch { /* kaputt gespeichert — gilt als „noch nichts bekannt" */ }
|
||||
return { ...r, hofMessages, mods };
|
||||
};
|
||||
export const setLsMessage = (serverId, messageId) => lsMessageStmt.run(serverId, messageId);
|
||||
export const forgetLsState = (serverId) => lsVergessenStmt.run(serverId).changes > 0;
|
||||
|
||||
@@ -27,12 +27,14 @@ import {
|
||||
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
|
||||
createService, updateService, listServices, deleteService,
|
||||
savePage, getPage, listPages, listPublishedPages, deletePage,
|
||||
lsState, lsModSizes, setLsModSize,
|
||||
} from '../db.js';
|
||||
import crypto from 'node:crypto';
|
||||
import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, moduleSettingKeys, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js';
|
||||
import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js';
|
||||
import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
|
||||
import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../bot/server-monitor.js';
|
||||
import { istLsServer, modBasis } from '../bot/ls-farm.js';
|
||||
import { watchdogState, watchedServices } from '../bot/watchdog.js';
|
||||
import { listRules, setRuleEnabled } from '../bot/automod.js';
|
||||
import {
|
||||
@@ -226,6 +228,9 @@ export function registerApiRoutes(app, client) {
|
||||
address: s.address || null,
|
||||
connect_url: s.connect_url || null,
|
||||
mods_url: s.mods_url || null,
|
||||
// Wie viele Mods der Bot von diesem Server kennt — nur dann
|
||||
// lohnt der Weg auf die Mod-Seite. null heisst „gibt es nicht".
|
||||
mod_liste: lsState(s.id)?.mods?.length || null,
|
||||
links: s.links ?? [],
|
||||
zugang: zugangStand(s, r ?? {}),
|
||||
// Wie der Server sich im Spiel nennt, falls anders als hier
|
||||
@@ -1646,6 +1651,73 @@ ${rssItems}
|
||||
return { servers: listGameservers() };
|
||||
});
|
||||
|
||||
// --- Modliste eines LS-Servers (öffentlich) ---
|
||||
//
|
||||
// Die Liste steht in der Statusabfrage, die der LS-Tick ohnehin holt; hier
|
||||
// kommt sie aus der Datenbank statt bei jedem Seitenaufruf vom Spielserver.
|
||||
app.get('/api/servers/:id/mods', async (request, reply) => {
|
||||
const server = getGameserver(Number(request.params.id));
|
||||
if (!server || !istLsServer(server)) return reply.code(404).send({ error: 'Kein LS-Server' });
|
||||
const stand = lsState(server.id);
|
||||
const groessen = lsModSizes(server.id);
|
||||
return {
|
||||
server: { id: server.id, name: server.name, spiel: spielName(server.type) },
|
||||
geprueft: stand?.geprueft ?? null,
|
||||
mods: (stand?.mods ?? []).map((m) => ({
|
||||
// Der Hash ist der Fingerabdruck des Spiels und hat auf einer
|
||||
// öffentlichen Seite nichts verloren — er sagt nichts, was ein
|
||||
// Besucher braucht, und lädt zum Fehlschluss ein, er sei eine
|
||||
// Prüfsumme der Datei.
|
||||
name: m.name, titel: m.titel, autor: m.autor, version: m.version,
|
||||
bytes: groessen[m.name] ?? null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Download durch den Bot hindurch. Der Zugangs-Code des Spielservers bleibt
|
||||
// damit hier und steht nicht in einem Link, den jemand weitergibt — mit dem
|
||||
// Code liesse sich sonst auch der ganze Spielstand lesen.
|
||||
app.get('/api/servers/:id/mods/:datei', async (request, reply) => {
|
||||
const server = getGameserver(Number(request.params.id));
|
||||
if (!server || !istLsServer(server)) return reply.code(404).send({ error: 'Kein LS-Server' });
|
||||
|
||||
// Nur Namen, die wirklich in der Modliste stehen. Das ist keine
|
||||
// Formatprüfung, sondern eine Positivliste: ohne sie liesse sich über
|
||||
// den Dateinamen jede beliebige Adresse des Spielservers abrufen.
|
||||
const name = String(request.params.datei ?? '').replace(/\.zip$/i, '');
|
||||
const stand = lsState(server.id);
|
||||
const mod = (stand?.mods ?? []).find((m) => m.name === name);
|
||||
if (!mod) return reply.code(404).send({ error: 'Mod nicht in der Liste' });
|
||||
|
||||
const ziel = `${modBasis(server)}/mods/${encodeURIComponent(name)}.zip`
|
||||
+ `?code=${encodeURIComponent(server.token)}`;
|
||||
let quelle;
|
||||
try {
|
||||
quelle = await fetch(ziel, { signal: AbortSignal.timeout(30_000) });
|
||||
} catch {
|
||||
return reply.code(502).send({ error: 'Spielserver antwortet nicht' });
|
||||
}
|
||||
if (!quelle.ok || !quelle.body) {
|
||||
// 403 heisst in aller Regel: gekauftes DLC, das der Server nicht
|
||||
// weitergeben darf. Das ist kein Fehler, sondern eine Auskunft.
|
||||
const code = quelle.status === 403 ? 403 : 502;
|
||||
return reply.code(code).send({
|
||||
error: code === 403 ? 'Gekaufter Inhalt — über Steam installieren' : 'Download fehlgeschlagen',
|
||||
});
|
||||
}
|
||||
|
||||
// Grösse merken: der Spielserver beantwortet kein HEAD, also ist das
|
||||
// die einzige Gelegenheit, sie ohne zusätzlichen Verkehr zu erfahren.
|
||||
const laenge = Number(quelle.headers.get('content-length'));
|
||||
if (Number.isFinite(laenge) && laenge > 0) {
|
||||
setLsModSize(server.id, name, laenge);
|
||||
reply.header('content-length', String(laenge));
|
||||
}
|
||||
reply.header('content-type', 'application/zip');
|
||||
reply.header('content-disposition', `attachment; filename="${name}.zip"`);
|
||||
return reply.send(quelle.body);
|
||||
});
|
||||
|
||||
// Alle Spiele, die gamedig abfragen kann — damit die Auswahl im Panel nicht
|
||||
// an einer von Hand gepflegten Liste hängt und jedes Update automatisch
|
||||
// neue Spiele mitbringt. Steht in der Liste auch, welcher Standard-Port
|
||||
|
||||
Reference in New Issue
Block a user