From c4838da1fbd95c4d51c781dc1cfe767ac3343233 Mon Sep 17 00:00:00 2001 From: vguruparan Date: Thu, 13 Mar 2025 11:39:37 -0500 Subject: [PATCH 1/9] Update no sandbox argument for playwright instance --- .../Drivers/PlaywrightDriver/PlaywrightInstance.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Plugins/BotSharp.Plugin.WebDriver/Drivers/PlaywrightDriver/PlaywrightInstance.cs b/src/Plugins/BotSharp.Plugin.WebDriver/Drivers/PlaywrightDriver/PlaywrightInstance.cs index 755d4faf..283ad314 100644 --- a/src/Plugins/BotSharp.Plugin.WebDriver/Drivers/PlaywrightDriver/PlaywrightInstance.cs +++ b/src/Plugins/BotSharp.Plugin.WebDriver/Drivers/PlaywrightDriver/PlaywrightInstance.cs @@ -77,7 +77,8 @@ public class PlaywrightInstance : IDisposable Args = [ "--disable-infobars", - "--test-type" + "--test-type", + "--no-sandbox" // "--start-maximized" ] }); From a49b75f4953376dade9243110fc0a24df236ab4d Mon Sep 17 00:00:00 2001 From: Haiping Chen Date: Fri, 14 Mar 2025 13:59:13 -0500 Subject: [PATCH 2/9] 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; } From 4cde28f09509686e9e6a4a00bb8db59f11670d7b Mon Sep 17 00:00:00 2001 From: vguruparan Date: Fri, 14 Mar 2025 14:43:23 -0500 Subject: [PATCH 3/9] Add customization for web driver. --- .../Browsing/Models/PageActionArgs.cs | 7 ++++++- .../PlaywrightDriver/PlaywrightWebDriver.GoToPage.cs | 10 ++++++++-- .../UtilFunctions/UtilWebGoToPageFn.cs | 6 ++++-- .../functions/util-web-go_to_page.json | 8 ++++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/Infrastructure/BotSharp.Abstraction/Browsing/Models/PageActionArgs.cs b/src/Infrastructure/BotSharp.Abstraction/Browsing/Models/PageActionArgs.cs index fe575462..1abf7994 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Browsing/Models/PageActionArgs.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Browsing/Models/PageActionArgs.cs @@ -16,7 +16,10 @@ public class PageActionArgs /// /// This value has to be set to true if you want to get the page XHR/ Fetch responses /// - public bool OpenNewTab { get; set; } = false; + [JsonPropertyName("open_new_tab")] + public bool OpenNewTab { get; set; } = true; + [JsonPropertyName("open_blank_page")] + public bool OpenBlankPage { get; set; } = true; public bool EnableResponseCallback { get; set; } = false; @@ -47,4 +50,6 @@ public class PageActionArgs public int WaitTime { get; set; } public bool ReadInnerHTMLAsBody { get; set; } = false; + [JsonPropertyName("keep_browser_open")] + public bool KeepBrowserOpen { get; set; } = false; } diff --git a/src/Plugins/BotSharp.Plugin.WebDriver/Drivers/PlaywrightDriver/PlaywrightWebDriver.GoToPage.cs b/src/Plugins/BotSharp.Plugin.WebDriver/Drivers/PlaywrightDriver/PlaywrightWebDriver.GoToPage.cs index 7327bb40..922e8f58 100644 --- a/src/Plugins/BotSharp.Plugin.WebDriver/Drivers/PlaywrightDriver/PlaywrightWebDriver.GoToPage.cs +++ b/src/Plugins/BotSharp.Plugin.WebDriver/Drivers/PlaywrightDriver/PlaywrightWebDriver.GoToPage.cs @@ -15,7 +15,14 @@ public partial class PlaywrightWebDriver if (page != null) { - if (page.Url != "about:blank") + if (!args.OpenBlankPage) + { + if (!_instance.Pages[message.ContextId].Contains(page)) + { + _instance.Pages[message.ContextId].Add(page); + } + } + if (args.OpenBlankPage && page.Url != "about:blank") { await page.EvaluateAsync(@"() => { window.open('', '_blank'); @@ -48,7 +55,6 @@ public partial class PlaywrightWebDriver // Active current tab await page.BringToFrontAsync(); - var response = await page.GotoAsync(args.Url, new PageGotoOptions { Timeout = args.Timeout > 0 ? args.Timeout : 30000 diff --git a/src/Plugins/BotSharp.Plugin.WebDriver/UtilFunctions/UtilWebGoToPageFn.cs b/src/Plugins/BotSharp.Plugin.WebDriver/UtilFunctions/UtilWebGoToPageFn.cs index 0c5507bf..7a5d8f89 100644 --- a/src/Plugins/BotSharp.Plugin.WebDriver/UtilFunctions/UtilWebGoToPageFn.cs +++ b/src/Plugins/BotSharp.Plugin.WebDriver/UtilFunctions/UtilWebGoToPageFn.cs @@ -27,7 +27,6 @@ public class UtilWebGoToPageFn : IFunctionCallback args.Timeout = _webDriver.DefaultTimeout; args.WaitForNetworkIdle = false; args.WaitTime = _webDriver.DefaultWaitTime; - args.OpenNewTab = true; var conv = _services.GetRequiredService(); @@ -38,7 +37,10 @@ public class UtilWebGoToPageFn : IFunctionCallback MessageId = message.MessageId, ContextId = message.CurrentAgentId, }; - await browser.CloseCurrentPage(msg); + if (!args.KeepBrowserOpen) + { + await browser.CloseCurrentPage(msg); + } var result = await browser.GoToPage(msg, args); if (!result.IsSuccess) { diff --git a/src/Plugins/BotSharp.Plugin.WebDriver/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-web-go_to_page.json b/src/Plugins/BotSharp.Plugin.WebDriver/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-web-go_to_page.json index 65f088bd..4f6b21fe 100644 --- a/src/Plugins/BotSharp.Plugin.WebDriver/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-web-go_to_page.json +++ b/src/Plugins/BotSharp.Plugin.WebDriver/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-web-go_to_page.json @@ -7,6 +7,14 @@ "url": { "type": "string", "description": "Web URL" + }, + "open_new_tab": { + "type": "boolean", + "description": "Open new tab" + }, + "open_blank_page": { + "type": "boolean", + "description": "Open blank page" } }, "required": [ "url" ] From addb1b1bba1052a449e6177bf79e3695f9d927ea Mon Sep 17 00:00:00 2001 From: Haiping Chen Date: Fri, 14 Mar 2025 15:08:34 -0500 Subject: [PATCH 4/9] Outbound call phone_recording_url --- .../Controllers/TwilioRecordController.cs | 44 +++++++++++++++++++ .../Controllers/TwilioStreamController.cs | 1 + .../Controllers/TwilioVoiceController.cs | 18 +++++++- .../Interfaces/ITwilioCallStatusHook.cs | 1 + .../Models/ConversationalVoiceResponse.cs | 1 + .../Functions/OutboundPhoneCallFn.cs | 5 ++- .../Services/TwilioService.cs | 18 ++++---- .../Settings/TwilioSetting.cs | 1 - 8 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioRecordController.cs diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioRecordController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioRecordController.cs new file mode 100644 index 00000000..65b2a5ad --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioRecordController.cs @@ -0,0 +1,44 @@ +using BotSharp.Core.Infrastructures; +using BotSharp.Plugin.Twilio.Interfaces; +using BotSharp.Plugin.Twilio.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace BotSharp.Plugin.Twilio.Controllers; + +public class TwilioRecordController : TwilioController +{ + private readonly TwilioSetting _settings; + private readonly IServiceProvider _services; + private readonly ILogger _logger; + + public TwilioRecordController(TwilioSetting settings, IServiceProvider services, IHttpContextAccessor context, ILogger logger) + { + _settings = settings; + _services = services; + _logger = logger; + } + + [ValidateRequest] + [HttpPost("twilio/record/status")] + public async Task PhoneRecordingStatus(ConversationalVoiceRequest request) + { + if (request.RecordingStatus == "completed") + { + _logger.LogInformation($"Recording completed for {request.CallSid}, the record URL is {request.RecordingUrl}"); + + // Set the recording URL to the conversation state + var convService = _services.GetRequiredService(); + convService.SetConversationId(request.ConversationId, new List + { + new("phone_recording_url", request.RecordingUrl) + }); + convService.SaveStates(); + + // recording completed + await HookEmitter.Emit(_services, x => x.OnRecordingCompleted(request)); + } + + return Ok(); + } +} diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs index fb35c25c..8fea9bb4 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs @@ -48,6 +48,7 @@ public class TwilioStreamController : TwilioController var instruction = new ConversationalVoiceResponse { + ConversationId = request.ConversationId, SpeechPaths = [], ActionOnEmptyResult = true }; diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs index 0193cae9..18225d09 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs @@ -52,6 +52,7 @@ public class TwilioVoiceController : TwilioController VoiceResponse response = null; var instruction = new ConversationalVoiceResponse { + ConversationId = request.ConversationId, SpeechPaths = ["twilio/welcome.mp3"], ActionOnEmptyResult = true }; @@ -170,6 +171,7 @@ public class TwilioVoiceController : TwilioController { var instruction = new ConversationalVoiceResponse { + ConversationId = request.ConversationId, SpeechPaths = new List(), CallbackPath = $"twilio/voice/receive/{request.SeqNum}?conversation-id={request.ConversationId}&{GenerateStatesParameter(request.States)}&attempts={++request.Attempts}", ActionOnEmptyResult = true @@ -273,6 +275,7 @@ public class TwilioVoiceController : TwilioController var instruction = new ConversationalVoiceResponse { + ConversationId = request.ConversationId, SpeechPaths = speechPaths, CallbackPath = $"twilio/voice/reply/{request.SeqNum}?conversation-id={request.ConversationId}&{GenerateStatesParameter(request.States)}&AIResponseWaitTime={++request.AIResponseWaitTime}", ActionOnEmptyResult = true @@ -312,6 +315,7 @@ public class TwilioVoiceController : TwilioController var instruction = new ConversationalVoiceResponse { + ConversationId = request.ConversationId, SpeechPaths = instructions, CallbackPath = $"twilio/voice/reply/{request.SeqNum}?conversation-id={request.ConversationId}&{GenerateStatesParameter(request.States)}&AIResponseWaitTime={++request.AIResponseWaitTime}", ActionOnEmptyResult = true @@ -358,6 +362,7 @@ public class TwilioVoiceController : TwilioController { var instruction = new ConversationalVoiceResponse { + ConversationId = request.ConversationId, SpeechPaths = [$"twilio/voice/speeches/{request.ConversationId}/{reply.SpeechFileName}"], CallbackPath = $"twilio/voice/receive/{nextSeqNum}?conversation-id={request.ConversationId}&{GenerateStatesParameter(request.States)}", ActionOnEmptyResult = true, @@ -383,8 +388,19 @@ public class TwilioVoiceController : TwilioController [HttpPost("twilio/voice/init-outbound-call")] public TwiMLResult InitiateOutboundCall(ConversationalVoiceRequest request) { + VoiceResponse response = default!; + if (request.AnsweredBy == "machine_start" && + request.Direction == "outbound-api" && + request.InitAudioFile != null) + { + response = new VoiceResponse(); + response.Play(new Uri(request.InitAudioFile)); + return TwiML(response); + } + var instruction = new ConversationalVoiceResponse { + ConversationId = request.ConversationId, ActionOnEmptyResult = true, CallbackPath = $"twilio/voice/receive/1?conversation-id={request.ConversationId}", }; @@ -396,7 +412,7 @@ public class TwilioVoiceController : TwilioController } var twilio = _services.GetRequiredService(); - var response = twilio.ReturnNoninterruptedInstructions(instruction); + response = twilio.ReturnNoninterruptedInstructions(instruction); return TwiML(response); } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs b/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs index 904f16a1..c9ed4710 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs @@ -7,4 +7,5 @@ public interface ITwilioCallStatusHook { Task OnVoicemailLeft(ConversationalVoiceRequest request); Task OnUserDisconnected(ConversationalVoiceRequest request); + Task OnRecordingCompleted(ConversationalVoiceRequest request); } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationalVoiceResponse.cs b/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationalVoiceResponse.cs index ea8072d6..7870f961 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationalVoiceResponse.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Models/ConversationalVoiceResponse.cs @@ -2,6 +2,7 @@ namespace BotSharp.Plugin.Twilio.Models; public class ConversationalVoiceResponse { + public string ConversationId { get; set; } = null!; public List SpeechPaths { get; set; } = []; public string CallbackPath { get; set; } public bool ActionOnEmptyResult { 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 488ad79b..3bcd0260 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs @@ -63,6 +63,7 @@ public class OutboundPhoneCallFn : IFunctionCallback var processUrl = $"{_twilioSetting.CallbackHost}/twilio"; var statusUrl = $"{_twilioSetting.CallbackHost}/twilio/voice/status?conversation-id={newConversationId}"; + var recordingStatusUrl = $"{_twilioSetting.CallbackHost}/twilio/recording/status?conversation-id={newConversationId}"; // Generate initial assistant audio string initAudioUrl = null; @@ -102,7 +103,9 @@ public class OutboundPhoneCallFn : IFunctionCallback from: new PhoneNumber(_twilioSetting.PhoneNumber), statusCallback: new Uri(statusUrl), // https://www.twilio.com/docs/voice/answering-machine-detection - machineDetection: _twilioSetting.MachineDetection); + machineDetection: _twilioSetting.MachineDetection, + record: _twilioSetting.RecordingEnabled, + recordingStatusCallback: $"{_twilioSetting.CallbackHost}/twilio/record/status?conversation-id={newConversationId}"); var convService = _services.GetRequiredService(); var routing = _services.GetRequiredService(); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs index d64c34c2..8ef9f8e6 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs @@ -1,7 +1,6 @@ using BotSharp.Abstraction.Utilities; using BotSharp.Plugin.Twilio.Models; using Twilio.Jwt.AccessToken; -using Twilio.TwiML.Messaging; using Token = Twilio.Jwt.AccessToken.Token; namespace BotSharp.Plugin.Twilio.Services; @@ -101,13 +100,13 @@ public class TwilioService return response; } - public VoiceResponse ReturnNoninterruptedInstructions(ConversationalVoiceResponse conversationalVoiceResponse) + public VoiceResponse ReturnNoninterruptedInstructions(ConversationalVoiceResponse voiceResponse) { var response = new VoiceResponse(); - response.Pause(2); - if (conversationalVoiceResponse.SpeechPaths != null && conversationalVoiceResponse.SpeechPaths.Any()) + + if (voiceResponse.SpeechPaths != null && voiceResponse.SpeechPaths.Any()) { - foreach (var speechPath in conversationalVoiceResponse.SpeechPaths) + foreach (var speechPath in voiceResponse.SpeechPaths) { if (speechPath.StartsWith(_settings.CallbackHost)) { @@ -119,6 +118,7 @@ public class TwilioService } } } + var gather = new Gather() { Input = new List() @@ -126,14 +126,15 @@ public class TwilioService Gather.InputEnum.Speech, Gather.InputEnum.Dtmf }, - Action = new Uri($"{_settings.CallbackHost}/{conversationalVoiceResponse.CallbackPath}"), + Action = new Uri($"{_settings.CallbackHost}/{voiceResponse.CallbackPath}"), Enhanced = true, SpeechModel = Gather.SpeechModelEnum.PhoneCall, SpeechTimeout = "auto", // conversationalVoiceResponse.Timeout > 0 ? conversationalVoiceResponse.Timeout.ToString() : "3", - Timeout = conversationalVoiceResponse.Timeout > 0 ? conversationalVoiceResponse.Timeout : 3, - ActionOnEmptyResult = conversationalVoiceResponse.ActionOnEmptyResult + Timeout = voiceResponse.Timeout > 0 ? voiceResponse.Timeout : 3, + ActionOnEmptyResult = voiceResponse.ActionOnEmptyResult, }; response.Append(gather); + return response; } @@ -192,6 +193,7 @@ public class TwilioService public VoiceResponse ReturnBidirectionalMediaStreamsInstructions(string conversationId, ConversationalVoiceResponse conversationalVoiceResponse) { var response = new VoiceResponse(); + if (conversationalVoiceResponse.SpeechPaths != null && conversationalVoiceResponse.SpeechPaths.Any()) { foreach (var speechPath in conversationalVoiceResponse.SpeechPaths) diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs b/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs index 5d34299f..81629a9e 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs @@ -33,5 +33,4 @@ public class TwilioSetting public string? MachineDetection { get; set; } public bool RecordingEnabled { get; set; } = false; - public bool RecordingTranscribe { get; set; } = false; } From ace2e906f04923e7d8da8c431db473b0b2100dc7 Mon Sep 17 00:00:00 2001 From: Haiping Chen Date: Fri, 14 Mar 2025 16:44:19 -0500 Subject: [PATCH 5/9] util-twilio-text_message --- .../BotSharp.Plugin.Twilio.csproj | 3 ++ .../Functions/HangupPhoneCallFn.cs | 1 - .../Functions/TextMessageFn.cs | 49 +++++++++++++++++++ .../OutboundPhoneCallHandlerUtilityHook.cs | 4 +- .../Settings/TwilioSetting.cs | 2 + .../functions/util-twilio-text_message.json | 18 +++++++ 6 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/TextMessageFn.cs create mode 100644 src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-twilio-text_message.json diff --git a/src/Plugins/BotSharp.Plugin.Twilio/BotSharp.Plugin.Twilio.csproj b/src/Plugins/BotSharp.Plugin.Twilio/BotSharp.Plugin.Twilio.csproj index 72714388..5b83d77d 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/BotSharp.Plugin.Twilio.csproj +++ b/src/Plugins/BotSharp.Plugin.Twilio/BotSharp.Plugin.Twilio.csproj @@ -13,6 +13,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs index cd755306..5a7eb3f3 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs @@ -1,5 +1,4 @@ using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts; -using Microsoft.VisualBasic; using Twilio.Rest.Api.V2010.Account; using Task = System.Threading.Tasks.Task; diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/TextMessageFn.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/TextMessageFn.cs new file mode 100644 index 00000000..c7c6fc88 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/TextMessageFn.cs @@ -0,0 +1,49 @@ +using BotSharp.Abstraction.Options; +using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts; +using Twilio.Rest.Api.V2010.Account; + +namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Functions; + +public class TextMessageFn : IFunctionCallback +{ + private readonly IServiceProvider _services; + private readonly ILogger _logger; + private readonly TwilioSetting _twilioSetting; + + public string Name => "util-twilio-text_message"; + public string Indication => "Sending text message"; + + public TextMessageFn( + IServiceProvider services, + ILogger logger, + TwilioSetting twilioSetting) + { + _services = services; + _logger = logger; + _twilioSetting = twilioSetting; + } + + public async Task Execute(RoleDialogModel message) + { + var args = JsonSerializer.Deserialize(message.FunctionArgs, BotSharpOptions.defaultJsonOptions); + + // Send the message + var twilioMessage = MessageResource.Create( + to: args.PhoneNumber, + from: _twilioSetting.MessagingShortCode, + body: args.InitialMessage + ); + + if (twilioMessage.Status == MessageResource.StatusEnum.Queued) + { + message.Content = $"Queued message to {args.PhoneNumber}: {args.InitialMessage} [MESSAGING SID: {twilioMessage.Sid}]"; + message.StopCompletion = true; + } + else + { + message.Content = twilioMessage.ErrorMessage; + } + + return true; + } +} diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Hooks/OutboundPhoneCallHandlerUtilityHook.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Hooks/OutboundPhoneCallHandlerUtilityHook.cs index 692610ff..fa0b3179 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Hooks/OutboundPhoneCallHandlerUtilityHook.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Hooks/OutboundPhoneCallHandlerUtilityHook.cs @@ -8,6 +8,7 @@ public class OutboundPhoneCallHandlerUtilityHook : IAgentUtilityHook private static string PREFIX = "util-twilio-"; private static string OUTBOUND_PHONE_CALL_FN = $"{PREFIX}outbound_phone_call"; private static string HANGUP_PHONE_CALL_FN = $"{PREFIX}hangup_phone_call"; + public static string TEXT_MESSAGE_FN = $"{PREFIX}text_message"; public void AddUtilities(List utilities) { @@ -17,7 +18,8 @@ public class OutboundPhoneCallHandlerUtilityHook : IAgentUtilityHook Functions = [ new($"{OUTBOUND_PHONE_CALL_FN}"), - new($"{HANGUP_PHONE_CALL_FN}") + new($"{HANGUP_PHONE_CALL_FN}"), + new($"{TEXT_MESSAGE_FN}") ], Templates = [ diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs b/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs index 81629a9e..2de42ec8 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Settings/TwilioSetting.cs @@ -18,6 +18,8 @@ public class TwilioSetting public string ApiSecret { get; set; } public string CallbackHost { get; set; } + public string? MessagingShortCode { get; set; } + /// /// Default Agent Id to handle inbound phone call /// diff --git a/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-twilio-text_message.json b/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-twilio-text_message.json new file mode 100644 index 00000000..a4340410 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-twilio-text_message.json @@ -0,0 +1,18 @@ +{ + "name": "util-twilio-text_message", + "description": "If the user wants to send SMS message to a phone.", + "parameters": { + "type": "object", + "properties": { + "phone_number": { + "type": "string", + "description": "The phone number which will receive message. It needs to be a valid phone number starting with +1." + }, + "initial_message": { + "type": "string", + "description": "The initial message which will be sent." + } + }, + "required": [ "phone_number", "initial_message" ] + } +} \ No newline at end of file From 3c23e58351446059c666d0895ee28d646ef1de7b Mon Sep 17 00:00:00 2001 From: Haiping Chen Date: Fri, 14 Mar 2025 21:06:40 -0500 Subject: [PATCH 6/9] Optimize phone hang-up --- .../MLTasks/IRealTimeCompletion.cs | 2 +- .../Realtime/Models/ModelTurnDetection.cs | 10 +++++++ .../Realtime/Models/RealtimeModelSettings.cs | 8 ++++++ .../BotSharp.Core.Realtime/RealtimePlugin.cs | 7 +++++ .../Services/RealtimeHub.cs | 23 ++++++++-------- .../Models/Realtime/RealtimeSessionBody.cs | 3 +++ .../Realtime/RealTimeCompletionProvider.cs | 20 +++++++------- .../BotSharp.Plugin.Twilio.csproj | 10 +++---- .../Controllers/TwilioVoiceController.cs | 9 +++++++ .../Functions/HangupPhoneCallFn.cs | 26 ++++++++++++------- .../Functions/OutboundPhoneCallFn.cs | 16 +++++++----- .../LlmContexts/HangupPhoneCallArgs.cs | 4 +-- .../util-twilio-hangup_phone_call.json | 12 ++++++--- .../util-twilio-hangup_phone_call.fn.liquid | 2 +- 14 files changed, 99 insertions(+), 53 deletions(-) create mode 100644 src/Infrastructure/BotSharp.Abstraction/Realtime/Models/ModelTurnDetection.cs create mode 100644 src/Infrastructure/BotSharp.Abstraction/Realtime/Models/RealtimeModelSettings.cs diff --git a/src/Infrastructure/BotSharp.Abstraction/MLTasks/IRealTimeCompletion.cs b/src/Infrastructure/BotSharp.Abstraction/MLTasks/IRealTimeCompletion.cs index 075d7291..68a96dfa 100644 --- a/src/Infrastructure/BotSharp.Abstraction/MLTasks/IRealTimeCompletion.cs +++ b/src/Infrastructure/BotSharp.Abstraction/MLTasks/IRealTimeCompletion.cs @@ -23,7 +23,7 @@ public interface IRealTimeCompletion Task Disconnect(); Task CreateSession(Agent agent, List conversations); - Task UpdateSession(RealtimeHubConnection conn, bool turnDetection = true); + Task UpdateSession(RealtimeHubConnection conn, bool interruptResponse = true); Task InsertConversationItem(RoleDialogModel message); Task RemoveConversationItem(string itemId); Task TriggerModelInference(string? instructions = null); diff --git a/src/Infrastructure/BotSharp.Abstraction/Realtime/Models/ModelTurnDetection.cs b/src/Infrastructure/BotSharp.Abstraction/Realtime/Models/ModelTurnDetection.cs new file mode 100644 index 00000000..a65a8985 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Realtime/Models/ModelTurnDetection.cs @@ -0,0 +1,10 @@ +namespace BotSharp.Abstraction.Realtime.Models; + +public class ModelTurnDetection +{ + public int PrefixPadding { get; set; } = 300; + + public int SilenceDuration { get; set; } = 800; + + public float Threshold { get; set; } = 0.8f; +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Realtime/Models/RealtimeModelSettings.cs b/src/Infrastructure/BotSharp.Abstraction/Realtime/Models/RealtimeModelSettings.cs new file mode 100644 index 00000000..8755be9d --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Realtime/Models/RealtimeModelSettings.cs @@ -0,0 +1,8 @@ +namespace BotSharp.Abstraction.Realtime.Models; + +public class RealtimeModelSettings +{ + public float Temperature { get; set; } = 0.6f; + public int MaxResponseOutputTokens { get; set; } = 512; + public ModelTurnDetection TurnDetection { get; set; } = new(); +} diff --git a/src/Infrastructure/BotSharp.Core.Realtime/RealtimePlugin.cs b/src/Infrastructure/BotSharp.Core.Realtime/RealtimePlugin.cs index a45325de..fa9ee268 100644 --- a/src/Infrastructure/BotSharp.Core.Realtime/RealtimePlugin.cs +++ b/src/Infrastructure/BotSharp.Core.Realtime/RealtimePlugin.cs @@ -1,4 +1,5 @@ using BotSharp.Abstraction.Plugins; +using BotSharp.Abstraction.Settings; using BotSharp.Core.Realtime.Hooks; using BotSharp.Core.Realtime.Services; using Microsoft.Extensions.Configuration; @@ -14,6 +15,12 @@ public class RealtimePlugin : IBotSharpPlugin public void RegisterDI(IServiceCollection services, IConfiguration config) { + services.AddScoped(provider => + { + var settingService = provider.GetRequiredService(); + return settingService.Bind("RealtimeModel"); + }); + services.AddScoped(); services.AddScoped(); } diff --git a/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs b/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs index 8adf63f0..22f331ee 100644 --- a/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs +++ b/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs @@ -98,15 +98,18 @@ public class RealtimeHub : IRealtimeHub await _completer.Connect(_conn, onModelReady: async () => { - if (states.ContainsState("init_audio_file")) - { - await _completer.UpdateSession(_conn, turnDetection: true); - } - else - { - // Control initial session, prevent initial response interruption - await _completer.UpdateSession(_conn, turnDetection: false); + // Not TriggerModelInference, waiting for user utter. + await _completer.UpdateSession(_conn); + // Push dialogs into model context + foreach (var message in dialogs) + { + await _completer.InsertConversationItem(message); + } + + // Trigger model inference if there is no audio file in the conversation + if (!states.ContainsState("init_audio_file")) + { if (dialogs.LastOrDefault()?.Role == AgentRole.Assistant) { await _completer.TriggerModelInference($"Rephase your last response:\r\n{dialogs.LastOrDefault()?.Content}"); @@ -115,10 +118,6 @@ public class RealtimeHub : IRealtimeHub { await _completer.TriggerModelInference("Reply based on the conversation context."); } - - // Start turn detection - await Task.Delay(1000 * 8); - await _completer.UpdateSession(_conn, turnDetection: true); } }, onModelAudioDeltaReceived: async (audioDeltaData, itemId) => diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Models/Realtime/RealtimeSessionBody.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Models/Realtime/RealtimeSessionBody.cs index f6a6322c..2f7f79c6 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Models/Realtime/RealtimeSessionBody.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Models/Realtime/RealtimeSessionBody.cs @@ -52,6 +52,9 @@ public class RealtimeSessionBody public class RealtimeSessionTurnDetection { + [JsonPropertyName("interrupt_response")] + public bool InterruptResponse { get; set; } = true; + /// /// Milliseconds /// diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Realtime/RealTimeCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Realtime/RealTimeCompletionProvider.cs index 47a9030d..2510c852 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Realtime/RealTimeCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Realtime/RealTimeCompletionProvider.cs @@ -290,7 +290,7 @@ public class RealTimeCompletionProvider : IRealTimeCompletion return session; } - public async Task UpdateSession(RealtimeHubConnection conn, bool turnDetection = true) + public async Task UpdateSession(RealtimeHubConnection conn, bool interruptResponse = true) { var convService = _services.GetRequiredService(); var conv = await convService.GetConversation(conn.ConversationId); @@ -317,6 +317,8 @@ public class RealTimeCompletionProvider : IRealTimeCompletion var words = new List(); HookEmitter.Emit(_services, hook => words.AddRange(hook.OnModelTranscriptPrompt(agent))); + var realitmeModelSettings = _services.GetRequiredService(); + var sessionUpdate = new { type = "session.update", @@ -335,22 +337,18 @@ public class RealTimeCompletionProvider : IRealTimeCompletion ToolChoice = "auto", Tools = functions, Modalities = [ "text", "audio" ], - Temperature = Math.Max(options.Temperature ?? 0f, 0.6f), - MaxResponseOutputTokens = 512, + Temperature = Math.Max(options.Temperature ?? realitmeModelSettings.Temperature, 0.6f), + MaxResponseOutputTokens = realitmeModelSettings.MaxResponseOutputTokens, TurnDetection = new RealtimeSessionTurnDetection { - Threshold = 0.9f, - PrefixPadding = 300, - SilenceDuration = 800 + InterruptResponse = interruptResponse, + Threshold = realitmeModelSettings.TurnDetection.Threshold, + PrefixPadding = realitmeModelSettings.TurnDetection.PrefixPadding, + SilenceDuration = realitmeModelSettings.TurnDetection.SilenceDuration } } }; - if (!turnDetection) - { - sessionUpdate.session.TurnDetection = null; - } - await HookEmitter.Emit(_services, async hook => { await hook.OnSessionUpdated(agent, instruction, functions); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/BotSharp.Plugin.Twilio.csproj b/src/Plugins/BotSharp.Plugin.Twilio/BotSharp.Plugin.Twilio.csproj index 5b83d77d..8fe2ecce 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/BotSharp.Plugin.Twilio.csproj +++ b/src/Plugins/BotSharp.Plugin.Twilio/BotSharp.Plugin.Twilio.csproj @@ -19,12 +19,6 @@ PreserveNewest - - PreserveNewest - - - PreserveNewest - @@ -39,4 +33,8 @@ + + + + diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs index 18225d09..079e13c5 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs @@ -429,6 +429,15 @@ public class TwilioVoiceController : TwilioController return result; } + [ValidateRequest] + [HttpPost("twilio/voice/hang-up")] + public async Task Hangup(ConversationalVoiceRequest request) + { + var twilio = _services.GetRequiredService(); + var response = twilio.HangUp("twilio/bye.mp3"); + return TwiML(response); + } + [ValidateRequest] [HttpPost("twilio/voice/status")] public async Task PhoneCallStatus(ConversationalVoiceRequest request) diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs index 5a7eb3f3..9fa7d03c 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs @@ -1,6 +1,6 @@ +using BotSharp.Abstraction.Routing; using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts; using Twilio.Rest.Api.V2010.Account; -using Task = System.Threading.Tasks.Task; namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Functions; @@ -8,21 +8,27 @@ public class HangupPhoneCallFn : IFunctionCallback { private readonly IServiceProvider _services; private readonly ILogger _logger; + private readonly TwilioSetting _twilioSetting; public string Name => "util-twilio-hangup_phone_call"; public string Indication => "Hangup"; public HangupPhoneCallFn( IServiceProvider services, - ILogger logger) + ILogger logger, + TwilioSetting twilioSetting) { _services = services; _logger = logger; + _twilioSetting = twilioSetting; } public async Task Execute(RoleDialogModel message) { var args = JsonSerializer.Deserialize(message.FunctionArgs); + + var routing = _services.GetRequiredService(); + var conversationId = routing.Context.ConversationId; var states = _services.GetRequiredService(); var callSid = states.GetState("twilio_call_sid"); @@ -33,20 +39,20 @@ public class HangupPhoneCallFn : IFunctionCallback return false; } - message.Content = args.GoodbyeMessage; - - _ = Task.Run(async () => + if (args.AnythingElseToHelp) + { + message.Content = "Tell me how I can help."; + } + else { - await Task.Delay(args.GoodbyeMessage.Split(' ').Length * 400); - // Have to find the SID by the phone number var call = CallResource.Update( - status: CallResource.UpdateStatusEnum.Completed, + url: new Uri($"{_twilioSetting.CallbackHost}/twilio/voice/hang-up?conversation-id={conversationId}"), pathSid: callSid ); - message.Content = "The call has been ended."; + message.Content = "The call is ending."; message.StopCompletion = true; - }); + } return true; } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs index 3bcd0260..1e10a135 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs @@ -66,15 +66,15 @@ public class OutboundPhoneCallFn : IFunctionCallback var recordingStatusUrl = $"{_twilioSetting.CallbackHost}/twilio/recording/status?conversation-id={newConversationId}"; // Generate initial assistant audio - string initAudioUrl = null; + string initAudioFile = 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); + initAudioFile = "intial.mp3"; + fileStorage.SaveSpeechFile(newConversationId, initAudioFile, data); - statusUrl += $"&init-audio-file={initAudioUrl}"; + statusUrl += $"&init-audio-file={initAudioFile}"; } // Set up process URL streaming or synchronous @@ -88,13 +88,17 @@ public class OutboundPhoneCallFn : IFunctionCallback await sessionManager.SetAssistantReplyAsync(newConversationId, 0, new AssistantMessage { Content = args.InitialMessage, - SpeechFileName = initAudioUrl + SpeechFileName = initAudioFile }); processUrl += "/voice/init-outbound-call"; } - processUrl += $"?conversation-id={newConversationId}&init-audio-file={initAudioUrl}"; + processUrl += $"?conversation-id={newConversationId}"; + if (!string.IsNullOrEmpty(initAudioFile)) + { + processUrl += $"&init-audio-file={initAudioFile}"; + } // Make outbound call var call = await CallResource.CreateAsync( diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/LlmContexts/HangupPhoneCallArgs.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/LlmContexts/HangupPhoneCallArgs.cs index ea2075d9..efd8114a 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/LlmContexts/HangupPhoneCallArgs.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/LlmContexts/HangupPhoneCallArgs.cs @@ -4,6 +4,6 @@ namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts; public class HangupPhoneCallArgs { - [JsonPropertyName("goodbye_message")] - public string? GoodbyeMessage { get; set; } + [JsonPropertyName("anything_else_to_help")] + public bool AnythingElseToHelp { get; set; } = true; } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-twilio-hangup_phone_call.json b/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-twilio-hangup_phone_call.json index 773fac23..be5f3ca6 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-twilio-hangup_phone_call.json +++ b/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-twilio-hangup_phone_call.json @@ -1,15 +1,19 @@ { "name": "util-twilio-hangup_phone_call", - "description": "Call this function if the user wants to end the phone call", + "description": "Call this function if the user wants to end the phone call or conversation", "visibility_expression": "{% if states.channel == 'phone' %}visible{% endif %}", "parameters": { "type": "object", "properties": { - "goodbye_message": { + "reason": { "type": "string", - "description": "A polite closing statement for ending a conversation." + "description": "The reason why user wants to end the phone call." + }, + "anything_else_to_help": { + "type": "boolean", + "description": "Check if user has anything else to help." } }, - "required": [ "goodbye_message" ] + "required": [ "reason", "anything_else_to_help" ] } } \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/templates/util-twilio-hangup_phone_call.fn.liquid b/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/templates/util-twilio-hangup_phone_call.fn.liquid index 4a6b5034..5e89cbd2 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/templates/util-twilio-hangup_phone_call.fn.liquid +++ b/src/Plugins/BotSharp.Plugin.Twilio/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/templates/util-twilio-hangup_phone_call.fn.liquid @@ -1,3 +1,3 @@ {% if channel == 'phone' %} -** Please call util-twilio-hangup_phone_call if user wants to end the phone call. +** If user wants to end the phone call or conversation, ask user if there is anything else to help. If not, end the phone call. {% endif %} \ No newline at end of file From 292da2aee7244619be2d13de85d4e478c3cb6663 Mon Sep 17 00:00:00 2001 From: Haiping Chen Date: Fri, 14 Mar 2025 21:08:29 -0500 Subject: [PATCH 7/9] Change template default to 0.8f --- .../Realtime/Models/RealtimeModelSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Infrastructure/BotSharp.Abstraction/Realtime/Models/RealtimeModelSettings.cs b/src/Infrastructure/BotSharp.Abstraction/Realtime/Models/RealtimeModelSettings.cs index 8755be9d..30b6cfb8 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Realtime/Models/RealtimeModelSettings.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Realtime/Models/RealtimeModelSettings.cs @@ -2,7 +2,7 @@ namespace BotSharp.Abstraction.Realtime.Models; public class RealtimeModelSettings { - public float Temperature { get; set; } = 0.6f; + public float Temperature { get; set; } = 0.8f; public int MaxResponseOutputTokens { get; set; } = 512; public ModelTurnDetection TurnDetection { get; set; } = new(); } From a1c73d045a819ff1468a4bcbea8b1a139746782c Mon Sep 17 00:00:00 2001 From: Haiping Chen Date: Sat, 15 Mar 2025 09:29:46 -0500 Subject: [PATCH 8/9] Fix Push dialogs into model context --- .../Services/RealtimeHub.cs | 18 +++++++++++------- .../Controllers/TwilioStreamController.cs | 2 +- .../Services/TwilioService.cs | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs b/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs index 22f331ee..55378b01 100644 --- a/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs +++ b/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs @@ -99,13 +99,7 @@ public class RealtimeHub : IRealtimeHub onModelReady: async () => { // Not TriggerModelInference, waiting for user utter. - await _completer.UpdateSession(_conn); - - // Push dialogs into model context - foreach (var message in dialogs) - { - await _completer.InsertConversationItem(message); - } + var instruction = await _completer.UpdateSession(_conn); // Trigger model inference if there is no audio file in the conversation if (!states.ContainsState("init_audio_file")) @@ -119,6 +113,16 @@ public class RealtimeHub : IRealtimeHub await _completer.TriggerModelInference("Reply based on the conversation context."); } } + else + { + // Push dialogs into model context + foreach (var message in dialogs) + { + await _completer.InsertConversationItem(message); + } + + await _completer.TriggerModelInference($"{instruction}\r\n\r\nAssist user without repeating your previous statement."); + } }, onModelAudioDeltaReceived: async (audioDeltaData, itemId) => { diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs index 8fea9bb4..6960f489 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs @@ -55,7 +55,7 @@ public class TwilioStreamController : TwilioController if (request.InitAudioFile != null) { - instruction.SpeechPaths.Add($"twilio/voice/speeches/{request.ConversationId}/{request.InitAudioFile}"); + instruction.SpeechPaths.Add(request.InitAudioFile); } await HookEmitter.Emit(_services, async hook => diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs index 8ef9f8e6..082a0721 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs @@ -208,7 +208,7 @@ public class TwilioService } else { - response.Play(new Uri($"{_settings.CallbackHost}/{speechPath}")); + response.Play(new Uri($"{_settings.CallbackHost}/twilio/voice/speeches/{conversationalVoiceResponse.ConversationId}/{speechPath}")); } } } From cc6f4d609cd752278cf0b29a8118fd4b71a2d459 Mon Sep 17 00:00:00 2001 From: Haiping Chen Date: Sun, 16 Mar 2025 09:45:18 -0500 Subject: [PATCH 9/9] Release realtime v4.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 6716a3c7..3cb973b5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ net8.0 12.0 - 4.0.0 + 4.1.0 true false