From b2f3e35414ebce0afba4b5d94f3dc29debe722b8 Mon Sep 17 00:00:00 2001 From: calebtt Date: Fri, 25 Sep 2026 23:56:29 -0500 Subject: [PATCH] Raise CallRinging when an outbound call starts ringing SipClient reported the first ringing response only as StatusMessage text, and CallAsync's ring timeout starts when the INVITE is sent, so digest challenges, PBX routing, and carrier setup all counted against the ring time. A host that wants to time the ring from when the phone actually rings had no reliable signal. SipClient.CallRinging is raised once per CallAsync call, on the first 180 Ringing or 183 Session Progress (exactly what SIPSorcery routes to ClientCallRinging), with that status code. It is not raised without a ringing response and never twice for one call. StatusMessage and the other events are unchanged; the private handler was renamed to free the name. Tests: a scripted loopback callee sends 180, 183, 180 twice, 183 then 180, 181 only, or nothing, then 486. The event fires once with the first 180 or 183, never for 181 or no provisional response, resets for the next call, and StatusMessage still reports ringing. Closes #27 Co-Authored-By: Claude Opus 5.5 --- Network/SipClient.cs | 20 ++- .../SipBotLib.Tests/CallRingingEventTests.cs | 116 ++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 tests/SipBotLib.Tests/CallRingingEventTests.cs 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")); + } +}