From a49b75f4953376dade9243110fc0a24df236ab4d Mon Sep 17 00:00:00 2001 From: Haiping Chen <hchen@lessen.com> Date: Fri, 14 Mar 2025 13:59:13 -0500 Subject: [PATCH 1/3] 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 a84c8693a..da87646e8 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/ConversationHookBase.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/ConversationHookBase.cs @@ -78,7 +78,4 @@ public virtual Task OnBreakpointUpdated(string conversationId, bool resetStates) 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 f99078eac..c764f3910 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationHook.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationHook.cs @@ -25,13 +25,6 @@ public interface IConversationHook /// <returns></returns> Task OnUserAgentConnectedInitially(Conversation conversation); - /// <summary> - /// Triggered when user disconnects with agent. - /// </summary> - /// <param name="conversation"></param> - /// <returns></returns> - Task OnUserDisconnected(Conversation conversation); - /// <summary> /// Triggered once for every new conversation. /// </summary> diff --git a/src/Infrastructure/BotSharp.Core.Realtime/BotSharp.Core.Realtime.csproj b/src/Infrastructure/BotSharp.Core.Realtime/BotSharp.Core.Realtime.csproj index c004dc503..25218b089 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 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> + <TargetFramework>$(TargetFramework)</TargetFramework> + <LangVersion>$(LangVersion)</LangVersion> + <VersionPrefix>$(BotSharpVersion)</VersionPrefix> + <GeneratePackageOnBuild>$(GeneratePackageOnBuild)</GeneratePackageOnBuild> + <OutputPath>$(SolutionDir)packages</OutputPath> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> diff --git a/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs b/src/Infrastructure/BotSharp.Core.Realtime/Services/RealtimeHub.cs index 763022627..8adf63f00 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 @@ private async Task HandleUserDtmfReceived() private async Task HandleUserDisconnected() { - var convService = _services.GetRequiredService<IConversationService>(); - var conversation = await convService.GetConversation(_conn.ConversationId); - await HookEmitter.Emit<IConversationHook>(_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 e69f79f62..fb35c25ce 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs @@ -42,7 +42,7 @@ public async Task<TwiMLResult> InitiateStreamConversation(ConversationalVoiceReq 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 async Task<TwiMLResult> InitiateStreamConversation(ConversationalVoiceReq if (request.InitAudioFile != null) { - instruction.SpeechPaths.Add(request.InitAudioFile); + instruction.SpeechPaths.Add($"twilio/voice/speeches/{request.ConversationId}/{request.InitAudioFile}"); } await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook => @@ -82,24 +82,6 @@ await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook => return TwiML(response); } - [ValidateRequest] - [HttpPost("twilio/stream/status")] - public async Task<ActionResult> StreamConversationStatus(ConversationalVoiceRequest request) - { - if (request.AnsweredBy == "machine_start" && - request.Direction == "outbound-api" && - request.InitAudioFile != null && - request.CallStatus == "completed") - { - // voicemail - await HookEmitter.Emit<ITwilioCallStatusHook>(_services, async hook => - { - await hook.OnVoicemailLeft(request.ConversationId); - }); - } - return Ok(); - } - private async Task<string> InitConversation(ConversationalVoiceRequest request) { var convService = _services.GetRequiredService<IConversationService>(); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs index 6507a1053..0193cae9b 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 @@ await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook => } [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<string> - { - $"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<IBotSharpRepository>(); - db.AppendConversationTags(conversationId, new List<string> { 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<TwilioService>(); var response = twilio.ReturnNoninterruptedInstructions(instruction); return TwiML(response); @@ -415,6 +413,32 @@ public async Task<FileContentResult> GetSpeechFile([FromRoute] string conversati return result; } + [ValidateRequest] + [HttpPost("twilio/voice/status")] + public async Task<ActionResult> PhoneCallStatus(ConversationalVoiceRequest request) + { + if (request.CallStatus == "completed") + { + if (request.AnsweredBy == "machine_start" && + request.Direction == "outbound-api" && + request.InitAudioFile != null) + { + // voicemail + await HookEmitter.Emit<ITwilioCallStatusHook>(_services, async hook => + { + await hook.OnVoicemailLeft(request); + }); + } + else + { + // phone call completed + await HookEmitter.Emit<ITwilioCallStatusHook>(_services, x => x.OnUserDisconnected(request)); + } + } + + return Ok(); + } + private Dictionary<string, string> ParseStates(List<string> states) { var result = new Dictionary<string, string>(); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs b/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs index d35a2a353..904f16a12 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 2587fed9d..547b09d93 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Models/AssistantMessage.cs @@ -6,7 +6,7 @@ public class AssistantMessage 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 cb707474f..488ad79b2 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 async Task<bool> Execute(RoleDialogModel message) 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<ITwilioSessionManager>(); + 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<IConversationService>(); var routing = _services.GetRequiredService<IRoutingContext>(); @@ -80,7 +111,7 @@ public async Task<bool> Execute(RoleDialogModel message) 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 b17f287a0..d64c34c26 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs @@ -109,7 +109,14 @@ public VoiceResponse ReturnNoninterruptedInstructions(ConversationalVoiceRespons { 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 VoiceResponse ReturnBidirectionalMediaStreamsInstructions(string conversa { 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 4c65481fa..5d34299fd 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; } + /// <summary> + /// Outbound phone number + /// </summary> + public string? PhoneNumber { get; set; } + + /// <summary> + /// Enable streaming for outbound phone call + /// </summary> + 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; } + + /// <summary> + /// Default Agent Id to handle inbound phone call + /// </summary> + public string? AgentId { get; set; } + + /// <summary> + /// Human agent phone number if AI can't handle the call + /// </summary> + 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 addb1b1bba1052a449e6177bf79e3695f9d927ea Mon Sep 17 00:00:00 2001 From: Haiping Chen <hchen@lessen.com> Date: Fri, 14 Mar 2025 15:08:34 -0500 Subject: [PATCH 2/3] 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 000000000..65b2a5adf --- /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<TwilioRecordController> logger) + { + _settings = settings; + _services = services; + _logger = logger; + } + + [ValidateRequest] + [HttpPost("twilio/record/status")] + public async Task<ActionResult> 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<IConversationService>(); + convService.SetConversationId(request.ConversationId, new List<MessageState> + { + new("phone_recording_url", request.RecordingUrl) + }); + convService.SaveStates(); + + // recording completed + await HookEmitter.Emit<ITwilioCallStatusHook>(_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 fb35c25ce..8fea9bb4c 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioStreamController.cs @@ -48,6 +48,7 @@ public async Task<TwiMLResult> InitiateStreamConversation(ConversationalVoiceReq 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 0193cae9b..18225d092 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs @@ -52,6 +52,7 @@ public async Task<TwiMLResult> InitiateConversation(ConversationalVoiceRequest r VoiceResponse response = null; var instruction = new ConversationalVoiceResponse { + ConversationId = request.ConversationId, SpeechPaths = ["twilio/welcome.mp3"], ActionOnEmptyResult = true }; @@ -170,6 +171,7 @@ await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook => { var instruction = new ConversationalVoiceResponse { + ConversationId = request.ConversationId, SpeechPaths = new List<string>(), CallbackPath = $"twilio/voice/receive/{request.SeqNum}?conversation-id={request.ConversationId}&{GenerateStatesParameter(request.States)}&attempts={++request.Attempts}", ActionOnEmptyResult = true @@ -273,6 +275,7 @@ await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook => 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 @@ await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook => 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 @@ await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook => { 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 @@ await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook => [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 TwiMLResult InitiateOutboundCall(ConversationalVoiceRequest request) } var twilio = _services.GetRequiredService<TwilioService>(); - 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 904f16a12..c9ed47106 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 ea8072d60..7870f9615 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<string> 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 488ad79b2..3bcd02602 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 async Task<bool> Execute(RoleDialogModel message) 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 async Task<bool> Execute(RoleDialogModel message) 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<IConversationService>(); var routing = _services.GetRequiredService<IRoutingContext>(); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs index d64c34c26..8ef9f8e6a 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 VoiceResponse ReturnInstructions(ConversationalVoiceResponse conversation 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 VoiceResponse ReturnNoninterruptedInstructions(ConversationalVoiceRespons } } } + var gather = new Gather() { Input = new List<Gather.InputEnum>() @@ -126,14 +126,15 @@ public VoiceResponse ReturnNoninterruptedInstructions(ConversationalVoiceRespons 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 VoiceResponse HoldOn(int interval, string message = null) 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 5d34299fd..81629a9ed 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 <hchen@lessen.com> Date: Fri, 14 Mar 2025 16:44:19 -0500 Subject: [PATCH 3/3] 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 727143886..5b83d77d2 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 @@ <Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-twilio-hangup_phone_call.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> + <Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-twilio-text_message.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> <Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-twilio-outbound_phone_call.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HangupPhoneCallFn.cs index cd7553069..5a7eb3f39 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 000000000..c7c6fc888 --- /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<TextMessageFn> logger, + TwilioSetting twilioSetting) + { + _services = services; + _logger = logger; + _twilioSetting = twilioSetting; + } + + public async Task<bool> Execute(RoleDialogModel message) + { + var args = JsonSerializer.Deserialize<LlmContextIn>(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 692610ffc..fa0b31798 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<AgentUtility> utilities) { @@ -17,7 +18,8 @@ public void AddUtilities(List<AgentUtility> utilities) 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 81629a9ed..2de42ec8f 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; } + /// <summary> /// Default Agent Id to handle inbound phone call /// </summary> 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 000000000..a4340410f --- /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