BotSharp/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/OutboundPhoneCallFn.cs

231 lines
9.1 KiB
C#
Raw Normal View History

2025-02-26 17:41:52 +00:00
using BotSharp.Abstraction.Files;
using BotSharp.Abstraction.Infrastructures.Enums;
using BotSharp.Abstraction.Options;
2025-06-18 19:29:25 +00:00
using BotSharp.Abstraction.Repositories;
2025-02-26 17:41:52 +00:00
using BotSharp.Abstraction.Routing;
2025-06-18 19:29:25 +00:00
using BotSharp.Abstraction.Utilities;
2025-02-26 17:41:52 +00:00
using BotSharp.Core.Infrastructures;
2025-03-14 18:59:13 +00:00
using BotSharp.Plugin.Twilio.Interfaces;
using BotSharp.Plugin.Twilio.Models;
2025-02-26 17:41:52 +00:00
using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts;
using Twilio.Rest.Api.V2010.Account;
2025-07-14 17:40:59 +00:00
using Twilio.TwiML.Messaging;
2025-02-26 17:41:52 +00:00
using Twilio.Types;
using Conversation = BotSharp.Abstraction.Conversations.Models.Conversation;
using Task = System.Threading.Tasks.Task;
namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Functions;
public class OutboundPhoneCallFn : IFunctionCallback
{
private readonly IServiceProvider _services;
private readonly ILogger<OutboundPhoneCallFn> _logger;
private readonly BotSharpOptions _options;
private readonly TwilioSetting _twilioSetting;
public string Name => "util-twilio-outbound_phone_call";
public string Indication => "Dialing the phone number";
public OutboundPhoneCallFn(
IServiceProvider services,
ILogger<OutboundPhoneCallFn> logger,
BotSharpOptions options,
TwilioSetting twilioSetting)
{
_services = services;
_logger = logger;
_options = options;
_twilioSetting = twilioSetting;
}
public async Task<bool> Execute(RoleDialogModel message)
{
var args = JsonSerializer.Deserialize<LlmContextIn>(message.FunctionArgs, _options.JsonSerializerOptions);
if (args.PhoneNumber.Length != 12 || !args.PhoneNumber.StartsWith("+1", StringComparison.OrdinalIgnoreCase))
{
var error = $"Invalid phone number format: {args.PhoneNumber}";
_logger.LogError(error);
message.Content = error;
return false;
}
if (string.IsNullOrWhiteSpace(args.InitialMessage))
{
_logger.LogError("Initial message is empty.");
message.Content = "There is an error when generating phone message.";
return false;
}
var fileStorage = _services.GetRequiredService<IFileStorageService>();
var states = _services.GetRequiredService<IConversationStateService>();
// Fork conversation
var newConversationId = Guid.NewGuid().ToString();
states.SetState(StateConst.SUB_CONVERSATION_ID, newConversationId);
2025-03-14 18:59:13 +00:00
var processUrl = $"{_twilioSetting.CallbackHost}/twilio";
2025-03-21 14:17:21 +00:00
var statusUrl = $"{_twilioSetting.CallbackHost}/twilio/voice/status?agent-id={message.CurrentAgentId}&conversation-id={newConversationId}";
var recordingStatusUrl = $"{_twilioSetting.CallbackHost}/twilio/recording/status?agent-id={message.CurrentAgentId}&conversation-id={newConversationId}";
2025-03-14 18:59:13 +00:00
2025-02-26 17:41:52 +00:00
// Generate initial assistant audio
2025-03-15 02:06:40 +00:00
string initAudioFile = null;
2025-03-14 18:59:13 +00:00
if (!string.IsNullOrEmpty(args.InitialMessage))
{
2025-03-22 00:28:22 +00:00
var completion = CompletionProvider.GetAudioSynthesizer(_services);
var data = await completion.GenerateAudioAsync(args.InitialMessage);
2025-03-15 02:06:40 +00:00
initAudioFile = "intial.mp3";
fileStorage.SaveSpeechFile(newConversationId, initAudioFile, data);
2025-03-14 18:59:13 +00:00
2025-03-15 02:06:40 +00:00
statusUrl += $"&init-audio-file={initAudioFile}";
2025-03-14 18:59:13 +00:00
}
2025-04-02 15:35:03 +00:00
// load agent profile
var agentService = _services.GetRequiredService<IAgentService>();
var agent = await agentService.GetAgent(message.CurrentAgentId);
2025-03-14 18:59:13 +00:00
// Set up process URL streaming or synchronous
2025-04-24 16:58:44 +00:00
if (agent.Labels.Contains("realtime"))
2025-03-14 18:59:13 +00:00
{
2025-04-02 21:25:37 +00:00
processUrl += "/inbound";
2025-03-14 18:59:13 +00:00
}
else
{
var sessionManager = _services.GetRequiredService<ITwilioSessionManager>();
await sessionManager.SetAssistantReplyAsync(newConversationId, 0, new AssistantMessage
{
Content = args.InitialMessage,
2025-03-15 02:06:40 +00:00
SpeechFileName = initAudioFile
2025-03-14 18:59:13 +00:00
});
processUrl += "/voice/init-outbound-call";
}
2025-03-21 14:17:21 +00:00
processUrl += $"?agent-id={message.CurrentAgentId}&conversation-id={newConversationId}";
2025-03-15 02:06:40 +00:00
if (!string.IsNullOrEmpty(initAudioFile))
{
processUrl += $"&init-audio-file={initAudioFile}";
}
2025-02-26 17:41:52 +00:00
// Make outbound call
var call = await CallResource.CreateAsync(
2025-03-14 18:59:13 +00:00
url: new Uri(processUrl),
2025-02-26 17:41:52 +00:00
to: new PhoneNumber(args.PhoneNumber),
2025-03-13 19:05:06 +00:00
from: new PhoneNumber(_twilioSetting.PhoneNumber),
2025-03-14 18:59:13 +00:00
statusCallback: new Uri(statusUrl),
2025-03-13 19:05:06 +00:00
// https://www.twilio.com/docs/voice/answering-machine-detection
2025-03-14 20:08:34 +00:00
machineDetection: _twilioSetting.MachineDetection,
2025-03-17 22:09:02 +00:00
machineDetectionSilenceTimeout: _twilioSetting.MachineDetectionSilenceTimeout,
2025-03-14 20:08:34 +00:00
record: _twilioSetting.RecordingEnabled,
2025-03-21 14:17:21 +00:00
recordingStatusCallback: $"{_twilioSetting.CallbackHost}/twilio/record/status?agent-id={message.CurrentAgentId}&conversation-id={newConversationId}");
2025-02-26 17:41:52 +00:00
if (call.Status == CallResource.StatusEnum.Queued)
{
var convService = _services.GetRequiredService<IConversationService>();
var routing = _services.GetRequiredService<IRoutingContext>();
var originConversationId = convService.ConversationId;
var entryAgentId = routing.EntryAgentId;
await ForkConversation(args, entryAgentId, originConversationId, newConversationId, call);
message.Content = $"The call has been successfully queued. The initial information is as follows: {args.InitialMessage}.";
return true;
}
else
{
message.Content = $"Failed to make a call, status is {call.Status}.";
return false;
}
2025-02-26 17:41:52 +00:00
}
2025-06-18 19:29:25 +00:00
private async Task ForkConversation(
LlmContextIn args,
string entryAgentId,
string originConversationId,
2025-02-26 17:41:52 +00:00
string newConversationId,
2025-03-21 14:17:21 +00:00
CallResource call)
2025-02-26 17:41:52 +00:00
{
// new scope service for isolated conversation
using var scope = _services.CreateScope();
var services = scope.ServiceProvider;
var convService = services.GetRequiredService<IConversationService>();
var convStorage = services.GetRequiredService<IConversationStorage>();
2025-06-18 19:29:25 +00:00
var state = _services.GetRequiredService<IConversationStateService>();
var db = _services.GetRequiredService<IBotSharpRepository>();
2025-02-26 17:41:52 +00:00
var newConv = await convService.NewConversation(new Conversation
{
Id = newConversationId,
AgentId = entryAgentId,
Channel = ConversationChannel.Phone,
2025-03-21 14:17:21 +00:00
ChannelId = call.Sid,
2025-02-26 17:41:52 +00:00
Title = args.InitialMessage
});
var messageId = Guid.NewGuid().ToString();
2025-02-26 17:41:52 +00:00
convStorage.Append(newConversationId, new List<RoleDialogModel>
{
new RoleDialogModel(AgentRole.User, "Hi")
2025-02-26 17:41:52 +00:00
{
MessageId = messageId,
2025-02-26 17:41:52 +00:00
CurrentAgentId = entryAgentId
},
new RoleDialogModel(AgentRole.Assistant, args.InitialMessage)
{
MessageId = messageId,
2025-02-26 17:41:52 +00:00
CurrentAgentId = entryAgentId
}
});
2025-06-18 19:29:25 +00:00
var utcNow = DateTime.UtcNow;
2025-07-14 17:40:59 +00:00
var excludeStates = new List<string>
2025-06-18 19:29:25 +00:00
{
"provider",
"model",
"prompt_total",
"completion_total",
"llm_total_cost"
};
2025-07-14 17:40:59 +00:00
var curConvStates = state.GetStates().Select(x => new MessageState(x.Key, x.Value)).ToList();
2025-06-18 19:29:25 +00:00
var subConvStates = new List<MessageState>
{
2025-07-14 17:51:59 +00:00
new(StateConst.ORIGIN_CONVERSATION_ID, originConversationId, isGlobal: true),
new("channel", "phone", isGlobal: true),
new("phone_from", call.From, isGlobal: true),
new("phone_direction", call.Direction, isGlobal: true),
new("phone_number", call.To, isGlobal: true),
new("twilio_call_sid", call.Sid, isGlobal: true)
2025-06-18 19:29:25 +00:00
};
var subStateKeys = subConvStates.Select(x => x.Key).ToList();
2025-07-14 17:40:59 +00:00
var included = curConvStates.Where(x => !subStateKeys.Contains(x.Key) && !excludeStates.Contains(x.Key));
var mappedCurConvStates = MapStates(included, messageId, utcNow);
var mappedSubConvStates = MapStates(subConvStates, messageId, utcNow);
2025-07-14 17:54:20 +00:00
var allStates = mappedCurConvStates.Concat(mappedSubConvStates).ToList();
2025-07-14 17:40:59 +00:00
2025-07-14 17:54:20 +00:00
db.UpdateConversationStates(newConversationId, allStates);
2025-07-14 17:40:59 +00:00
}
private IEnumerable<StateKeyValue> MapStates(IEnumerable<MessageState> states, string messageId, DateTime updateTime)
{
if (states.IsNullOrEmpty()) return [];
return states.Select(x => new StateKeyValue
2025-06-18 19:29:25 +00:00
{
Key = x.Key,
2025-07-14 17:40:59 +00:00
Versioning = !x.Global,
2025-06-18 19:29:25 +00:00
Values = [
new StateValue
{
Data = x.Value.ConvertToString(_options.JsonSerializerOptions),
MessageId = messageId,
Active = true,
ActiveRounds = x.ActiveRounds,
Source = StateSource.Application,
2025-07-14 17:40:59 +00:00
UpdateTime = updateTime
2025-06-18 19:29:25 +00:00
}
]
}).ToList();
2025-02-26 17:41:52 +00:00
}
}