From a49b75f4953376dade9243110fc0a24df236ab4d Mon Sep 17 00:00:00 2001 From: Haiping Chen Date: Fri, 14 Mar 2025 13:59:13 -0500 Subject: [PATCH] Add Twilio settings. --- .../Conversations/ConversationHookBase.cs | 3 -- .../Conversations/IConversationHook.cs | 7 --- .../BotSharp.Core.Realtime.csproj | 6 ++- .../Services/RealtimeHub.cs | 8 +--- .../Controllers/TwilioStreamController.cs | 22 +-------- .../Controllers/TwilioVoiceController.cs | 48 ++++++++++++++----- .../Interfaces/ITwilioCallStatusHook.cs | 4 +- .../Models/AssistantMessage.cs | 2 +- .../Functions/OutboundPhoneCallFn.cs | 47 ++++++++++++++---- .../Services/TwilioService.cs | 15 +++++- .../Settings/TwilioSetting.cs | 28 +++++++++-- 11 files changed, 125 insertions(+), 65 deletions(-) diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/ConversationHookBase.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/ConversationHookBase.cs index a84c8693..da87646e 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/ConversationHookBase.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/ConversationHookBase.cs @@ -78,7 +78,4 @@ public abstract class ConversationHookBase : IConversationHook public virtual Task OnNotificationGenerated(RoleDialogModel message) => Task.CompletedTask; - - public virtual Task OnUserDisconnected(Conversation conversation) - => Task.CompletedTask; } diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationHook.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationHook.cs index f99078ea..c764f391 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationHook.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationHook.cs @@ -25,13 +25,6 @@ public interface IConversationHook /// Task OnUserAgentConnectedInitially(Conversation conversation); - /// - /// Triggered when user disconnects with agent. - /// - /// - /// - Task OnUserDisconnected(Conversation conversation); - /// /// Triggered once for every new conversation. /// diff --git a/src/Infrastructure/BotSharp.Core.Realtime/BotSharp.Core.Realtime.csproj b/src/Infrastructure/BotSharp.Core.Realtime/BotSharp.Core.Realtime.csproj index c004dc50..25218b08 100644 --- a/src/Infrastructure/BotSharp.Core.Realtime/BotSharp.Core.Realtime.csproj +++ b/src/Infrastructure/BotSharp.Core.Realtime/BotSharp.Core.Realtime.csproj @@ -1,7 +1,11 @@  - net8.0 + $(TargetFramework) + $(LangVersion) + $(BotSharpVersion) + $(GeneratePackageOnBuild) + $(SolutionDir)packages enable enable diff --git a/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs b/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs index 76302262..8adf63f0 100644 --- a/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs +++ b/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs @@ -1,7 +1,3 @@ -using BotSharp.Abstraction.Utilities; -using BotSharp.Core.Infrastructures; -using Microsoft.AspNetCore.Cors.Infrastructure; - namespace BotSharp.Core.Realtime.Services; public class RealtimeHub : IRealtimeHub @@ -257,9 +253,7 @@ public class RealtimeHub : IRealtimeHub private async Task HandleUserDisconnected() { - var convService = _services.GetRequiredService(); - var conversation = await convService.GetConversation(_conn.ConversationId); - await HookEmitter.Emit(_services, x => x.OnUserDisconnected(conversation)); + } private async Task SendEventToUser(WebSocket webSocket, object message) diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs index e69f79f6..fb35c25c 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs @@ -42,7 +42,7 @@ public class TwilioStreamController : TwilioController request.InitAudioFile != null) { response = new VoiceResponse(); - response.Play(new Uri($"{_settings.CallbackHost}/twilio/voice/speeches/{request.ConversationId}/{request.InitAudioFile}")); + response.Play(new Uri(request.InitAudioFile)); return TwiML(response); } @@ -54,7 +54,7 @@ public class TwilioStreamController : TwilioController if (request.InitAudioFile != null) { - instruction.SpeechPaths.Add(request.InitAudioFile); + instruction.SpeechPaths.Add($"twilio/voice/speeches/{request.ConversationId}/{request.InitAudioFile}"); } await HookEmitter.Emit(_services, async hook => @@ -82,24 +82,6 @@ public class TwilioStreamController : TwilioController return TwiML(response); } - [ValidateRequest] - [HttpPost("twilio/stream/status")] - public async Task StreamConversationStatus(ConversationalVoiceRequest request) - { - if (request.AnsweredBy == "machine_start" && - request.Direction == "outbound-api" && - request.InitAudioFile != null && - request.CallStatus == "completed") - { - // voicemail - await HookEmitter.Emit(_services, async hook => - { - await hook.OnVoicemailLeft(request.ConversationId); - }); - } - return Ok(); - } - private async Task InitConversation(ConversationalVoiceRequest request) { var convService = _services.GetRequiredService(); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs index 6507a105..0193cae9 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs @@ -1,13 +1,11 @@ using BotSharp.Abstraction.Files; using BotSharp.Abstraction.Infrastructures; -using BotSharp.Abstraction.Repositories; using BotSharp.Core.Infrastructures; using BotSharp.Plugin.Twilio.Interfaces; using BotSharp.Plugin.Twilio.Models; using BotSharp.Plugin.Twilio.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using System.ComponentModel.DataAnnotations; using Twilio.Http; namespace BotSharp.Plugin.Twilio.Controllers; @@ -382,21 +380,21 @@ public class TwilioVoiceController : TwilioController } [ValidateRequest] - [HttpPost("twilio/voice/init-call")] - public TwiMLResult InitiateOutboundCall(VoiceRequest request, [Required][FromQuery] string conversationId) + [HttpPost("twilio/voice/init-outbound-call")] + public TwiMLResult InitiateOutboundCall(ConversationalVoiceRequest request) { var instruction = new ConversationalVoiceResponse { ActionOnEmptyResult = true, - CallbackPath = $"twilio/voice/receive/1?conversation-id={conversationId}", - SpeechPaths = new List - { - $"twilio/voice/speeches/{conversationId}/intial.mp3" - } + CallbackPath = $"twilio/voice/receive/1?conversation-id={request.ConversationId}", }; - string tag = $"twilio:{Request.Form["AnsweredBy"]}"; - var db = _services.GetRequiredService(); - db.AppendConversationTags(conversationId, new List { tag }); + + if (request.InitAudioFile != null) + { + instruction.CallbackPath += $"&init-audio-file={request.InitAudioFile}"; + instruction.SpeechPaths.Add($"twilio/voice/speeches/{request.ConversationId}/{request.InitAudioFile}"); + } + var twilio = _services.GetRequiredService(); var response = twilio.ReturnNoninterruptedInstructions(instruction); return TwiML(response); @@ -415,6 +413,32 @@ public class TwilioVoiceController : TwilioController return result; } + [ValidateRequest] + [HttpPost("twilio/voice/status")] + public async Task PhoneCallStatus(ConversationalVoiceRequest request) + { + if (request.CallStatus == "completed") + { + if (request.AnsweredBy == "machine_start" && + request.Direction == "outbound-api" && + request.InitAudioFile != null) + { + // voicemail + await HookEmitter.Emit(_services, async hook => + { + await hook.OnVoicemailLeft(request); + }); + } + else + { + // phone call completed + await HookEmitter.Emit(_services, x => x.OnUserDisconnected(request)); + } + } + + return Ok(); + } + private Dictionary ParseStates(List states) { var result = new Dictionary(); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs b/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs index d35a2a35..904f16a1 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs @@ -1,8 +1,10 @@ +using BotSharp.Plugin.Twilio.Models; using Task = System.Threading.Tasks.Task; namespace BotSharp.Plugin.Twilio.Interfaces; public interface ITwilioCallStatusHook { - Task OnVoicemailLeft(string conversationId); + Task OnVoicemailLeft(ConversationalVoiceRequest request); + Task OnUserDisconnected(ConversationalVoiceRequest request); } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs b/src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs index 2587fed9..547b09d9 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs @@ -6,7 +6,7 @@ namespace BotSharp.Plugin.Twilio.Models public bool HumanIntervationNeeded { get; set; } public string Content { get; set; } public string MessageId { get; set; } - public string SpeechFileName { get; set; } + public string? SpeechFileName { get; set; } public string Hints { get; set; } } } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs index cb707474..488ad79b 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs @@ -1,8 +1,11 @@ using BotSharp.Abstraction.Files; +using BotSharp.Abstraction.Files.Models; using BotSharp.Abstraction.Infrastructures.Enums; using BotSharp.Abstraction.Options; using BotSharp.Abstraction.Routing; using BotSharp.Core.Infrastructures; +using BotSharp.Plugin.Twilio.Interfaces; +using BotSharp.Plugin.Twilio.Models; using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts; using Twilio.Rest.Api.V2010.Account; using Twilio.Types; @@ -58,20 +61,48 @@ public class OutboundPhoneCallFn : IFunctionCallback var newConversationId = Guid.NewGuid().ToString(); states.SetState(StateConst.SUB_CONVERSATION_ID, newConversationId); + var processUrl = $"{_twilioSetting.CallbackHost}/twilio"; + var statusUrl = $"{_twilioSetting.CallbackHost}/twilio/voice/status?conversation-id={newConversationId}"; + // Generate initial assistant audio - var completion = CompletionProvider.GetAudioCompletion(_services, "openai", "tts-1"); - var data = await completion.GenerateAudioFromTextAsync(args.InitialMessage); - var fileName = $"intial.mp3"; - fileStorage.SaveSpeechFile(newConversationId, fileName, data); + string initAudioUrl = null; + if (!string.IsNullOrEmpty(args.InitialMessage)) + { + var completion = CompletionProvider.GetAudioCompletion(_services, "openai", "tts-1"); + var data = await completion.GenerateAudioFromTextAsync(args.InitialMessage); + initAudioUrl = "intial.mp3"; + fileStorage.SaveSpeechFile(newConversationId, initAudioUrl, data); + + statusUrl += $"&init-audio-file={initAudioUrl}"; + } + + // Set up process URL streaming or synchronous + if (_twilioSetting.StreamingEnabled) + { + processUrl += "/stream"; + } + else + { + var sessionManager = _services.GetRequiredService(); + await sessionManager.SetAssistantReplyAsync(newConversationId, 0, new AssistantMessage + { + Content = args.InitialMessage, + SpeechFileName = initAudioUrl + }); + + processUrl += "/voice/init-outbound-call"; + } + + processUrl += $"?conversation-id={newConversationId}&init-audio-file={initAudioUrl}"; // Make outbound call var call = await CallResource.CreateAsync( - url: new Uri($"{_twilioSetting.CallbackHost}/twilio/stream?conversation-id={newConversationId}&init-audio-file={fileName}"), + url: new Uri(processUrl), to: new PhoneNumber(args.PhoneNumber), from: new PhoneNumber(_twilioSetting.PhoneNumber), - statusCallback: new Uri($"{_twilioSetting.CallbackHost}/twilio/stream/status?conversation-id={newConversationId}&init-audio-file={fileName}"), + statusCallback: new Uri(statusUrl), // https://www.twilio.com/docs/voice/answering-machine-detection - machineDetection: "Enable"); + machineDetection: _twilioSetting.MachineDetection); var convService = _services.GetRequiredService(); var routing = _services.GetRequiredService(); @@ -80,7 +111,7 @@ public class OutboundPhoneCallFn : IFunctionCallback await ForkConversation(args, entryAgentId, originConversationId, newConversationId, call); - message.Content = $"The generated phone message: \"{args.InitialMessage}.\" [NEW CONVERSATION ID: {newConversationId}, TWILIO CALL SID: {call.Sid}]"; + message.Content = $"The generated phone initial message: \"{args.InitialMessage}.\" [NEW CONVERSATION ID: {newConversationId}, TWILIO CALL SID: {call.Sid}, STREAMING: {_twilioSetting.StreamingEnabled}, RECORDING: {_twilioSetting.RecordingEnabled}]"; message.StopCompletion = true; return true; } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs index b17f287a..d64c34c2 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs @@ -109,7 +109,14 @@ public class TwilioService { foreach (var speechPath in conversationalVoiceResponse.SpeechPaths) { - response.Play(new Uri($"{_settings.CallbackHost}/{speechPath}")); + if (speechPath.StartsWith(_settings.CallbackHost)) + { + response.Play(new Uri(speechPath)); + } + else + { + response.Play(new Uri($"{_settings.CallbackHost}/{speechPath}")); + } } } var gather = new Gather() @@ -193,9 +200,13 @@ public class TwilioService { response.Play(new Uri($"{_settings.CallbackHost}/{speechPath}")); } + else if (speechPath.StartsWith(_settings.CallbackHost)) + { + response.Play(new Uri(speechPath)); + } else { - response.Play(new Uri($"{_settings.CallbackHost}/twilio/voice/speeches/{conversationId}/{speechPath}")); + response.Play(new Uri($"{_settings.CallbackHost}/{speechPath}")); } } } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs b/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs index 4c65481f..5d34299f 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs @@ -2,14 +2,36 @@ namespace BotSharp.Plugin.Twilio.Settings; public class TwilioSetting { - public string PhoneNumber { get; set; } + /// + /// Outbound phone number + /// + public string? PhoneNumber { get; set; } + + /// + /// Enable streaming for outbound phone call + /// + public bool StreamingEnabled { get; set; } = false; public string AccountSID { get; set; } public string AuthToken { get; set; } public string AppSID { get; set; } public string ApiKeySID { get; set; } public string ApiSecret { get; set; } public string CallbackHost { get; set; } - public string AgentId { get; set; } - public string CsrAgentNumber { get; set; } + + /// + /// Default Agent Id to handle inbound phone call + /// + public string? AgentId { get; set; } + + /// + /// Human agent phone number if AI can't handle the call + /// + public string? CsrAgentNumber { get; set; } + public int MaxGatherAttempts { get; set; } = 4; + + public string? MachineDetection { get; set; } + + public bool RecordingEnabled { get; set; } = false; + public bool RecordingTranscribe { get; set; } = false; }