diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Hooks/TwilioConversationHook.cs b/src/Plugins/BotSharp.Plugin.Twilio/Hooks/TwilioConversationHook.cs index cb1dad813..3505e73d8 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Hooks/TwilioConversationHook.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Hooks/TwilioConversationHook.cs @@ -3,7 +3,7 @@ using BotSharp.Abstraction.Routing.Models; using BotSharp.Plugin.Twilio.Interfaces; using BotSharp.Plugin.Twilio.Models; -using Twilio.Rest.Api.V2010.Account; +using BotSharp.Plugin.Twilio.Services; using Task = System.Threading.Tasks.Task; namespace BotSharp.Plugin.Twilio.Hooks; @@ -57,10 +57,7 @@ public override async Task OnFunctionExecuted(RoleDialogModel message, InvokeFun // Save all states before reconnect await states.Save(); - CallResource.Update( - pathSid: sid, - url: new Uri(processUrl)); - + await _services.GetRequiredService().TryRedirectCallAsync(sid, processUrl); break; } } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs index aea4d2df2..6aa5ae27b 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs @@ -3,7 +3,6 @@ using BotSharp.Core.Infrastructures; using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts; using BotSharp.Plugin.Twilio.Services; -using Twilio.Rest.Api.V2010.Account; namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Functions; @@ -42,20 +41,18 @@ public async Task Execute(RoleDialogModel message) } var processUrl = $"{_twilioSetting.CallbackHost}/twilio/voice/hang-up?agent-id={message.CurrentAgentId}&conversation-id={conversationId}"; + var twilio = _services.GetRequiredService(); // Generate initial assistant audio if (!string.IsNullOrEmpty(args.ResponseContent) && _twilioSetting.GenerateEndingAudio) { - var twilio = _services.GetRequiredService(); var initAudioFile = await twilio.SaveSpeechAsync(conversationId, "ending", args.ResponseContent); processUrl += $"&init-audio-file={initAudioFile}"; } - var call = CallResource.Update( - url: new Uri(processUrl), - pathSid: callSid - ); + // A call that already ended is what hanging up wants, so it still counts as hung up + await twilio.TryRedirectCallAsync(callSid, processUrl); message.Content = args.Reason; message.StopCompletion = true; diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/TransferPhoneCallFn.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/TransferPhoneCallFn.cs index 71aa9c5a5..33d494361 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/TransferPhoneCallFn.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/TransferPhoneCallFn.cs @@ -4,7 +4,6 @@ using BotSharp.Core.Infrastructures; using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts; using BotSharp.Plugin.Twilio.Services; -using Twilio.Rest.Api.V2010.Account; namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Functions; @@ -47,19 +46,22 @@ public async Task Execute(RoleDialogModel message) var conversationId = routing.Context.ConversationId; var processUrl = $"{_twilioSetting.CallbackHost}/twilio/voice/transfer-call?agent-id={routing.Context.EntryAgentId}&conversation-id={conversationId}&transfer-to={args.PhoneNumber}"; + var twilio = _services.GetRequiredService(); + // Generate initial assistant audio if (!string.IsNullOrEmpty(args.TransitionMessage)) { - var twilio = _services.GetRequiredService(); var initAudioFile = await twilio.SaveSpeechAsync(conversationId, "transfer", args.TransitionMessage); processUrl += $"&init-audio-file={initAudioFile}"; } // Transfer call - var call = CallResource.Update( - pathSid: sid, - url: new Uri(processUrl)); + if (!await twilio.TryRedirectCallAsync(sid, processUrl)) + { + message.Content = "The phone call has already ended, so it was not transferred."; + return false; + } message.Content = args.TransitionMessage; return true; diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs index 9c1ef3e3b..f798eb6ba 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs @@ -6,7 +6,9 @@ using BotSharp.Plugin.Twilio.Interfaces; using BotSharp.Plugin.Twilio.Models; using BotSharp.Plugin.Twilio.Models.Relay; +using Twilio.Exceptions; using Twilio.Jwt.AccessToken; +using CallResource = Twilio.Rest.Api.V2010.Account.CallResource; using Twilio.Rest.Api.V2010.Account.Call; using Task = System.Threading.Tasks.Task; using Token = Twilio.Jwt.AccessToken.Token; @@ -423,6 +425,30 @@ public async Task SaveSpeechAsync(string conversationId, string name, st return fileName; } + /// + /// Points a live call at new TwiML. Returns false when the call is no longer in progress (Twilio 21220): the caller + /// already hung up, or the message did not come from a live call at all (e.g. a phone conversation continued over + /// the REST API). Any other Twilio error is thrown. + /// + public async Task TryRedirectCallAsync(string callSid, string url) + { + try + { + await CallResource.UpdateAsync(pathSid: callSid, url: new Uri(url)); + return true; + } + catch (ApiException ex) when (ex.Code == CallNotInProgressErrorCode) + { + _logger.LogInformation($"Call {callSid} is no longer in progress, not redirected: {ex.Message}"); + return false; + } + } + + /// + /// Twilio's "Call is not in-progress. Cannot redirect.",see https://www.twilio.com/docs/api/errors/21220 + /// + private const int CallNotInProgressErrorCode = 21220; + /// /// Returns the file name of a speech saved by SaveSpeechAsync, whichever mode it was saved in. ///