BotSharp/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs

375 lines
13 KiB
C#
Raw Normal View History

2025-04-02 15:35:03 +00:00
using BotSharp.Abstraction.Agents.Models;
2025-03-22 00:28:22 +00:00
using BotSharp.Abstraction.Files;
2025-04-02 15:35:03 +00:00
using BotSharp.Abstraction.Realtime;
2024-08-30 16:29:21 +00:00
using BotSharp.Abstraction.Utilities;
2025-03-22 00:28:22 +00:00
using BotSharp.Core.Infrastructures;
using BotSharp.Plugin.Twilio.Interfaces;
2024-11-12 22:51:33 +00:00
using BotSharp.Plugin.Twilio.Models;
2023-12-06 22:55:55 +00:00
using Twilio.Jwt.AccessToken;
2025-04-29 16:53:40 +00:00
using Twilio.Rest.Api.V2010.Account.Call;
using Task = System.Threading.Tasks.Task;
2023-12-06 22:55:55 +00:00
using Token = Twilio.Jwt.AccessToken.Token;
namespace BotSharp.Plugin.Twilio.Services;
/// <summary>
/// https://github.com/TwilioDevEd/voice-javascript-sdk-quickstart-csharp
/// </summary>
public class TwilioService
{
private readonly TwilioSetting _settings;
private readonly IServiceProvider _services;
2025-03-22 00:28:22 +00:00
public readonly ILogger _logger;
2023-12-06 22:55:55 +00:00
2025-03-22 00:28:22 +00:00
public TwilioService(TwilioSetting settings, IServiceProvider services, ILogger<TwilioService> logger)
2023-12-06 22:55:55 +00:00
{
_settings = settings;
_services = services;
2025-03-22 00:28:22 +00:00
_logger = logger;
2023-12-06 22:55:55 +00:00
}
public string GetAccessToken()
{
// These are specific to Voice
var user = _services.GetRequiredService<IUserIdentity>();
// Create a Voice grant for this token
var grant = new VoiceGrant();
grant.OutgoingApplicationSid = _settings.AppSID;
// Optional: add to allow incoming calls
grant.IncomingAllow = true;
var grants = new HashSet<IGrant>
{
{ grant }
};
// Create an Access Token generator
var token = new Token(
_settings.AccountSID,
_settings.ApiKeySID,
_settings.ApiSecret,
user.Id,
grants: grants);
return token.ToJwt();
}
2024-11-12 22:51:33 +00:00
public VoiceResponse ReturnInstructions(ConversationalVoiceResponse conversationalVoiceResponse)
2024-08-08 19:54:32 +00:00
{
var response = new VoiceResponse();
var gather = new Gather()
{
Input = new List<Gather.InputEnum>()
{
2024-08-26 22:33:47 +00:00
Gather.InputEnum.Speech,
Gather.InputEnum.Dtmf
2024-08-08 19:54:32 +00:00
},
2024-11-12 22:51:33 +00:00
Action = new Uri($"{_settings.CallbackHost}/{conversationalVoiceResponse.CallbackPath}"),
2025-01-14 17:11:42 +00:00
Enhanced = true,
2025-04-21 03:49:53 +00:00
SpeechModel = _settings.SpeechModel,
2024-08-30 21:19:05 +00:00
SpeechTimeout = "auto", // timeout > 0 ? timeout.ToString() : "3",
Timeout = Math.Max(_settings.GatherTimeout, 1),
2024-11-12 22:51:33 +00:00
ActionOnEmptyResult = conversationalVoiceResponse.ActionOnEmptyResult,
Hints = conversationalVoiceResponse.Hints
2024-08-08 19:54:32 +00:00
};
2024-08-30 16:29:21 +00:00
2024-11-12 22:51:33 +00:00
if (!conversationalVoiceResponse.SpeechPaths.IsNullOrEmpty())
2024-08-08 19:54:32 +00:00
{
2024-11-12 22:51:33 +00:00
foreach (var speechPath in conversationalVoiceResponse.SpeechPaths)
2024-08-22 21:13:38 +00:00
{
2024-08-28 20:03:23 +00:00
gather.Play(new Uri($"{_settings.CallbackHost}/{speechPath}"));
2024-08-22 21:13:38 +00:00
}
2024-08-08 19:54:32 +00:00
}
if (!string.IsNullOrEmpty(conversationalVoiceResponse.Text))
{
gather.Say(conversationalVoiceResponse.Text);
}
2024-08-08 19:54:32 +00:00
response.Append(gather);
return response;
}
2025-03-14 20:08:34 +00:00
public VoiceResponse ReturnNoninterruptedInstructions(ConversationalVoiceResponse voiceResponse)
2024-08-29 19:36:07 +00:00
{
var response = new VoiceResponse();
2025-03-21 14:17:21 +00:00
var conversationId = voiceResponse.ConversationId;
2025-03-14 20:08:34 +00:00
if (voiceResponse.SpeechPaths != null && voiceResponse.SpeechPaths.Any())
2024-08-29 19:36:07 +00:00
{
2025-03-14 20:08:34 +00:00
foreach (var speechPath in voiceResponse.SpeechPaths)
2024-08-29 19:36:07 +00:00
{
2025-03-21 14:17:21 +00:00
var uri = GetSpeechPath(conversationId, speechPath);
response.Play(new Uri(uri));
2024-08-29 19:36:07 +00:00
}
}
2025-03-14 20:08:34 +00:00
2024-08-29 19:36:07 +00:00
var gather = new Gather()
{
Input = new List<Gather.InputEnum>()
{
Gather.InputEnum.Speech,
Gather.InputEnum.Dtmf
},
2025-03-14 20:08:34 +00:00
Action = new Uri($"{_settings.CallbackHost}/{voiceResponse.CallbackPath}"),
2025-01-14 17:11:42 +00:00
Enhanced = true,
2025-04-21 03:49:53 +00:00
SpeechModel = _settings.SpeechModel,
2024-11-13 21:09:39 +00:00
SpeechTimeout = "auto", // conversationalVoiceResponse.Timeout > 0 ? conversationalVoiceResponse.Timeout.ToString() : "3",
Timeout = Math.Max(_settings.GatherTimeout, 1),
2025-03-14 20:08:34 +00:00
ActionOnEmptyResult = voiceResponse.ActionOnEmptyResult,
2024-08-29 19:36:07 +00:00
};
response.Append(gather);
2025-03-14 20:08:34 +00:00
2024-08-29 19:36:07 +00:00
return response;
}
2025-04-29 16:53:40 +00:00
public async Task StartRecording(string callSid, string agentId, string conversationId)
{
if (_settings.RecordingEnabled)
{
// https://help.twilio.com/articles/360010317333-Recording-Incoming-Twilio-Voice-Calls
var recordStatusUrl = $"{_settings.CallbackHost}/twilio/record/status?agent-id={agentId}&conversation-id={conversationId}";
var recording = await RecordingResource.CreateAsync(pathCallSid: callSid,
recordingStatusCallback: new Uri(recordStatusUrl),
trim: "trim-silence",
recordingChannels: "dual",
recordingTrack: "both");
_logger.LogInformation($"Recording started: {recording.CallSid} {recording.Sid}");
}
}
2024-08-09 03:28:39 +00:00
public VoiceResponse HangUp(string speechPath)
2024-08-08 19:54:32 +00:00
{
var response = new VoiceResponse();
2024-08-09 03:28:39 +00:00
if (!string.IsNullOrEmpty(speechPath))
2023-12-06 22:55:55 +00:00
{
2024-08-09 03:28:39 +00:00
response.Play(new Uri($"{_settings.CallbackHost}/{speechPath}"));
2023-12-06 22:55:55 +00:00
}
response.Hangup();
return response;
}
2025-03-18 18:10:35 +00:00
public VoiceResponse HangUp(ConversationalVoiceResponse voiceResponse)
{
var response = new VoiceResponse();
var conversationId = voiceResponse.ConversationId;
if (voiceResponse.SpeechPaths != null && voiceResponse.SpeechPaths.Any())
{
foreach (var speechPath in voiceResponse.SpeechPaths)
{
var uri = GetSpeechPath(conversationId, speechPath);
response.Play(new Uri(uri));
}
}
2025-04-19 01:44:42 +00:00
2025-03-18 18:10:35 +00:00
response.Hangup();
return response;
}
2024-09-06 15:14:10 +00:00
public VoiceResponse DialCsrAgent(string speechPath)
{
var response = new VoiceResponse();
if (!string.IsNullOrEmpty(speechPath))
{
response.Play(new Uri($"{_settings.CallbackHost}/{speechPath}"));
}
response.Dial(_settings.CsrAgentNumber);
return response;
}
2025-03-17 22:09:02 +00:00
public VoiceResponse TransferCall(ConversationalVoiceResponse conversationalVoiceResponse)
{
var response = new VoiceResponse();
var conversationId = conversationalVoiceResponse.ConversationId;
if (conversationalVoiceResponse.SpeechPaths != null && conversationalVoiceResponse.SpeechPaths.Any())
{
foreach (var speechPath in conversationalVoiceResponse.SpeechPaths)
{
var uri = GetSpeechPath(conversationId, speechPath);
response.Play(new Uri(uri));
}
}
response.Dial(conversationalVoiceResponse.TransferTo, answerOnBridge: true);
return response;
}
2023-12-06 22:55:55 +00:00
public VoiceResponse HoldOn(int interval, string message = null)
{
2024-01-13 21:17:40 +00:00
var twilioSetting = _services.GetRequiredService<TwilioSetting>();
2023-12-06 22:55:55 +00:00
var response = new VoiceResponse();
var gather = new Gather()
{
2024-11-19 19:05:12 +00:00
Input = new List<Gather.InputEnum>()
{
2024-08-26 22:33:47 +00:00
Gather.InputEnum.Speech,
Gather.InputEnum.Dtmf
},
2025-03-21 14:17:21 +00:00
Action = new Uri($"{_settings.CallbackHost}/twilio/voice/"),
2023-12-06 22:55:55 +00:00
ActionOnEmptyResult = true
};
2024-08-28 20:34:41 +00:00
2023-12-06 22:55:55 +00:00
if (!string.IsNullOrEmpty(message))
{
gather.Say(message);
}
gather.Pause(interval);
response.Append(gather);
return response;
}
2025-02-07 17:18:47 +00:00
/// <summary>
/// Bidirectional Media Streams
/// </summary>
/// <param name="conversationalVoiceResponse"></param>
/// <returns></returns>
2025-04-02 15:35:03 +00:00
public VoiceResponse ReturnBidirectionalMediaStreamsInstructions(ConversationalVoiceResponse conversationalVoiceResponse, Agent agent)
2025-02-07 17:18:47 +00:00
{
var response = new VoiceResponse();
2025-03-25 21:37:54 +00:00
2025-03-17 22:09:02 +00:00
var conversationId = conversationalVoiceResponse.ConversationId;
2025-03-14 20:08:34 +00:00
2025-03-25 21:37:54 +00:00
if (_settings.TranscribeEnabled)
{
2025-04-02 15:35:03 +00:00
var words = new List<string>();
HookEmitter.Emit<IRealtimeHook>(_services, hook => words.AddRange(hook.OnModelTranscriptPrompt(agent)));
var hints = string.Join(", ", words);
2025-03-25 21:37:54 +00:00
var start = new Start();
start.Transcription(
track: "inbound_track",
partialResults: false,
2025-04-02 15:35:03 +00:00
statusCallbackUrl: $"{_settings.CallbackHost}/twilio/transcribe?agent-id={conversationalVoiceResponse.AgentId}&conversation-id={conversationId}",
name: conversationId,
hints: hints);
2025-03-25 21:37:54 +00:00
response.Append(start);
}
2025-02-07 17:18:47 +00:00
if (conversationalVoiceResponse.SpeechPaths != null && conversationalVoiceResponse.SpeechPaths.Any())
{
foreach (var speechPath in conversationalVoiceResponse.SpeechPaths)
{
2025-03-17 22:09:02 +00:00
var uri = GetSpeechPath(conversationId, speechPath);
response.Play(new Uri(uri));
2025-02-07 17:18:47 +00:00
}
}
2025-03-13 19:05:06 +00:00
2025-02-07 17:18:47 +00:00
var connect = new Connect();
var host = _settings.CallbackHost.Split("://").Last();
2025-04-23 20:39:42 +00:00
connect.Stream(url: $"wss://{host}/twilio/stream/{agent.Id}/{conversationId}");
2025-02-07 17:18:47 +00:00
response.Append(connect);
return response;
}
2025-03-17 22:09:02 +00:00
2025-03-22 00:28:22 +00:00
public async Task<VoiceResponse> WaitingForAiResponse(ConversationalVoiceRequest request)
{
VoiceResponse response;
var sessionManager = _services.GetRequiredService<ITwilioSessionManager>();
var fileStorage = _services.GetRequiredService<IFileStorageService>();
var indication = await sessionManager.GetReplyIndicationAsync(request.ConversationId, request.SeqNum);
if (indication != null)
{
_logger.LogWarning($"Indication ({request.SeqNum}): {indication}");
var speechPaths = new List<string>();
foreach (var text in indication.Split('|'))
{
var seg = text.Trim();
if (seg.StartsWith('#'))
{
speechPaths.Add($"twilio/{seg.Substring(1)}.mp3");
}
else
{
var hash = Utilities.HashTextMd5(seg);
var fileName = $"indication_{hash}.mp3";
var existing = fileStorage.GetSpeechFile(request.ConversationId, fileName);
if (existing == BinaryData.Empty)
{
var completion = CompletionProvider.GetAudioSynthesizer(_services);
var data = await completion.GenerateAudioAsync(seg);
fileStorage.SaveSpeechFile(request.ConversationId, fileName, data);
}
speechPaths.Add($"twilio/voice/speeches/{request.ConversationId}/{fileName}");
}
}
var instruction = new ConversationalVoiceResponse
{
AgentId = request.AgentId,
ConversationId = request.ConversationId,
SpeechPaths = speechPaths,
CallbackPath = $"twilio/voice/reply/{request.SeqNum}?agent-id={request.AgentId}&conversation-id={request.ConversationId}&{GenerateStatesParameter(request.States)}&AIResponseWaitTime={++request.AIResponseWaitTime}",
ActionOnEmptyResult = true
};
response = ReturnInstructions(instruction);
await sessionManager.RemoveReplyIndicationAsync(request.ConversationId, request.SeqNum);
}
else
{
var instruction = new ConversationalVoiceResponse
{
AgentId = request.AgentId,
ConversationId = request.ConversationId,
SpeechPaths = [],
CallbackPath = $"twilio/voice/reply/{request.SeqNum}?agent-id={request.AgentId}&conversation-id={request.ConversationId}&{GenerateStatesParameter(request.States)}&AIResponseWaitTime={++request.AIResponseWaitTime}",
ActionOnEmptyResult = true
};
await HookEmitter.Emit<ITwilioSessionHook>(_services, async hook =>
{
await hook.OnWaitingAgentResponse(request, instruction);
});
response = ReturnInstructions(instruction);
}
return response;
}
/// <summary>
/// https://www.twilio.com/docs/voice/answering-machine-detection
/// </summary>
/// <param name="answeredBy"></param>
/// <returns></returns>
public bool MachineDetected(ConversationalVoiceRequest request)
{
var answeredBy = request.AnsweredBy ?? "unknown";
var isOutboundCall = request.Direction == "outbound-api";
var isMachine = answeredBy.StartsWith("machine_") || answeredBy == "fax";
return isOutboundCall && isMachine;
}
2025-03-17 22:09:02 +00:00
public string GetSpeechPath(string conversationId, string speechPath)
{
if (speechPath.StartsWith("twilio/"))
{
return $"{_settings.CallbackHost}/{speechPath}";
}
else if (speechPath.StartsWith(_settings.CallbackHost))
{
return speechPath;
}
else
{
return $"{_settings.CallbackHost}/twilio/voice/speeches/{conversationId}/{speechPath}";
}
}
2025-03-22 00:28:22 +00:00
public string GenerateStatesParameter(List<string> states)
{
if (states is null || states.Count == 0)
{
return null;
}
return string.Join("&", states.Select(x => $"states={x}"));
}
2023-12-06 22:55:55 +00:00
}