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)]);
}