chahinebrini 685782b538 fix(coach): dynamische Sprache (Text-Detection + App-Locale-Fallback)
LLM-Prompt (message.post + sos-stream):
- LANG_INSTRUCTIONS Map raus, ersetzt durch dynamische Instruktion
  'Reply in {detectedFromUser} ... fallback: {appLang}'
- Lyra matcht jetzt die Sprache der letzten User-Message (per
  detectLang Unicode-Detection); App-Locale ist nur noch Fallback
- Instruktion doppelt eingehängt (Anfang + Ende des System-Prompts)
  gegen recency bias bei langen deutschen Prompts

TTS (speak dispatcher + speak-cartesia + speak-elevenlabs):
- Kein 'de'-Default mehr für language. detectLang(text, locale) leitet
  Sprache primär aus dem Antwort-Text ab (Arabic/Cyrillic/CJK/Turkish-
  Letters), Locale als Fallback
- Cartesia + ElevenLabs: language/language_code nur senden wenn
  ableitbar, sonst Provider auto-detect statt erzwungenem 'de'
- speak-cartesia: sonic-2 → sonic-3 (Multi-Lang, war beim Dispatcher-
  Fix gestern vergessen worden)
- Google: en-US neutraler Fallback statt de-DE-Bias

Neu: server/utils/detect-lang.ts
2026-05-31 00:12:40 +02:00

