Add call recording integration
This commit is contained in:
1 parent
ce9e6816f8
commit
b081ab275b
15 files changed
+1058
-19
No files matched your search
@@ -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.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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)]);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
Reference in new issue
Block a user