Add call recording integration
This commit is contained in:
1 parent
ce9e6816f8
commit
b081ab275b
15 files changed
+1058
-19
No files matched your search
@@ -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.Text;
|
||||
using IpPbx.CLMgrLib;
|
||||
using SwyxTray.Recording;
|
||||
|
||||
namespace SwyxTray.Swyx;
|
||||
|
||||
@@ -27,6 +28,10 @@ internal sealed class SwyxClient : IDisposable
|
||||
|
||||
private ClientLineMgrClass? _mgr;
|
||||
private IClientLineMgrDisp? _disp;
|
||||
private readonly CallRecordingTracker _recordings = new();
|
||||
public RecordingOutbox Outbox { get; } = new(Path.Combine(Log.Directory, "recordings"));
|
||||
|
||||
public SwyxClient() => Outbox.Start();
|
||||
private IClientLineMgrEventsPub_Event? _events;
|
||||
private IClientLineMgrEventsPub_PubOnLineMgrNotificationEventHandler? _handler;
|
||||
|
||||
@@ -229,7 +234,7 @@ internal sealed class SwyxClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static SwyxSnapshot ReadSnapshot(IClientLineMgrDisp disp)
|
||||
private SwyxSnapshot ReadSnapshot(IClientLineMgrDisp disp)
|
||||
{
|
||||
var lineCount = disp.DispNumberOfLines;
|
||||
var selected = disp.DispSelectedLineNumber;
|
||||
@@ -250,7 +255,10 @@ internal sealed class SwyxClient : IDisposable
|
||||
State: (CLMgrLineStates)line.DispState,
|
||||
PeerNumber: line.DispPeerNumber ?? string.Empty,
|
||||
PeerName: line.DispPeerName ?? string.Empty,
|
||||
IsSelected: i == selected));
|
||||
IsSelected: i == selected,
|
||||
CallId: line.DispCallId,
|
||||
RecordingRequested: _recordings.IsRequested(i, line.DispCallId),
|
||||
RecordingId: Outbox.CurrentId(i + 1, line.DispCallId)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -268,6 +276,12 @@ internal sealed class SwyxClient : IDisposable
|
||||
|
||||
private void Publish(SwyxSnapshot snapshot)
|
||||
{
|
||||
try { Outbox.Observe(snapshot); }
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{ Log.Error("Aufnahmeende konnte noch nicht gespeichert werden; wird beim nächsten Zustand erneut versucht.", ex); }
|
||||
if (snapshot.IsConnected && snapshot.IsServerUp)
|
||||
_recordings.Retain(snapshot.Lines.Where(l => l.IsBusy && l.CallId.HasValue)
|
||||
.Select(l => (l.Index, l.CallId!.Value)));
|
||||
var signature = BuildSignature(snapshot);
|
||||
if (signature == _lastSignature)
|
||||
{
|
||||
@@ -306,7 +320,8 @@ internal sealed class SwyxClient : IDisposable
|
||||
.Append((int)l.State).Append(':')
|
||||
.Append(l.PeerNumber).Append(':')
|
||||
.Append(l.PeerName).Append(':')
|
||||
.Append(l.IsSelected ? '1' : '0');
|
||||
.Append(l.IsSelected ? '1' : '0').Append(':')
|
||||
.Append(l.CallId).Append(':').Append(l.RecordingRequested).Append(':').Append(l.RecordingId);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
@@ -341,23 +356,90 @@ internal sealed class SwyxClient : IDisposable
|
||||
}
|
||||
|
||||
/// <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.");
|
||||
WithLine(index, l => l.DispHookOff());
|
||||
WithLine(index, l =>
|
||||
{
|
||||
if (callId.HasValue && l.DispCallId != callId.Value)
|
||||
throw new InvalidOperationException("Der Anruf auf dieser Leitung hat sich geaendert.");
|
||||
if (!((CLMgrLineStates)l.DispState).IsRinging())
|
||||
throw new InvalidOperationException("Die Leitung klingelt nicht mehr.");
|
||||
l.DispHookOff();
|
||||
});
|
||||
Refresh();
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
/// <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.");
|
||||
WithLine(index, l => l.DispHookOn());
|
||||
WithLine(index, l =>
|
||||
{
|
||||
if (((CLMgrLineStates)l.DispState).IsIdle())
|
||||
throw new InvalidOperationException("Das Gespräch ist bereits beendet.");
|
||||
if (callId.HasValue && l.DispCallId != callId.Value)
|
||||
throw new InvalidOperationException("Der Anruf auf dieser Leitung hat sich geändert.");
|
||||
l.DispHookOn();
|
||||
});
|
||||
Refresh();
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public int Hold(int? line)
|
||||
{
|
||||
@@ -718,6 +800,7 @@ internal sealed class SwyxClient : IDisposable
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
Outbox.Dispose();
|
||||
Cleanup();
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,10 @@ public sealed record SwyxLineInfo(
|
||||
CLMgrLineStates State,
|
||||
string PeerNumber,
|
||||
string PeerName,
|
||||
bool IsSelected)
|
||||
bool IsSelected,
|
||||
int? CallId = null,
|
||||
bool RecordingRequested = false,
|
||||
Guid? RecordingId = null)
|
||||
{
|
||||
/// <summary>Anzeigename des Gespraechspartners, Nummer als Rueckfallwert.</summary>
|
||||
public string PeerDisplay =>
|
||||
|
||||
@@ -27,6 +27,8 @@ internal sealed class CommandExecutor
|
||||
_tabs = tabs;
|
||||
}
|
||||
|
||||
public SwyxTray.Recording.RecordingOutbox Outbox => _client.Outbox;
|
||||
|
||||
public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
|
||||
=> RunAsync(() => _client.Current, cancellationToken);
|
||||
|
||||
@@ -50,12 +52,25 @@ internal sealed class CommandExecutor
|
||||
|
||||
case "answer":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Line: await RunAsync(() => _client.Answer(command.Line), cancellationToken)
|
||||
Line: await RunAsync(() => _client.Answer(command.Line, command.CallId), cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
case "recording.start":
|
||||
case "recording.stop":
|
||||
var start = command.Cmd.Trim().Equals("recording.start", StringComparison.OrdinalIgnoreCase);
|
||||
var recordingId = await RunAsync(() =>
|
||||
_client.SetRecording(command.Line, command.CallId, start, command.RecordingId),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return new ResultMessage(command.Id, true, Line: command.Line,
|
||||
CallId: command.CallId, RecordingRequested: start, RecordingId: recordingId);
|
||||
|
||||
case "recording.ack":
|
||||
_client.Outbox.Acknowledge(command.RecordingId ?? Guid.Empty, command.JobId);
|
||||
return new ResultMessage(command.Id, true);
|
||||
|
||||
case "hangup":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Line: await RunAsync(() => _client.Hangup(command.Line), cancellationToken)
|
||||
Line: await RunAsync(() => _client.Hangup(command.Line, command.CallId), cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
case "hold":
|
||||
@@ -108,7 +123,7 @@ internal sealed class CommandExecutor
|
||||
return new ResultMessage(command.Id, false, $"Unbekanntes Kommando '{command.Cmd}'.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Erwartbare Bedienfehler: falsche Nummer, keine passende Leitung,
|
||||
// SwyxIt! nicht verbunden.
|
||||
|
||||
@@ -40,6 +40,7 @@ internal sealed class LocalWebSocketServer : IDisposable
|
||||
_config = config;
|
||||
_tabChannel = new PluginTabChannel();
|
||||
_executor = new CommandExecutor(client, ui, _tabChannel);
|
||||
_executor.Outbox.Changed += BroadcastRecording;
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
@@ -444,8 +445,16 @@ internal sealed class LocalWebSocketServer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void BroadcastRecording(SwyxTray.Recording.RecordingNotice notice)
|
||||
{
|
||||
var json = Protocol.Serialize(notice);
|
||||
foreach (var session in _sessions.Values.Where(s => !s.IsPlugin))
|
||||
_ = session.TrySendManyAsync([json], _shutdown.Token);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Outbox.Changed -= BroadcastRecording;
|
||||
_shutdown.Cancel();
|
||||
|
||||
foreach (var listener in _listeners)
|
||||
|
||||
@@ -38,6 +38,12 @@ internal sealed class ClientCommand
|
||||
/// <summary>1-basierte Leitung; fehlt sie, waehlt die App selbst.</summary>
|
||||
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>
|
||||
public string? Title { get; set; }
|
||||
|
||||
@@ -110,7 +116,8 @@ internal sealed record ResultMessage(
|
||||
int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null,
|
||||
int? TabId = null, IReadOnlyList<TabInfo>? Tabs = 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";
|
||||
}
|
||||
@@ -138,7 +145,9 @@ internal sealed record LineMessage(
|
||||
string PeerNumber,
|
||||
string PeerName,
|
||||
bool Busy,
|
||||
bool Selected);
|
||||
bool Selected,
|
||||
int? CallId,
|
||||
bool RecordingRequested, Guid? RecordingId);
|
||||
|
||||
/// <summary>
|
||||
/// Anruf-Ereignis, abgeleitet aus dem Vergleich zweier aufeinanderfolgender
|
||||
@@ -157,18 +166,22 @@ internal sealed record CallEventMessage(
|
||||
string Peer,
|
||||
string PeerNumber,
|
||||
string PeerName,
|
||||
string? Direction = null)
|
||||
string? Direction = null,
|
||||
int? CallId = null)
|
||||
{
|
||||
public string Type => "call";
|
||||
|
||||
public static IReadOnlyList<CallEventMessage> Diff(SwyxSnapshot previous, SwyxSnapshot current)
|
||||
{
|
||||
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);
|
||||
|
||||
foreach (var line in current.Lines)
|
||||
{
|
||||
before.TryGetValue(line.Index, out var prev);
|
||||
if (prev?.CallId != line.CallId) prev = null;
|
||||
var wasRinging = prev?.State.IsRinging() ?? false;
|
||||
var wasDialing = prev?.State.IsDialing() ?? false;
|
||||
var wasTalking = prev is not null && (prev.State.IsActive() || prev.State.IsOnHold());
|
||||
@@ -193,7 +206,8 @@ internal sealed record CallEventMessage(
|
||||
var after = current.Lines.ToDictionary(l => l.Index);
|
||||
foreach (var prev in previous.Lines.Where(l => l.IsBusy))
|
||||
{
|
||||
if (!after.TryGetValue(prev.Index, out var line) || !line.IsBusy)
|
||||
if (after.TryGetValue(prev.Index, out var line)
|
||||
&& (!line.IsBusy || line.CallId != prev.CallId))
|
||||
{
|
||||
events.Add(From("ended", prev));
|
||||
}
|
||||
@@ -203,7 +217,7 @@ internal sealed record CallEventMessage(
|
||||
}
|
||||
|
||||
private static CallEventMessage From(string evt, SwyxLineInfo line, string? direction = null)
|
||||
=> new(evt, line.Index + 1, line.PeerDisplayLong, line.PeerNumber, line.PeerName, direction);
|
||||
=> new(evt, line.Index + 1, line.PeerDisplayLong, line.PeerNumber, line.PeerName, direction, line.CallId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -237,5 +251,7 @@ internal sealed record SnapshotMessage(
|
||||
PeerNumber: l.PeerNumber,
|
||||
PeerName: l.PeerName,
|
||||
Busy: l.IsBusy,
|
||||
Selected: l.IsSelected)).ToList());
|
||||
Selected: l.IsSelected,
|
||||
CallId: l.CallId,
|
||||
RecordingRequested: l.RecordingRequested, RecordingId: l.RecordingId)).ToList());
|
||||
}
|
||||
@@ -44,7 +44,9 @@ internal sealed class WebSocketConfig
|
||||
/// Ruf holt SwyxTray den Firefox-Tab mit diesem Titel selbsttaetig nach
|
||||
/// vorn.
|
||||
/// </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>
|
||||
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -20,6 +21,8 @@ internal sealed class WebSocketSession : IDisposable
|
||||
private readonly CommandExecutor _executor;
|
||||
private readonly PluginTabChannel _tabs;
|
||||
private readonly SemaphoreSlim _sendGate = new(1, 1);
|
||||
private readonly CancellationTokenSource _closed = new();
|
||||
private int _downloading;
|
||||
|
||||
public int Id { get; }
|
||||
|
||||
@@ -55,6 +58,7 @@ internal sealed class WebSocketSession : IDisposable
|
||||
{
|
||||
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await SendRecordingsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var buffer = new byte[8 * 1024];
|
||||
@@ -129,6 +133,20 @@ internal sealed class WebSocketSession : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.Cmd.Equals("recording.download", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (command.Id is null or <= 0 || command.RecordingId == null ||
|
||||
Interlocked.CompareExchange(ref _downloading, 1, 0) != 0)
|
||||
{
|
||||
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, false,
|
||||
"Ungültiger Downloadauftrag oder bereits laufende Übertragung.")), cancellationToken);
|
||||
return;
|
||||
}
|
||||
// Receive loop remains available for hangup, stop, ping etc. while sending audio.
|
||||
_ = DownloadAsync(command, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// 'status' beantwortet der Server mit dem vollen Zustand samt der
|
||||
// zwischengespeicherten Adressdaten; die Quittung danach haelt den
|
||||
// Ablauf fuer den Client einheitlich.
|
||||
@@ -136,6 +154,7 @@ internal sealed class WebSocketSession : IDisposable
|
||||
{
|
||||
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await SendRecordingsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
@@ -145,6 +164,45 @@ internal sealed class WebSocketSession : IDisposable
|
||||
await SendAsync(Protocol.Serialize(response), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task SendRecordingsAsync(CancellationToken cancellationToken)
|
||||
=> SendManyAsync(_executor.Outbox.Notices().Select(Protocol.Serialize).ToArray(), cancellationToken);
|
||||
|
||||
private async Task DownloadAsync(ClientCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closed.Token);
|
||||
timeout.CancelAfter(TimeSpan.FromMinutes(5));
|
||||
try
|
||||
{
|
||||
using var input = _executor.Outbox.Open(command.RecordingId!.Value);
|
||||
var block = new byte[64 * 1024 + 12];
|
||||
"SWYX"u8.CopyTo(block);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(block.AsSpan(4), command.Id!.Value);
|
||||
int offset = 0;
|
||||
int count;
|
||||
while ((count = await input.ReadAsync(block.AsMemory(12), timeout.Token)) > 0)
|
||||
{
|
||||
BinaryPrimitives.WriteInt32LittleEndian(block.AsSpan(8), offset);
|
||||
await _sendGate.WaitAsync(timeout.Token);
|
||||
try
|
||||
{
|
||||
await _socket.SendAsync(block.AsMemory(0, count + 12), WebSocketMessageType.Binary,
|
||||
true, timeout.Token);
|
||||
}
|
||||
finally { _sendGate.Release(); }
|
||||
offset += count;
|
||||
await Task.Yield();
|
||||
}
|
||||
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), timeout.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException
|
||||
or WebSocketException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
await TrySendManyAsync([Protocol.Serialize(new ResultMessage(command.Id, false,
|
||||
"WAV-Übertragung unterbrochen: " + ex.Message))], cancellationToken);
|
||||
}
|
||||
finally { Interlocked.Exchange(ref _downloading, 0); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auf dem Plugin-Port gibt es nur die Tab-Verwaltung: Antworten auf
|
||||
/// Tab-Auftraege und ein Lebenszeichen. Telefonie laeuft ausschliesslich
|
||||
@@ -252,7 +310,8 @@ internal sealed class WebSocketSession : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_closed.Cancel();
|
||||
_socket.Dispose();
|
||||
_sendGate.Dispose();
|
||||
// A download/broadcast may still be unwinding its finally block.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user