diff --git a/README.md b/README.md index 38b1e023..89fcc0e8 100644 --- a/README.md +++ b/README.md @@ -126,10 +126,9 @@ BotSharp uses component design, the kernel is kept to a minimum, and business fu - BotSharp.Plugin.ChatbotUI ### Roadmap - - [ ] A2A - [x] MCP -- [ ] Realtime +- [x] Realtime - [ ] Computer Use - [x] Browser Use - [x] Database Assistant diff --git a/src/Infrastructure/BotSharp.Abstraction/Crontab/Models/TaskWaitArgs.cs b/src/Infrastructure/BotSharp.Abstraction/Crontab/Models/TaskWaitArgs.cs new file mode 100644 index 00000000..9eb72854 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Crontab/Models/TaskWaitArgs.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BotSharp.Abstraction.Crontab.Models; + +public class TaskWaitArgs +{ + + [JsonPropertyName("delay_time")] + public int DelayTime { get; set; } +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFilter.cs b/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFilter.cs index e5eed361..9ba98c5d 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFilter.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFilter.cs @@ -12,6 +12,7 @@ public class ConversationFilter public string? AgentId { get; set; } public string? Status { get; set; } public string? Channel { get; set; } + public string? ChannelId { get; set; } public string? UserId { get; set; } public DateTime? StartTime { get; set; } diff --git a/src/Infrastructure/BotSharp.Core.Crontab/BotSharp.Core.Crontab.csproj b/src/Infrastructure/BotSharp.Core.Crontab/BotSharp.Core.Crontab.csproj index ff6c2368..f406ac97 100644 --- a/src/Infrastructure/BotSharp.Core.Crontab/BotSharp.Core.Crontab.csproj +++ b/src/Infrastructure/BotSharp.Core.Crontab/BotSharp.Core.Crontab.csproj @@ -16,6 +16,9 @@ + + PreserveNewest + PreserveNewest diff --git a/src/Infrastructure/BotSharp.Core.Crontab/Functions/TaskWaitFn.cs b/src/Infrastructure/BotSharp.Core.Crontab/Functions/TaskWaitFn.cs new file mode 100644 index 00000000..0fb68dc9 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.Crontab/Functions/TaskWaitFn.cs @@ -0,0 +1,44 @@ +using BotSharp.Core.Crontab.Hooks; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace BotSharp.Core.Crontab.Functions; + +public class TaskWaitFn : IFunctionCallback +{ + public string Name => $"{CrontabUtilityHook.PREFIX}task_wait"; + + private readonly ILogger _logger; + public TaskWaitFn(ILogger logger) + { + _logger = logger; + } + public async Task Execute(RoleDialogModel message) + { + try + { + var args = JsonSerializer.Deserialize(message.FunctionArgs); + if (args != null && args.DelayTime > 0) + { + await Task.Delay(args.DelayTime * 1000); + } + message.Content = "wait task completed"; + } + catch (JsonException jsonEx) + { + message.Content = "Invalid function arguments format."; + _logger.LogError(jsonEx, "Json deserialization failed."); + } + catch (Exception ex) + { + message.Content = "Unable to perform delay task"; + _logger.LogError(ex, "crontab wait task failed."); + } + return true; + } +} diff --git a/src/Infrastructure/BotSharp.Core.Crontab/Hooks/CrontabUtilityHook.cs b/src/Infrastructure/BotSharp.Core.Crontab/Hooks/CrontabUtilityHook.cs index ed150861..7fed754a 100644 --- a/src/Infrastructure/BotSharp.Core.Crontab/Hooks/CrontabUtilityHook.cs +++ b/src/Infrastructure/BotSharp.Core.Crontab/Hooks/CrontabUtilityHook.cs @@ -7,7 +7,8 @@ public class CrontabUtilityHook : IAgentUtilityHook { public const string PREFIX = "util-crontab-"; private const string SCHEDULE_TASK_FN = $"{PREFIX}schedule_task"; - + private const string TASK_WAIT_FN = $"{PREFIX}task_wait"; + public void AddUtilities(List utilities) { var items = new List @@ -15,7 +16,7 @@ public class CrontabUtilityHook : IAgentUtilityHook new AgentUtility { Name = UtilityName.ScheduleTask, - Functions = [new(SCHEDULE_TASK_FN)], + Functions = [new(SCHEDULE_TASK_FN), new(TASK_WAIT_FN)], Templates = [new($"{SCHEDULE_TASK_FN}.fn")] } }; diff --git a/src/Infrastructure/BotSharp.Core.Crontab/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-crontab-task_wait.json b/src/Infrastructure/BotSharp.Core.Crontab/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-crontab-task_wait.json new file mode 100644 index 00000000..b3ddd167 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.Crontab/data/agents/6745151e-6d46-4a02-8de4-1c4f21c7da95/functions/util-crontab-task_wait.json @@ -0,0 +1,16 @@ +{ + "name": "util-crontab-task_wait", + "description": "wait for a peroid of time then process", + "parameters": { + "type": "object", + "properties": { + "delay_time": { + "type": "number", + "description": "delay time in seconds" + } + }, + "required": [ + "delay_time" + ] + } +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.GetAgents.cs b/src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.GetAgents.cs index 6db0819a..0860598a 100644 --- a/src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.GetAgents.cs +++ b/src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.GetAgents.cs @@ -44,13 +44,17 @@ public partial class AgentService [SharpCache(10)] public async Task GetAgent(string id) { - var profile = _db.GetAgent(id); + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } - if (profile == null) - { - _logger.LogError($"Can't find agent {id}"); - return null; - } + var profile = _db.GetAgent(id); + if (profile == null) + { + _logger.LogError($"Can't find agent {id}"); + return null; + } // Load llm config var agentSetting = _services.GetRequiredService(); diff --git a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs index b8ff420e..3b60a61a 100644 --- a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs +++ b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs @@ -430,6 +430,10 @@ public partial class FileRepository { matched = matched && record.Channel == filter.Channel; } + if(filter?.ChannelId != null) + { + matched = matched && record.ChannelId == filter.ChannelId; + } if (filter?.UserId != null) { matched = matched && record.UserId == filter.UserId; diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs index f0e89473..722c7730 100644 --- a/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs @@ -371,6 +371,10 @@ public partial class MongoRepository { convFilters.Add(convBuilder.Eq(x => x.Channel, filter.Channel)); } + if (!string.IsNullOrEmpty(filter?.ChannelId)) + { + convFilters.Add(convBuilder.Eq(x => x.ChannelId, filter.ChannelId)); + } if (!string.IsNullOrEmpty(filter?.UserId)) { convFilters.Add(convBuilder.Eq(x => x.UserId, filter.UserId)); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioInboundController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioInboundController.cs index e82908a0..095f3d25 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioInboundController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioInboundController.cs @@ -107,6 +107,12 @@ public class TwilioInboundController : TwilioController response.Redirect(new Uri($"{_settings.CallbackHost}/twilio/voice/reply/{seqNum}?agent-id={request.AgentId}&conversation-id={request.ConversationId}&{twilio.GenerateStatesParameter(request.States)}"), HttpMethod.Post); } } + + _ = Task.Run(async () => + { + await Task.Delay(1500); + await twilio.StartRecording(request.CallSid, request.AgentId, request.ConversationId); + }); } await HookEmitter.Emit(_services, async hook => diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioRecordController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioRecordController.cs index 65b2a5ad..4e4b1c75 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioRecordController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioRecordController.cs @@ -38,6 +38,10 @@ public class TwilioRecordController : TwilioController // recording completed await HookEmitter.Emit(_services, x => x.OnRecordingCompleted(request)); } + else + { + _logger.LogError($"Unknown record status: {request.CallStatus}, {request.CallSid}"); + } return Ok(); } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs index 0ff8b90c..42582f70 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs @@ -214,7 +214,7 @@ public class TwilioVoiceController : TwilioController var reply = await sessionManager.GetAssistantReplyAsync(request.ConversationId, request.SeqNum); VoiceResponse response; - + if (request.AIResponseWaitTime > 10) { // Wait AI Response Timeout @@ -346,35 +346,57 @@ public class TwilioVoiceController : TwilioController if (twilio.MachineDetected(request)) { // voicemail - await HookEmitter.Emit(_services, - async hook => await hook.OnVoicemailLeft(request)); + await HookEmitter.Emit(_services, + async hook => + { + if (hook.IsMatch(request)) await hook.OnVoicemailLeft(request); + }); } else { // phone call completed - await HookEmitter.Emit(_services, - async x => await x.OnUserDisconnected(request)); + await HookEmitter.Emit(_services, + async hook => + { + if (hook.IsMatch(request)) await hook.OnUserDisconnected(request); + }); } } else if (request.CallStatus == "busy") { - await HookEmitter.Emit(_services, - async x => await x.OnCallBusyStatus(request)); + await HookEmitter.Emit(_services, + async hook => + { + if (hook.IsMatch(request)) await hook.OnCallBusyStatus(request); + }); } else if (request.CallStatus == "no-answer") { - await HookEmitter.Emit(_services, - async x => await x.OnCallNoAnswerStatus(request)); + await HookEmitter.Emit(_services, + async hook => + { + if (hook.IsMatch(request)) await hook.OnCallNoAnswerStatus(request); + }); } else if (request.CallStatus == "canceled") { - await HookEmitter.Emit(_services, - async x => await x.OnCallCanceledStatus(request)); + await HookEmitter.Emit(_services, + async hook => + { + if (hook.IsMatch(request)) await hook.OnCallCanceledStatus(request); + }); } else if (request.CallStatus == "failed") { - await HookEmitter.Emit(_services, - async x => await x.OnCallFailedStatus(request)); + await HookEmitter.Emit(_services, + async hook => + { + if (hook.IsMatch(request)) await hook.OnCallFailedStatus(request); + }); + } + else + { + _logger.LogError($"Unknown call status: {request.CallStatus}, {request.CallSid}"); } return Ok(); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs b/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs index e04d3ae7..ae461595 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Interfaces/ITwilioCallStatusHook.cs @@ -5,10 +5,11 @@ namespace BotSharp.Plugin.Twilio.Interfaces; public interface ITwilioCallStatusHook { - Task OnVoicemailLeft(ConversationalVoiceRequest request); - Task OnUserDisconnected(ConversationalVoiceRequest request); - Task OnRecordingCompleted(ConversationalVoiceRequest request); - Task OnVoicemailStarting(ConversationalVoiceRequest request); + bool IsMatch(ConversationalVoiceRequest request) => true; + Task OnVoicemailLeft(ConversationalVoiceRequest request) => Task.CompletedTask; + Task OnUserDisconnected(ConversationalVoiceRequest request) => Task.CompletedTask; + Task OnRecordingCompleted(ConversationalVoiceRequest request) => Task.CompletedTask; + Task OnVoicemailStarting(ConversationalVoiceRequest request)=> Task.CompletedTask; /// /// 1. The recipient's phone line is already engaged. @@ -17,11 +18,11 @@ public interface ITwilioCallStatusHook /// /// /// - Task OnCallBusyStatus(ConversationalVoiceRequest request); + Task OnCallBusyStatus(ConversationalVoiceRequest request)=> Task.CompletedTask; - Task OnCallNoAnswerStatus(ConversationalVoiceRequest request); + Task OnCallNoAnswerStatus(ConversationalVoiceRequest request) => Task.CompletedTask; - Task OnCallCanceledStatus(ConversationalVoiceRequest request); + Task OnCallCanceledStatus(ConversationalVoiceRequest request)=> Task.CompletedTask; - Task OnCallFailedStatus(ConversationalVoiceRequest request); + Task OnCallFailedStatus(ConversationalVoiceRequest request)=> Task.CompletedTask; } diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs index c0254c1e..7bc95382 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs @@ -6,6 +6,8 @@ using BotSharp.Core.Infrastructures; using BotSharp.Plugin.Twilio.Interfaces; using BotSharp.Plugin.Twilio.Models; using Twilio.Jwt.AccessToken; +using Twilio.Rest.Api.V2010.Account.Call; +using Task = System.Threading.Tasks.Task; using Token = Twilio.Jwt.AccessToken.Token; namespace BotSharp.Plugin.Twilio.Services; @@ -122,6 +124,22 @@ public class TwilioService return response; } + 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}"); + } + } + public VoiceResponse HangUp(string speechPath) { var response = new VoiceResponse();