Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <uri> (<SIP status>)` 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: <uri> (<SIP status>)` 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.

Expand Down Expand Up @@ -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`.

Expand Down
5 changes: 5 additions & 0 deletions Network/SipClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@
public event Action<SipClient>? CallAnswer;
public event Action<SipClient>? CallEnded;
public event Action<SipClient, string>? StatusMessage;
public event Action<SipClient>? RemotePutOnHold;

Check warning on line 57 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemotePutOnHold' is never used

Check warning on line 57 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemotePutOnHold' is never used
public event Action<SipClient>? RemoteTookOffHold;

Check warning on line 58 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemoteTookOffHold' is never used

Check warning on line 58 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemoteTookOffHold' is never used
public event Action<SipClient, Exception>? ErrorOccurred;
public event Action<SipClient>? RegistrationStatusChanged;
public event Action<SipClient, TimeSpan>? CallDurationUpdated;
Expand Down Expand Up @@ -100,7 +100,7 @@
public string Username => _sipUsername;
public string Server => _sipServer;

public SipClient(

Check warning on line 103 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_registrationAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 103 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_userAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 103 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_userAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 103 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_registrationAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 103 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_userAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
SIPTransport sipTransport,
SipConfig sipSettings,
int registrationExpirySeconds = DefaultRegistrationExpirySeconds,
Expand Down Expand Up @@ -583,6 +583,11 @@
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
{
Expand Down
115 changes: 115 additions & 0 deletions tests/SipBotLib.Tests/BlindTransferTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<bool> _byeAtCallee = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<bool> _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<bool> 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);
}
}
Loading