303 lines
9.3 KiB
JavaScript
303 lines
9.3 KiB
JavaScript
"use strict";
|
|
|
|
const { WebSocketServer } = require("ws");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
// ── Konfiguration aus Umgebungsvariablen ────────────────────────────
|
|
const PORT = parseInt(process.env.PORT || "3000", 10);
|
|
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
|
|
const UPDATES_DIR = process.env.UPDATES_DIR || "/updates";
|
|
// Kein Polling — APK wird manuell per git pull bereitgestellt
|
|
|
|
// Erlaubte Nachrichtentypen — alles andere wird verworfen
|
|
const ALLOWED_TYPES = new Set([
|
|
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
|
|
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
|
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
|
"update_check", "update_available", "update_download", "update_data",
|
|
"agent_activity", "cancel_request",
|
|
"audio_pcm",
|
|
"xtts_delete_voice",
|
|
"voice_preload", "voice_ready",
|
|
"stt_request", "stt_response",
|
|
"service_status",
|
|
"config_request",
|
|
]);
|
|
|
|
// Token-Raum: token -> { clients: Set<ws> }
|
|
const rooms = new Map();
|
|
|
|
// ── Hilfsfunktionen ─────────────────────────────────────────────────
|
|
|
|
function timestamp() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function log(msg) {
|
|
console.log(`[${timestamp()}] ${msg}`);
|
|
}
|
|
|
|
// Leere Räume und tote Clients aufräumen
|
|
function cleanupRooms() {
|
|
for (const [token, room] of rooms) {
|
|
// Tote Clients entfernen
|
|
for (const client of room.clients) {
|
|
if (client.readyState > 1) room.clients.delete(client);
|
|
}
|
|
// Raum löschen wenn leer
|
|
if (room.clients.size === 0) {
|
|
rooms.delete(token);
|
|
log(`Leerer Raum entfernt: ${token.slice(0, 8)}...`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── WebSocket-Server starten ────────────────────────────────────────
|
|
|
|
// maxPayload 50MB: TTS-Streaming + Voice-Upload (WAV als base64) +
|
|
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
|
|
// Default-Limit war der Killer fuer die voice_upload Pipeline.
|
|
const wss = new WebSocketServer({ port: PORT, maxPayload: 50 * 1024 * 1024 });
|
|
|
|
wss.on("listening", () => {
|
|
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
|
|
// Beim Start pruefen ob eine APK da ist
|
|
const apkInfo = getLatestAPK();
|
|
if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`);
|
|
});
|
|
|
|
wss.on("connection", (ws, req) => {
|
|
// Token aus URL-Query lesen: ws://host:port/?token=abc123
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
let token = url.searchParams.get("token");
|
|
|
|
// Wenn kein Token in der URL, auf erste Nachricht warten
|
|
if (!token) {
|
|
ws.once("message", (raw) => {
|
|
try {
|
|
const msg = JSON.parse(raw);
|
|
if (msg.token) {
|
|
registerClient(ws, msg.token);
|
|
} else {
|
|
ws.close(4000, "Kein Token angegeben");
|
|
}
|
|
} catch {
|
|
ws.close(4000, "Ungültige erste Nachricht — Token erwartet");
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
registerClient(ws, token);
|
|
});
|
|
|
|
function registerClient(ws, token) {
|
|
// Maximale Anzahl aktiver Sessions prüfen
|
|
if (!rooms.has(token) && rooms.size >= MAX_SESSIONS) {
|
|
ws.close(4002, "Maximale Anzahl aktiver Sessions erreicht");
|
|
log(`Abgelehnt: Session-Limit (${MAX_SESSIONS}) erreicht`);
|
|
return;
|
|
}
|
|
|
|
// Raum erstellen oder betreten
|
|
if (!rooms.has(token)) {
|
|
rooms.set(token, { clients: new Set() });
|
|
log(`Neuer Raum: ${token.slice(0, 8)}...`);
|
|
}
|
|
|
|
const room = rooms.get(token);
|
|
room.clients.add(ws);
|
|
ws._token = token;
|
|
|
|
log(`Client verbunden: ${token.slice(0, 8)}... (${room.clients.size} im Raum)`);
|
|
|
|
// Nachrichten an alle anderen Clients im selben Raum weiterleiten
|
|
ws.on("message", (raw) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(raw);
|
|
} catch {
|
|
return; // Keine gültige JSON-Nachricht — ignorieren
|
|
}
|
|
|
|
// Nur erlaubte Nachrichtentypen durchlassen
|
|
if (!msg.type || !ALLOWED_TYPES.has(msg.type)) {
|
|
return;
|
|
}
|
|
|
|
// Update-Check: direkt an den anfragenden Client antworten (nicht relay'en)
|
|
if (msg.type === "update_check") {
|
|
const clientVersion = msg.payload?.version || "0.0.0.0";
|
|
const apkInfo = getLatestAPK();
|
|
if (apkInfo && compareVersions(apkInfo.version, clientVersion) > 0) {
|
|
ws.send(JSON.stringify({
|
|
type: "update_available",
|
|
payload: {
|
|
version: apkInfo.version,
|
|
downloadUrl: `/update/latest.apk`,
|
|
size: fs.statSync(apkInfo.path).size,
|
|
},
|
|
timestamp: Date.now(),
|
|
}));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Update-Download: APK als Base64 ueber WebSocket senden
|
|
if (msg.type === "update_download") {
|
|
const apkInfo = getLatestAPK();
|
|
if (!apkInfo) {
|
|
ws.send(JSON.stringify({ type: "update_data", payload: { error: "Keine APK verfuegbar" }, timestamp: Date.now() }));
|
|
return;
|
|
}
|
|
try {
|
|
const data = fs.readFileSync(apkInfo.path);
|
|
const base64 = data.toString("base64");
|
|
const sizeMB = (data.length / 1024 / 1024).toFixed(1);
|
|
log(`APK sende: v${apkInfo.version} (${sizeMB}MB) an Client`);
|
|
ws.send(JSON.stringify({
|
|
type: "update_data",
|
|
payload: {
|
|
version: apkInfo.version,
|
|
base64,
|
|
size: data.length,
|
|
fileName: `ARIA-v${apkInfo.version}.apk`,
|
|
},
|
|
timestamp: Date.now(),
|
|
}));
|
|
} catch (err) {
|
|
ws.send(JSON.stringify({ type: "update_data", payload: { error: err.message }, timestamp: Date.now() }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// An alle anderen Clients im Raum weiterleiten
|
|
for (const client of room.clients) {
|
|
if (client !== ws && client.readyState === 1) {
|
|
client.send(raw.toString());
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on("close", () => {
|
|
room.clients.delete(ws);
|
|
log(`Client getrennt: ${token.slice(0, 8)}... (${room.clients.size} verbleibend)`);
|
|
if (room.clients.size === 0) {
|
|
rooms.delete(token);
|
|
log(`Raum geschlossen: ${token.slice(0, 8)}...`);
|
|
}
|
|
});
|
|
|
|
ws.on("error", (err) => {
|
|
log(`Fehler: ${err.message}`);
|
|
room.clients.delete(ws);
|
|
});
|
|
}
|
|
|
|
// ── Heartbeat — hält Verbindungen am Leben, räumt tote auf ──────────
|
|
|
|
const HEARTBEAT_INTERVAL = 15_000;
|
|
|
|
const heartbeat = setInterval(() => {
|
|
for (const client of wss.clients) {
|
|
if (client.isAlive === false) {
|
|
log(`Toter Client entfernt (kein Pong)`);
|
|
client.terminate();
|
|
continue;
|
|
}
|
|
client.isAlive = false;
|
|
client.ping();
|
|
}
|
|
}, HEARTBEAT_INTERVAL);
|
|
|
|
wss.on("connection", (ws) => {
|
|
ws.isAlive = true;
|
|
ws.on("pong", () => { ws.isAlive = true; });
|
|
// App-seitiger Heartbeat (JSON) zaehlt auch als lebendig
|
|
const origOnMessage = ws._events?.message;
|
|
ws.on("message", (raw) => {
|
|
try {
|
|
const msg = JSON.parse(raw);
|
|
if (msg.type === "heartbeat") ws.isAlive = true;
|
|
} catch {}
|
|
});
|
|
});
|
|
|
|
// Aufräumen alle 30 Sekunden (statt 60)
|
|
const cleanup = setInterval(cleanupRooms, 30_000);
|
|
|
|
wss.on("close", () => {
|
|
clearInterval(heartbeat);
|
|
clearInterval(cleanup);
|
|
});
|
|
|
|
// ── Auto-Update: APK-Erkennung + Push ──────────────────────────────
|
|
|
|
let latestVersion = null;
|
|
|
|
function getLatestAPK() {
|
|
try {
|
|
if (!fs.existsSync(UPDATES_DIR)) return null;
|
|
const files = fs.readdirSync(UPDATES_DIR)
|
|
.filter(f => f.endsWith(".apk"))
|
|
.map(f => {
|
|
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
|
|
const match = f.match(/(\d+\.\d+\.\d+[\.\d]*)/);
|
|
return { file: f, path: path.join(UPDATES_DIR, f), version: match ? match[1] : null };
|
|
})
|
|
.filter(f => f.version)
|
|
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst
|
|
|
|
return files[0] || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function compareVersions(a, b) {
|
|
const pa = a.split(".").map(Number);
|
|
const pb = b.split(".").map(Number);
|
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
if (diff !== 0) return diff;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function notifyClientsAboutUpdate(apkInfo) {
|
|
const msg = JSON.stringify({
|
|
type: "update_available",
|
|
payload: {
|
|
version: apkInfo.version,
|
|
downloadUrl: `/update/latest.apk`,
|
|
size: fs.statSync(apkInfo.path).size,
|
|
},
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// An alle Clients in allen Rooms senden
|
|
for (const [, room] of rooms) {
|
|
for (const client of room.clients) {
|
|
if (client.readyState === 1) {
|
|
client.send(msg);
|
|
}
|
|
}
|
|
}
|
|
log(`Update-Benachrichtigung gesendet: v${apkInfo.version} (${rooms.size} Raum/Raeume)`);
|
|
}
|
|
|
|
// Kein Polling — Update-Check passiert on-demand (update_check Message von App)
|
|
|
|
// ── Sauberes Herunterfahren ─────────────────────────────────────────
|
|
|
|
process.on("SIGTERM", () => {
|
|
log("SIGTERM empfangen — fahre herunter");
|
|
wss.close(() => process.exit(0));
|
|
});
|
|
|
|
process.on("SIGINT", () => {
|
|
log("SIGINT empfangen — fahre herunter");
|
|
wss.close(() => process.exit(0));
|
|
});
|