BotSharp/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs

423 lines
16 KiB
C#
Raw Normal View History

2024-08-08 19:54:32 +00:00
using BotSharp.Abstraction.Files;
using BotSharp.Core.Infrastructures;
2024-11-12 22:51:33 +00:00
using BotSharp.Plugin.Twilio.Interfaces;
2024-08-08 19:54:32 +00:00
using BotSharp.Plugin.Twilio.Models;
using BotSharp.Plugin.Twilio.Services;
2024-08-22 21:13:38 +00:00
using Microsoft.AspNetCore.Http;
2023-11-09 20:02:57 +00:00
using Microsoft.AspNetCore.Mvc;
2024-08-28 20:03:23 +00:00
using Twilio.Http;
2025-03-20 22:54:49 +00:00
using Task = System.Threading.Tasks.Task;
2023-11-09 20:02:57 +00:00
namespace BotSharp.Plugin.Twilio.Controllers;
public class TwilioVoiceController : TwilioController
{
2025-04-02 15:35:03 +00:00
protected readonly TwilioSetting _settings;
protected readonly IServiceProvider _services;
protected readonly IHttpContextAccessor _context;
protected readonly ILogger _logger;
2023-11-09 20:02:57 +00:00
public TwilioVoiceController(TwilioSetting settings, IServiceProvider services, IHttpContextAccessor context, ILogger<TwilioVoiceController> logger)
2023-11-09 20:02:57 +00:00
{
_settings = settings;
_services = services;
2024-08-22 21:13:38 +00:00
_context = context;
_logger = logger;
2023-11-09 20:02:57 +00:00
}
2023-12-06 22:55:55 +00:00
2024-08-22 21:13:38 +00:00
/// <summary>
/// https://github.com/twilio-labs/twilio-aspnet?tab=readme-ov-file#validate-twilio-http-requests
/// </summary>
/// <param name="request"></param>
/// <param name="states"></param>
/// <returns></returns>
/// <exception cref="ArgumentNullException"></exception>
2025-04-02 15:35:03 +00:00
[Obsolete("Use twilio/inbound for streaming and non-streaming instead of this endpoint")]
2025-02-07 03:27:13 +00:00
[ValidateRequest]
2024-08-22 21:13:38 +00:00
[HttpPost("twilio/voice/welcome")]
2024-11-12 22:51:33 +00:00
public async Task<TwiMLResult> InitiateConversation(ConversationalVoiceRequest request)
2024-08-08 19:54:32 +00:00
{
2025-02-07 02:30:55 +00:00
foreach(var header in Request.Headers)
{
_logger.LogWarning($"{header.Key}: {header.Value}");
}
2025-02-07 03:27:13 +00:00
_logger.LogWarning($"{Request.Path}{Request.QueryString}");
2024-11-12 22:51:33 +00:00
var text = JsonSerializer.Serialize(request);
2024-08-28 20:34:41 +00:00
if (request?.CallSid == null)
{
throw new ArgumentNullException(nameof(VoiceRequest.CallSid));
}
2025-03-21 14:17:21 +00:00
VoiceResponse response = default!;
request.ConversationId = $"twilio_{request.CallSid}";
2024-11-12 22:51:33 +00:00
var instruction = new ConversationalVoiceResponse
{
2025-03-21 14:17:21 +00:00
AgentId = request.AgentId,
2025-03-14 20:08:34 +00:00
ConversationId = request.ConversationId,
2025-03-21 14:17:21 +00:00
SpeechPaths = [$"twilio/welcome-{request.AgentId}.mp3"],
2024-11-13 21:09:39 +00:00
ActionOnEmptyResult = true
2024-11-12 22:51:33 +00:00
};
2025-03-21 14:17:21 +00:00
2024-11-12 22:51:33 +00:00
await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook =>
{
await hook.OnSessionCreating(request, instruction);
});
2024-08-08 19:54:32 +00:00
var twilio = _services.GetRequiredService<TwilioService>();
2024-11-12 22:51:33 +00:00
if (string.IsNullOrWhiteSpace(request.Intent))
2024-10-29 18:55:55 +00:00
{
2025-03-22 00:28:22 +00:00
instruction.CallbackPath = $"twilio/voice/receive/0?agent-id={request.AgentId}&conversation-id={request.ConversationId}&{twilio.GenerateStatesParameter(request.States)}";
2024-11-12 22:51:33 +00:00
response = twilio.ReturnNoninterruptedInstructions(instruction);
2024-10-29 18:55:55 +00:00
}
else
{
int seqNum = 0;
var messageQueue = _services.GetRequiredService<TwilioMessageQueue>();
var sessionManager = _services.GetRequiredService<ITwilioSessionManager>();
2024-11-12 22:51:33 +00:00
await sessionManager.StageCallerMessageAsync(request.ConversationId, seqNum, request.Intent);
2024-10-29 18:55:55 +00:00
var callerMessage = new CallerMessage()
{
2025-03-21 14:17:21 +00:00
AgentId = request.AgentId,
2024-11-12 22:51:33 +00:00
ConversationId = request.ConversationId,
2024-10-29 18:55:55 +00:00
SeqNumber = seqNum,
2024-11-12 22:51:33 +00:00
Content = request.Intent,
2024-10-29 18:55:55 +00:00
From = request.From,
2024-11-12 22:51:33 +00:00
States = ParseStates(request.States)
2024-10-29 18:55:55 +00:00
};
await messageQueue.EnqueueAsync(callerMessage);
2024-11-12 22:51:33 +00:00
response = new VoiceResponse();
2025-03-21 14:17:21 +00:00
// delay 3 seconds to wait for the first message reply and caller is listening dudu sound
await Task.Delay(1000 * 3);
2025-03-22 00:28:22 +00:00
response.Redirect(new Uri($"{_settings.CallbackHost}/twilio/voice/reply/{seqNum}?agent-id={request.AgentId}&conversation-id={request.ConversationId}&{twilio.GenerateStatesParameter(request.States)}"), HttpMethod.Post);
2024-10-29 18:55:55 +00:00
}
2024-11-12 22:51:33 +00:00
await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook =>
{
await hook.OnSessionCreated(request);
});
2024-08-08 19:54:32 +00:00
return TwiML(response);
}
2024-11-12 22:51:33 +00:00
/// <summary>
/// Wait for caller's response
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
2025-02-13 19:59:57 +00:00
[ValidateRequest]
2025-03-13 19:05:06 +00:00
[HttpPost("twilio/voice/receive/{seqNum}")]
2024-11-12 22:51:33 +00:00
public async Task<TwiMLResult> ReceiveCallerMessage(ConversationalVoiceRequest request)
2024-08-08 19:54:32 +00:00
{
var twilio = _services.GetRequiredService<TwilioService>();
var messageQueue = _services.GetRequiredService<TwilioMessageQueue>();
var sessionManager = _services.GetRequiredService<ITwilioSessionManager>();
2025-03-21 14:17:21 +00:00
// Fetch all accumulated caller message.
2024-11-12 22:51:33 +00:00
var messages = await sessionManager.RetrieveStagedCallerMessagesAsync(request.ConversationId, request.SeqNum);
2024-08-26 22:33:47 +00:00
string text = (request.SpeechResult + "\r\n" + request.Digits).Trim();
2024-08-28 20:34:41 +00:00
2024-08-26 22:33:47 +00:00
if (!string.IsNullOrWhiteSpace(text))
2024-08-08 19:54:32 +00:00
{
2025-03-21 14:17:21 +00:00
// Concanate with incoming message
2024-08-26 22:33:47 +00:00
messages.Add(text);
2024-11-12 22:51:33 +00:00
await sessionManager.StageCallerMessageAsync(request.ConversationId, request.SeqNum, text);
2024-08-08 19:54:32 +00:00
}
2024-08-28 20:03:23 +00:00
2025-03-21 14:17:21 +00:00
VoiceResponse response = default!;
2024-08-28 20:03:23 +00:00
if (messages.Any())
2024-08-08 19:54:32 +00:00
{
2024-08-22 15:56:10 +00:00
var messageContent = string.Join("\r\n", messages);
2024-08-08 19:54:32 +00:00
var callerMessage = new CallerMessage()
{
2025-03-21 14:17:21 +00:00
AgentId = request.AgentId,
2024-11-12 22:51:33 +00:00
ConversationId = request.ConversationId,
SeqNumber = request.SeqNum,
2024-08-08 19:54:32 +00:00
Content = messageContent,
2024-10-29 18:55:55 +00:00
Digits = request.Digits,
2025-01-14 15:28:18 +00:00
From = string.Equals(request.Direction, "inbound") ? request.From : request.To,
2024-11-12 22:51:33 +00:00
States = ParseStates(request.States)
2024-08-08 19:54:32 +00:00
};
2025-01-14 15:28:18 +00:00
callerMessage.RequestHeaders = new KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues>[Request.Headers.Count];
Request.Headers.CopyTo(callerMessage.RequestHeaders, 0);
2024-09-05 15:42:48 +00:00
await messageQueue.EnqueueAsync(callerMessage);
2024-11-12 22:51:33 +00:00
response = new VoiceResponse();
2025-03-22 00:28:22 +00:00
response.Redirect(new Uri($"{_settings.CallbackHost}/twilio/voice/reply/{request.SeqNum}?agent-id={request.AgentId}&conversation-id={request.ConversationId}&{twilio.GenerateStatesParameter(request.States)}&AIResponseWaitTime=0"), HttpMethod.Post);
2024-11-12 22:51:33 +00:00
await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook =>
{
await hook.OnReceivedUserMessage(request);
});
2024-08-08 19:54:32 +00:00
}
2024-08-28 20:03:23 +00:00
else
{
2025-01-23 16:57:21 +00:00
if (request.Attempts > _settings.MaxGatherAttempts)
2024-08-29 19:36:07 +00:00
{
2024-11-12 22:51:33 +00:00
await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook =>
{
2025-01-23 16:57:21 +00:00
await hook.OnAgentHangUp(request);
2024-11-12 22:51:33 +00:00
});
2025-03-18 18:10:35 +00:00
response = twilio.HangUp(string.Empty);
2024-08-29 19:36:07 +00:00
}
2025-01-23 16:57:21 +00:00
// keep waiting for user response
2024-08-30 18:18:55 +00:00
else
{
2024-11-12 22:51:33 +00:00
var instruction = new ConversationalVoiceResponse
{
2025-03-21 14:17:21 +00:00
AgentId = request.AgentId,
2025-03-14 20:08:34 +00:00
ConversationId = request.ConversationId,
2024-11-12 22:51:33 +00:00
SpeechPaths = new List<string>(),
2025-03-22 00:28:22 +00:00
CallbackPath = $"twilio/voice/receive/{request.SeqNum}?agent-id={request.AgentId}&conversation-id={request.ConversationId}&{twilio.GenerateStatesParameter(request.States)}&attempts={++request.Attempts}",
2024-11-12 22:51:33 +00:00
ActionOnEmptyResult = true
};
2025-04-24 19:36:10 +00:00
if (request.Attempts == 5)
2024-11-12 22:51:33 +00:00
{
instruction.SpeechPaths.Add($"twilio/say-it-again-{Random.Shared.Next(1, 5)}.mp3");
}
await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook =>
{
await hook.OnWaitingUserResponse(request, instruction);
});
response = twilio.ReturnInstructions(instruction);
2024-09-06 15:14:10 +00:00
}
2024-08-28 20:03:23 +00:00
}
2024-11-12 22:51:33 +00:00
2024-08-08 19:54:32 +00:00
return TwiML(response);
}
2024-11-12 22:51:33 +00:00
/// <summary>
/// Polling for assistant reply after user responsed
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
2025-02-13 19:59:57 +00:00
[ValidateRequest]
2025-03-13 19:05:06 +00:00
[HttpPost("twilio/voice/reply/{seqNum}")]
2024-11-12 22:51:33 +00:00
public async Task<TwiMLResult> ReplyCallerMessage(ConversationalVoiceRequest request)
2024-08-08 19:54:32 +00:00
{
2024-11-12 22:51:33 +00:00
var nextSeqNum = request.SeqNum + 1;
2024-08-08 19:54:32 +00:00
var sessionManager = _services.GetRequiredService<ITwilioSessionManager>();
var twilio = _services.GetRequiredService<TwilioService>();
2024-08-30 16:29:21 +00:00
var fileStorage = _services.GetRequiredService<IFileStorageService>();
2025-03-22 00:28:22 +00:00
var text = (request.SpeechResult + "\r\n" + request.Digits).Trim();
if (!string.IsNullOrEmpty(text))
2024-08-08 19:54:32 +00:00
{
2025-03-22 00:28:22 +00:00
await sessionManager.StageCallerMessageAsync(request.ConversationId, nextSeqNum, text);
2024-08-08 19:54:32 +00:00
}
2024-08-28 20:34:41 +00:00
2024-11-12 22:51:33 +00:00
var reply = await sessionManager.GetAssistantReplyAsync(request.ConversationId, request.SeqNum);
2024-08-08 19:54:32 +00:00
VoiceResponse response;
2025-04-29 16:53:40 +00:00
if (request.AIResponseWaitTime > 10)
2025-03-10 15:10:22 +00:00
{
// Wait AI Response Timeout
await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook =>
{
request.AIResponseErrorMessage = $"AI response timeout: AIResponseWaitTime greater than {request.AIResponseWaitTime}, please check internal error log!";
await hook.OnAgentHangUp(request);
});
2024-08-30 16:29:21 +00:00
2025-03-10 15:10:22 +00:00
response = twilio.HangUp($"twilio/error.mp3");
}
else if (reply == null)
2024-08-08 19:54:32 +00:00
{
2025-03-22 00:28:22 +00:00
response = await twilio.WaitingForAiResponse(request);
2024-08-08 19:54:32 +00:00
}
else
{
2024-09-06 15:14:10 +00:00
if (reply.HumanIntervationNeeded)
{
2024-11-12 22:51:33 +00:00
await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook =>
{
await hook.OnAgentTransferring(request, _settings);
});
response = twilio.DialCsrAgent($"twilio/voice/speeches/{request.ConversationId}/{reply.SpeechFileName}");
2024-09-06 15:14:10 +00:00
}
else if (reply.ConversationEnd)
2024-08-14 21:44:47 +00:00
{
2024-11-12 22:51:33 +00:00
response = twilio.HangUp($"twilio/voice/speeches/{request.ConversationId}/{reply.SpeechFileName}");
await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook =>
{
await hook.OnAgentHangUp(request);
});
2024-08-14 21:44:47 +00:00
}
else
{
2024-11-12 22:51:33 +00:00
var instruction = new ConversationalVoiceResponse
{
2025-03-21 14:17:21 +00:00
AgentId = request.AgentId,
2025-03-14 20:08:34 +00:00
ConversationId = request.ConversationId,
2025-03-22 00:28:22 +00:00
CallbackPath = $"twilio/voice/receive/{nextSeqNum}?agent-id={request.AgentId}&conversation-id={request.ConversationId}&{twilio.GenerateStatesParameter(request.States)}",
2024-11-12 22:51:33 +00:00
ActionOnEmptyResult = true,
Hints = reply.Hints
};
if (!string.IsNullOrEmpty(reply.SpeechFileName))
{
instruction.SpeechPaths = [$"twilio/voice/speeches/{request.ConversationId}/{reply.SpeechFileName}"];
}
else
{
instruction.Text = reply.Content;
}
2024-11-12 22:51:33 +00:00
await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook =>
2024-08-30 16:29:21 +00:00
{
2024-11-12 22:51:33 +00:00
await hook.OnAgentResponsing(request, instruction);
});
response = twilio.ReturnInstructions(instruction);
2024-08-14 21:44:47 +00:00
}
2024-08-08 19:54:32 +00:00
}
2024-08-28 20:34:41 +00:00
2024-08-08 19:54:32 +00:00
return TwiML(response);
}
2025-02-13 19:59:57 +00:00
[ValidateRequest]
2024-08-22 21:13:38 +00:00
[HttpGet("twilio/voice/speeches/{conversationId}/{fileName}")]
2024-08-28 20:34:41 +00:00
public async Task<FileContentResult> GetSpeechFile([FromRoute] string conversationId, [FromRoute] string fileName)
2024-08-08 19:54:32 +00:00
{
2024-08-28 20:34:41 +00:00
var fileStorage = _services.GetRequiredService<IFileStorageService>();
var data = fileStorage.GetSpeechFile(conversationId, fileName);
2024-08-30 16:29:21 +00:00
var result = new FileContentResult(data.ToArray(), "audio/mpeg")
{
FileDownloadName = fileName
};
2024-08-08 19:54:32 +00:00
return result;
}
2024-10-29 18:55:55 +00:00
2025-03-15 02:06:40 +00:00
[ValidateRequest]
[HttpPost("twilio/voice/hang-up")]
public async Task<TwiMLResult> Hangup(ConversationalVoiceRequest request)
{
2025-03-18 18:10:35 +00:00
var instruction = new ConversationalVoiceResponse
{
2025-03-21 14:17:21 +00:00
AgentId = request.AgentId,
2025-03-18 18:10:35 +00:00
ConversationId = request.ConversationId
};
if (request.InitAudioFile != null)
{
instruction.SpeechPaths.Add(request.InitAudioFile);
}
2025-03-15 02:06:40 +00:00
var twilio = _services.GetRequiredService<TwilioService>();
2025-03-18 18:10:35 +00:00
var response = twilio.HangUp(instruction);
2025-03-15 02:06:40 +00:00
return TwiML(response);
}
2025-03-17 22:09:02 +00:00
[ValidateRequest]
[HttpPost("twilio/voice/transfer-call")]
public async Task<TwiMLResult> TransferCall(ConversationalVoiceRequest request)
{
var instruction = new ConversationalVoiceResponse
{
2025-03-21 14:17:21 +00:00
AgentId = request.AgentId,
2025-03-17 22:09:02 +00:00
ConversationId = request.ConversationId,
TransferTo = request.TransferTo
};
if (request.InitAudioFile != null)
{
instruction.SpeechPaths.Add(request.InitAudioFile);
}
var twilio = _services.GetRequiredService<TwilioService>();
var response = twilio.TransferCall(instruction);
return TwiML(response);
}
2025-03-14 18:59:13 +00:00
[ValidateRequest]
[HttpPost("twilio/voice/status")]
public async Task<ActionResult> PhoneCallStatus(ConversationalVoiceRequest request)
{
var twilio = _services.GetRequiredService<TwilioService>();
2025-03-14 18:59:13 +00:00
if (request.CallStatus == "completed")
{
if (twilio.MachineDetected(request))
2025-03-14 18:59:13 +00:00
{
// voicemail
2025-04-29 03:28:56 +00:00
await HookEmitter.Emit<ITwilioCallStatusHook>(_services,
async hook =>
{
if (hook.IsMatch(request)) await hook.OnVoicemailLeft(request);
});
2025-03-14 18:59:13 +00:00
}
else
{
// phone call completed
2025-04-29 03:28:56 +00:00
await HookEmitter.Emit<ITwilioCallStatusHook>(_services,
async hook =>
{
if (hook.IsMatch(request)) await hook.OnUserDisconnected(request);
});
2025-03-14 18:59:13 +00:00
}
}
2025-04-01 19:30:44 +00:00
else if (request.CallStatus == "busy")
{
2025-04-29 03:28:56 +00:00
await HookEmitter.Emit<ITwilioCallStatusHook>(_services,
async hook =>
{
if (hook.IsMatch(request)) await hook.OnCallBusyStatus(request);
});
2025-04-01 19:30:44 +00:00
}
2025-04-02 15:35:03 +00:00
else if (request.CallStatus == "no-answer")
{
2025-04-29 03:28:56 +00:00
await HookEmitter.Emit<ITwilioCallStatusHook>(_services,
async hook =>
{
if (hook.IsMatch(request)) await hook.OnCallNoAnswerStatus(request);
});
2025-04-02 15:35:03 +00:00
}
2025-04-08 22:00:35 +00:00
else if (request.CallStatus == "canceled")
{
2025-04-29 03:28:56 +00:00
await HookEmitter.Emit<ITwilioCallStatusHook>(_services,
2025-04-29 16:53:40 +00:00
async hook =>
{
if (hook.IsMatch(request)) await hook.OnCallCanceledStatus(request);
2025-04-29 03:28:56 +00:00
});
2025-04-08 22:00:35 +00:00
}
else if (request.CallStatus == "failed")
{
2025-04-29 03:28:56 +00:00
await HookEmitter.Emit<ITwilioCallStatusHook>(_services,
2025-04-29 16:53:40 +00:00
async hook =>
{
if (hook.IsMatch(request)) await hook.OnCallFailedStatus(request);
2025-04-29 03:28:56 +00:00
});
2025-04-08 22:00:35 +00:00
}
2025-04-29 16:53:40 +00:00
else
{
_logger.LogError($"Unknown call status: {request.CallStatus}, {request.CallSid}");
}
2025-03-14 18:59:13 +00:00
return Ok();
}
2025-04-02 15:35:03 +00:00
protected Dictionary<string, string> ParseStates(List<string> states)
2024-10-29 18:55:55 +00:00
{
var result = new Dictionary<string, string>();
2024-10-29 22:25:35 +00:00
if (states is null || !states.Any())
2024-10-29 18:55:55 +00:00
{
return result;
}
2024-10-29 22:25:35 +00:00
foreach (var kvp in states)
2024-10-29 18:55:55 +00:00
{
2024-10-29 22:25:35 +00:00
var parts = kvp.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
2024-10-29 18:55:55 +00:00
if (parts.Length == 2)
{
result.Add(parts[0], parts[1]);
}
}
return result;
}
2023-11-09 20:02:57 +00:00
}