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..2fc6c08 --- /dev/null +++ b/tests/SipBotLib.Tests/BlindTransferTests.cs @@ -0,0 +1,115 @@ +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 _byeAtCallee = 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); + }; + // 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" }; + _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(_byeAtCallee.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); + } +}