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

@@ -0,0 +1,184 @@
using System.Buffers.Binary;
using IpPbx.CLMgrLib;
using SwyxTray.Recording;
using SwyxTray.Swyx;
internal static class OutboxChecks
{
public static void Run(Action<bool, string> check)
{
var temp = Path.Combine(Path.GetTempPath(), "swyx-outbox-check-" + Guid.NewGuid());
Directory.CreateDirectory(temp);
try
{
for (int scenario = 0; scenario < 7; scenario++)
{
var folder = Path.Combine(temp, scenario.ToString());
Directory.CreateDirectory(folder);
var queue = Path.Combine(folder, "queue");
var now = DateTime.UtcNow;
var wav = Path.Combine(folder, "conversation.wav");
WriteWav(Path.Combine(folder, "old.wav"), now.AddDays(-1));
using var box = new RecordingOutbox(queue, () => now);
var id = box.Begin(1, 41, "123", "Test", folder, null);
check(box.Begin(1, 41, "123", "Test", folder, Guid.NewGuid()) == id, "duplicate start keeps identity");
WriteWav(wav, now);
box.Process();
check(box.Notices().Length == 0, "no transfer during recording");
box.Observe(SwyxSnapshot.Offline);
check(box.CurrentId(1, 41) == id, "disconnect does not end recording");
if (scenario == 1) WriteWav(Path.Combine(folder, "ambiguous.wav"), now);
if (scenario == 2) File.Delete(wav);
if (scenario == 3)
{
// Overlapping recording windows cannot claim the same WAV.
File.Delete(wav);
box.Begin(2, 42, "456", "Other", folder, null);
WriteWav(wav, now);
box.End(2, 42);
}
box.Observe(Snapshot(CLMgrLineStates.LSOnHold));
check(box.CurrentId(1, 41) == id, "hold does not end recording");
box.Observe(Snapshot(CLMgrLineStates.LSInactive));
check(box.CurrentId(1, 41) == null, "call end finishes recording section");
now = now.AddSeconds(4);
box.Process();
if (scenario == 1 || scenario == 3)
{
check(box.Notices().First(n => n.RecordingId == id).State == "error", "ambiguous file is never offered");
continue;
}
if (scenario == 2)
{
now = now.AddMinutes(3); box.Process();
check(box.Notices()[0].State == "error", "missing new WAV does not select old file");
box.Acknowledge(id, new string('a', 32));
check(box.Notices()[0].State == "submitted", "manual fallback receipt persists too");
continue;
}
now = now.AddSeconds(3);
if (scenario == 4)
{
using (var writer = new FileStream(wav, FileMode.Open, FileAccess.Write, FileShare.Read))
{
box.Process();
check(box.Notices()[0].State == "waiting", "writer must close before ready");
}
}
if (scenario == 5)
{
var content = File.ReadAllBytes(wav);
BinaryPrimitives.WriteUInt32LittleEndian(content.AsSpan(4), (uint)content.Length + 100);
File.WriteAllBytes(wav, content);
box.Process(); now = now.AddSeconds(3); box.Process();
check(box.Notices()[0].State == "waiting", "unfinished RIFF header waits");
WriteWav(wav, now.AddSeconds(-7));
box.Process(); now = now.AddSeconds(3);
}
if (scenario == 6)
{
using var restarted = new RecordingOutbox(queue, () => now);
restarted.Process(); now = now.AddSeconds(3); restarted.Process();
check(restarted.Notices()[0].State == "ready", "pending section survives tray restart");
continue;
}
box.Process();
check(box.Notices()[0].State == "ready", "stable closed WAV becomes ready");
using (var input = box.Open(id)) check(input.Length == 2044, "copy contains complete audio");
using (var reloaded = new RecordingOutbox(queue, () => now))
check(reloaded.Notices()[0].State == "ready", "ready replays after restart");
box.Acknowledge(id, new string('a', 32));
box.Acknowledge(id, new string('a', 32));
using var received = new RecordingOutbox(queue, () => now);
check(received.Notices()[0].JobId == new string('a', 32), "durable idempotent receipt");
check(File.Exists(wav) && !File.Exists(Path.Combine(queue, id + ".wav")), "remove only acknowledged temporary copy");
}
// A call can end outside SwyxWeb. No recording.stop/hangup command
// is needed: the same outbox must produce a downloadable WAV.
var externalEnds = new[] {
("remote", CLMgrLineStates.LSTerminated, 41),
("local", CLMgrLineStates.LSInactive, 41),
("disabled", CLMgrLineStates.LSDisabled, 41),
("none", CLMgrLineStates.LSNone, 41),
("replaced", CLMgrLineStates.LSRinging, 42),
("reconnected", CLMgrLineStates.LSInactive, 41)
};
foreach (var (reason, state, callId) in externalEnds)
{
var folder = Path.Combine(temp, "external-" + reason);
Directory.CreateDirectory(folder);
var now = DateTime.UtcNow;
using var box = new RecordingOutbox(Path.Combine(folder, "queue"), () => now);
var id = box.Begin(1, 41, "123", "Test", folder, null);
WriteWav(Path.Combine(folder, "call.wav"), now);
box.Observe(Snapshot(CLMgrLineStates.LSActive));
if (reason == "reconnected")
{
box.Observe(SwyxSnapshot.Offline);
check(box.Notices().Length == 0, "connection loss alone does not release audio");
}
box.Observe(Snapshot(state, callId));
now = now.AddSeconds(4); box.Process(); now = now.AddSeconds(3); box.Process();
check(box.Notices().Single() is { State: "ready", CallEnded: true },
"automatic handoff without local hangup: " + reason);
using var audio = box.Open(id);
check(audio.Length == 2044, "complete WAV after external end: " + reason);
}
var sequential = Path.Combine(temp, "sequential");
Directory.CreateDirectory(sequential);
var clock = DateTime.UtcNow;
using var sections = new RecordingOutbox(Path.Combine(sequential, "queue"), () => clock);
sections.Begin(1, 88, "123", "Test", sequential, null);
WriteWav(Path.Combine(sequential, "first.wav"), clock);
sections.End(1, 88);
clock = clock.AddMilliseconds(100);
sections.Begin(1, 88, "123", "Test", sequential, null);
WriteWav(Path.Combine(sequential, "second.wav"), clock);
sections.End(1, 88);
clock = clock.AddSeconds(4); sections.Process();
clock = clock.AddSeconds(3); sections.Process();
check(sections.Notices().Length == 2 && sections.Notices().All(n => n.State == "waiting_call_end" && !n.CallEnded),
"all stopped recording sections wait for hangup");
sections.Observe(Snapshot(CLMgrLineStates.LSOnHold, 88));
sections.Observe(SwyxSnapshot.Offline);
check(sections.Notices().All(n => n.State == "waiting_call_end"), "hold/disconnect do not release stopped sections");
using (var restarted = new RecordingOutbox(Path.Combine(sequential, "queue"), () => clock))
{
restarted.Observe(Snapshot(CLMgrLineStates.LSActive, 88));
check(restarted.Notices().All(n => n.State == "waiting_call_end"), "stopped sections wait across restart");
foreach (var pending in restarted.Notices())
{
bool refused = false;
try { using var blockedInput = restarted.Open(pending.RecordingId); }
catch (InvalidOperationException) { refused = true; }
check(refused, "download denied before confirmed call end");
refused = false;
try { restarted.Acknowledge(pending.RecordingId, new string('b', 32)); }
catch (InvalidOperationException) { refused = true; }
check(refused, "receipt denied before confirmed call end");
}
restarted.Observe(Snapshot(CLMgrLineStates.LSInactive, 88));
check(restarted.Notices().Length == 2 && restarted.Notices().All(n => n.State == "ready" && n.CallEnded),
"hangup releases all finalized sections of the ended call");
using var input = restarted.Open(restarted.Notices()[0].RecordingId);
check(input.Length == 2044, "complete WAV available after hangup");
}
}
finally { Directory.Delete(temp, true); }
}
public static void WriteWav(string path, DateTime created, int samples = 1000)
{
using (var writer = new BinaryWriter(File.Create(path)))
{
writer.Write("RIFF"u8); writer.Write(36 + samples * 2); writer.Write("WAVEfmt "u8);
writer.Write(16); writer.Write((short)1); writer.Write((short)1); writer.Write(16000);
writer.Write(32000); writer.Write((short)2); writer.Write((short)16);
writer.Write("data"u8); writer.Write(samples * 2); writer.Write(new byte[samples * 2]);
}
File.SetCreationTimeUtc(path, created);
}
private static SwyxSnapshot Snapshot(CLMgrLineStates state, int callId = 41) =>
new(true, true, "test", "test", [new SwyxLineInfo(0, state, "123", "Test", true, callId)]);
}
+51
View File
@@ -0,0 +1,51 @@
using IpPbx.CLMgrLib;
using SwyxTray.Swyx;
using SwyxTray.Web;
if (args.Contains("--serve")) { await TransportChecks.Serve(); return; }
int checks = 0;
void Check(bool condition, string name) { if (!condition) throw new Exception(name); checks++; }
void Reject(Action action, string name) {
try { action(); } catch (InvalidOperationException) { checks++; return; }
throw new Exception(name);
}
var recordings = new CallRecordingTracker();
int invocations = 0;
Action invoke = () => invocations++;
Reject(() => recordings.Set(0, 41, 42, true, false, true, invoke), "stale call");
Reject(() => recordings.Set(0, 41, 41, false, false, true, invoke), "ringing");
Reject(() => recordings.Set(0, 41, 41, false, true, true, invoke), "held start");
Check(invocations == 0, "invalid calls do not reach SDK");
recordings.Set(0, 41, 41, true, false, true, invoke);
recordings.Set(0, 41, 41, true, false, true, invoke);
Check(invocations == 1 && recordings.IsRequested(0, 41), "idempotent start");
recordings.Set(0, 41, 41, false, true, false, invoke);
Check(invocations == 2 && !recordings.IsRequested(0, 41), "stop while held");
Reject(() => recordings.Set(0, 41, 41, true, false, true, () => throw new InvalidOperationException()), "SDK error");
Check(!recordings.IsRequested(0, 41), "failed start not reported as success");
recordings.Set(0, 41, 41, true, false, true, invoke);
Reject(() => recordings.Set(0, 41, 41, true, false, false, () => throw new InvalidOperationException()), "stop error");
Check(recordings.IsRequested(0, 41), "failed stop retains request state");
recordings.Retain(new[] { (0, 42) });
Check(!recordings.IsRequested(0, 41), "call ended clears request state");
recordings.Set(0, 42, 42, true, false, false, invoke);
Check(invocations == 4, "stop external recording reaches SDK too");
SwyxSnapshot Snapshot(CLMgrLineStates state, int id = 41, bool recording = false) =>
new(true, true, "test", "test", new[] { new SwyxLineInfo(0, state, "123", "Test", true, id, recording) });
var active = Snapshot(CLMgrLineStates.LSActive, recording: true);
var end = CallEventMessage.Diff(active, Snapshot(CLMgrLineStates.LSInactive));
Check(end.Count == 1 && end[0].Event == "ended" && end[0].CallId == 41 && end[0].PeerNumber == "123", "end preserves call identity");
Check(CallEventMessage.Diff(active, SwyxSnapshot.Offline).Count == 0, "disconnect is not call end");
Check(CallEventMessage.Diff(active, active with { IsServerUp = false }).Count == 0, "server down is not call end");
Check(CallEventMessage.Diff(active, Snapshot(CLMgrLineStates.LSOnHold)).Count == 0, "holding is not call end");
Check(CallEventMessage.Diff(Snapshot(CLMgrLineStates.LSOnHold), active).Count == 0, "resume is not new call");
var replaced = CallEventMessage.Diff(active, Snapshot(CLMgrLineStates.LSRinging, 42));
Check(replaced.Count == 2 && replaced.Any(e => e.Event == "ended" && e.CallId == 41)
&& replaced.Any(e => e.Event == "incoming" && e.CallId == 42), "reused line keeps calls distinct");
var json = Protocol.Serialize(SnapshotMessage.From(active));
Check(json.Contains("\"callId\":41") && json.Contains("\"recordingRequested\":true"), "wire fields");
OutboxChecks.Run(Check);
await TransportChecks.Run(Check);
Console.WriteLine($"{checks} recording/protocol checks passed (no COM calls).");
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Platform>x64</Platform>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swyx.Client.ClmgrAPI" Version="14.21.0" />
<Compile Include="../../src/SwyxTray/Swyx/CallRecordingTracker.cs" Link="CallRecordingTracker.cs" />
<Compile Include="../../src/SwyxTray/Swyx/SwyxModels.cs" Link="SwyxModels.cs" />
<Compile Include="../../src/SwyxTray/Swyx/LineStateExtensions.cs" Link="LineStateExtensions.cs" />
<Compile Include="../../src/SwyxTray/Web/Protocol.cs" Link="Protocol.cs" />
<Compile Include="../../src/SwyxTray/Recording/RecordingOutbox.cs" Link="RecordingOutbox.cs" />
<Compile Include="../../src/SwyxTray/Log.cs" Link="Log.cs" />
<Compile Include="../../src/SwyxTray/Web/WebSocketSession.cs" Link="WebSocketSession.cs" />
<Compile Include="../../src/SwyxTray/Web/WebSocketConfig.cs" Link="WebSocketConfig.cs" />
<Compile Include="../../src/SwyxTray/Web/WebSocketHandshake.cs" Link="WebSocketHandshake.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,154 @@
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using SwyxTray.Recording;
using SwyxTray.Web;
internal static class TransportChecks
{
// Local-only fixture for browser -> real SwyxWeb -> isolated SwyxTrans integration checks.
public static async Task Serve()
{
var folder = Path.Combine(Path.GetTempPath(), "swyx-browser-check-" + Guid.NewGuid());
Directory.CreateDirectory(folder);
var now = DateTime.UtcNow;
using var outbox = new RecordingOutbox(Path.Combine(folder, "queue"), () => now);
var id = outbox.Begin(1, 123, "123", "WebSocket-Testgespräch", folder, null);
OutboxChecks.WriteWav(Path.Combine(folder, "call.wav"), now, 32_000);
outbox.Observe(new SwyxTray.Swyx.SwyxSnapshot(true, true, "test", "test",
[new SwyxTray.Swyx.SwyxLineInfo(0, IpPbx.CLMgrLib.CLMgrLineStates.LSInactive, "123", "Test", true, 123)]));
now = now.AddSeconds(4); outbox.Process(); now = now.AddSeconds(3); outbox.Process();
using var listener = new TcpListener(IPAddress.Loopback, 17658);
listener.Start();
Console.WriteLine("Synthetic WAV fixture ready: " + folder);
while (true)
{
var tcp = await listener.AcceptTcpClientAsync();
_ = Task.Run(async () =>
{
using (tcp)
{
try
{
var stream = tcp.GetStream();
var request = await WebSocketHandshake.ReadAsync(stream, CancellationToken.None);
if (request == null) return;
await WebSocketHandshake.AcceptAsync(stream, request, CancellationToken.None);
using var session = new WebSocketSession(1, WebSocket.CreateFromStream(stream, true, null,
TimeSpan.FromSeconds(15)), new CommandExecutor(outbox), new PluginTabChannel(), request.Origin, false);
await session.RunAsync(CancellationToken.None);
}
catch (Exception ex) { Console.WriteLine(ex.GetType().Name); }
}
});
}
}
public static async Task Run(Action<bool, string> check)
{
var folder = Path.Combine(Path.GetTempPath(), "swyx-transport-" + Guid.NewGuid());
Directory.CreateDirectory(folder);
try
{
var now = DateTime.UtcNow;
using var outbox = new RecordingOutbox(Path.Combine(folder, "queue"), () => now);
var id = outbox.Begin(1, 123, "123", "Test", folder, null);
var path = Path.Combine(folder, "call.wav");
OutboxChecks.WriteWav(path, now, samples: 1_000_000);
outbox.Observe(new SwyxTray.Swyx.SwyxSnapshot(true, true, "test", "test",
[new SwyxTray.Swyx.SwyxLineInfo(0, IpPbx.CLMgrLib.CLMgrLineStates.LSInactive, "123", "Test", true, 123)]));
now = now.AddSeconds(4); outbox.Process(); now = now.AddSeconds(3); outbox.Process();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
for (int round = 0; round < 3; round++)
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
using var tcp = new TcpClient();
var connection = tcp.ConnectAsync((IPEndPoint)listener.LocalEndpoint);
using var remote = await listener.AcceptTcpClientAsync(timeout.Token);
await connection;
using var socket = WebSocket.CreateFromStream(tcp.GetStream(), false, null, TimeSpan.FromSeconds(15));
using var server = new WebSocketSession(round, WebSocket.CreateFromStream(remote.GetStream(), true, null,
TimeSpan.FromSeconds(15)), new CommandExecutor(outbox), new PluginTabChannel(), "http://test", false);
var running = server.RunAsync(timeout.Token);
var notice = await Until(socket, e => e.TryGetProperty("recordingId", out _), timeout.Token);
check(notice.GetProperty("recordingId").GetGuid() == id, "recording replay uses original UUID");
if (round == 2)
{
check(notice.GetProperty("state").GetString() == "submitted", "receipt replay on reconnect");
}
else
{
await Send(socket, new { id = 7, cmd = "recording.download", recordingId = id }, timeout.Token);
await Send(socket, new { id = 8, cmd = "ping" }, timeout.Token);
using var received = new MemoryStream();
bool done = false, ping = false;
while (!done || !ping)
{
var (type, bytes) = await Receive(socket, timeout.Token);
if (type == WebSocketMessageType.Binary)
{
check(bytes.AsSpan(0, 4).SequenceEqual("SWYX"u8), "binary block magic");
check(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(4)) == 7, "binary request id");
check(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(8)) == received.Length, "binary offset");
received.Write(bytes, 12, bytes.Length - 12);
if (round == 0) break; // Interrupt, then request the same retained WAV on another session.
}
else
{
var result = JsonDocument.Parse(bytes).RootElement;
if (result.TryGetProperty("id", out var requestId))
{
check(result.GetProperty("ok").GetBoolean(), "transfer/control command succeeds");
if (requestId.GetInt32() == 7) done = true;
if (requestId.GetInt32() == 8) ping = true;
}
}
}
if (round == 1)
{
check(received.ToArray().SequenceEqual(File.ReadAllBytes(path)), "actual WebSocket preserves entire WAV");
check(ping, "control commands remain responsive during transfer");
await Send(socket, new { id = 9, cmd = "recording.ack", recordingId = id, jobId = new string('a', 32) }, timeout.Token);
var ack = await Until(socket, e => e.TryGetProperty("id", out var n) && n.GetInt32() == 9, timeout.Token);
check(ack.GetProperty("ok").GetBoolean(), "transfer receipt accepted");
}
}
socket.Abort(); server.Dispose();
try { await running; } catch (Exception ex) when (ex is WebSocketException or IOException or ObjectDisposedException) { }
}
check(File.Exists(path), "original WAV survives websocket acknowledgements");
}
finally { Directory.Delete(folder, true); }
}
private static Task Send(WebSocket socket, object value, CancellationToken token) =>
socket.SendAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value)), WebSocketMessageType.Text, true, token);
private static async Task<JsonElement> Until(WebSocket socket, Func<JsonElement, bool> predicate, CancellationToken token)
{
while (true)
{
var (type, bytes) = await Receive(socket, token);
if (type != WebSocketMessageType.Text) continue;
var element = JsonDocument.Parse(bytes).RootElement;
if (predicate(element)) return element;
}
}
private static async Task<(WebSocketMessageType, byte[])> Receive(WebSocket socket, CancellationToken token)
{
using var message = new MemoryStream();
var buffer = new byte[32 * 1024];
WebSocketReceiveResult result;
do
{
result = await socket.ReceiveAsync(buffer, token);
message.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
return (result.MessageType, message.ToArray());
}
}
@@ -0,0 +1,17 @@
// Only the COM executor and browser-plugin channel are replaced. The outbox,
// session, framing, cancellation and persistence are production code.
using SwyxTray.Recording;
using SwyxTray.Swyx;
namespace SwyxTray.Web;
internal sealed class CommandExecutor(RecordingOutbox outbox)
{
public RecordingOutbox Outbox => outbox;
public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken _) => Task.FromResult(SwyxSnapshot.Offline);
public Task<IReadOnlyList<SwyxContact>> GetAddressesAsync(CancellationToken _) => Task.FromResult<IReadOnlyList<SwyxContact>>([]);
public Task<ResultMessage> ExecuteAsync(ClientCommand command, CancellationToken _)
{
if (command.Cmd == "recording.ack") Outbox.Acknowledge(command.RecordingId!.Value, command.JobId);
return Task.FromResult(new ResultMessage(command.Id, true));
}
}
internal sealed class PluginTabChannel { public bool TryComplete(ClientCommand _) => false; }