diff --git a/src/Infrastructure/BotSharp.Abstraction/Agents/IAgentRuleHook.cs b/src/Infrastructure/BotSharp.Abstraction/Agents/IAgentRuleHook.cs deleted file mode 100644 index 8a19a561a..000000000 --- a/src/Infrastructure/BotSharp.Abstraction/Agents/IAgentRuleHook.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace BotSharp.Abstraction.Agents; - -public interface IAgentRuleHook -{ - void AddRules(List rules); -} diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Enums/ConversationChannel.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Enums/ConversationChannel.cs index f23ff4bae..1843a1bcc 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Enums/ConversationChannel.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Enums/ConversationChannel.cs @@ -5,8 +5,9 @@ public class ConversationChannel public const string WebChat = "webchat"; public const string OpenAPI = "openapi"; public const string Phone = "phone"; + public const string SMS = "sms"; public const string Messenger = "messenger"; public const string Email = "email"; - public const string Cron = "cron"; + public const string Crontab = "crontab"; public const string Database = "database"; } diff --git a/src/Infrastructure/BotSharp.Abstraction/Crontab/Models/CrontabItem.cs b/src/Infrastructure/BotSharp.Abstraction/Crontab/Models/CrontabItem.cs index 6a9dd43a8..3a5310125 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Crontab/Models/CrontabItem.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Crontab/Models/CrontabItem.cs @@ -23,6 +23,9 @@ public class CrontabItem : ScheduleTaskArgs [JsonPropertyName("expire_seconds")] public int ExpireSeconds { get; set; } = 60; + [JsonPropertyName("last_execution_time")] + public DateTime? LastExecutionTime { get; set; } + [JsonPropertyName("created_time")] public DateTime CreatedTime { get; set; } = DateTime.UtcNow; diff --git a/src/Infrastructure/BotSharp.Core.Crontab/Abstraction/ICrontabHook.cs b/src/Infrastructure/BotSharp.Core.Crontab/Abstraction/ICrontabHook.cs index 1738ef5e7..bf4e58685 100644 --- a/src/Infrastructure/BotSharp.Core.Crontab/Abstraction/ICrontabHook.cs +++ b/src/Infrastructure/BotSharp.Core.Crontab/Abstraction/ICrontabHook.cs @@ -2,5 +2,12 @@ namespace BotSharp.Core.Crontab.Abstraction; public interface ICrontabHook { - Task OnCronTriggered(CrontabItem item); + Task OnCronTriggered(CrontabItem item) + => Task.CompletedTask; + + Task OnTaskExecuting(CrontabItem item) + => Task.CompletedTask; + + Task OnTaskExecuted(CrontabItem item) + => Task.CompletedTask; } diff --git a/src/Infrastructure/BotSharp.Core.Crontab/Abstraction/ICrontabSource.cs b/src/Infrastructure/BotSharp.Core.Crontab/Abstraction/ICrontabSource.cs new file mode 100644 index 000000000..ee42c6486 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.Crontab/Abstraction/ICrontabSource.cs @@ -0,0 +1,9 @@ +namespace BotSharp.Core.Crontab.Abstraction; + +/// +/// Provide a cron source for the crontab service. +/// +public interface ICrontabSource +{ + CrontabItem GetCrontabItem(); +} diff --git a/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabService.cs b/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabService.cs index 78b737a05..70d16a0b2 100644 --- a/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabService.cs +++ b/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabService.cs @@ -39,7 +39,17 @@ public async Task> GetCrontable() { var repo = _services.GetRequiredService(); var crontable = repo.GetCrontabItems(CrontabItemFilter.Empty()); - return crontable.Items.ToList(); + + // Add fixed crontab items from cronsources + var fixedCrantabItems = crontable.Items.ToList(); + var cronsources = _services.GetServices(); + foreach (var source in cronsources) + { + var item = source.GetCrontabItem(); + fixedCrantabItems.Add(source.GetCrontabItem()); + } + + return fixedCrantabItems; } public async Task ScheduledTimeArrived(CrontabItem item) @@ -47,8 +57,10 @@ public async Task ScheduledTimeArrived(CrontabItem item) _logger.LogDebug($"ScheduledTimeArrived {item}"); await HookEmitter.Emit(_services, async hook => - await hook.OnCronTriggered(item) - ); - await Task.Delay(1000 * 10); + { + await hook.OnTaskExecuting(item); + await hook.OnCronTriggered(item); + await hook.OnTaskExecuted(item); + }); } } diff --git a/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabWatcher.cs b/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabWatcher.cs index 4711a6aa1..7f47c3f04 100644 --- a/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabWatcher.cs +++ b/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabWatcher.cs @@ -24,9 +24,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var locker = scope.ServiceProvider.GetRequiredService(); - /*while (!stoppingToken.IsCancellationRequested) + while (!stoppingToken.IsCancellationRequested) { - var delay = Task.Delay(1000, stoppingToken); + var delay = Task.Delay(1000 * 10, stoppingToken); await locker.LockAsync("CrontabWatcher", async () => { @@ -34,7 +34,7 @@ await locker.LockAsync("CrontabWatcher", async () => }); await delay; - }*/ + } _logger.LogWarning("Crontab Watcher background service is stopped."); } @@ -58,10 +58,24 @@ private async Task RunCronChecker(IServiceProvider services) // Get the current time var currentTime = DateTime.UtcNow; + // Get the last occurrence from the schedule + var lastOccurrence = GetLastOccurrence(schedule); + // Get the next occurrence from the schedule var nextOccurrence = schedule.GetNextOccurrence(currentTime.AddSeconds(-1)); - // Check if the current time matches the schedule + // Get the previous occurrence from the execution log + var previousOccurrence = item.LastExecutionTime; + + // First check if this occurrence was already triggered + if (previousOccurrence.HasValue && + previousOccurrence.Value >= lastOccurrence && + previousOccurrence.Value < nextOccurrence.AddSeconds(1)) + { + continue; + } + + // Then check if the current time matches the schedule bool matches = currentTime >= nextOccurrence && currentTime < nextOccurrence.AddSeconds(1); if (matches) @@ -72,9 +86,21 @@ private async Task RunCronChecker(IServiceProvider services) } catch (Exception ex) { - _logger.LogWarning($"Error when running cron task ({item.ConversationId}, {item.Title}, {item.Cron}): {ex.Message}\r\n{ex.InnerException}"); + _logger.LogError($"Error when running cron task ({item.Title}, {item.Cron}): {ex.Message}"); continue; } } } + + private DateTime GetLastOccurrence(CrontabSchedule schedule) + { + var nextOccurrence = schedule.GetNextOccurrence(DateTime.UtcNow); + var afterNextOccurrence = schedule.GetNextOccurrence(nextOccurrence); + var interval = afterNextOccurrence - nextOccurrence; + if (interval.TotalMinutes < 10) + { + throw new ArgumentException("The minimum interval must be at least 10 minutes."); + } + return nextOccurrence - interval; + } } diff --git a/src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs b/src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs index 5841d08ae..4fdf66ba3 100644 --- a/src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs +++ b/src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs @@ -45,6 +45,8 @@ public async Task Triggered(IRuleTrigger trigger, string data) { var conv = await convService.NewConversation(new Conversation { + Channel = trigger.Channel, + Title = data, AgentId = agent.Id }); @@ -52,7 +54,7 @@ public async Task Triggered(IRuleTrigger trigger, string data) var states = new List { - new("channel", ConversationChannel.Database), + new("channel", trigger.Channel), new("channel_id", trigger.EntityId) }; convService.SetConversationId(conv.Id, states); diff --git a/src/Infrastructure/BotSharp.Core.Rules/Triggers/IRuleConfig.cs b/src/Infrastructure/BotSharp.Core.Rules/Triggers/IRuleConfig.cs new file mode 100644 index 000000000..e9d757334 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.Rules/Triggers/IRuleConfig.cs @@ -0,0 +1,5 @@ +namespace BotSharp.Core.Rules.Triggers; + +public interface IRuleConfig +{ +} diff --git a/src/Infrastructure/BotSharp.OpenAPI/BotSharp.OpenAPI.csproj b/src/Infrastructure/BotSharp.OpenAPI/BotSharp.OpenAPI.csproj index 28dde7557..94a866373 100644 --- a/src/Infrastructure/BotSharp.OpenAPI/BotSharp.OpenAPI.csproj +++ b/src/Infrastructure/BotSharp.OpenAPI/BotSharp.OpenAPI.csproj @@ -47,6 +47,7 @@ + diff --git a/src/Infrastructure/BotSharp.OpenAPI/Controllers/AgentController.cs b/src/Infrastructure/BotSharp.OpenAPI/Controllers/AgentController.cs index c8e1e8899..226783d32 100644 --- a/src/Infrastructure/BotSharp.OpenAPI/Controllers/AgentController.cs +++ b/src/Infrastructure/BotSharp.OpenAPI/Controllers/AgentController.cs @@ -160,16 +160,4 @@ public IEnumerable GetAgentUtilityOptions() } return utilities.Where(x => !string.IsNullOrWhiteSpace(x.Name)).OrderBy(x => x.Name).ToList(); } - - [HttpGet("/agent/rule/options")] - public IEnumerable GetAgentRuleOptions() - { - var rules = new List(); - var hooks = _services.GetServices(); - foreach (var hook in hooks) - { - hook.AddRules(rules); - } - return rules.Where(x => !string.IsNullOrWhiteSpace(x.TriggerName)).OrderBy(x => x.TriggerName).ToList(); - } } \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.OpenAPI/Controllers/RulesController.cs b/src/Infrastructure/BotSharp.OpenAPI/Controllers/RulesController.cs new file mode 100644 index 000000000..613f82c2d --- /dev/null +++ b/src/Infrastructure/BotSharp.OpenAPI/Controllers/RulesController.cs @@ -0,0 +1,33 @@ +using BotSharp.Abstraction.Agents.Models; +using BotSharp.Core.Rules.Triggers; + +namespace BotSharp.OpenAPI.Controllers; + +[Authorize] +[ApiController] +public class RulesController +{ + private readonly IServiceProvider _services; + + public RulesController( + IServiceProvider services) + { + _services = services; + } + + [HttpGet("/rule/triggers")] + public IEnumerable GetRuleTriggers() + { + var triggers = _services.GetServices(); + return triggers.Select(x => new AgentRule + { + TriggerName = x.GetType().Name + }).OrderBy(x => x.TriggerName).ToList(); + } + + [HttpGet("/rule/formalization")] + public async Task GetFormalizedRuleDefinition([FromBody] AgentRule rule) + { + return "{}"; + } +} diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs index 199e1e34a..9d96bf3c9 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Controllers/TwilioVoiceController.cs @@ -390,7 +390,7 @@ public TwiMLResult InitiateOutboundCall(VoiceRequest request, [Required][FromQue $"twilio/voice/speeches/{conversationId}/intial.mp3" } }; - string tag = $"AnsweredBy: {Request.Form["AnsweredBy"]}"; + string tag = $"twilio:{Request.Form["AnsweredBy"]}"; var db = _services.GetRequiredService(); db.AppendConversationTags(conversationId, new List { tag }); var twilio = _services.GetRequiredService(); diff --git a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HandleOutboundPhoneCallFn.cs b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HandleOutboundPhoneCallFn.cs index bdc05a615..b032d8edf 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HandleOutboundPhoneCallFn.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/OutboundPhoneCallHandler/Functions/HandleOutboundPhoneCallFn.cs @@ -93,6 +93,7 @@ public async Task Execute(RoleDialogModel message) url: new Uri($"{_twilioSetting.CallbackHost}/twilio/voice/init-call?conversationId={conversationId}"), to: new PhoneNumber(args.PhoneNumber), from: new PhoneNumber(_twilioSetting.PhoneNumber), + asyncAmd: "true", machineDetection: "DetectMessageEnd"); message.Content = $"The generated phone message: {args.InitialMessage}. \r\n[Conversation ID: {conversationId}]" ?? message.Content; diff --git a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs index 3b29864fd..5d407b23d 100644 --- a/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs +++ b/src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioService.cs @@ -59,7 +59,10 @@ public VoiceResponse ReturnInstructions(string message) Gather.InputEnum.Speech, Gather.InputEnum.Dtmf }, - Action = new Uri($"{_settings.CallbackHost}/twilio/voice/{twilioSetting.AgentId}") + Action = new Uri($"{_settings.CallbackHost}/twilio/voice/{twilioSetting.AgentId}"), + Enhanced = true, + SpeechModel = Gather.SpeechModelEnum.PhoneCall, + SpeechTimeout = "auto" }; gather.Say(message); @@ -78,6 +81,7 @@ public VoiceResponse ReturnInstructions(ConversationalVoiceResponse conversation Gather.InputEnum.Dtmf }, Action = new Uri($"{_settings.CallbackHost}/{conversationalVoiceResponse.CallbackPath}"), + Enhanced = true, SpeechModel = Gather.SpeechModelEnum.PhoneCall, SpeechTimeout = "auto", // timeout > 0 ? timeout.ToString() : "3", Timeout = conversationalVoiceResponse.Timeout > 0 ? conversationalVoiceResponse.Timeout : 3, @@ -115,6 +119,7 @@ public VoiceResponse ReturnNoninterruptedInstructions(ConversationalVoiceRespons Gather.InputEnum.Dtmf }, Action = new Uri($"{_settings.CallbackHost}/{conversationalVoiceResponse.CallbackPath}"), + Enhanced = true, SpeechModel = Gather.SpeechModelEnum.PhoneCall, SpeechTimeout = "auto", // conversationalVoiceResponse.Timeout > 0 ? conversationalVoiceResponse.Timeout.ToString() : "3", Timeout = conversationalVoiceResponse.Timeout > 0 ? conversationalVoiceResponse.Timeout : 3,