Add call recording integration

This commit is contained in:
Sven committed 2026-11-02 11:45:41 +01:00
1 parent ce9e6816f8
commit b081ab275b
15 files changed
+1058 -19

No files matched your search

+107
View File
@@ -667,3 +667,110 @@ relevant:
- [IClientLineMgrPub](https://clientsdk.swyx.engineering/interface_i_client_line_mgr_pub.html) - [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) - [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) - [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.
+290
View File
@@ -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);
/// <summary>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.</summary>
internal sealed class RecordingOutbox : IDisposable
{
public const long MaxBytes = 500L * 1024 * 1024;
private readonly string _folder;
private readonly Func<DateTime> _now;
private readonly object _gate = new();
private readonly Dictionary<Guid, RecordingEntry> _entries = new();
private readonly Dictionary<Guid, (string Path, long Length, DateTime Written, DateTime Seen)> _stable = new();
private readonly CancellationTokenSource _shutdown = new();
private Task? _worker;
public event Action<RecordingNotice>? Changed;
public RecordingOutbox(string folder, Func<DateTime>? now = null)
{
_folder = folder;
_now = now ?? (() => DateTime.UtcNow);
Directory.CreateDirectory(folder);
foreach (var path in Directory.EnumerateFiles(folder, "*.json"))
{
try
{
var entry = JsonSerializer.Deserialize<RecordingEntry>(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<byte> 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<byte> 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();
}
+28
View File
@@ -0,0 +1,28 @@
namespace SwyxTray.Swyx;
/// <summary>Merkt ausschliesslich erfolgreich an CLMgr uebergebene Aufnahmeauftraege.
/// Das SDK liefert keinen Aufnahmezustand; dies ist keine Dateischreibbestaetigung.</summary>
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);
}
+90 -7
View File
@@ -2,6 +2,7 @@ using System.Collections;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using IpPbx.CLMgrLib; using IpPbx.CLMgrLib;
using SwyxTray.Recording;
namespace SwyxTray.Swyx; namespace SwyxTray.Swyx;
@@ -27,6 +28,10 @@ internal sealed class SwyxClient : IDisposable
private ClientLineMgrClass? _mgr; private ClientLineMgrClass? _mgr;
private IClientLineMgrDisp? _disp; 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_Event? _events;
private IClientLineMgrEventsPub_PubOnLineMgrNotificationEventHandler? _handler; 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 lineCount = disp.DispNumberOfLines;
var selected = disp.DispSelectedLineNumber; var selected = disp.DispSelectedLineNumber;
@@ -250,7 +255,10 @@ internal sealed class SwyxClient : IDisposable
State: (CLMgrLineStates)line.DispState, State: (CLMgrLineStates)line.DispState,
PeerNumber: line.DispPeerNumber ?? string.Empty, PeerNumber: line.DispPeerNumber ?? string.Empty,
PeerName: line.DispPeerName ?? 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 finally
{ {
@@ -268,6 +276,12 @@ internal sealed class SwyxClient : IDisposable
private void Publish(SwyxSnapshot snapshot) 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); var signature = BuildSignature(snapshot);
if (signature == _lastSignature) if (signature == _lastSignature)
{ {
@@ -306,7 +320,8 @@ internal sealed class SwyxClient : IDisposable
.Append((int)l.State).Append(':') .Append((int)l.State).Append(':')
.Append(l.PeerNumber).Append(':') .Append(l.PeerNumber).Append(':')
.Append(l.PeerName).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(); return sb.ToString();
@@ -341,23 +356,90 @@ internal sealed class SwyxClient : IDisposable
} }
/// <summary>Nimmt einen eingehenden Ruf an; liefert die Leitung (1-basiert).</summary> /// <summary>Nimmt einen eingehenden Ruf an; liefert die Leitung (1-basiert).</summary>
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."); 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(); Refresh();
return index + 1; return index + 1;
} }
/// <summary>Legt auf; liefert die Leitung (1-basiert).</summary> /// <summary>Legt auf; liefert die Leitung (1-basiert).</summary>
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."); 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(); Refresh();
return index + 1; return index + 1;
} }
/// <summary>Aufnahme des exakt bezeichneten Anrufs; SDK-Aufruf im STA-Thread.</summary>
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); }
}
/// <summary>Stellt ein Gespraech in die Warteschleife.</summary> /// <summary>Stellt ein Gespraech in die Warteschleife.</summary>
public int Hold(int? line) public int Hold(int? line)
{ {
@@ -718,6 +800,7 @@ internal sealed class SwyxClient : IDisposable
} }
_disposed = true; _disposed = true;
Outbox.Dispose();
Cleanup(); Cleanup();
} }
} }
+4 -1
View File
@@ -35,7 +35,10 @@ public sealed record SwyxLineInfo(
CLMgrLineStates State, CLMgrLineStates State,
string PeerNumber, string PeerNumber,
string PeerName, string PeerName,
bool IsSelected) bool IsSelected,
int? CallId = null,
bool RecordingRequested = false,
Guid? RecordingId = null)
{ {
/// <summary>Anzeigename des Gespraechspartners, Nummer als Rueckfallwert.</summary> /// <summary>Anzeigename des Gespraechspartners, Nummer als Rueckfallwert.</summary>
public string PeerDisplay => public string PeerDisplay =>
+18 -3
View File
@@ -27,6 +27,8 @@ internal sealed class CommandExecutor
_tabs = tabs; _tabs = tabs;
} }
public SwyxTray.Recording.RecordingOutbox Outbox => _client.Outbox;
public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken cancellationToken) public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
=> RunAsync(() => _client.Current, cancellationToken); => RunAsync(() => _client.Current, cancellationToken);
@@ -50,12 +52,25 @@ internal sealed class CommandExecutor
case "answer": case "answer":
return new ResultMessage(command.Id, true, 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)); .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": case "hangup":
return new ResultMessage(command.Id, true, 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)); .ConfigureAwait(false));
case "hold": case "hold":
@@ -108,7 +123,7 @@ internal sealed class CommandExecutor
return new ResultMessage(command.Id, false, $"Unbekanntes Kommando '{command.Cmd}'."); 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, // Erwartbare Bedienfehler: falsche Nummer, keine passende Leitung,
// SwyxIt! nicht verbunden. // SwyxIt! nicht verbunden.
+9
View File
@@ -40,6 +40,7 @@ internal sealed class LocalWebSocketServer : IDisposable
_config = config; _config = config;
_tabChannel = new PluginTabChannel(); _tabChannel = new PluginTabChannel();
_executor = new CommandExecutor(client, ui, _tabChannel); _executor = new CommandExecutor(client, ui, _tabChannel);
_executor.Outbox.Changed += BroadcastRecording;
} }
public bool IsRunning { get; private set; } 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() public void Dispose()
{ {
_executor.Outbox.Changed -= BroadcastRecording;
_shutdown.Cancel(); _shutdown.Cancel();
foreach (var listener in _listeners) foreach (var listener in _listeners)
+22 -6
View File
@@ -38,6 +38,12 @@ internal sealed class ClientCommand
/// <summary>1-basierte Leitung; fehlt sie, waehlt die App selbst.</summary> /// <summary>1-basierte Leitung; fehlt sie, waehlt die App selbst.</summary>
public int? Line { get; set; } public int? Line { get; set; }
/// <summary>SDK-Anrufkennung aus dem Snapshot; Pflicht fuer Aufnahmebefehle.</summary>
public int? CallId { get; set; }
public Guid? RecordingId { get; set; }
public string? JobId { get; set; }
/// <summary>Nur bei <c>focus</c>: gesuchter Fenstertitel.</summary> /// <summary>Nur bei <c>focus</c>: gesuchter Fenstertitel.</summary>
public string? Title { get; set; } 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? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null,
int? TabId = null, IReadOnlyList<TabInfo>? Tabs = null, int? TabId = null, IReadOnlyList<TabInfo>? Tabs = null,
IReadOnlyList<ContactMessage>? Contacts = null, string? Name = null, IReadOnlyList<ContactMessage>? Contacts = null, string? Name = null,
IReadOnlyList<ContactMessage>? Addresses = null) IReadOnlyList<ContactMessage>? Addresses = null,
int? CallId = null, bool? RecordingRequested = null, Guid? RecordingId = null)
{ {
public string Type => "result"; public string Type => "result";
} }
@@ -138,7 +145,9 @@ internal sealed record LineMessage(
string PeerNumber, string PeerNumber,
string PeerName, string PeerName,
bool Busy, bool Busy,
bool Selected); bool Selected,
int? CallId,
bool RecordingRequested, Guid? RecordingId);
/// <summary> /// <summary>
/// Anruf-Ereignis, abgeleitet aus dem Vergleich zweier aufeinanderfolgender /// Anruf-Ereignis, abgeleitet aus dem Vergleich zweier aufeinanderfolgender
@@ -157,18 +166,22 @@ internal sealed record CallEventMessage(
string Peer, string Peer,
string PeerNumber, string PeerNumber,
string PeerName, string PeerName,
string? Direction = null) string? Direction = null,
int? CallId = null)
{ {
public string Type => "call"; public string Type => "call";
public static IReadOnlyList<CallEventMessage> Diff(SwyxSnapshot previous, SwyxSnapshot current) public static IReadOnlyList<CallEventMessage> Diff(SwyxSnapshot previous, SwyxSnapshot current)
{ {
var events = new List<CallEventMessage>(); var events = new List<CallEventMessage>();
// Verbindungsverlust ist kein nachgewiesenes Gespraechsende.
if (!current.IsConnected || !current.IsServerUp) return events;
var before = previous.Lines.ToDictionary(l => l.Index); var before = previous.Lines.ToDictionary(l => l.Index);
foreach (var line in current.Lines) foreach (var line in current.Lines)
{ {
before.TryGetValue(line.Index, out var prev); before.TryGetValue(line.Index, out var prev);
if (prev?.CallId != line.CallId) prev = null;
var wasRinging = prev?.State.IsRinging() ?? false; var wasRinging = prev?.State.IsRinging() ?? false;
var wasDialing = prev?.State.IsDialing() ?? false; var wasDialing = prev?.State.IsDialing() ?? false;
var wasTalking = prev is not null && (prev.State.IsActive() || prev.State.IsOnHold()); 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); var after = current.Lines.ToDictionary(l => l.Index);
foreach (var prev in previous.Lines.Where(l => l.IsBusy)) 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)); events.Add(From("ended", prev));
} }
@@ -203,7 +217,7 @@ internal sealed record CallEventMessage(
} }
private static CallEventMessage From(string evt, SwyxLineInfo line, string? direction = null) 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);
} }
/// <summary> /// <summary>
@@ -237,5 +251,7 @@ internal sealed record SnapshotMessage(
PeerNumber: l.PeerNumber, PeerNumber: l.PeerNumber,
PeerName: l.PeerName, PeerName: l.PeerName,
Busy: l.IsBusy, Busy: l.IsBusy,
Selected: l.IsSelected)).ToList()); Selected: l.IsSelected,
CallId: l.CallId,
RecordingRequested: l.RecordingRequested, RecordingId: l.RecordingId)).ToList());
} }
+3 -1
View File
@@ -44,7 +44,9 @@ internal sealed class WebSocketConfig
/// Ruf holt SwyxTray den Firefox-Tab mit diesem Titel selbsttaetig nach /// Ruf holt SwyxTray den Firefox-Tab mit diesem Titel selbsttaetig nach
/// vorn. /// vorn.
/// </summary> /// </summary>
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;
/// <summary> /// <summary>
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen — /// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
+60 -1
View File
@@ -1,4 +1,5 @@
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Buffers.Binary;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@@ -20,6 +21,8 @@ internal sealed class WebSocketSession : IDisposable
private readonly CommandExecutor _executor; private readonly CommandExecutor _executor;
private readonly PluginTabChannel _tabs; private readonly PluginTabChannel _tabs;
private readonly SemaphoreSlim _sendGate = new(1, 1); private readonly SemaphoreSlim _sendGate = new(1, 1);
private readonly CancellationTokenSource _closed = new();
private int _downloading;
public int Id { get; } public int Id { get; }
@@ -55,6 +58,7 @@ internal sealed class WebSocketSession : IDisposable
{ {
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false); await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
await SendAddressesAsync(cancellationToken).ConfigureAwait(false); await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
await SendRecordingsAsync(cancellationToken).ConfigureAwait(false);
} }
var buffer = new byte[8 * 1024]; var buffer = new byte[8 * 1024];
@@ -129,6 +133,20 @@ internal sealed class WebSocketSession : IDisposable
return; 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 // 'status' beantwortet der Server mit dem vollen Zustand samt der
// zwischengespeicherten Adressdaten; die Quittung danach haelt den // zwischengespeicherten Adressdaten; die Quittung danach haelt den
// Ablauf fuer den Client einheitlich. // Ablauf fuer den Client einheitlich.
@@ -136,6 +154,7 @@ internal sealed class WebSocketSession : IDisposable
{ {
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false); await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
await SendAddressesAsync(cancellationToken).ConfigureAwait(false); await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
await SendRecordingsAsync(cancellationToken).ConfigureAwait(false);
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken) await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return; return;
@@ -145,6 +164,45 @@ internal sealed class WebSocketSession : IDisposable
await SendAsync(Protocol.Serialize(response), cancellationToken).ConfigureAwait(false); 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); }
}
/// <summary> /// <summary>
/// Auf dem Plugin-Port gibt es nur die Tab-Verwaltung: Antworten auf /// Auf dem Plugin-Port gibt es nur die Tab-Verwaltung: Antworten auf
/// Tab-Auftraege und ein Lebenszeichen. Telefonie laeuft ausschliesslich /// Tab-Auftraege und ein Lebenszeichen. Telefonie laeuft ausschliesslich
@@ -252,7 +310,8 @@ internal sealed class WebSocketSession : IDisposable
public void Dispose() public void Dispose()
{ {
_closed.Cancel();
_socket.Dispose(); _socket.Dispose();
_sendGate.Dispose(); // A download/broadcast may still be unwinding its finally block.
} }
} }
@@ -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<bool, string> 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)]);
}
+51
View File
@@ -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).");
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Platform>x64</Platform>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swyx.Client.ClmgrAPI" Version="14.21.0" />
<Compile Include="../../src/SwyxTray/Swyx/CallRecordingTracker.cs" Link="CallRecordingTracker.cs" />
<Compile Include="../../src/SwyxTray/Swyx/SwyxModels.cs" Link="SwyxModels.cs" />
<Compile Include="../../src/SwyxTray/Swyx/LineStateExtensions.cs" Link="LineStateExtensions.cs" />
<Compile Include="../../src/SwyxTray/Web/Protocol.cs" Link="Protocol.cs" />
<Compile Include="../../src/SwyxTray/Recording/RecordingOutbox.cs" Link="RecordingOutbox.cs" />
<Compile Include="../../src/SwyxTray/Log.cs" Link="Log.cs" />
<Compile Include="../../src/SwyxTray/Web/WebSocketSession.cs" Link="WebSocketSession.cs" />
<Compile Include="../../src/SwyxTray/Web/WebSocketConfig.cs" Link="WebSocketConfig.cs" />
<Compile Include="../../src/SwyxTray/Web/WebSocketHandshake.cs" Link="WebSocketHandshake.cs" />
</ItemGroup>
</Project>
@@ -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<bool, string> 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<JsonElement> Until(WebSocket socket, Func<JsonElement, bool> 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());
}
}
@@ -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<SwyxSnapshot> GetSnapshotAsync(CancellationToken _) => Task.FromResult(SwyxSnapshot.Offline);
public Task<IReadOnlyList<SwyxContact>> GetAddressesAsync(CancellationToken _) => Task.FromResult<IReadOnlyList<SwyxContact>>([]);
public Task<ResultMessage> 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; }