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
135 lines
6.3 KiB
Swift
135 lines
6.3 KiB
Swift
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)
|
||
}
|
||
}
|