Add call recording integration

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

No files matched your search

+290
View File
@@ -0,0 +1,290 @@
using System.Buffers.Binary;
using System.Text.Json;
using System.Text.RegularExpressions;
using SwyxTray.Swyx;
using SwyxTray.Web;
namespace SwyxTray.Recording;
internal sealed record RecordingNotice(Guid RecordingId, int Line, int CallId, string PeerNumber,
string PeerName, string State, string? Filename = null, long? Bytes = null,
string? JobId = null, string? Error = null, bool CallEnded = false)
{
public string Type => State == "ready" ? "recording.ready" : "recording.status";
public string Transport => "websocket";
}
internal sealed record RecordingEntry(Guid Id, int Line, int CallId, string PeerNumber, string PeerName,
string Folder, DateTime Started, string[] Baseline, DateTime? Ended = null,
string State = "capturing", string? Filename = null, long Bytes = 0,
string? JobId = null, string? Error = null, string[]? SourceFiles = null, DateTime? CallEnded = null);
/// <summary>Persistent outbox. Original Swyx files are never moved or deleted.
/// A WAV is only offered after a confirmed call end and an exclusive, stable read.</summary>
internal sealed class RecordingOutbox : IDisposable
{
public const long MaxBytes = 500L * 1024 * 1024;
private readonly string _folder;
private readonly Func<DateTime> _now;
private readonly object _gate = new();
private readonly Dictionary<Guid, RecordingEntry> _entries = new();
private readonly Dictionary<Guid, (string Path, long Length, DateTime Written, DateTime Seen)> _stable = new();
private readonly CancellationTokenSource _shutdown = new();
private Task? _worker;
public event Action<RecordingNotice>? Changed;
public RecordingOutbox(string folder, Func<DateTime>? now = null)
{
_folder = folder;
_now = now ?? (() => DateTime.UtcNow);
Directory.CreateDirectory(folder);
foreach (var path in Directory.EnumerateFiles(folder, "*.json"))
{
try
{
var entry = JsonSerializer.Deserialize<RecordingEntry>(File.ReadAllText(path), Protocol.Json);
if (entry != null && Path.GetFileNameWithoutExtension(path) == entry.Id.ToString())
// Older manifests only recorded the recording stop, not the call end.
_entries[entry.Id] = entry.CallEnded == null && entry.State == "ready"
? entry with { State = "waiting_call_end" } : entry;
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{ Log.Error("Aufnahme-Warteschlange konnte nicht gelesen werden: " + path, ex); }
}
}
public void Start() => _worker ??= Task.Run(async () =>
{
while (!_shutdown.IsCancellationRequested)
{
try { Process(); }
catch (Exception ex) { Log.Error("Aufnahme-Warteschlange: Verarbeitung fehlgeschlagen.", ex); }
try { await Task.Delay(2000, _shutdown.Token); }
catch (OperationCanceledException) { break; }
}
});
public Guid? CurrentId(int line, int callId)
{
lock (_gate) return _entries.Values.FirstOrDefault(e => e.Line == line && e.CallId == callId && e.Ended == null)?.Id;
}
public Guid Begin(int line, int callId, string peerNumber, string peerName, string folder, Guid? id)
{
lock (_gate)
{
if (CurrentId(line, callId) is { } existing) return existing;
var key = id ?? Guid.NewGuid();
if (key == Guid.Empty || _entries.ContainsKey(key))
throw new InvalidOperationException("Diese Aufnahme-Kennung wurde bereits verwendet.");
string[] baseline = [];
string? error = null;
try { baseline = Wavs(folder).Select(f => f.FullName).ToArray(); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{ error = "Aufnahmeordner ist nicht erreichbar: " + ex.Message; }
Put(new(key, line, callId, peerNumber, peerName, folder, _now(), baseline, Error: error));
return key;
}
}
public void Abort(Guid id)
{
lock (_gate) { _entries.Remove(id); File.Delete(Manifest(id)); }
}
public void End(int line, int callId)
{
lock (_gate)
{
if (CurrentId(line, callId) is not { } id) return;
var entry = _entries[id];
string[]? sources = null;
try
{
var found = Wavs(entry.Folder).Where(f => Candidate(entry, f)).Select(f => f.FullName).ToArray();
if (found.Length > 0) sources = found;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{ Log.Debug("Aufnahmedatei wird nach dem Stopp erneut gesucht: " + ex.Message); }
Put(entry with { Ended = _now(), SourceFiles = sources,
State = entry.Error == null ? "waiting_call_end" : "error" });
}
}
public void Observe(SwyxSnapshot snapshot)
{
// COM/server disconnection is not evidence of a finished call.
if (!snapshot.IsConnected || !snapshot.IsServerUp) return;
lock (_gate)
{
foreach (var entry in _entries.Values.Where(e => e.CallEnded == null && e.State != "submitted").ToArray())
{
var line = snapshot.Lines.FirstOrDefault(l => l.Index + 1 == entry.Line);
if (line == null || (line.IsBusy && line.CallId == entry.CallId)) continue;
if (entry.Ended == null) End(entry.Line, entry.CallId);
var stopped = _entries[entry.Id];
Put(stopped with { CallEnded = _now(), State = stopped.Error != null ? "error"
: stopped.Bytes > 0 && File.Exists(Wav(entry.Id)) ? "ready" : "waiting" });
}
}
}
public RecordingNotice[] Notices()
{
lock (_gate) return _entries.Values.Where(e => e.Ended != null && e.State != "submitted")
.Concat(_entries.Values.Where(e => e.State == "submitted").OrderByDescending(e => e.Started).Take(100))
.OrderByDescending(e => e.Started).Select(Notice).ToArray();
}
public FileStream Open(Guid id)
{
lock (_gate)
{
if (!_entries.TryGetValue(id, out var entry) || entry.State != "ready" || entry.CallEnded == null)
throw new InvalidOperationException("Die fertige Aufnahme ist nicht verfügbar.");
return new FileStream(Wav(id), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
}
}
public void Acknowledge(Guid id, string? jobId)
{
if (jobId == null || !Regex.IsMatch(jobId, "\\A[a-f0-9]{32}\\z"))
throw new InvalidOperationException("Ungültige SwyxTrans-Auftragskennung.");
lock (_gate)
{
if (!_entries.TryGetValue(id, out var entry) || (entry.CallEnded == null && entry.State != "submitted")
|| entry.State is not ("ready" or "error" or "submitted"))
throw new InvalidOperationException("Aufnahme ist noch nicht zur Übergabe bereit.");
if (entry.JobId != null && entry.JobId != jobId)
throw new InvalidOperationException("Aufnahme wurde bereits einem anderen Auftrag zugeordnet.");
Put(entry with { State = "submitted", JobId = jobId, Baseline = [], Error = null });
// Persist receipt before removing our temporary copy. Swyx original remains untouched.
TryDeleteCopy(id);
}
}
public void Process()
{
RecordingEntry[] pending;
lock (_gate) pending = _entries.Values.Where(e => e.Ended != null &&
(e.State == "waiting" || (e.State == "waiting_call_end" && e.Bytes == 0))).ToArray();
foreach (var entry in pending)
{
if (_now() - entry.Ended < TimeSpan.FromSeconds(3)) continue;
try
{
// Recover a copy completed immediately before an application crash.
if (!File.Exists(Wav(entry.Id))) Prepare(entry);
if (!File.Exists(Wav(entry.Id))) continue;
lock (_gate)
{
// Observe may have confirmed the end while the local copy was being made.
var current = _entries[entry.Id];
Put(current with { State = current.CallEnded != null ? "ready" : "waiting_call_end",
Filename = entry.Id + ".wav", Bytes = new FileInfo(Wav(entry.Id)).Length });
}
}
catch (InvalidDataException ex) { Fail(entry, ex.Message); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
if (_now() - entry.Ended > TimeSpan.FromMinutes(2))
Fail(entry, "Die WAV-Datei konnte nicht fertig gelesen werden: " + ex.Message);
}
}
}
private void Prepare(RecordingEntry entry)
{
var candidates = Wavs(entry.Folder).Where(f => Candidate(entry, f)).ToArray();
if (candidates.Length > 1)
throw new InvalidDataException("Mehrere neue WAV-Dateien gefunden. Bitte die Aufnahme manuell zuordnen.");
if (candidates.Length == 0)
{
if (_now() - entry.Ended > TimeSpan.FromMinutes(2))
throw new InvalidDataException("Keine neue WAV-Datei gefunden. Bitte Swyx-Aufnahmeordner prüfen oder Datei manuell auswählen.");
return;
}
var file = candidates[0];
lock (_gate)
{
if (_entries.Values.Any(other => other.Id != entry.Id &&
string.Equals(other.Folder, entry.Folder, StringComparison.OrdinalIgnoreCase) && Candidate(other, file)))
throw new InvalidDataException("WAV-Datei gehört möglicherweise zu mehreren Aufnahmeabschnitten. Bitte manuell zuordnen.");
if (!_stable.TryGetValue(entry.Id, out var old) || old.Path != file.FullName ||
old.Length != file.Length || old.Written != file.LastWriteTimeUtc)
{
_stable[entry.Id] = (file.FullName, file.Length, file.LastWriteTimeUtc, _now());
return;
}
if (_now() - old.Seen < TimeSpan.FromSeconds(2)) return;
}
using var input = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.None);
ValidateWav(input);
input.Position = 0;
var temp = Wav(entry.Id) + ".tmp";
using (var output = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None))
{ input.CopyTo(output); output.Flush(true); }
File.Move(temp, Wav(entry.Id), true);
}
private static bool Candidate(RecordingEntry e, FileInfo f) =>
!e.Baseline.Contains(f.FullName, StringComparer.OrdinalIgnoreCase)
&& (e.SourceFiles == null || e.SourceFiles.Contains(f.FullName, StringComparer.OrdinalIgnoreCase))
&& f.CreationTimeUtc >= e.Started.AddSeconds(-2)
&& (e.Ended == null || f.CreationTimeUtc <= e.Ended.Value.AddSeconds(3));
private static FileInfo[] Wavs(string folder) => new DirectoryInfo(folder).GetFiles()
.Where(f => f.Extension.Equals(".wav", StringComparison.OrdinalIgnoreCase)).ToArray();
internal static void ValidateWav(Stream input)
{
if (input.Length < 44 || input.Length > MaxBytes) throw new InvalidDataException("WAV-Datei ist leer oder größer als 500 MB.");
Span<byte> header = stackalloc byte[12];
input.ReadExactly(header);
if (!header[..4].SequenceEqual("RIFF"u8) || !header[8..].SequenceEqual("WAVE"u8))
throw new InvalidDataException("Die Aufnahme hat keinen gültigen WAV-Dateikopf.");
long end = BinaryPrimitives.ReadUInt32LittleEndian(header[4..8]) + 8L;
if (end != input.Length) throw new IOException("WAV-Dateikopf ist noch nicht abgeschlossen.");
Span<byte> chunk = stackalloc byte[8];
bool audio = false, format = false;
while (input.Position + 8 <= end)
{
input.ReadExactly(chunk);
var length = BinaryPrimitives.ReadUInt32LittleEndian(chunk[4..]);
if (input.Position + length > end) throw new IOException("Unvollständiger WAV-Datenblock.");
if (chunk[..4].SequenceEqual("fmt "u8) && length >= 16) format = true;
if (chunk[..4].SequenceEqual("data"u8) && length > 0) audio = true;
input.Position += length + (length % 2);
}
if (!audio || !format) throw new InvalidDataException("Die WAV-Datei enthält keine Audiodaten.");
}
private void Fail(RecordingEntry e, string error)
{ lock (_gate) Put(_entries[e.Id] with { State = "error", Error = error }); }
private string Manifest(Guid id) => Path.Combine(_folder, id + ".json");
private string Wav(Guid id) => Path.Combine(_folder, id + ".wav");
private static RecordingNotice Notice(RecordingEntry e) =>
new(e.Id, e.Line, e.CallId, e.PeerNumber, e.PeerName,
e.CallEnded == null && e.State != "submitted" ? "waiting_call_end" : e.State,
e.Filename, e.Bytes, e.JobId, e.Error, e.CallEnded != null || e.State == "submitted");
private void Put(RecordingEntry entry)
{
var path = Manifest(entry.Id);
using (var output = new FileStream(path + ".tmp", FileMode.Create, FileAccess.Write, FileShare.None))
{ JsonSerializer.Serialize(output, entry, Protocol.Json); output.Flush(true); }
File.Move(path + ".tmp", path, true);
_entries[entry.Id] = entry;
if (entry.Ended != null) Changed?.Invoke(Notice(entry));
}
private void TryDeleteCopy(Guid id)
{
try { File.Delete(Wav(id)); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{ Log.Error("Bestätigte Aufnahmekopie konnte nicht entfernt werden.", ex); }
}
public void Dispose() => _shutdown.Cancel();
}
+28
View File
@@ -0,0 +1,28 @@
namespace SwyxTray.Swyx;
/// <summary>Merkt ausschliesslich erfolgreich an CLMgr uebergebene Aufnahmeauftraege.
/// Das SDK liefert keinen Aufnahmezustand; dies ist keine Dateischreibbestaetigung.</summary>
internal sealed class CallRecordingTracker
{
private readonly HashSet<(int Line, int CallId)> _requested = new();
public bool IsRequested(int line, int callId) => _requested.Contains((line, callId));
public void Set(int line, int expectedCallId, int actualCallId, bool active, bool held,
bool start, Action invokeSdk)
{
if (expectedCallId != actualCallId)
throw new InvalidOperationException("Der Anruf auf dieser Leitung hat sich geaendert.");
if (start ? !active : !(active || held))
throw new InvalidOperationException("Keine passende Sprechverbindung fuer die Aufnahme.");
// Wiederholtes Starten darf weder neu beginnen noch eine zweite Datei erzeugen.
// Stop immer ans SDK: auch ausserhalb der Schnittstelle gestartete Mitschnitte stoppen.
if (start && IsRequested(line, actualCallId)) return;
invokeSdk();
if (start) _requested.Add((line, actualCallId));
else _requested.Remove((line, actualCallId));
}
public void Retain(IEnumerable<(int Line, int CallId)> calls)
=> _requested.IntersectWith(calls);
}
+90 -7
View File
@@ -2,6 +2,7 @@ using System.Collections;
using System.Runtime.InteropServices;
using System.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();
}
}
+4 -1
View File
@@ -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 =>
+18 -3
View File
@@ -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.
+9
View File
@@ -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)
+22 -6
View File
@@ -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());
}
+3 -1
View File
@@ -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 —
+60 -1
View File
@@ -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.
}
}