diff --git a/Network/SipClient.cs b/Network/SipClient.cs index 8a673a8..302c18e 100644 --- a/Network/SipClient.cs +++ b/Network/SipClient.cs @@ -65,6 +65,7 @@ public class SipClient : IDisposable private volatile bool _isShutdown = false; private volatile bool _isRegistered = false; private int _reconnectionAttempts = 0; + private int _outboundRingingRaised; private bool _reconnectPending; private bool _registrationStarted; private RegistrationState _registrationState = RegistrationState.NotStarted; @@ -104,6 +105,15 @@ public class SipClient : IDisposable /// RFC2833 / telephone-event digit (0-9, *, #, A-D). Raised once per complete tone. public event Action? DtmfDigitReceived; + /// + /// Raised once per outbound call () when the first ringing response + /// arrives: 180 Ringing or 183 Session Progress. The argument is that status code. Not raised + /// if the call gets no ringing response. 's ring timeout starts when the + /// INVITE is sent, so a host that wants to time the ring from when the phone rings can pass a + /// larger timeout, start its own timer here, and cancel through the CancellationToken. + /// + public event Action? CallRinging; + // Properties /// /// True after a successful REGISTER. Cleared by any registration failure, temporary or hard, @@ -327,7 +337,7 @@ private SIPUserAgent CreateNewUserAgent(SIPTransport sipTransport) { var userAgent = new SIPUserAgent(sipTransport, null); userAgent.ClientCallTrying += CallTrying; - userAgent.ClientCallRinging += CallRinging; + userAgent.ClientCallRinging += OnClientCallRinging; userAgent.ClientCallAnswered += CallAnswered; userAgent.ClientCallFailed += CallFailed; userAgent.OnCallHungup += CallFinished; @@ -548,6 +558,7 @@ public async Task CallAsync( ArgumentNullException.ThrowIfNull(audioSource); LastOutboundFailure = null; + Interlocked.Exchange(ref _outboundRingingRaised, 0); string uri; try { @@ -1008,10 +1019,15 @@ private void CallTrying(ISIPClientUserAgent uac, SIPResponse sipResponse) IncrementMetric("call_trying"); } - private void CallRinging(ISIPClientUserAgent uac, SIPResponse sipResponse) + // SIPSorcery routes exactly 180 Ringing and 183 Session Progress here (other provisional + // responses go to ClientCallTrying). + private void OnClientCallRinging(ISIPClientUserAgent uac, SIPResponse sipResponse) { StatusMessage?.Invoke(this, "Call ringing: " + sipResponse.StatusCode + " " + sipResponse.ReasonPhrase + "."); IncrementMetric("call_ringing"); + + if (Interlocked.Exchange(ref _outboundRingingRaised, 1) == 0) + CallRinging?.Invoke(this, sipResponse.StatusCode); } private void CallFailed(ISIPClientUserAgent uac, string errorMessage, SIPResponse? failureResponse) diff --git a/tests/SipBotLib.Tests/CallRingingEventTests.cs b/tests/SipBotLib.Tests/CallRingingEventTests.cs new file mode 100644 index 0000000..46353f4 --- /dev/null +++ b/tests/SipBotLib.Tests/CallRingingEventTests.cs @@ -0,0 +1,116 @@ +using System.Collections.Concurrent; +using System.Net; +using SIPSorcery.SIP; +using SipBot; +using Xunit; + +namespace SipBotLib.Tests; + +/// +/// SipClient.CallRinging fires once per outbound call, on the first 180 or 183, with that status; +/// never for other provisional responses, never twice, and not at all without a ringing response. +/// A scripted callee on loopback answers each INVITE with the given provisional responses and then +/// 486, so no media is needed. +/// +public class CallRingingEventTests : 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; + } + + private readonly SIPTransport _callerTransport = new(); + private readonly SIPTransport _calleeTransport = new(); + private readonly SipClient _caller; + private readonly int _calleePort; + private readonly ConcurrentQueue _ringing = new(); + private readonly ConcurrentQueue _status = new(); + private volatile SIPResponseStatusCodesEnum[] _script = Array.Empty(); + + public CallRingingEventTests() + { + _callerTransport.AddSIPChannel(new SIPUDPChannel(IPAddress.Loopback, 0)); + var calleeChannel = new SIPUDPChannel(IPAddress.Loopback, 0); + _calleeTransport.AddSIPChannel(calleeChannel); + _calleePort = calleeChannel.Port; + _calleeTransport.SIPTransportRequestReceived += OnCalleeRequest; + + var config = new SipConfig { Server = "127.0.0.1", Username = "101", Password = "x" }; + _caller = new SipClient(_callerTransport, config, enableAutoReconnection: false, enableHealthMonitoring: false); + _caller.CallRinging += (_, status) => _ringing.Enqueue(status); + _caller.StatusMessage += (_, msg) => _status.Enqueue(msg); + } + + public void Dispose() + { + _caller.Dispose(); + _calleeTransport.Shutdown(); + } + + private Task OnCalleeRequest(SIPEndPoint localEP, SIPEndPoint remoteEP, SIPRequest req) + { + if (req.Method != SIPMethodsEnum.INVITE) + return Task.CompletedTask; + + var script = _script; + _ = Task.Run(async () => + { + var tx = new UASInviteTransaction(_calleeTransport, req, null); + foreach (var status in script) + { + await tx.SendProvisionalResponse(SIPResponse.GetResponse(req, status, null)); + await Task.Delay(100); + } + tx.SendFinalResponse(SIPResponse.GetResponse(req, SIPResponseStatusCodesEnum.BusyHere, null)); + }); + return Task.CompletedTask; + } + + private async Task PlaceCallAsync(params SIPResponseStatusCodesEnum[] provisional) + { + _script = provisional; + var audio = new SilentEndPoint(); + bool answered = await _caller.CallAsync($"sip:b@127.0.0.1:{_calleePort}", audio, audio, ringTimeoutSeconds: 10); + Assert.False(answered); + Assert.StartsWith("486", _caller.LastOutboundFailure); + await Task.Delay(200); // let any late event arrive before asserting on it + } + + public static TheoryData Scripts() => new() + { + { new[] { SIPResponseStatusCodesEnum.Ringing }, new[] { 180 } }, + { new[] { SIPResponseStatusCodesEnum.SessionProgress }, new[] { 183 } }, + { new[] { SIPResponseStatusCodesEnum.Ringing, SIPResponseStatusCodesEnum.Ringing }, new[] { 180 } }, + { new[] { SIPResponseStatusCodesEnum.SessionProgress, SIPResponseStatusCodesEnum.Ringing }, new[] { 183 } }, + { new[] { SIPResponseStatusCodesEnum.CallIsBeingForwarded }, Array.Empty() }, + { Array.Empty(), Array.Empty() }, + }; + + [Theory] + [MemberData(nameof(Scripts))] + public async Task Fires_once_on_the_first_180_or_183(SIPResponseStatusCodesEnum[] provisional, int[] expected) + { + await PlaceCallAsync(provisional); + + Assert.Equal(expected, _ringing.ToArray()); + } + + [Fact] + public async Task Fires_again_for_the_next_call() + { + await PlaceCallAsync(SIPResponseStatusCodesEnum.Ringing); + await PlaceCallAsync(SIPResponseStatusCodesEnum.SessionProgress); + + Assert.Equal(new[] { 180, 183 }, _ringing.ToArray()); + } + + [Fact] + public async Task StatusMessage_still_reports_ringing() + { + await PlaceCallAsync(SIPResponseStatusCodesEnum.Ringing); + + Assert.Contains(_status, m => m.StartsWith("Call ringing: 180")); + } +}