diff --git a/README.md b/README.md index dce194a..2a11fab 100644 --- a/README.md +++ b/README.md @@ -667,3 +667,110 @@ relevant: - [IClientLineMgrPub](https://clientsdk.swyx.engineering/interface_i_client_line_mgr_pub.html) - [CLMgrPubTypes.h — PubCLMgrMessages](https://clientsdk.swyx.engineering/_c_l_mgr_pub_types_8h.html) - [NuGet: Swyx.Client.ClmgrAPI](https://www.nuget.org/packages/Swyx.Client.ClmgrAPI) + +## Gespraechsaufnahme (WebSocket-Protokoll 10) + +Aufnahmebefehle verwenden die 1-basierte `line` **und** `callId` aus dem aktuellen +Snapshot. `callId` stammt aus `IClientLineDisp.DispCallId`. Ein alter Auftrag wird +abgewiesen, wenn inzwischen ein anderer Anruf dieselbe Leitung belegt. + +```json +{"id":21,"cmd":"recording.start","line":1,"callId":4711} +{"type":"result","id":21,"ok":true,"line":1,"callId":4711,"recordingRequested":true} +{"id":22,"cmd":"recording.stop","line":1,"callId":4711} +{"type":"result","id":22,"ok":true,"line":1,"callId":4711,"recordingRequested":false} +{"type":"call","event":"ended","line":1,"callId":4711,"peer":"Test","peerNumber":"123","peerName":"Test"} +``` + +`recording.start` ruft `DispStartRecording()` auf, sobald eine Sprechverbindung +besteht. Ein wiederholter Start desselben Anrufs ist wirkungslos. Stoppen ruft +`DispStopRecording()` auf, auch bei gehaltenen Gespraechen oder wenn die Aufnahme +in SwyxIt! selbst gestartet wurde. Fehler des SDK werden als `ok:false` gemeldet. +Alle COM-Zugriffe laufen weiterhin im STA-Thread. + +`recordingRequested` in den Leitungs-Snapshots bezeichnet den letzten erfolgreich +an das SDK uebergebenen Auftrag dieser App. Das SDK stellt keinen lesbaren +Aufnahmestatus bereit; das Feld bestaetigt weder eine geschriebene Datei noch +manuelle Aenderungen in SwyxIt!. Dateiname und Speicherort bestimmt SwyxIt! +entsprechend seiner Aufnahmekonfiguration. Die SDK-Funktion setzt den normalen +Betriebsmodus und die Aufnahmeberechtigung voraus; eine Audiodatei wird nicht +ueber WebSocket uebertragen. + +SwyxWeb sendet `answer` mit der Anrufkennung, wartet auf die aktive +Sprechverbindung desselben Anrufs und sendet dann `recording.start`. Ein Fehler +beim Start aendert nichts an der erfolgreichen Anrufannahme. Auflegen, Abbruch, +Wiederverwendung der Leitung und ein Timeout verhindern den verspaeteten Start. +`call/ended` folgt weiterhin auf den Snapshot und enthaelt die Kennung und +Gegenstelle des beendeten Anrufs. Ein Ausfall von CLMgr/Server wird nicht als +nachgewiesenes Gespraechsende gemeldet. + +Pruefung ohne Telefonate oder COM-Aufrufe: + +```powershell +dotnet build src/SwyxTray/SwyxTray.csproj -p:Platform=x64 +dotnet run --project tests/SwyxTray.RecordingChecks -p:Platform=x64 +``` + +API-Grundlage: [Swyx Client SDK, IClientLineDisp](https://www.swyxdownload.com/download/kb3373_SwyxClientSDKManual.pdf), +Methodensignaturen gegen das installierte NuGet-Paket 14.21.0 geprueft. + + +## Automatische WAV-Übergabe an SwyxWeb (Protokoll 11) + +`recording.start` und `recording.stop` akzeptieren zusätzlich `recordingId` (UUID). +Snapshots und Aufnahmequittungen geben dieselbe Kennung zurück. Ein doppelter Start +behält die UUID bei; ein neuer Abschnitt erhält eine neue. Die Zuordnung bleibt bei +COM-Unterbrechungen erhalten; nur ein bestätigter Stopp oder ein gesunder Snapshot +mit beendeter/ersetzter Verbindung schließt den Abschnitt ab. +Der Aufnahmestopp allein gibt die Datei nicht zur Übertragung frei. Erst ein +gesunder Snapshot mit beendeter oder ersetzter Verbindung bestätigt das +Gesprächsende für alle Aufnahmeabschnitte dieses Gesprächs. Halten und ein +Verbindungsabbruch zählen nicht als Ende; die Sperre übersteht einen Neustart. +Ob SwyxWeb, SwyxIt!, das Telefon oder die Gegenstelle beendet hat, ist dabei +unerheblich. Auch `LSTerminated`, freigegebene/deaktivierte Leitungen und eine +neue Anrufkennung lösen die Übergabe ohne vorherigen `hangup`- oder +`recording.stop`-Befehl aus. Nach einer Unterbrechung genügt die Bestätigung +des Gesprächsendes im nächsten gesunden Snapshot. + +Die neue `RecordingOutbox` liest den Aufnahmeordner aus `IClientConfig` und merkt +beim Start die bereits vorhandenen WAV-Dateien. Sie bietet nur eindeutig zuordenbare, +neu erzeugte, geschlossene WAVs mit stabiler Länge und gültigem RIFF-Dateikopf an. +Mehrdeutige Dateien werden als Fehler gemeldet und müssen in SwyxWeb manuell +zugeordnet werden. Originaldateien bleiben unverändert. Über SwyxIt! selbst +begonnene Mitschnitte haben keine automatische Zuordnung. + +Warteschlange: `%LOCALAPPDATA%\SwyxTray\recordings` (atomare JSON-Manifeste und WAV-Kopien). +Unbestätigte Dateien überstehen Neustarts. `recording.ready` wird erst nach +bestätigtem Gesprächsende gesendet und enthält `transport: "websocket"`, +`recordingId`, `line`, `callId`, `callEnded: true`, `filename`, `bytes` und Gegenstelle. +Vorher meldet `recording.status` den Zustand `waiting_call_end`; die WAV darf lokal +vorbereitet werden, aber `recording.download` und `recording.ack` werden abgewiesen. +Es wird kein zusätzlicher HTTP-Endpunkt eingerichtet. Auf `recording.download` +mit UUID und Kommando-ID sendet die bestehende Sitzung binäre Nachrichten: +`SWYX` (4 ASCII-Bytes), Kommando-ID (Int32 LE), Offset (Int32 LE), maximal 65536 Audio-Bytes. +Am Ende folgt die normale `result`-Quittung. Telefonbefehle bleiben parallel nutzbar. + +SwyxWeb reicht die WAV an sein Backend und von dort an SwyxTrans weiter. Erst danach +bestätigt der Browser mit `recording.ack`, `recordingId` und `jobId` (32 Hexzeichen). +Die Bestätigung wird vor dem Entfernen der temporären Kopie gespeichert. Das Original +wird nie entfernt. `recording.status` zeigt `waiting_call_end`, `waiting`, `error`, `submitted`; bei letzterem +enthält `jobId` die SwyxTrans-Kennung. Beim Verbinden und auf `status` werden alle offenen +Aufnahmen und die letzten 100 bestätigten Aufträge wiederholt. SwyxWeb verhindert +Doppelverarbeitung zusätzlich durch idempotente Uploads. Der Browser muss für die +Übertragung geöffnet und verbunden sein. + +`hangup` akzeptiert zusätzlich `callId` und prüft unmittelbar vor dem SDK-Aufruf, +ob die Leitung noch genau dieses Gespräch führt. SwyxWeb deaktiviert **Auflegen** +während des Befehls und wartet auch nach dessen Quittung auf das bestätigte Ende. + +Prüfungen ohne COM/Telefonate: + +```powershell +dotnet run --project tests/SwyxTray.RecordingChecks -p:Platform=x64 +dotnet build src/SwyxTray -p:Platform=x64 +``` + +Die Checks prüfen außerdem Windows-Dateisperren, unvollständige WAVs, mehrdeutige +Zuordnung, dauerhaft gespeicherte Bestätigungen und den tatsächlichen Binärtransport +mit Verbindungsabbruch. `-- --serve` startet bei Bedarf eine lokale Browser-Testquelle +auf Port 17658 mit einer synthetischen WAV, ohne COM-Verbindung und ohne Telefonat. diff --git a/src/SwyxTray/Recording/RecordingOutbox.cs b/src/SwyxTray/Recording/RecordingOutbox.cs new file mode 100644 index 0000000..ac128c3 --- /dev/null +++ b/src/SwyxTray/Recording/RecordingOutbox.cs @@ -0,0 +1,290 @@ +using System.Buffers.Binary; +using System.Text.Json; +using System.Text.RegularExpressions; +using SwyxTray.Swyx; +using SwyxTray.Web; + +namespace SwyxTray.Recording; + +internal sealed record RecordingNotice(Guid RecordingId, int Line, int CallId, string PeerNumber, + string PeerName, string State, string? Filename = null, long? Bytes = null, + string? JobId = null, string? Error = null, bool CallEnded = false) +{ + public string Type => State == "ready" ? "recording.ready" : "recording.status"; + public string Transport => "websocket"; +} + +internal sealed record RecordingEntry(Guid Id, int Line, int CallId, string PeerNumber, string PeerName, + string Folder, DateTime Started, string[] Baseline, DateTime? Ended = null, + string State = "capturing", string? Filename = null, long Bytes = 0, + string? JobId = null, string? Error = null, string[]? SourceFiles = null, DateTime? CallEnded = null); + +/// Persistent outbox. Original Swyx files are never moved or deleted. +/// A WAV is only offered after a confirmed call end and an exclusive, stable read. +internal sealed class RecordingOutbox : IDisposable +{ + public const long MaxBytes = 500L * 1024 * 1024; + private readonly string _folder; + private readonly Func _now; + private readonly object _gate = new(); + private readonly Dictionary _entries = new(); + private readonly Dictionary _stable = new(); + private readonly CancellationTokenSource _shutdown = new(); + private Task? _worker; + public event Action? Changed; + + public RecordingOutbox(string folder, Func? now = null) + { + _folder = folder; + _now = now ?? (() => DateTime.UtcNow); + Directory.CreateDirectory(folder); + foreach (var path in Directory.EnumerateFiles(folder, "*.json")) + { + try + { + var entry = JsonSerializer.Deserialize(File.ReadAllText(path), Protocol.Json); + if (entry != null && Path.GetFileNameWithoutExtension(path) == entry.Id.ToString()) + // Older manifests only recorded the recording stop, not the call end. + _entries[entry.Id] = entry.CallEnded == null && entry.State == "ready" + ? entry with { State = "waiting_call_end" } : entry; + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { Log.Error("Aufnahme-Warteschlange konnte nicht gelesen werden: " + path, ex); } + } + } + + public void Start() => _worker ??= Task.Run(async () => + { + while (!_shutdown.IsCancellationRequested) + { + try { Process(); } + catch (Exception ex) { Log.Error("Aufnahme-Warteschlange: Verarbeitung fehlgeschlagen.", ex); } + try { await Task.Delay(2000, _shutdown.Token); } + catch (OperationCanceledException) { break; } + } + }); + + public Guid? CurrentId(int line, int callId) + { + lock (_gate) return _entries.Values.FirstOrDefault(e => e.Line == line && e.CallId == callId && e.Ended == null)?.Id; + } + + public Guid Begin(int line, int callId, string peerNumber, string peerName, string folder, Guid? id) + { + lock (_gate) + { + if (CurrentId(line, callId) is { } existing) return existing; + var key = id ?? Guid.NewGuid(); + if (key == Guid.Empty || _entries.ContainsKey(key)) + throw new InvalidOperationException("Diese Aufnahme-Kennung wurde bereits verwendet."); + string[] baseline = []; + string? error = null; + try { baseline = Wavs(folder).Select(f => f.FullName).ToArray(); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { error = "Aufnahmeordner ist nicht erreichbar: " + ex.Message; } + Put(new(key, line, callId, peerNumber, peerName, folder, _now(), baseline, Error: error)); + return key; + } + } + + public void Abort(Guid id) + { + lock (_gate) { _entries.Remove(id); File.Delete(Manifest(id)); } + } + + public void End(int line, int callId) + { + lock (_gate) + { + if (CurrentId(line, callId) is not { } id) return; + var entry = _entries[id]; + string[]? sources = null; + try + { + var found = Wavs(entry.Folder).Where(f => Candidate(entry, f)).Select(f => f.FullName).ToArray(); + if (found.Length > 0) sources = found; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { Log.Debug("Aufnahmedatei wird nach dem Stopp erneut gesucht: " + ex.Message); } + Put(entry with { Ended = _now(), SourceFiles = sources, + State = entry.Error == null ? "waiting_call_end" : "error" }); + } + } + + public void Observe(SwyxSnapshot snapshot) + { + // COM/server disconnection is not evidence of a finished call. + if (!snapshot.IsConnected || !snapshot.IsServerUp) return; + lock (_gate) + { + foreach (var entry in _entries.Values.Where(e => e.CallEnded == null && e.State != "submitted").ToArray()) + { + var line = snapshot.Lines.FirstOrDefault(l => l.Index + 1 == entry.Line); + if (line == null || (line.IsBusy && line.CallId == entry.CallId)) continue; + if (entry.Ended == null) End(entry.Line, entry.CallId); + var stopped = _entries[entry.Id]; + Put(stopped with { CallEnded = _now(), State = stopped.Error != null ? "error" + : stopped.Bytes > 0 && File.Exists(Wav(entry.Id)) ? "ready" : "waiting" }); + } + } + } + + public RecordingNotice[] Notices() + { + lock (_gate) return _entries.Values.Where(e => e.Ended != null && e.State != "submitted") + .Concat(_entries.Values.Where(e => e.State == "submitted").OrderByDescending(e => e.Started).Take(100)) + .OrderByDescending(e => e.Started).Select(Notice).ToArray(); + } + + public FileStream Open(Guid id) + { + lock (_gate) + { + if (!_entries.TryGetValue(id, out var entry) || entry.State != "ready" || entry.CallEnded == null) + throw new InvalidOperationException("Die fertige Aufnahme ist nicht verfügbar."); + return new FileStream(Wav(id), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete); + } + } + + public void Acknowledge(Guid id, string? jobId) + { + if (jobId == null || !Regex.IsMatch(jobId, "\\A[a-f0-9]{32}\\z")) + throw new InvalidOperationException("Ungültige SwyxTrans-Auftragskennung."); + lock (_gate) + { + if (!_entries.TryGetValue(id, out var entry) || (entry.CallEnded == null && entry.State != "submitted") + || entry.State is not ("ready" or "error" or "submitted")) + throw new InvalidOperationException("Aufnahme ist noch nicht zur Übergabe bereit."); + if (entry.JobId != null && entry.JobId != jobId) + throw new InvalidOperationException("Aufnahme wurde bereits einem anderen Auftrag zugeordnet."); + Put(entry with { State = "submitted", JobId = jobId, Baseline = [], Error = null }); + // Persist receipt before removing our temporary copy. Swyx original remains untouched. + TryDeleteCopy(id); + } + } + + public void Process() + { + RecordingEntry[] pending; + lock (_gate) pending = _entries.Values.Where(e => e.Ended != null && + (e.State == "waiting" || (e.State == "waiting_call_end" && e.Bytes == 0))).ToArray(); + foreach (var entry in pending) + { + if (_now() - entry.Ended < TimeSpan.FromSeconds(3)) continue; + try + { + // Recover a copy completed immediately before an application crash. + if (!File.Exists(Wav(entry.Id))) Prepare(entry); + if (!File.Exists(Wav(entry.Id))) continue; + lock (_gate) + { + // Observe may have confirmed the end while the local copy was being made. + var current = _entries[entry.Id]; + Put(current with { State = current.CallEnded != null ? "ready" : "waiting_call_end", + Filename = entry.Id + ".wav", Bytes = new FileInfo(Wav(entry.Id)).Length }); + } + } + catch (InvalidDataException ex) { Fail(entry, ex.Message); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + if (_now() - entry.Ended > TimeSpan.FromMinutes(2)) + Fail(entry, "Die WAV-Datei konnte nicht fertig gelesen werden: " + ex.Message); + } + } + } + + private void Prepare(RecordingEntry entry) + { + var candidates = Wavs(entry.Folder).Where(f => Candidate(entry, f)).ToArray(); + if (candidates.Length > 1) + throw new InvalidDataException("Mehrere neue WAV-Dateien gefunden. Bitte die Aufnahme manuell zuordnen."); + if (candidates.Length == 0) + { + if (_now() - entry.Ended > TimeSpan.FromMinutes(2)) + throw new InvalidDataException("Keine neue WAV-Datei gefunden. Bitte Swyx-Aufnahmeordner prüfen oder Datei manuell auswählen."); + return; + } + var file = candidates[0]; + lock (_gate) + { + if (_entries.Values.Any(other => other.Id != entry.Id && + string.Equals(other.Folder, entry.Folder, StringComparison.OrdinalIgnoreCase) && Candidate(other, file))) + throw new InvalidDataException("WAV-Datei gehört möglicherweise zu mehreren Aufnahmeabschnitten. Bitte manuell zuordnen."); + if (!_stable.TryGetValue(entry.Id, out var old) || old.Path != file.FullName || + old.Length != file.Length || old.Written != file.LastWriteTimeUtc) + { + _stable[entry.Id] = (file.FullName, file.Length, file.LastWriteTimeUtc, _now()); + return; + } + if (_now() - old.Seen < TimeSpan.FromSeconds(2)) return; + } + using var input = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.None); + ValidateWav(input); + input.Position = 0; + var temp = Wav(entry.Id) + ".tmp"; + using (var output = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None)) + { input.CopyTo(output); output.Flush(true); } + File.Move(temp, Wav(entry.Id), true); + } + + private static bool Candidate(RecordingEntry e, FileInfo f) => + !e.Baseline.Contains(f.FullName, StringComparer.OrdinalIgnoreCase) + && (e.SourceFiles == null || e.SourceFiles.Contains(f.FullName, StringComparer.OrdinalIgnoreCase)) + && f.CreationTimeUtc >= e.Started.AddSeconds(-2) + && (e.Ended == null || f.CreationTimeUtc <= e.Ended.Value.AddSeconds(3)); + + private static FileInfo[] Wavs(string folder) => new DirectoryInfo(folder).GetFiles() + .Where(f => f.Extension.Equals(".wav", StringComparison.OrdinalIgnoreCase)).ToArray(); + + internal static void ValidateWav(Stream input) + { + if (input.Length < 44 || input.Length > MaxBytes) throw new InvalidDataException("WAV-Datei ist leer oder größer als 500 MB."); + Span header = stackalloc byte[12]; + input.ReadExactly(header); + if (!header[..4].SequenceEqual("RIFF"u8) || !header[8..].SequenceEqual("WAVE"u8)) + throw new InvalidDataException("Die Aufnahme hat keinen gültigen WAV-Dateikopf."); + long end = BinaryPrimitives.ReadUInt32LittleEndian(header[4..8]) + 8L; + if (end != input.Length) throw new IOException("WAV-Dateikopf ist noch nicht abgeschlossen."); + Span chunk = stackalloc byte[8]; + bool audio = false, format = false; + while (input.Position + 8 <= end) + { + input.ReadExactly(chunk); + var length = BinaryPrimitives.ReadUInt32LittleEndian(chunk[4..]); + if (input.Position + length > end) throw new IOException("Unvollständiger WAV-Datenblock."); + if (chunk[..4].SequenceEqual("fmt "u8) && length >= 16) format = true; + if (chunk[..4].SequenceEqual("data"u8) && length > 0) audio = true; + input.Position += length + (length % 2); + } + if (!audio || !format) throw new InvalidDataException("Die WAV-Datei enthält keine Audiodaten."); + } + + private void Fail(RecordingEntry e, string error) + { lock (_gate) Put(_entries[e.Id] with { State = "error", Error = error }); } + + private string Manifest(Guid id) => Path.Combine(_folder, id + ".json"); + private string Wav(Guid id) => Path.Combine(_folder, id + ".wav"); + private static RecordingNotice Notice(RecordingEntry e) => + new(e.Id, e.Line, e.CallId, e.PeerNumber, e.PeerName, + e.CallEnded == null && e.State != "submitted" ? "waiting_call_end" : e.State, + e.Filename, e.Bytes, e.JobId, e.Error, e.CallEnded != null || e.State == "submitted"); + + private void Put(RecordingEntry entry) + { + var path = Manifest(entry.Id); + using (var output = new FileStream(path + ".tmp", FileMode.Create, FileAccess.Write, FileShare.None)) + { JsonSerializer.Serialize(output, entry, Protocol.Json); output.Flush(true); } + File.Move(path + ".tmp", path, true); + _entries[entry.Id] = entry; + if (entry.Ended != null) Changed?.Invoke(Notice(entry)); + } + + private void TryDeleteCopy(Guid id) + { + try { File.Delete(Wav(id)); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { Log.Error("Bestätigte Aufnahmekopie konnte nicht entfernt werden.", ex); } + } + + public void Dispose() => _shutdown.Cancel(); +} diff --git a/src/SwyxTray/Swyx/CallRecordingTracker.cs b/src/SwyxTray/Swyx/CallRecordingTracker.cs new file mode 100644 index 0000000..d3e6762 --- /dev/null +++ b/src/SwyxTray/Swyx/CallRecordingTracker.cs @@ -0,0 +1,28 @@ +namespace SwyxTray.Swyx; + +/// Merkt ausschliesslich erfolgreich an CLMgr uebergebene Aufnahmeauftraege. +/// Das SDK liefert keinen Aufnahmezustand; dies ist keine Dateischreibbestaetigung. +internal sealed class CallRecordingTracker +{ + private readonly HashSet<(int Line, int CallId)> _requested = new(); + + public bool IsRequested(int line, int callId) => _requested.Contains((line, callId)); + + public void Set(int line, int expectedCallId, int actualCallId, bool active, bool held, + bool start, Action invokeSdk) + { + if (expectedCallId != actualCallId) + throw new InvalidOperationException("Der Anruf auf dieser Leitung hat sich geaendert."); + if (start ? !active : !(active || held)) + throw new InvalidOperationException("Keine passende Sprechverbindung fuer die Aufnahme."); + // Wiederholtes Starten darf weder neu beginnen noch eine zweite Datei erzeugen. + // Stop immer ans SDK: auch ausserhalb der Schnittstelle gestartete Mitschnitte stoppen. + if (start && IsRequested(line, actualCallId)) return; + invokeSdk(); + if (start) _requested.Add((line, actualCallId)); + else _requested.Remove((line, actualCallId)); + } + + public void Retain(IEnumerable<(int Line, int CallId)> calls) + => _requested.IntersectWith(calls); +} diff --git a/src/SwyxTray/Swyx/SwyxClient.cs b/src/SwyxTray/Swyx/SwyxClient.cs index 74d862e..72e7086 100644 --- a/src/SwyxTray/Swyx/SwyxClient.cs +++ b/src/SwyxTray/Swyx/SwyxClient.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Runtime.InteropServices; using System.Text; using IpPbx.CLMgrLib; +using SwyxTray.Recording; namespace SwyxTray.Swyx; @@ -27,6 +28,10 @@ internal sealed class SwyxClient : IDisposable private ClientLineMgrClass? _mgr; private IClientLineMgrDisp? _disp; + private readonly CallRecordingTracker _recordings = new(); + public RecordingOutbox Outbox { get; } = new(Path.Combine(Log.Directory, "recordings")); + + public SwyxClient() => Outbox.Start(); private IClientLineMgrEventsPub_Event? _events; private IClientLineMgrEventsPub_PubOnLineMgrNotificationEventHandler? _handler; @@ -229,7 +234,7 @@ internal sealed class SwyxClient : IDisposable } } - private static SwyxSnapshot ReadSnapshot(IClientLineMgrDisp disp) + private SwyxSnapshot ReadSnapshot(IClientLineMgrDisp disp) { var lineCount = disp.DispNumberOfLines; var selected = disp.DispSelectedLineNumber; @@ -250,7 +255,10 @@ internal sealed class SwyxClient : IDisposable State: (CLMgrLineStates)line.DispState, PeerNumber: line.DispPeerNumber ?? string.Empty, PeerName: line.DispPeerName ?? string.Empty, - IsSelected: i == selected)); + IsSelected: i == selected, + CallId: line.DispCallId, + RecordingRequested: _recordings.IsRequested(i, line.DispCallId), + RecordingId: Outbox.CurrentId(i + 1, line.DispCallId))); } finally { @@ -268,6 +276,12 @@ internal sealed class SwyxClient : IDisposable private void Publish(SwyxSnapshot snapshot) { + try { Outbox.Observe(snapshot); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { Log.Error("Aufnahmeende konnte noch nicht gespeichert werden; wird beim nächsten Zustand erneut versucht.", ex); } + if (snapshot.IsConnected && snapshot.IsServerUp) + _recordings.Retain(snapshot.Lines.Where(l => l.IsBusy && l.CallId.HasValue) + .Select(l => (l.Index, l.CallId!.Value))); var signature = BuildSignature(snapshot); if (signature == _lastSignature) { @@ -306,7 +320,8 @@ internal sealed class SwyxClient : IDisposable .Append((int)l.State).Append(':') .Append(l.PeerNumber).Append(':') .Append(l.PeerName).Append(':') - .Append(l.IsSelected ? '1' : '0'); + .Append(l.IsSelected ? '1' : '0').Append(':') + .Append(l.CallId).Append(':').Append(l.RecordingRequested).Append(':').Append(l.RecordingId); } return sb.ToString(); @@ -341,23 +356,90 @@ internal sealed class SwyxClient : IDisposable } /// Nimmt einen eingehenden Ruf an; liefert die Leitung (1-basiert). - public int Answer(int? line) + public int Answer(int? line, int? callId = null) { + Refresh(); var index = ResolveLine(line, l => l.State.IsRinging(), "Es klingelt keine Leitung."); - WithLine(index, l => l.DispHookOff()); + WithLine(index, l => + { + if (callId.HasValue && l.DispCallId != callId.Value) + throw new InvalidOperationException("Der Anruf auf dieser Leitung hat sich geaendert."); + if (!((CLMgrLineStates)l.DispState).IsRinging()) + throw new InvalidOperationException("Die Leitung klingelt nicht mehr."); + l.DispHookOff(); + }); Refresh(); return index + 1; } /// Legt auf; liefert die Leitung (1-basiert). - public int Hangup(int? line) + public int Hangup(int? line, int? callId = null) { + Refresh(); var index = ResolveLine(line, l => l.IsBusy, "Es ist keine Leitung belegt."); - WithLine(index, l => l.DispHookOn()); + WithLine(index, l => + { + if (((CLMgrLineStates)l.DispState).IsIdle()) + throw new InvalidOperationException("Das Gespräch ist bereits beendet."); + if (callId.HasValue && l.DispCallId != callId.Value) + throw new InvalidOperationException("Der Anruf auf dieser Leitung hat sich geändert."); + l.DispHookOn(); + }); Refresh(); return index + 1; } + /// Aufnahme des exakt bezeichneten Anrufs; SDK-Aufruf im STA-Thread. + public Guid? SetRecording(int? line, int? callId, bool start, Guid? recordingId = null) + { + if (line is null or <= 0 || callId is null) + throw new ArgumentException("Felder 'line' und 'callId' fehlen oder sind ungueltig."); + RequireConnection(); + Refresh(); + Guid? id = Outbox.CurrentId(line.Value, callId.Value); + if (!start && recordingId.HasValue && id.HasValue && id != recordingId) + throw new InvalidOperationException("Der Aufnahmeabschnitt hat sich geändert."); + WithLine(line.Value - 1, l => + { + var state = (CLMgrLineStates)l.DispState; + _recordings.Set(line.Value - 1, callId.Value, l.DispCallId, + state.IsActive(), state.IsOnHold(), start, + () => + { + if (start) + { + id = Outbox.Begin(line.Value, callId.Value, l.DispPeerNumber ?? "", l.DispPeerName ?? "", + RecordingFolder(), recordingId); + try { l.DispStartRecording(); } + catch { Outbox.Abort(id.Value); throw; } + } + else + { + l.DispStopRecording(); + Outbox.End(line.Value, callId.Value); + } + }); + }); + Refresh(); + return id; + } + + private string RecordingFolder() + { + if (RequireConnection().DispClientConfig is not IClientConfig config) + throw new InvalidOperationException("Swyx-Aufnahmeordner konnte nicht gelesen werden."); + try + { + var path = config.RecordedConversationsPath; + if (string.IsNullOrWhiteSpace(path)) path = config.DefaultConversationRecordingPath; + if (string.IsNullOrWhiteSpace(path)) + path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Swyx", "Recording"); + return Path.GetFullPath(Environment.ExpandEnvironmentVariables(path) + .Replace("[username]", Environment.UserName, StringComparison.OrdinalIgnoreCase)); + } + finally { Marshal.FinalReleaseComObject(config); } + } + /// Stellt ein Gespraech in die Warteschleife. public int Hold(int? line) { @@ -718,6 +800,7 @@ internal sealed class SwyxClient : IDisposable } _disposed = true; + Outbox.Dispose(); Cleanup(); } } diff --git a/src/SwyxTray/Swyx/SwyxModels.cs b/src/SwyxTray/Swyx/SwyxModels.cs index ee12f52..2a4a3bb 100644 --- a/src/SwyxTray/Swyx/SwyxModels.cs +++ b/src/SwyxTray/Swyx/SwyxModels.cs @@ -35,7 +35,10 @@ public sealed record SwyxLineInfo( CLMgrLineStates State, string PeerNumber, string PeerName, - bool IsSelected) + bool IsSelected, + int? CallId = null, + bool RecordingRequested = false, + Guid? RecordingId = null) { /// Anzeigename des Gespraechspartners, Nummer als Rueckfallwert. public string PeerDisplay => diff --git a/src/SwyxTray/Web/CommandExecutor.cs b/src/SwyxTray/Web/CommandExecutor.cs index 0dd8bda..ef2f410 100644 --- a/src/SwyxTray/Web/CommandExecutor.cs +++ b/src/SwyxTray/Web/CommandExecutor.cs @@ -27,6 +27,8 @@ internal sealed class CommandExecutor _tabs = tabs; } + public SwyxTray.Recording.RecordingOutbox Outbox => _client.Outbox; + public Task GetSnapshotAsync(CancellationToken cancellationToken) => RunAsync(() => _client.Current, cancellationToken); @@ -50,12 +52,25 @@ internal sealed class CommandExecutor case "answer": return new ResultMessage(command.Id, true, - Line: await RunAsync(() => _client.Answer(command.Line), cancellationToken) + Line: await RunAsync(() => _client.Answer(command.Line, command.CallId), cancellationToken) .ConfigureAwait(false)); + case "recording.start": + case "recording.stop": + var start = command.Cmd.Trim().Equals("recording.start", StringComparison.OrdinalIgnoreCase); + var recordingId = await RunAsync(() => + _client.SetRecording(command.Line, command.CallId, start, command.RecordingId), + cancellationToken).ConfigureAwait(false); + return new ResultMessage(command.Id, true, Line: command.Line, + CallId: command.CallId, RecordingRequested: start, RecordingId: recordingId); + + case "recording.ack": + _client.Outbox.Acknowledge(command.RecordingId ?? Guid.Empty, command.JobId); + return new ResultMessage(command.Id, true); + case "hangup": return new ResultMessage(command.Id, true, - Line: await RunAsync(() => _client.Hangup(command.Line), cancellationToken) + Line: await RunAsync(() => _client.Hangup(command.Line, command.CallId), cancellationToken) .ConfigureAwait(false)); case "hold": @@ -108,7 +123,7 @@ internal sealed class CommandExecutor return new ResultMessage(command.Id, false, $"Unbekanntes Kommando '{command.Cmd}'."); } } - catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException or UnauthorizedAccessException) { // Erwartbare Bedienfehler: falsche Nummer, keine passende Leitung, // SwyxIt! nicht verbunden. diff --git a/src/SwyxTray/Web/LocalWebSocketServer.cs b/src/SwyxTray/Web/LocalWebSocketServer.cs index 49e4573..bc04f0a 100644 --- a/src/SwyxTray/Web/LocalWebSocketServer.cs +++ b/src/SwyxTray/Web/LocalWebSocketServer.cs @@ -40,6 +40,7 @@ internal sealed class LocalWebSocketServer : IDisposable _config = config; _tabChannel = new PluginTabChannel(); _executor = new CommandExecutor(client, ui, _tabChannel); + _executor.Outbox.Changed += BroadcastRecording; } public bool IsRunning { get; private set; } @@ -444,8 +445,16 @@ internal sealed class LocalWebSocketServer : IDisposable } } + private void BroadcastRecording(SwyxTray.Recording.RecordingNotice notice) + { + var json = Protocol.Serialize(notice); + foreach (var session in _sessions.Values.Where(s => !s.IsPlugin)) + _ = session.TrySendManyAsync([json], _shutdown.Token); + } + public void Dispose() { + _executor.Outbox.Changed -= BroadcastRecording; _shutdown.Cancel(); foreach (var listener in _listeners) diff --git a/src/SwyxTray/Web/Protocol.cs b/src/SwyxTray/Web/Protocol.cs index 51441c8..f0e7faa 100644 --- a/src/SwyxTray/Web/Protocol.cs +++ b/src/SwyxTray/Web/Protocol.cs @@ -38,6 +38,12 @@ internal sealed class ClientCommand /// 1-basierte Leitung; fehlt sie, waehlt die App selbst. public int? Line { get; set; } + /// SDK-Anrufkennung aus dem Snapshot; Pflicht fuer Aufnahmebefehle. + public int? CallId { get; set; } + + public Guid? RecordingId { get; set; } + public string? JobId { get; set; } + /// Nur bei focus: gesuchter Fenstertitel. public string? Title { get; set; } @@ -110,7 +116,8 @@ internal sealed record ResultMessage( int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null, int? TabId = null, IReadOnlyList? Tabs = null, IReadOnlyList? Contacts = null, string? Name = null, - IReadOnlyList? Addresses = null) + IReadOnlyList? Addresses = null, + int? CallId = null, bool? RecordingRequested = null, Guid? RecordingId = null) { public string Type => "result"; } @@ -138,7 +145,9 @@ internal sealed record LineMessage( string PeerNumber, string PeerName, bool Busy, - bool Selected); + bool Selected, + int? CallId, + bool RecordingRequested, Guid? RecordingId); /// /// Anruf-Ereignis, abgeleitet aus dem Vergleich zweier aufeinanderfolgender @@ -157,18 +166,22 @@ internal sealed record CallEventMessage( string Peer, string PeerNumber, string PeerName, - string? Direction = null) + string? Direction = null, + int? CallId = null) { public string Type => "call"; public static IReadOnlyList Diff(SwyxSnapshot previous, SwyxSnapshot current) { var events = new List(); + // Verbindungsverlust ist kein nachgewiesenes Gespraechsende. + if (!current.IsConnected || !current.IsServerUp) return events; var before = previous.Lines.ToDictionary(l => l.Index); foreach (var line in current.Lines) { before.TryGetValue(line.Index, out var prev); + if (prev?.CallId != line.CallId) prev = null; var wasRinging = prev?.State.IsRinging() ?? false; var wasDialing = prev?.State.IsDialing() ?? false; var wasTalking = prev is not null && (prev.State.IsActive() || prev.State.IsOnHold()); @@ -193,7 +206,8 @@ internal sealed record CallEventMessage( var after = current.Lines.ToDictionary(l => l.Index); foreach (var prev in previous.Lines.Where(l => l.IsBusy)) { - if (!after.TryGetValue(prev.Index, out var line) || !line.IsBusy) + if (after.TryGetValue(prev.Index, out var line) + && (!line.IsBusy || line.CallId != prev.CallId)) { events.Add(From("ended", prev)); } @@ -203,7 +217,7 @@ internal sealed record CallEventMessage( } private static CallEventMessage From(string evt, SwyxLineInfo line, string? direction = null) - => new(evt, line.Index + 1, line.PeerDisplayLong, line.PeerNumber, line.PeerName, direction); + => new(evt, line.Index + 1, line.PeerDisplayLong, line.PeerNumber, line.PeerName, direction, line.CallId); } /// @@ -237,5 +251,7 @@ internal sealed record SnapshotMessage( PeerNumber: l.PeerNumber, PeerName: l.PeerName, Busy: l.IsBusy, - Selected: l.IsSelected)).ToList()); + Selected: l.IsSelected, + CallId: l.CallId, + RecordingRequested: l.RecordingRequested, RecordingId: l.RecordingId)).ToList()); } diff --git a/src/SwyxTray/Web/WebSocketConfig.cs b/src/SwyxTray/Web/WebSocketConfig.cs index 964ecab..7940fef 100644 --- a/src/SwyxTray/Web/WebSocketConfig.cs +++ b/src/SwyxTray/Web/WebSocketConfig.cs @@ -44,7 +44,9 @@ internal sealed class WebSocketConfig /// Ruf holt SwyxTray den Firefox-Tab mit diesem Titel selbsttaetig nach /// vorn. /// - public const int ProtocolVersion = 9; + // Version 10: recording.start/recording.stop, callId und recordingRequested. + // Version 11: Aufnahme-UUID, persistente WAV-Warteschlange und WebSocket-Dateitransfer. + public const int ProtocolVersion = 11; /// /// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen — diff --git a/src/SwyxTray/Web/WebSocketSession.cs b/src/SwyxTray/Web/WebSocketSession.cs index 67b6af4..771b4a3 100644 --- a/src/SwyxTray/Web/WebSocketSession.cs +++ b/src/SwyxTray/Web/WebSocketSession.cs @@ -1,4 +1,5 @@ using System.Net.WebSockets; +using System.Buffers.Binary; using System.Text; using System.Text.Json; @@ -20,6 +21,8 @@ internal sealed class WebSocketSession : IDisposable private readonly CommandExecutor _executor; private readonly PluginTabChannel _tabs; private readonly SemaphoreSlim _sendGate = new(1, 1); + private readonly CancellationTokenSource _closed = new(); + private int _downloading; public int Id { get; } @@ -55,6 +58,7 @@ internal sealed class WebSocketSession : IDisposable { await SendSnapshotAsync(cancellationToken).ConfigureAwait(false); await SendAddressesAsync(cancellationToken).ConfigureAwait(false); + await SendRecordingsAsync(cancellationToken).ConfigureAwait(false); } var buffer = new byte[8 * 1024]; @@ -129,6 +133,20 @@ internal sealed class WebSocketSession : IDisposable return; } + if (command.Cmd.Equals("recording.download", StringComparison.OrdinalIgnoreCase)) + { + if (command.Id is null or <= 0 || command.RecordingId == null || + Interlocked.CompareExchange(ref _downloading, 1, 0) != 0) + { + await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, false, + "Ungültiger Downloadauftrag oder bereits laufende Übertragung.")), cancellationToken); + return; + } + // Receive loop remains available for hangup, stop, ping etc. while sending audio. + _ = DownloadAsync(command, cancellationToken); + return; + } + // 'status' beantwortet der Server mit dem vollen Zustand samt der // zwischengespeicherten Adressdaten; die Quittung danach haelt den // Ablauf fuer den Client einheitlich. @@ -136,6 +154,7 @@ internal sealed class WebSocketSession : IDisposable { await SendSnapshotAsync(cancellationToken).ConfigureAwait(false); await SendAddressesAsync(cancellationToken).ConfigureAwait(false); + await SendRecordingsAsync(cancellationToken).ConfigureAwait(false); await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken) .ConfigureAwait(false); return; @@ -145,6 +164,45 @@ internal sealed class WebSocketSession : IDisposable await SendAsync(Protocol.Serialize(response), cancellationToken).ConfigureAwait(false); } + private Task SendRecordingsAsync(CancellationToken cancellationToken) + => SendManyAsync(_executor.Outbox.Notices().Select(Protocol.Serialize).ToArray(), cancellationToken); + + private async Task DownloadAsync(ClientCommand command, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closed.Token); + timeout.CancelAfter(TimeSpan.FromMinutes(5)); + try + { + using var input = _executor.Outbox.Open(command.RecordingId!.Value); + var block = new byte[64 * 1024 + 12]; + "SWYX"u8.CopyTo(block); + BinaryPrimitives.WriteInt32LittleEndian(block.AsSpan(4), command.Id!.Value); + int offset = 0; + int count; + while ((count = await input.ReadAsync(block.AsMemory(12), timeout.Token)) > 0) + { + BinaryPrimitives.WriteInt32LittleEndian(block.AsSpan(8), offset); + await _sendGate.WaitAsync(timeout.Token); + try + { + await _socket.SendAsync(block.AsMemory(0, count + 12), WebSocketMessageType.Binary, + true, timeout.Token); + } + finally { _sendGate.Release(); } + offset += count; + await Task.Yield(); + } + await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), timeout.Token); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException + or WebSocketException or OperationCanceledException or ObjectDisposedException) + { + await TrySendManyAsync([Protocol.Serialize(new ResultMessage(command.Id, false, + "WAV-Übertragung unterbrochen: " + ex.Message))], cancellationToken); + } + finally { Interlocked.Exchange(ref _downloading, 0); } + } + /// /// Auf dem Plugin-Port gibt es nur die Tab-Verwaltung: Antworten auf /// Tab-Auftraege und ein Lebenszeichen. Telefonie laeuft ausschliesslich @@ -252,7 +310,8 @@ internal sealed class WebSocketSession : IDisposable public void Dispose() { + _closed.Cancel(); _socket.Dispose(); - _sendGate.Dispose(); + // A download/broadcast may still be unwinding its finally block. } } diff --git a/tests/SwyxTray.RecordingChecks/OutboxChecks.cs b/tests/SwyxTray.RecordingChecks/OutboxChecks.cs new file mode 100644 index 0000000..710e2c1 --- /dev/null +++ b/tests/SwyxTray.RecordingChecks/OutboxChecks.cs @@ -0,0 +1,184 @@ +using System.Buffers.Binary; +using IpPbx.CLMgrLib; +using SwyxTray.Recording; +using SwyxTray.Swyx; + +internal static class OutboxChecks +{ + public static void Run(Action check) + { + var temp = Path.Combine(Path.GetTempPath(), "swyx-outbox-check-" + Guid.NewGuid()); + Directory.CreateDirectory(temp); + try + { + for (int scenario = 0; scenario < 7; scenario++) + { + var folder = Path.Combine(temp, scenario.ToString()); + Directory.CreateDirectory(folder); + var queue = Path.Combine(folder, "queue"); + var now = DateTime.UtcNow; + var wav = Path.Combine(folder, "conversation.wav"); + WriteWav(Path.Combine(folder, "old.wav"), now.AddDays(-1)); + using var box = new RecordingOutbox(queue, () => now); + var id = box.Begin(1, 41, "123", "Test", folder, null); + check(box.Begin(1, 41, "123", "Test", folder, Guid.NewGuid()) == id, "duplicate start keeps identity"); + WriteWav(wav, now); + box.Process(); + check(box.Notices().Length == 0, "no transfer during recording"); + box.Observe(SwyxSnapshot.Offline); + check(box.CurrentId(1, 41) == id, "disconnect does not end recording"); + if (scenario == 1) WriteWav(Path.Combine(folder, "ambiguous.wav"), now); + if (scenario == 2) File.Delete(wav); + if (scenario == 3) + { + // Overlapping recording windows cannot claim the same WAV. + File.Delete(wav); + box.Begin(2, 42, "456", "Other", folder, null); + WriteWav(wav, now); + box.End(2, 42); + } + box.Observe(Snapshot(CLMgrLineStates.LSOnHold)); + check(box.CurrentId(1, 41) == id, "hold does not end recording"); + box.Observe(Snapshot(CLMgrLineStates.LSInactive)); + check(box.CurrentId(1, 41) == null, "call end finishes recording section"); + now = now.AddSeconds(4); + box.Process(); + if (scenario == 1 || scenario == 3) + { + check(box.Notices().First(n => n.RecordingId == id).State == "error", "ambiguous file is never offered"); + continue; + } + if (scenario == 2) + { + now = now.AddMinutes(3); box.Process(); + check(box.Notices()[0].State == "error", "missing new WAV does not select old file"); + box.Acknowledge(id, new string('a', 32)); + check(box.Notices()[0].State == "submitted", "manual fallback receipt persists too"); + continue; + } + now = now.AddSeconds(3); + if (scenario == 4) + { + using (var writer = new FileStream(wav, FileMode.Open, FileAccess.Write, FileShare.Read)) + { + box.Process(); + check(box.Notices()[0].State == "waiting", "writer must close before ready"); + } + } + if (scenario == 5) + { + var content = File.ReadAllBytes(wav); + BinaryPrimitives.WriteUInt32LittleEndian(content.AsSpan(4), (uint)content.Length + 100); + File.WriteAllBytes(wav, content); + box.Process(); now = now.AddSeconds(3); box.Process(); + check(box.Notices()[0].State == "waiting", "unfinished RIFF header waits"); + WriteWav(wav, now.AddSeconds(-7)); + box.Process(); now = now.AddSeconds(3); + } + if (scenario == 6) + { + using var restarted = new RecordingOutbox(queue, () => now); + restarted.Process(); now = now.AddSeconds(3); restarted.Process(); + check(restarted.Notices()[0].State == "ready", "pending section survives tray restart"); + continue; + } + box.Process(); + check(box.Notices()[0].State == "ready", "stable closed WAV becomes ready"); + using (var input = box.Open(id)) check(input.Length == 2044, "copy contains complete audio"); + using (var reloaded = new RecordingOutbox(queue, () => now)) + check(reloaded.Notices()[0].State == "ready", "ready replays after restart"); + box.Acknowledge(id, new string('a', 32)); + box.Acknowledge(id, new string('a', 32)); + using var received = new RecordingOutbox(queue, () => now); + check(received.Notices()[0].JobId == new string('a', 32), "durable idempotent receipt"); + check(File.Exists(wav) && !File.Exists(Path.Combine(queue, id + ".wav")), "remove only acknowledged temporary copy"); + } + // A call can end outside SwyxWeb. No recording.stop/hangup command + // is needed: the same outbox must produce a downloadable WAV. + var externalEnds = new[] { + ("remote", CLMgrLineStates.LSTerminated, 41), + ("local", CLMgrLineStates.LSInactive, 41), + ("disabled", CLMgrLineStates.LSDisabled, 41), + ("none", CLMgrLineStates.LSNone, 41), + ("replaced", CLMgrLineStates.LSRinging, 42), + ("reconnected", CLMgrLineStates.LSInactive, 41) + }; + foreach (var (reason, state, callId) in externalEnds) + { + var folder = Path.Combine(temp, "external-" + reason); + Directory.CreateDirectory(folder); + var now = DateTime.UtcNow; + using var box = new RecordingOutbox(Path.Combine(folder, "queue"), () => now); + var id = box.Begin(1, 41, "123", "Test", folder, null); + WriteWav(Path.Combine(folder, "call.wav"), now); + box.Observe(Snapshot(CLMgrLineStates.LSActive)); + if (reason == "reconnected") + { + box.Observe(SwyxSnapshot.Offline); + check(box.Notices().Length == 0, "connection loss alone does not release audio"); + } + box.Observe(Snapshot(state, callId)); + now = now.AddSeconds(4); box.Process(); now = now.AddSeconds(3); box.Process(); + check(box.Notices().Single() is { State: "ready", CallEnded: true }, + "automatic handoff without local hangup: " + reason); + using var audio = box.Open(id); + check(audio.Length == 2044, "complete WAV after external end: " + reason); + } + var sequential = Path.Combine(temp, "sequential"); + Directory.CreateDirectory(sequential); + var clock = DateTime.UtcNow; + using var sections = new RecordingOutbox(Path.Combine(sequential, "queue"), () => clock); + sections.Begin(1, 88, "123", "Test", sequential, null); + WriteWav(Path.Combine(sequential, "first.wav"), clock); + sections.End(1, 88); + clock = clock.AddMilliseconds(100); + sections.Begin(1, 88, "123", "Test", sequential, null); + WriteWav(Path.Combine(sequential, "second.wav"), clock); + sections.End(1, 88); + clock = clock.AddSeconds(4); sections.Process(); + clock = clock.AddSeconds(3); sections.Process(); + check(sections.Notices().Length == 2 && sections.Notices().All(n => n.State == "waiting_call_end" && !n.CallEnded), + "all stopped recording sections wait for hangup"); + sections.Observe(Snapshot(CLMgrLineStates.LSOnHold, 88)); + sections.Observe(SwyxSnapshot.Offline); + check(sections.Notices().All(n => n.State == "waiting_call_end"), "hold/disconnect do not release stopped sections"); + using (var restarted = new RecordingOutbox(Path.Combine(sequential, "queue"), () => clock)) + { + restarted.Observe(Snapshot(CLMgrLineStates.LSActive, 88)); + check(restarted.Notices().All(n => n.State == "waiting_call_end"), "stopped sections wait across restart"); + foreach (var pending in restarted.Notices()) + { + bool refused = false; + try { using var blockedInput = restarted.Open(pending.RecordingId); } + catch (InvalidOperationException) { refused = true; } + check(refused, "download denied before confirmed call end"); + refused = false; + try { restarted.Acknowledge(pending.RecordingId, new string('b', 32)); } + catch (InvalidOperationException) { refused = true; } + check(refused, "receipt denied before confirmed call end"); + } + restarted.Observe(Snapshot(CLMgrLineStates.LSInactive, 88)); + check(restarted.Notices().Length == 2 && restarted.Notices().All(n => n.State == "ready" && n.CallEnded), + "hangup releases all finalized sections of the ended call"); + using var input = restarted.Open(restarted.Notices()[0].RecordingId); + check(input.Length == 2044, "complete WAV available after hangup"); + } + } + finally { Directory.Delete(temp, true); } + } + + public static void WriteWav(string path, DateTime created, int samples = 1000) + { + using (var writer = new BinaryWriter(File.Create(path))) + { + writer.Write("RIFF"u8); writer.Write(36 + samples * 2); writer.Write("WAVEfmt "u8); + writer.Write(16); writer.Write((short)1); writer.Write((short)1); writer.Write(16000); + writer.Write(32000); writer.Write((short)2); writer.Write((short)16); + writer.Write("data"u8); writer.Write(samples * 2); writer.Write(new byte[samples * 2]); + } + File.SetCreationTimeUtc(path, created); + } + + private static SwyxSnapshot Snapshot(CLMgrLineStates state, int callId = 41) => + new(true, true, "test", "test", [new SwyxLineInfo(0, state, "123", "Test", true, callId)]); +} diff --git a/tests/SwyxTray.RecordingChecks/Program.cs b/tests/SwyxTray.RecordingChecks/Program.cs new file mode 100644 index 0000000..93d0b7a --- /dev/null +++ b/tests/SwyxTray.RecordingChecks/Program.cs @@ -0,0 +1,51 @@ +using IpPbx.CLMgrLib; +using SwyxTray.Swyx; +using SwyxTray.Web; + +if (args.Contains("--serve")) { await TransportChecks.Serve(); return; } + +int checks = 0; +void Check(bool condition, string name) { if (!condition) throw new Exception(name); checks++; } +void Reject(Action action, string name) { + try { action(); } catch (InvalidOperationException) { checks++; return; } + throw new Exception(name); +} +var recordings = new CallRecordingTracker(); +int invocations = 0; +Action invoke = () => invocations++; +Reject(() => recordings.Set(0, 41, 42, true, false, true, invoke), "stale call"); +Reject(() => recordings.Set(0, 41, 41, false, false, true, invoke), "ringing"); +Reject(() => recordings.Set(0, 41, 41, false, true, true, invoke), "held start"); +Check(invocations == 0, "invalid calls do not reach SDK"); +recordings.Set(0, 41, 41, true, false, true, invoke); +recordings.Set(0, 41, 41, true, false, true, invoke); +Check(invocations == 1 && recordings.IsRequested(0, 41), "idempotent start"); +recordings.Set(0, 41, 41, false, true, false, invoke); +Check(invocations == 2 && !recordings.IsRequested(0, 41), "stop while held"); +Reject(() => recordings.Set(0, 41, 41, true, false, true, () => throw new InvalidOperationException()), "SDK error"); +Check(!recordings.IsRequested(0, 41), "failed start not reported as success"); +recordings.Set(0, 41, 41, true, false, true, invoke); +Reject(() => recordings.Set(0, 41, 41, true, false, false, () => throw new InvalidOperationException()), "stop error"); +Check(recordings.IsRequested(0, 41), "failed stop retains request state"); +recordings.Retain(new[] { (0, 42) }); +Check(!recordings.IsRequested(0, 41), "call ended clears request state"); +recordings.Set(0, 42, 42, true, false, false, invoke); +Check(invocations == 4, "stop external recording reaches SDK too"); + +SwyxSnapshot Snapshot(CLMgrLineStates state, int id = 41, bool recording = false) => + new(true, true, "test", "test", new[] { new SwyxLineInfo(0, state, "123", "Test", true, id, recording) }); +var active = Snapshot(CLMgrLineStates.LSActive, recording: true); +var end = CallEventMessage.Diff(active, Snapshot(CLMgrLineStates.LSInactive)); +Check(end.Count == 1 && end[0].Event == "ended" && end[0].CallId == 41 && end[0].PeerNumber == "123", "end preserves call identity"); +Check(CallEventMessage.Diff(active, SwyxSnapshot.Offline).Count == 0, "disconnect is not call end"); +Check(CallEventMessage.Diff(active, active with { IsServerUp = false }).Count == 0, "server down is not call end"); +Check(CallEventMessage.Diff(active, Snapshot(CLMgrLineStates.LSOnHold)).Count == 0, "holding is not call end"); +Check(CallEventMessage.Diff(Snapshot(CLMgrLineStates.LSOnHold), active).Count == 0, "resume is not new call"); +var replaced = CallEventMessage.Diff(active, Snapshot(CLMgrLineStates.LSRinging, 42)); +Check(replaced.Count == 2 && replaced.Any(e => e.Event == "ended" && e.CallId == 41) + && replaced.Any(e => e.Event == "incoming" && e.CallId == 42), "reused line keeps calls distinct"); +var json = Protocol.Serialize(SnapshotMessage.From(active)); +Check(json.Contains("\"callId\":41") && json.Contains("\"recordingRequested\":true"), "wire fields"); +OutboxChecks.Run(Check); +await TransportChecks.Run(Check); +Console.WriteLine($"{checks} recording/protocol checks passed (no COM calls)."); diff --git a/tests/SwyxTray.RecordingChecks/SwyxTray.RecordingChecks.csproj b/tests/SwyxTray.RecordingChecks/SwyxTray.RecordingChecks.csproj new file mode 100644 index 0000000..7f6095b --- /dev/null +++ b/tests/SwyxTray.RecordingChecks/SwyxTray.RecordingChecks.csproj @@ -0,0 +1,21 @@ + + + Exe + net10.0 + enable + enable + x64 + + + + + + + + + + + + + + diff --git a/tests/SwyxTray.RecordingChecks/TransportChecks.cs b/tests/SwyxTray.RecordingChecks/TransportChecks.cs new file mode 100644 index 0000000..0e71e0c --- /dev/null +++ b/tests/SwyxTray.RecordingChecks/TransportChecks.cs @@ -0,0 +1,154 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using SwyxTray.Recording; +using SwyxTray.Web; + +internal static class TransportChecks +{ + // Local-only fixture for browser -> real SwyxWeb -> isolated SwyxTrans integration checks. + public static async Task Serve() + { + var folder = Path.Combine(Path.GetTempPath(), "swyx-browser-check-" + Guid.NewGuid()); + Directory.CreateDirectory(folder); + var now = DateTime.UtcNow; + using var outbox = new RecordingOutbox(Path.Combine(folder, "queue"), () => now); + var id = outbox.Begin(1, 123, "123", "WebSocket-Testgespräch", folder, null); + OutboxChecks.WriteWav(Path.Combine(folder, "call.wav"), now, 32_000); + outbox.Observe(new SwyxTray.Swyx.SwyxSnapshot(true, true, "test", "test", + [new SwyxTray.Swyx.SwyxLineInfo(0, IpPbx.CLMgrLib.CLMgrLineStates.LSInactive, "123", "Test", true, 123)])); + now = now.AddSeconds(4); outbox.Process(); now = now.AddSeconds(3); outbox.Process(); + using var listener = new TcpListener(IPAddress.Loopback, 17658); + listener.Start(); + Console.WriteLine("Synthetic WAV fixture ready: " + folder); + while (true) + { + var tcp = await listener.AcceptTcpClientAsync(); + _ = Task.Run(async () => + { + using (tcp) + { + try + { + var stream = tcp.GetStream(); + var request = await WebSocketHandshake.ReadAsync(stream, CancellationToken.None); + if (request == null) return; + await WebSocketHandshake.AcceptAsync(stream, request, CancellationToken.None); + using var session = new WebSocketSession(1, WebSocket.CreateFromStream(stream, true, null, + TimeSpan.FromSeconds(15)), new CommandExecutor(outbox), new PluginTabChannel(), request.Origin, false); + await session.RunAsync(CancellationToken.None); + } + catch (Exception ex) { Console.WriteLine(ex.GetType().Name); } + } + }); + } + } + + public static async Task Run(Action check) + { + var folder = Path.Combine(Path.GetTempPath(), "swyx-transport-" + Guid.NewGuid()); + Directory.CreateDirectory(folder); + try + { + var now = DateTime.UtcNow; + using var outbox = new RecordingOutbox(Path.Combine(folder, "queue"), () => now); + var id = outbox.Begin(1, 123, "123", "Test", folder, null); + var path = Path.Combine(folder, "call.wav"); + OutboxChecks.WriteWav(path, now, samples: 1_000_000); + outbox.Observe(new SwyxTray.Swyx.SwyxSnapshot(true, true, "test", "test", + [new SwyxTray.Swyx.SwyxLineInfo(0, IpPbx.CLMgrLib.CLMgrLineStates.LSInactive, "123", "Test", true, 123)])); + now = now.AddSeconds(4); outbox.Process(); now = now.AddSeconds(3); outbox.Process(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + for (int round = 0; round < 3; round++) + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + using var tcp = new TcpClient(); + var connection = tcp.ConnectAsync((IPEndPoint)listener.LocalEndpoint); + using var remote = await listener.AcceptTcpClientAsync(timeout.Token); + await connection; + using var socket = WebSocket.CreateFromStream(tcp.GetStream(), false, null, TimeSpan.FromSeconds(15)); + using var server = new WebSocketSession(round, WebSocket.CreateFromStream(remote.GetStream(), true, null, + TimeSpan.FromSeconds(15)), new CommandExecutor(outbox), new PluginTabChannel(), "http://test", false); + var running = server.RunAsync(timeout.Token); + var notice = await Until(socket, e => e.TryGetProperty("recordingId", out _), timeout.Token); + check(notice.GetProperty("recordingId").GetGuid() == id, "recording replay uses original UUID"); + if (round == 2) + { + check(notice.GetProperty("state").GetString() == "submitted", "receipt replay on reconnect"); + } + else + { + await Send(socket, new { id = 7, cmd = "recording.download", recordingId = id }, timeout.Token); + await Send(socket, new { id = 8, cmd = "ping" }, timeout.Token); + using var received = new MemoryStream(); + bool done = false, ping = false; + while (!done || !ping) + { + var (type, bytes) = await Receive(socket, timeout.Token); + if (type == WebSocketMessageType.Binary) + { + check(bytes.AsSpan(0, 4).SequenceEqual("SWYX"u8), "binary block magic"); + check(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(4)) == 7, "binary request id"); + check(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(8)) == received.Length, "binary offset"); + received.Write(bytes, 12, bytes.Length - 12); + if (round == 0) break; // Interrupt, then request the same retained WAV on another session. + } + else + { + var result = JsonDocument.Parse(bytes).RootElement; + if (result.TryGetProperty("id", out var requestId)) + { + check(result.GetProperty("ok").GetBoolean(), "transfer/control command succeeds"); + if (requestId.GetInt32() == 7) done = true; + if (requestId.GetInt32() == 8) ping = true; + } + } + } + if (round == 1) + { + check(received.ToArray().SequenceEqual(File.ReadAllBytes(path)), "actual WebSocket preserves entire WAV"); + check(ping, "control commands remain responsive during transfer"); + await Send(socket, new { id = 9, cmd = "recording.ack", recordingId = id, jobId = new string('a', 32) }, timeout.Token); + var ack = await Until(socket, e => e.TryGetProperty("id", out var n) && n.GetInt32() == 9, timeout.Token); + check(ack.GetProperty("ok").GetBoolean(), "transfer receipt accepted"); + } + } + socket.Abort(); server.Dispose(); + try { await running; } catch (Exception ex) when (ex is WebSocketException or IOException or ObjectDisposedException) { } + } + check(File.Exists(path), "original WAV survives websocket acknowledgements"); + } + finally { Directory.Delete(folder, true); } + } + + private static Task Send(WebSocket socket, object value, CancellationToken token) => + socket.SendAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value)), WebSocketMessageType.Text, true, token); + + private static async Task Until(WebSocket socket, Func predicate, CancellationToken token) + { + while (true) + { + var (type, bytes) = await Receive(socket, token); + if (type != WebSocketMessageType.Text) continue; + var element = JsonDocument.Parse(bytes).RootElement; + if (predicate(element)) return element; + } + } + + private static async Task<(WebSocketMessageType, byte[])> Receive(WebSocket socket, CancellationToken token) + { + using var message = new MemoryStream(); + var buffer = new byte[32 * 1024]; + WebSocketReceiveResult result; + do + { + result = await socket.ReceiveAsync(buffer, token); + message.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + return (result.MessageType, message.ToArray()); + } +} diff --git a/tests/SwyxTray.RecordingChecks/TransportStubs.cs b/tests/SwyxTray.RecordingChecks/TransportStubs.cs new file mode 100644 index 0000000..bdebc98 --- /dev/null +++ b/tests/SwyxTray.RecordingChecks/TransportStubs.cs @@ -0,0 +1,17 @@ +// Only the COM executor and browser-plugin channel are replaced. The outbox, +// session, framing, cancellation and persistence are production code. +using SwyxTray.Recording; +using SwyxTray.Swyx; +namespace SwyxTray.Web; +internal sealed class CommandExecutor(RecordingOutbox outbox) +{ + public RecordingOutbox Outbox => outbox; + public Task GetSnapshotAsync(CancellationToken _) => Task.FromResult(SwyxSnapshot.Offline); + public Task> GetAddressesAsync(CancellationToken _) => Task.FromResult>([]); + public Task ExecuteAsync(ClientCommand command, CancellationToken _) + { + if (command.Cmd == "recording.ack") Outbox.Acknowledge(command.RecordingId!.Value, command.JobId); + return Task.FromResult(new ResultMessage(command.Id, true)); + } +} +internal sealed class PluginTabChannel { public bool TryComplete(ClientCommand _) => false; }