using BotSharp.Abstraction.Files; using BotSharp.Core.Infrastructures; using BotSharp.Plugin.Twilio.Models; using BotSharp.Plugin.Twilio.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Twilio.Http; namespace BotSharp.Plugin.Twilio.Controllers; public class TwilioVoiceController : TwilioController { private readonly TwilioSetting _settings; private readonly IServiceProvider _services; private readonly IHttpContextAccessor _context; private readonly ILogger _logger; public TwilioVoiceController(TwilioSetting settings, IServiceProvider services, IHttpContextAccessor context, ILogger logger) { _settings = settings; _services = services; _context = context; _logger = logger; } /// /// https://github.com/twilio-labs/twilio-aspnet?tab=readme-ov-file#validate-twilio-http-requests /// /// /// /// /// [ValidateRequest] [HttpPost("twilio/voice/welcome")] public async Task InitiateConversation(VoiceRequest request, [FromQuery] string states, [FromQuery] string intent) { if (request?.CallSid == null) { throw new ArgumentNullException(nameof(VoiceRequest.CallSid)); } string conversationId = $"TwilioVoice_{request.CallSid}"; var twilio = _services.GetRequiredService(); VoiceResponse response; if (string.IsNullOrWhiteSpace(intent)) { var url = $"twilio/voice/{conversationId}/receive/0?states={states}"; response = twilio.ReturnNoninterruptedInstructions(new List { "twilio/welcome.mp3" }, url, true, timeout: 2); } else { int seqNum = 0; var messageQueue = _services.GetRequiredService(); var sessionManager = _services.GetRequiredService(); await sessionManager.StageCallerMessageAsync(conversationId, seqNum, intent); var callerMessage = new CallerMessage() { ConversationId = conversationId, SeqNumber = seqNum, Content = intent, From = request.From, States = ParseStates(states) }; await messageQueue.EnqueueAsync(callerMessage); response = new VoiceResponse().Redirect(new Uri($"{_settings.CallbackHost}/twilio/voice/{conversationId}/reply/{seqNum}?states={states}"), HttpMethod.Post); } return TwiML(response); } [ValidateRequest] [HttpPost("twilio/voice/{conversationId}/receive/{seqNum}")] public async Task ReceiveCallerMessage([FromRoute] string conversationId, [FromRoute] int seqNum, [FromQuery] string states, VoiceRequest request, [FromQuery] int attempts = 1) { var twilio = _services.GetRequiredService(); var messageQueue = _services.GetRequiredService(); var sessionManager = _services.GetRequiredService(); var messages = await sessionManager.RetrieveStagedCallerMessagesAsync(conversationId, seqNum); string text = (request.SpeechResult + "\r\n" + request.Digits).Trim(); if (!string.IsNullOrWhiteSpace(text)) { messages.Add(text); await sessionManager.StageCallerMessageAsync(conversationId, seqNum, text); } VoiceResponse response; if (messages.Any()) { var messageContent = string.Join("\r\n", messages); var callerMessage = new CallerMessage() { ConversationId = conversationId, SeqNumber = seqNum, Content = messageContent, Digits = request.Digits, From = request.From, States = ParseStates(states) }; await messageQueue.EnqueueAsync(callerMessage); response = new VoiceResponse().Redirect(new Uri($"{_settings.CallbackHost}/twilio/voice/{conversationId}/reply/{seqNum}?states={states}"), HttpMethod.Post); } else { if (attempts >= 2) { var speechPaths = new List(); if (seqNum == 0) { speechPaths.Add("twilio/welcome.mp3"); } else { var lastRepy = await sessionManager.GetAssistantReplyAsync(conversationId, seqNum - 1); speechPaths.Add($"twilio/say-it-again-{Random.Shared.Next(1, 5)}.mp3"); speechPaths.Add($"twilio/voice/speeches/{conversationId}/{lastRepy.SpeechFileName}"); } response = twilio.ReturnInstructions(speechPaths, $"twilio/voice/{conversationId}/receive/{seqNum}?states={states}", true); } else { response = twilio.ReturnInstructions(null, $"twilio/voice/{conversationId}/receive/{seqNum}?states={states}&attempts={++attempts}", true); } } return TwiML(response); } [ValidateRequest] [HttpPost("twilio/voice/{conversationId}/reply/{seqNum}")] public async Task ReplyCallerMessage([FromRoute] string conversationId, [FromRoute] int seqNum, [FromQuery] string states, VoiceRequest request) { var nextSeqNum = seqNum + 1; var sessionManager = _services.GetRequiredService(); var twilio = _services.GetRequiredService(); var fileStorage = _services.GetRequiredService(); if (request.SpeechResult != null) { await sessionManager.StageCallerMessageAsync(conversationId, nextSeqNum, request.SpeechResult); } var reply = await sessionManager.GetAssistantReplyAsync(conversationId, seqNum); VoiceResponse response; if (reply == null) { var indication = await sessionManager.GetReplyIndicationAsync(conversationId, seqNum); if (indication != null) { _logger.LogWarning($"Indication: {indication}"); var speechPaths = new List(); int segIndex = 0; foreach (var text in indication.Split('|')) { var seg = text.Trim(); if (seg.StartsWith('#')) { speechPaths.Add($"twilio/{seg.Substring(1)}.mp3"); } else { var completion = CompletionProvider.GetAudioCompletion(_services, "openai", "tts-1"); var data = await completion.GenerateAudioFromTextAsync(seg); // add hold-on var holdOnIndex = Random.Shared.Next(1, 10); if (holdOnIndex < 7) { speechPaths.Add($"twilio/hold-on-short-{holdOnIndex}.mp3"); } var fileName = $"indication_{seqNum}_{segIndex}.mp3"; fileStorage.SaveSpeechFile(conversationId, fileName, data); speechPaths.Add($"twilio/voice/speeches/{conversationId}/{fileName}"); // add typing var typingIndex = Random.Shared.Next(1, 7); if (typingIndex < 4) { speechPaths.Add($"twilio/typing-{typingIndex}.mp3"); } segIndex++; } } response = twilio.ReturnInstructions(speechPaths, $"twilio/voice/{conversationId}/reply/{seqNum}?states={states}", true); await sessionManager.RemoveReplyIndicationAsync(conversationId, seqNum); } else { var instructions = new List { }; // add hold-on var holdOnIndex = Random.Shared.Next(1, 15); if (holdOnIndex < 9) { instructions.Add($"twilio/hold-on-long-{holdOnIndex}.mp3"); } // add typing var typingIndex = Random.Shared.Next(1, 7); if (typingIndex < 4) { instructions.Add($"twilio/typing-{typingIndex}.mp3"); } response = twilio.ReturnInstructions(instructions, $"twilio/voice/{conversationId}/reply/{seqNum}?states={states}", true); } } else { if (reply.HumanIntervationNeeded) { response = twilio.DialCsrAgent($"twilio/voice/speeches/{conversationId}/{reply.SpeechFileName}"); } else if (reply.ConversationEnd) { response = twilio.HangUp($"twilio/voice/speeches/{conversationId}/{reply.SpeechFileName}"); } else { response = twilio.ReturnInstructions(new List { $"twilio/voice/speeches/{conversationId}/{reply.SpeechFileName}" }, $"twilio/voice/{conversationId}/receive/{nextSeqNum}?states={states}", true, hints: reply.Hints); } } return TwiML(response); } [ValidateRequest] [HttpGet("twilio/voice/speeches/{conversationId}/{fileName}")] public async Task GetSpeechFile([FromRoute] string conversationId, [FromRoute] string fileName) { var fileStorage = _services.GetRequiredService(); var data = fileStorage.GetSpeechFile(conversationId, fileName); var result = new FileContentResult(data.ToArray(), "audio/mpeg") { FileDownloadName = fileName }; return result; } private Dictionary ParseStates(string? states) { var result = new Dictionary(); if (string.IsNullOrWhiteSpace(states)) { return result; } var kvps = states.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); foreach (var kvp in kvps) { var parts = kvp.Split(':'); if (parts.Length == 2) { result.Add(parts[0], parts[1]); } } return result; } }