From 2f9b11de0717d2995f2dfbbe8dcf3092c26b4a49 Mon Sep 17 00:00:00 2001 From: calebtt Date: Fri, 25 Sep 2026 19:46:03 -0500 Subject: [PATCH 1/2] Hang up the original leg after a successful blind transfer BlindTransferAsync is documented to hang up the original call leg on success, but it never did. Against Asterisk/VitalPBX the PBX completes the transfer and takes the transferor's channel out of the bridge without hanging it up, so SipClient stayed in the call: IsCallActive stayed true, CallEnded was not raised, and sipbot serve emitted no `ended` and refused the next dial with "already handling a call". On success (REFER accepted) SipClient now calls Hangup(), which sends BYE and raises CallEnded. A declined or failed transfer leaves the call up. Closes #14 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- Network/SipClient.cs | 5 + tests/SipBotLib.Tests/BlindTransferTests.cs | 107 ++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 tests/SipBotLib.Tests/BlindTransferTests.cs diff --git a/AGENTS.md b/AGENTS.md index e814b25..3ce54f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ Env-only is enough if `SIP_SERVER` and `SIP_USERNAME` are set. Never commit real {"cmd":"quit"} ``` -Transfer / dial targets: bare extension (`102`), `user@host`, full `sip:` URI, `tel:` URI, or a PSTN number (`+1…`, punctuation OK). Bare extensions become `sip:{ext}@{SIP_SERVER}`. Phone numbers are reduced to digits (leading `+` stripped) so typical PBX outbound routes match. A `tel:` URI is always that number on `SIP_SERVER`: `;phone-context`, `;ext`, and a trailing `@host` are not a SIP target. Dial failure JSONL is `dial failed: ()` when the far end sent one (e.g. `603 Decline`). +Transfer / dial targets: bare extension (`102`), `user@host`, full `sip:` URI, `tel:` URI, or a PSTN number (`+1…`, punctuation OK). Bare extensions become `sip:{ext}@{SIP_SERVER}`. Phone numbers are reduced to digits (leading `+` stripped) so typical PBX outbound routes match. A `tel:` URI is always that number on `SIP_SERVER`: `;phone-context`, `;ext`, and a trailing `@host` are not a SIP target. Dial failure JSONL is `dial failed: ()` when the far end sent one (e.g. `603 Decline`). After a successful `transfer`, sipbot hangs up its own leg and emits `ended`. A failed transfer emits `error` and the call stays up. Flags: `--auto-answer`, `--play FILE` (with auto-answer), `--settings PATH`, `--config N`, `--pcmu-only`, `--no-keepalive`. `sipbot serve --help` states the play/resample rule. @@ -136,7 +136,7 @@ Use two extensions (e.g. 101 = sipbot, 102 = a phone or a second client). 1. **Register:** start `sipbot serve` with env or settings; confirm `registered` on stdout and the PBX shows the peer reachable (OPTIONS qualify). 2. **Inbound answer:** call 101 from 102; `invite` then `answer` (or `--auto-answer`); far end hears keep-alive/silence or a WAV from `play`. 3. **DTMF:** from the phone, press digits; expect `dtmf` events. `wait_dtmf` should emit `dtmf_result`. -4. **Transfer:** while in call, `{"cmd":"transfer","target":"103"}` (or another reachable ext). +4. **Transfer:** while in call, `{"cmd":"transfer","target":"103"}` (or another reachable ext). Expect `ended` once the transfer is accepted. 5. **Outbound dial:** `{"cmd":"dial","uri":"102"}`; 102 rings and answers; expect `answered` then `hangup`. 6. **Shutdown:** Ctrl+C / SIGTERM; process exits without hanging; no `ReadKey`. diff --git a/Network/SipClient.cs b/Network/SipClient.cs index 66a6125..f0e9588 100644 --- a/Network/SipClient.cs +++ b/Network/SipClient.cs @@ -583,6 +583,11 @@ public async Task BlindTransferAsync( TransferSucceeded?.Invoke(this); StatusMessage?.Invoke(this, $"Blind transfer to {sipUri} succeeded."); Log.Information($"Blind transfer to {sipUri} succeeded"); + + // The REFER was accepted, so the transferee now belongs to the transfer target. + // PBXs such as Asterisk take this leg out of the bridge but leave it up; end it + // here (BYE), which raises CallEnded as for any hangup. + Hangup(); } else { diff --git a/tests/SipBotLib.Tests/BlindTransferTests.cs b/tests/SipBotLib.Tests/BlindTransferTests.cs new file mode 100644 index 0000000..9953720 --- /dev/null +++ b/tests/SipBotLib.Tests/BlindTransferTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using SIPSorcery.Media; +using SIPSorcery.SIP; +using SIPSorcery.SIP.App; +using SIPSorceryMedia.Abstractions; +using SipBot; +using Xunit; + +namespace SipBotLib.Tests; + +/// +/// SipClient calls a plain SIPUserAgent over loopback and blind-transfers the call. When the +/// REFER is accepted the original leg must end (BYE + CallEnded), as BlindTransferAsync documents. +/// When it is declined the call stays up. +/// +public class BlindTransferTests : IDisposable +{ + private sealed class SilentEndPoint : BaseAudioEndPoint + { + public override Task InitializeAsync() => Task.CompletedTask; + public override Task ShutdownAsync() => Task.CompletedTask; + protected override Task ProcessAudioAsync(byte[] pcm, int sampleRateHz) => Task.CompletedTask; + } + + // Nothing listens here; the transferee's call to it just fails later. + private const string TransferTarget = "sip:c@127.0.0.1:9"; + + private readonly SIPTransport _callerTransport = new(); + private readonly SIPTransport _calleeTransport = new(); + private readonly SipClient _caller; + private readonly SIPUserAgent _callee; + private readonly int _calleePort; + private readonly TaskCompletionSource _calleeHungUp = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _callerEnded = new(TaskCreationOptions.RunContinuationsAsynchronously); + private bool _acceptTransfer = true; + + public BlindTransferTests() + { + _callerTransport.AddSIPChannel(new SIPUDPChannel(IPAddress.Loopback, 0)); + var calleeChannel = new SIPUDPChannel(IPAddress.Loopback, 0); + _calleeTransport.AddSIPChannel(calleeChannel); + _calleePort = calleeChannel.Port; + + _callee = new SIPUserAgent(_calleeTransport, null); + _callee.OnIncomingCall += async (ua, req) => + { + var uas = ua.AcceptCall(req); + var media = new VoIPMediaSession(new MediaEndPoints { AudioSource = new AudioExtrasSource() }) + { + AcceptRtpFromAny = true + }; + await ua.Answer(uas, media); + }; + _callee.OnCallHungup += _ => _calleeHungUp.TrySetResult(true); + _callee.OnTransferRequested += (_, _) => _acceptTransfer; + + var config = new SipConfig { Server = "127.0.0.1", Username = "101", Password = "x" }; + _caller = new SipClient(_callerTransport, config, enableAutoReconnection: false, enableHealthMonitoring: false); + _caller.CallEnded += _ => _callerEnded.TrySetResult(true); + } + + public void Dispose() + { + _caller.Dispose(); + if (_callee.IsCallActive) + _callee.Hangup(); + _calleeTransport.Shutdown(); + } + + private static async Task Within(Task task, TimeSpan timeout) => + await Task.WhenAny(task, Task.Delay(timeout)) == task; + + private async Task ConnectAsync() + { + var audio = new SilentEndPoint(); + bool answered = await _caller.CallAsync($"sip:b@127.0.0.1:{_calleePort}", audio, audio, ringTimeoutSeconds: 10); + Assert.True(answered, $"call not answered: {_caller.LastOutboundFailure}"); + Assert.True(_caller.IsCallActive); + } + + [Fact] + public async Task Accepted_transfer_hangs_up_the_original_leg() + { + await ConnectAsync(); + + bool transferred = await _caller.BlindTransferAsync(TransferTarget, TimeSpan.FromSeconds(5)); + + Assert.True(transferred); + Assert.True(await Within(_callerEnded.Task, TimeSpan.FromSeconds(5)), "CallEnded was not raised after the transfer"); + Assert.False(_caller.IsCallActive); + Assert.True(await Within(_calleeHungUp.Task, TimeSpan.FromSeconds(5)), "the transferee never received a BYE"); + } + + [Fact] + public async Task Declined_transfer_leaves_the_call_up() + { + _acceptTransfer = false; + await ConnectAsync(); + + bool transferred = await _caller.BlindTransferAsync(TransferTarget, TimeSpan.FromSeconds(5)); + await Task.Delay(500); + + Assert.False(transferred); + Assert.True(_caller.IsCallActive); + Assert.False(_callerEnded.Task.IsCompleted); + } +} From 30fb331716090f31d7a7fdb97a029cca6b4fc510 Mon Sep 17 00:00:00 2001 From: calebtt Date: Fri, 25 Sep 2026 20:02:34 -0500 Subject: [PATCH 2/2] Check the transferee's BYE at the transport in BlindTransferTests After accepting the REFER, the test's SIPUserAgent starts its own call to the transfer target, which can replace its dialog before the caller's BYE arrives; on the CI runner OnCallHungup then never fired. Watch for the BYE on the callee's transport instead, which does not depend on the transferee's dialog handling. Co-Authored-By: Claude Opus 5.5 --- tests/SipBotLib.Tests/BlindTransferTests.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/SipBotLib.Tests/BlindTransferTests.cs b/tests/SipBotLib.Tests/BlindTransferTests.cs index 9953720..2fc6c08 100644 --- a/tests/SipBotLib.Tests/BlindTransferTests.cs +++ b/tests/SipBotLib.Tests/BlindTransferTests.cs @@ -30,7 +30,7 @@ private sealed class SilentEndPoint : BaseAudioEndPoint private readonly SipClient _caller; private readonly SIPUserAgent _callee; private readonly int _calleePort; - private readonly TaskCompletionSource _calleeHungUp = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _byeAtCallee = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _callerEnded = new(TaskCreationOptions.RunContinuationsAsynchronously); private bool _acceptTransfer = true; @@ -51,7 +51,15 @@ public BlindTransferTests() }; await ua.Answer(uas, media); }; - _callee.OnCallHungup += _ => _calleeHungUp.TrySetResult(true); + // Watch for the BYE at the transport. After accepting the REFER, SIPUserAgent starts its + // own call to the transfer target, which can replace its dialog before the BYE arrives, + // so OnCallHungup is not a reliable signal here. + _calleeTransport.SIPTransportRequestReceived += (_, _, req) => + { + if (req.Method == SIPMethodsEnum.BYE) + _byeAtCallee.TrySetResult(true); + return Task.CompletedTask; + }; _callee.OnTransferRequested += (_, _) => _acceptTransfer; var config = new SipConfig { Server = "127.0.0.1", Username = "101", Password = "x" }; @@ -88,7 +96,7 @@ public async Task Accepted_transfer_hangs_up_the_original_leg() Assert.True(transferred); Assert.True(await Within(_callerEnded.Task, TimeSpan.FromSeconds(5)), "CallEnded was not raised after the transfer"); Assert.False(_caller.IsCallActive); - Assert.True(await Within(_calleeHungUp.Task, TimeSpan.FromSeconds(5)), "the transferee never received a BYE"); + Assert.True(await Within(_byeAtCallee.Task, TimeSpan.FromSeconds(5)), "the transferee never received a BYE"); } [Fact]