135 lines
6.3 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
/// Real-Check ob ein iPhone (per UDID) tatsächlich beim NanoMDM enrolled ist
/// und wann es zuletzt Commands acked hat. Implementiert via SSH+psql gegen
/// den rebreak-mdm-Server (Phase 1 / lokal-only).
///
/// Production-Pfad wäre ein dedizierter `mdm.rebreak.org/status/<udid>` REST-Endpoint
/// solange den NanoMDM nicht hat, ist SSH der pragmatische Weg.
struct EnrollmentStatus: Equatable {
/// True wenn UDID in NanoMDM-`devices`-Tabelle vorhanden.
var isEnrolled: Bool
/// Wann das iPhone zuletzt sein Auth-Token erneuert hat ( enrollment-time
/// oder letzter MDM-handshake).
var tokenUpdateAt: Date?
/// Wann iPhone zuletzt einen Command Acknowledged hat. nil = nie.
var lastAckAt: Date?
/// Wieviele Commands aktuell in der Queue stecken + active sind.
var pendingCommandCount: Int
/// Heuristik: "frisch" wenn MDM-Channel kürzlich aktiv war.
/// Entweder iPhone hat kürzlich Command acked, ODER hat kürzlich
/// sein Auth-Token erneuert (= frisch enrolled). Letzteres deckt
/// den Fall ab dass nach Re-Enroll noch keine Commands gepusht wurden.
var isFresh: Bool {
let now = Date()
if let ack = lastAckAt, now.timeIntervalSince(ack) < 30 * 60 { return true }
if let tok = tokenUpdateAt, now.timeIntervalSince(tok) < 30 * 60 { return true }
return false
}
}
enum MDMStatusError: Error, LocalizedError {
case sshFailed(String)
case parseError(String)
var errorDescription: String? {
switch self {
case .sshFailed(let msg): return "SSH zu rebreak-mdm fehlgeschlagen: \(msg)"
case .parseError(let msg): return "DB-Output-Parse-Error: \(msg)"
}
}
}
enum MDMStatus {
/// SSH-Host-Alias aus ~/.ssh/config. Vorbedingung: `ssh rebreak-mdm`
/// funktioniert ohne Passwort-Prompt.
static let sshHost = "rebreak-mdm"
/// Spawnt `ssh rebreak-mdm 'psql ... -c "<query>"'` und parsed die Antwort.
/// Output-Format: tab-separated, eine Zeile pro Row.
static func query(udid: String) async throws -> EnrollmentStatus {
// udid ist user-controlled (kommt aus libimobiledevice-Output).
// Apple-UDIDs sind aber strikt hex+dash wir validieren defensiv.
guard udid.range(of: "^[A-Fa-f0-9-]{20,50}$", options: .regularExpression) != nil else {
throw MDMStatusError.parseError("UDID hat unerwartetes Format: \(udid)")
}
// Date-Format mit space-separator (kein 'T'), weil 'T' in einfachen
// Anführungszeichen über doppelte-Anführungszeichen-shell quoting bricht.
let sql = """
SELECT \
(SELECT count(*) FROM devices WHERE id='\(udid)') AS enrolled, \
(SELECT to_char(token_update_at, 'YYYY-MM-DD HH24:MI:SS') FROM devices WHERE id='\(udid)') AS token_update_at, \
(SELECT to_char(max(updated_at), 'YYYY-MM-DD HH24:MI:SS') FROM command_results WHERE id='\(udid)') AS last_ack, \
(SELECT count(*) FROM enrollment_queue WHERE id='\(udid)' AND active=true) AS pending
"""
let remoteCmd = #"source /opt/nanomdm/.env; PGPASSWORD=$NANOMDM_DB_PASS psql -h 127.0.0.1 -U nanomdm -d nanomdm -A -F$'\t' -t -c "\#(sql)""#
let result = try await ProcessRunner.run(
"/usr/bin/ssh",
arguments: [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
sshHost,
remoteCmd,
]
)
if result.exitCode != 0 {
throw MDMStatusError.sshFailed(result.stderr.isEmpty ? result.stdout : result.stderr)
}
let line = result.stdout.split(separator: "\n").first.map(String.init) ?? ""
let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
guard fields.count >= 4 else {
throw MDMStatusError.parseError("Erwartete 4 Felder, bekam: \(fields.count) — Output: \(result.stdout)")
}
let isEnrolled = (Int(fields[0]) ?? 0) > 0
let tokenUpdateAt = parseTimestamp(fields[1])
let lastAckAt = parseTimestamp(fields[2])
let pending = Int(fields[3].trimmingCharacters(in: .whitespaces)) ?? 0
return EnrollmentStatus(
isEnrolled: isEnrolled,
tokenUpdateAt: tokenUpdateAt,
lastAckAt: lastAckAt,
pendingCommandCount: pending
)
}
/// Liest das `result`-Field eines spezifischen Commands aus command_results.
/// Returnt nil wenn iPhone noch nicht ge-acked hat oder command-uuid nicht
/// existiert. Result ist Apple-Plist-XML (response payload des iPhones).
static func readCommandResult(udid: String, commandUUID: String) async throws -> String? {
guard udid.range(of: "^[A-Fa-f0-9-]{20,50}$", options: .regularExpression) != nil else {
throw MDMStatusError.parseError("UDID-Format: \(udid)")
}
guard commandUUID.range(of: "^[A-Fa-f0-9-]{20,50}$", options: .regularExpression) != nil else {
throw MDMStatusError.parseError("CommandUUID-Format: \(commandUUID)")
}
let sql = "SELECT result FROM command_results WHERE id='\(udid)' AND command_uuid='\(commandUUID)' AND status='Acknowledged' LIMIT 1"
let remoteCmd = #"source /opt/nanomdm/.env; PGPASSWORD=$NANOMDM_DB_PASS psql -h 127.0.0.1 -U nanomdm -d nanomdm -A -t -c "\#(sql)""#
let result = try await ProcessRunner.run(
"/usr/bin/ssh",
arguments: ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", sshHost, remoteCmd]
)
if result.exitCode != 0 {
throw MDMStatusError.sshFailed(result.stderr)
}
let trimmed = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func parseTimestamp(_ s: String) -> Date? {
let trimmed = s.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { return nil }
let fb = DateFormatter()
// psql to_char(... 'YYYY-MM-DD HH24:MI:SS') liefert "2026-05-27 05:52:57"
// (UTC, ohne offset suffix wir wissen aber dass postgres UTC liefert)
fb.dateFormat = "yyyy-MM-dd HH:mm:ss"
fb.timeZone = TimeZone(secondsFromGMT: 0)
fb.locale = Locale(identifier: "en_US_POSIX")
return fb.date(from: trimmed)
}
}