From 78e0bc924cce10475c689b1654e16b8c46f7703e Mon Sep 17 00:00:00 2001 From: Hossein Karimy Date: Wed, 13 Apr 2022 15:38:35 -0400 Subject: [PATCH 1/5] having timout components working --- .gitignore | 3 +- packages/Telephony/Actions/ITimeoutInput.cs | 43 ++++ .../Telephony/Actions/TimeoutChoiceInput.cs | 120 +-------- packages/Telephony/Actions/TimeoutInput.cs | 231 ++++++++++++++++++ .../Telephony/Actions/TimeoutTextInput.cs | 54 ++++ packages/Telephony/Common/IStateMatrix.cs | 3 +- .../Telephony/Common/LatchingStateMatrix.cs | 20 +- .../Microsoft.Bot.Components.Telephony.csproj | 15 +- .../Middleware/SetSpeakMiddleware.cs | 60 +++++ ...icrosoft.Telephony.TimeoutTextInput.schema | 50 ++++ ...rosoft.Telephony.TimeoutTextInput.uischema | 63 +++++ packages/Telephony/TelephonyBotComponent.cs | 6 + 12 files changed, 552 insertions(+), 116 deletions(-) create mode 100644 packages/Telephony/Actions/ITimeoutInput.cs create mode 100644 packages/Telephony/Actions/TimeoutInput.cs create mode 100644 packages/Telephony/Actions/TimeoutTextInput.cs create mode 100644 packages/Telephony/Middleware/SetSpeakMiddleware.cs create mode 100644 packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.schema create mode 100644 packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.uischema diff --git a/.gitignore b/.gitignore index 75c6634b81..b646ad1e38 100644 --- a/.gitignore +++ b/.gitignore @@ -403,4 +403,5 @@ experimental/generator-dotnet-yeoman/node_modules package-lock.json # Ignore test bots -testing/* \ No newline at end of file +testing/* +packages/.vs/* diff --git a/packages/Telephony/Actions/ITimeoutInput.cs b/packages/Telephony/Actions/ITimeoutInput.cs new file mode 100644 index 0000000000..1126f8f30b --- /dev/null +++ b/packages/Telephony/Actions/ITimeoutInput.cs @@ -0,0 +1,43 @@ +using AdaptiveExpressions.Properties; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Bot.Components.Telephony.Actions +{ + public interface ITimeoutInput + { + /// + /// Defines dialog context turn count property value. + /// + const string NoMatchCount= "this.noMatchCount"; + + /// + /// Defines dialog context turn count property value. + /// + const string NoInputCount = "this.noInputCount"; + + /// + // Defines dialog context state property value. + /// + const string SilenceDetected = "dialog.silenceDetected"; + + /// + /// Gets or sets a value indicating how long to wait for before timing out and using the default value. + /// + [JsonProperty("timeOutInMilliseconds")] + IntExpression TimeOutInMilliseconds { get; set; } + + /// + /// Gets or sets a value indicating how many times should retry in case of the input didn't match + /// + [JsonProperty("maxNoMatchCount")] + IntExpression MaxNoMatchCount{ get; set; } + /// + /// Gets or sets a value indicating how many times should retry in case of no input provided + /// + [JsonProperty("maxNoInputCount")] + IntExpression MaxNoInputCount{ get; set; } + } +} diff --git a/packages/Telephony/Actions/TimeoutChoiceInput.cs b/packages/Telephony/Actions/TimeoutChoiceInput.cs index f86cf6e66b..6f3f3d872b 100644 --- a/packages/Telephony/Actions/TimeoutChoiceInput.cs +++ b/packages/Telephony/Actions/TimeoutChoiceInput.cs @@ -16,12 +16,16 @@ namespace Microsoft.Bot.Components.Telephony.Actions { - public class TimeoutChoiceInput : ChoiceInput + public class TimeoutChoiceInput : ChoiceInput, ITimeoutInput { [JsonProperty("$kind")] public new const string Kind = "Microsoft.Telephony.TimeoutChoiceInput"; - private static IStateMatrix stateMatrix = new LatchingStateMatrix(); + /// + /// Gets or sets a value indicating how long to wait for before timing out and using the default value. + /// + [JsonProperty("timeOutInMilliseconds")] + public IntExpression TimeOutInMilliseconds { get; set; } [JsonConstructor] public TimeoutChoiceInput([CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) @@ -31,118 +35,20 @@ public TimeoutChoiceInput([CallerFilePath] string sourceFilePath = "", [CallerLi this.RegisterSourceLocation(sourceFilePath, sourceLineNumber); } - /// - /// Gets or sets a value indicating how long to wait for before timing out and using the default value. - /// - [JsonProperty("timeOutInMilliseconds")] - public IntExpression TimeOutInMilliseconds { get; set; } - public async override Task BeginDialogAsync(DialogContext dc, object options = null, CancellationToken cancellationToken = default(CancellationToken)) { - if (options is CancellationToken) - { - throw new ArgumentException($"{nameof(options)} cannot be a cancellation token"); - } - - if (this.Disabled != null && this.Disabled.GetValue(dc.State) == true) - { - return await dc.EndDialogAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - } - - //start a timer that will continue this conversation - var timerId = Guid.NewGuid().ToString(); - CreateTimerForConversation(dc, timerId, cancellationToken); - await stateMatrix.StartAsync(timerId).ConfigureAwait(false); - dc.State.SetValue("this.TimerId", timerId); - - return await base.BeginDialogAsync(dc, options, cancellationToken).ConfigureAwait(false); + return await TimeoutInput.BeginDialogAsync(this, dc, + base.BeginDialogAsync, + options, cancellationToken); } public override async Task ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken = default(CancellationToken)) { - var activity = dc.Context.Activity; - - //Handle case where we timed out - var interrupted = dc.State.GetValue(TurnPath.Interrupted, () => false); - if (!interrupted && activity.Type != ActivityTypes.Message && activity.Name == ActivityEventNames.ContinueConversation) - { - //Set max turns so that we evaluate the default when we visit the inputdialog. - MaxTurnCount = 1; - - //We need to set interrupted here or it will discard the continueconversation event... - dc.State.SetValue(TurnPath.Interrupted, true); - return await base.ContinueDialogAsync(dc, cancellationToken).ConfigureAwait(false); - } - else - { - //If we didn't timeout then we have to manage our timer somehow. - //For starters, complete our existing timer. - var timerId = dc.State.GetValue("this.TimerId"); - - //Should never happen but if it does, it shouldn't be fatal. - if (timerId != null) - { - await stateMatrix.CompleteAsync(timerId).ConfigureAwait(false); - } - - //Begin dirty hack to start a timer for the reprompt - - //If our input was not valid, restart the timer. - dc.State.SetValue(VALUE_PROPERTY, activity.Text); //OnRecognizeInput assumes this was already set earlier on in the dialog. We will set it and then unset it to simulate passing an argument to a function... :D - if (await OnRecognizeInputAsync(dc, cancellationToken).ConfigureAwait(false) != InputState.Valid) - { - //We are cheating to force this recognition here. Maybe not good? - - //Known bug: Sometimes invalid input gets accepted anyway(due to max turns and defaulting rules), this will start a continuation for a thing it shouldn't. - //Sure do wish EndDialog was available to the adaptive stack. - - var newTimerId = Guid.NewGuid().ToString(); - CreateTimerForConversation(dc, newTimerId, cancellationToken); - await stateMatrix.StartAsync(newTimerId).ConfigureAwait(false); - } - - //Clear our the input property after recognition since it will happen again later :D - dc.State.SetValue(VALUE_PROPERTY, null); - - //End dirty hack - } - - return await base.ContinueDialogAsync(dc, cancellationToken).ConfigureAwait(false); + return await TimeoutInput.ContinueDialogAsync(this, dc, VALUE_PROPERTY, TURN_COUNT_PROPERTY, + OnRecognizeInputAsync, + base.ContinueDialogAsync, + cancellationToken); } - private void CreateTimerForConversation(DialogContext dc, string timerId, CancellationToken cancellationToken) - { - BotAdapter adapter = dc.Context.Adapter; - var identity = dc.Context.TurnState.Get("BotIdentity"); - - var appId = identity?.Claims?.FirstOrDefault(c => c.Type == AuthenticationConstants.AudienceClaim)?.Value; - ConversationReference conversationReference = dc.Context.Activity.GetConversationReference(); - int timeout = TimeOutInMilliseconds.GetValue(dc.State); - - //Question remaining to be answered: Will this task get garbage collected? If so, we need to maintain a handle for it. - Task.Run(async () => - { - await Task.Delay(timeout).ConfigureAwait(false); - string msAppId = appId; - - // If the channel is the Emulator, and authentication is not in use, - // the AppId will be null. We generate a random AppId for this case only. - // This is not required for production, since the AppId will have a value. - if (string.IsNullOrEmpty(msAppId)) - { - msAppId = Guid.NewGuid().ToString(); //if no AppId, use a random Guid - } - - //if we aren't already complete, go ahead and timeout - await stateMatrix.RunForStatusAsync(timerId, StateStatus.Running, async () => - { - await adapter.ContinueConversationAsync( - msAppId, - conversationReference, - BotWithLookup.OnTurn, //Leverage dirty hack to achieve Bot lookup from component - cancellationToken).ConfigureAwait(false); - }).ConfigureAwait(false); - }); - } } } \ No newline at end of file diff --git a/packages/Telephony/Actions/TimeoutInput.cs b/packages/Telephony/Actions/TimeoutInput.cs new file mode 100644 index 0000000000..935bf4835c --- /dev/null +++ b/packages/Telephony/Actions/TimeoutInput.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using AdaptiveExpressions.Properties; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Adapters; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Builder.Dialogs.Adaptive.Input; +using Microsoft.Bot.Components.Telephony.Common; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Newtonsoft.Json; + +namespace Microsoft.Bot.Components.Telephony.Actions +{ + public static class TimeoutInput + { + // Summary: + // Defines dialog context state property value. + private const string TimerID = "dialog.TimerId"; + //private const string TimeoutId = "this.TimeoutId"; + //private const string ActiveTimeoutId = "conversation.ActiveTimeoutId"; + + private static ConcurrentDictionary triggeredTimers, IStateMatrix timersState)> conversationStateMatrix = new ConcurrentDictionary triggeredTimers, IStateMatrix timersState)>(); + + public static async Task BeginDialogAsync(K inputActivity, DialogContext dc, + Func> baseClassCall, + object options = null, CancellationToken cancellationToken = default(CancellationToken)) where K : InputDialog, ITimeoutInput + { + if (options is CancellationToken) + { + throw new ArgumentException($"{nameof(options)} cannot be a cancellation token"); + } + + if (inputActivity.Disabled?.GetValue(dc.State) == true) + { + return await dc.EndDialogAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + } + + dc.State.SetValue(ITimeoutInput.SilenceDetected, false); + + //start a timer that will continue this conversation + var timerId = CreateTimerForConversation(inputActivity, dc, cancellationToken); + if (!conversationStateMatrix.TryGetValue(dc.Context.Activity.Conversation.Id, out var convState)) + { + convState = (new ConcurrentQueue(), new LatchingStateMatrix()); + conversationStateMatrix[dc.Context.Activity.Conversation.Id] = convState; + } + await convState.timersState.StartAsync(timerId).ConfigureAwait(false); + + dc.Services.Get().TrackEvent("Start TimeoutInput", new Dictionary + { + {"timerId", timerId} + }); + + var res = await baseClassCall(dc, options, cancellationToken).ConfigureAwait(false); + return res; + } + + public static async Task ContinueDialogAsync(K inputActivity, DialogContext dc, string valueProperty, string turnCountProperty, + Func> onRecognizeInputAsync, + Func> continueDialogAsync, + CancellationToken cancellationToken = default(CancellationToken)) where K : InputDialog, ITimeoutInput + { + var timerId = dc.State.GetValue(TimerID); + + //conversation is ended + if (!conversationStateMatrix.TryGetValue(dc.Context.Activity.Conversation.Id, out var convState)) + return Dialog.EndOfTurn; + + + return await convState.timersState.RunForStatusAsync(timerId, StateStatus.Running, async () => + { + var activity = dc.Context.Activity; + + //we have to manage our timer somehow. + //For starters, complete our existing timer. + + + //Handle case where we timed out + var interrupted = dc.State.GetValue(TurnPath.Interrupted, () => false); + if (!interrupted && activity.Type != ActivityTypes.Message && activity.Name == ActivityEventNames.ContinueConversation) + { + string calledTimerId = ""; + //if there is no matched conversation (could be removed by endOfConversations, or there is no called timer (shouldn't happen) or + //the last called Timer is not the same as activity's timer, then don't continue + if (!convState.triggeredTimers.TryDequeue(out calledTimerId) || calledTimerId != timerId) + { + dc.Services.Get().TrackEvent("Abort Timer routine", + new Dictionary { { "timerId", calledTimerId } }); + return Dialog.EndOfTurn; + } + + + // dc.Services.Get().TrackEvent("Continue Timer", new Dictionary + //{ + // {"timerId", timerId} + //}); + + //stop any more incoming event for this activity + convState.timersState.ForceComplete(timerId); + + //Set max turns so that we evaluate the default when we visit the inputdialog. + var oldValue = inputActivity.MaxTurnCount; + inputActivity.MaxTurnCount = 1; + + //We need to set interrupted here or it will discard the continueconversation event... + DialogTurnResult result; + try + { + dc.State.SetValue(ITimeoutInput.SilenceDetected, true); + dc.State.SetValue(TurnPath.Interrupted, true); + result = await continueDialogAsync(dc, cancellationToken).ConfigureAwait(false); + } + finally + { + inputActivity.MaxTurnCount = oldValue; + } + + return result; + } + + //continue for any other events + + //stop any more incoming event for this activity + convState.timersState.ForceComplete(timerId); + + //check if it is user input + if (activity.Type == ActivityTypes.Message) + { + dc.Services.Get().TrackEvent("User Continue", new Dictionary + { + {"timerId", timerId} + }); + + //Begin dirty hack to start a timer for the reprompt + + //If our input was not valid, restart the timer. + dc.State.SetValue(valueProperty, dc.Context.Activity.Text); //OnRecognizeInput assumes this was already set earlier on in the dialog. We will set it and then unset it to simulate passing an argument to a function... :D + + if (await onRecognizeInputAsync(dc, cancellationToken).ConfigureAwait(false) != InputState.Valid) + { + var turnCount = dc.State.GetValue(turnCountProperty, () => 0); + if (turnCount < inputActivity.MaxTurnCount?.GetValue(dc.State)) + { + //We are cheating to force this recognition here. Maybe not good? + + //Known bug: Sometimes invalid input gets accepted anyway(due to max turns and defaulting rules), this will start a continuation for a thing it shouldn't. + //Sure do wish EndDialog was available to the adaptive stack. + + timerId = inputActivity.CreateTimerForConversation(dc, cancellationToken); + await convState.timersState.StartAsync(timerId).ConfigureAwait(false); + } + } + + //Clear our the input property after recognition since it will happen again later :D + dc.State.SetValue(valueProperty, null); + + + //End dirty hack + } + + return await continueDialogAsync(dc, cancellationToken).ConfigureAwait(false); + }, + () => + { + dc.Services.Get().TrackEvent("Stop the routine", new Dictionary + { + {"timerId", timerId} + }); + return Task.FromResult(Dialog.EndOfTurn); + }); + } + + public static string CreateTimerForConversation(this K inputActivity, DialogContext dc, CancellationToken cancellationToken) where K : InputDialog, ITimeoutInput + { + var timerId = Guid.NewGuid().ToString(); + dc.State.SetValue(TimerID, timerId); + BotAdapter adapter = dc.Context.Adapter; + var identity = dc.Context.TurnState.Get("BotIdentity"); + + var appId = identity?.Claims?.FirstOrDefault(c => c.Type == AuthenticationConstants.AudienceClaim)?.Value; + ConversationReference conversationReference = dc.Context.Activity.GetConversationReference(); + + dc.Services.Get().TrackEvent("Creating Timer", new Dictionary + { + {"timerId", timerId} + }); + + int timeout = inputActivity.TimeOutInMilliseconds.GetValue(dc.State); + + //Question remaining to be answered: Will this task get garbage collected? If so, we need to maintain a handle for it. + Task.Run(async () => + { + await Task.Delay(timeout).ConfigureAwait(false); + if (!conversationStateMatrix.TryGetValue(conversationReference.Conversation.Id, out var convState)) + return; + convState.triggeredTimers.Enqueue(timerId); + // If the channel is the Emulator, and authentication is not in use, + // the AppId will be null. We generate a random AppId for this case only. + // This is not required for production, since the AppId will have a value. + if (string.IsNullOrEmpty(appId)) + { + appId = Guid.NewGuid().ToString(); //if no AppId, use a random Guid + } + + + dc.Services.Get().TrackEvent("Timer Triggered", new Dictionary + { + {"timerId", timerId} + }); + await adapter.ContinueConversationAsync( + appId, + conversationReference, + BotWithLookup.OnTurn, //Leverage dirty hack to achieve Bot lookup from component + cancellationToken).ConfigureAwait(false); + }); + return timerId; + } + + public static void RemoveTimers(ITurnContext turnContext) + { + conversationStateMatrix.TryRemove(turnContext.Activity.Conversation.Id, out _); + } + } +} \ No newline at end of file diff --git a/packages/Telephony/Actions/TimeoutTextInput.cs b/packages/Telephony/Actions/TimeoutTextInput.cs new file mode 100644 index 0000000000..9c4c4f187f --- /dev/null +++ b/packages/Telephony/Actions/TimeoutTextInput.cs @@ -0,0 +1,54 @@ +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using AdaptiveExpressions.Properties; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Adapters; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Builder.Dialogs.Adaptive.Input; +using Microsoft.Bot.Components.Telephony.Common; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Newtonsoft.Json; + +namespace Microsoft.Bot.Components.Telephony.Actions +{ + public class TimeoutTextInput : TextInput, ITimeoutInput + { + [JsonProperty("$kind")] + public new const string Kind = "Microsoft.Telephony.TimeoutTextInput"; + + /// + /// Gets or sets a value indicating how long to wait for before timing out and using the default value. + /// + [JsonProperty("timeOutInMilliseconds")] + public IntExpression TimeOutInMilliseconds { get; set; } + + [JsonConstructor] + public TimeoutTextInput([CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + : base() + { + // enable instances of this command as debug break point + this.RegisterSourceLocation(sourceFilePath, sourceLineNumber); + } + + public async override Task BeginDialogAsync(DialogContext dc, object options = null, CancellationToken cancellationToken = default(CancellationToken)) + { + return await TimeoutInput.BeginDialogAsync(this, dc, + base.BeginDialogAsync, + options, cancellationToken); + } + + public override async Task ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken = default(CancellationToken)) + { + return await TimeoutInput.ContinueDialogAsync(this, dc, VALUE_PROPERTY, TURN_COUNT_PROPERTY, + OnRecognizeInputAsync, + base.ContinueDialogAsync, + cancellationToken); + } + + } +} \ No newline at end of file diff --git a/packages/Telephony/Common/IStateMatrix.cs b/packages/Telephony/Common/IStateMatrix.cs index da4a1359a9..783e90d42b 100644 --- a/packages/Telephony/Common/IStateMatrix.cs +++ b/packages/Telephony/Common/IStateMatrix.cs @@ -24,9 +24,10 @@ internal interface IStateMatrix Task GetStatusForIdAsync(string id); Task CompleteAsync(string id); + void ForceComplete(string id); Task StartAsync(string id); - Task RunForStatusAsync(string id, StateStatus status, Func action); + Task RunForStatusAsync(string id, StateStatus status, Func> matchedAction, Func> notMatchedAction); } } diff --git a/packages/Telephony/Common/LatchingStateMatrix.cs b/packages/Telephony/Common/LatchingStateMatrix.cs index 8d08045784..94335d5f5a 100644 --- a/packages/Telephony/Common/LatchingStateMatrix.cs +++ b/packages/Telephony/Common/LatchingStateMatrix.cs @@ -27,6 +27,19 @@ public async Task CompleteAsync(string id) } } + public void ForceComplete(string id) + { + (SemaphoreSlim semaphore, _) = perStateIndexLatching[id]; + try + { + perStateIndexLatching[id] = (semaphore, StateStatus.Completed); + } + finally + { + semaphore.Release(); + } + } + public async Task GetStatusForIdAsync(string id) { (SemaphoreSlim semaphore, _) = perStateIndexLatching[id]; @@ -44,7 +57,7 @@ public async Task GetStatusForIdAsync(string id) } } - public async Task RunForStatusAsync(string id, StateStatus status, Func action) + public async Task RunForStatusAsync(string id, StateStatus status, Func> matchedAction, Func> notMatchedAction) { (SemaphoreSlim semaphore, _) = perStateIndexLatching[id]; try @@ -54,9 +67,8 @@ public async Task RunForStatusAsync(string id, StateStatus status, Func ac //retrieve status again, since we got it wrong the first time XD (_, StateStatus updatedStatus) = perStateIndexLatching[id]; if (updatedStatus == status) - { - await action().ConfigureAwait(false); - } + return await matchedAction().ConfigureAwait(false); + else return await notMatchedAction().ConfigureAwait(false); } finally { diff --git a/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj b/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj index 39bb6cbcc3..c80e85c4d5 100644 --- a/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj +++ b/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj @@ -2,7 +2,7 @@ Library - netstandard2.0 + netstandard2.1 @@ -11,7 +11,7 @@ This library implements .NET support for adaptive dialogs with Telephony. This library implements .NET support for adaptive dialogs with Telephony. https://github.com/Microsoft/botframework-components/tree/main/packages/Telephony - true + False ..\..\build\35MSSharedLib1024.snk true content @@ -19,7 +19,8 @@ - + + all @@ -51,12 +52,20 @@ + + + + + + $(NoWarn),SA0001,SA1649 + False + False diff --git a/packages/Telephony/Middleware/SetSpeakMiddleware.cs b/packages/Telephony/Middleware/SetSpeakMiddleware.cs new file mode 100644 index 0000000000..72247547bb --- /dev/null +++ b/packages/Telephony/Middleware/SetSpeakMiddleware.cs @@ -0,0 +1,60 @@ +// --------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// --------------------------------------------------------------------------- + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Schema; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Bot.Components.Telephony.Middleware +{ + /// + /// The middleware that handles all outgoing messages + /// + public class SetSpeakMiddleware : IMiddleware + { + + public delegate void EventReceiverHandler(ITurnContext turnContext); + private Dictionary> _receivers; + + /// + /// Initializes a new SetSpeakMiddleware class + /// + public SetSpeakMiddleware() + { + _receivers = new Dictionary>(); + } + + /// + /// Handles the outgoing message + /// + /// The turn context + /// The next delegate + /// The cancellation token + /// + public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default) + { + if (!string.IsNullOrEmpty(turnContext.Activity?.Type) && _receivers.TryGetValue(turnContext.Activity.Type, out var receivers)) + receivers.ForEach(rc => rc.Invoke(turnContext)); + + await next(cancellationToken); + } + + public void addEventReceiver(string eventName, EventReceiverHandler handler) + { + if (!_receivers.ContainsKey(eventName)) _receivers.Add(eventName, new List()); + _receivers[eventName].Add(handler); + } + + public void removeEventReceiver(string eventName, EventReceiverHandler handler) + { + if (!_receivers.ContainsKey(eventName)) return; + _receivers[eventName].Remove(handler); + } + + } +} diff --git a/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.schema b/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.schema new file mode 100644 index 0000000000..d7cb4e3966 --- /dev/null +++ b/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.schema @@ -0,0 +1,50 @@ +{ + "$schema": "https://schemas.botframework.com/schemas/component/v1.0/component.schema", + "$role": [ "implements(Microsoft.IDialog)", "extends(Microsoft.InputDialog)" ], + "type": "object", + "title": "(Preview)1 text input dialog with silence detection", + "description": "getting arbitrary text and detecting silence in case of no entry", + "properties": { + "timeOutInMilliseconds": { + "$ref": "schema:#/definitions/integerExpression", + "title": "Timeout in milliseconds", + "description": "After the specified amount of milliseconds the dialog will complete with its default value if the user doesn't respond.", + "examples": [ + "10", + "=conversation.xyz" + ] + }, + "defaultValue": { + "$ref": "schema:#/definitions/stringExpression", + "title": "Default value", + "description": "'Property' will be set to the value of this expression when max turn count is exceeded.", + "examples": [ + "hello world", + "Hello ${user.name}", + "=concat(user.firstname, user.lastName)" + ] + }, + "value": { + "$ref": "schema:#/definitions/stringExpression", + "title": "Value", + "description": "'Property' will be set to the value of this expression unless it evaluates to null.", + "examples": [ + "hello world", + "Hello ${user.name}", + "=concat(user.firstname, user.lastName)" + ] + }, + "outputFormat": { + "$ref": "schema:#/definitions/stringExpression", + "title": "Output format", + "description": "Expression to format the output.", + "examples": [ + "=toUpper(this.value)", + "${toUpper(this.value)}" + ] + } + }, + "$policies": { + "interactive": true + } +} diff --git a/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.uischema b/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.uischema new file mode 100644 index 0000000000..8f7965aa25 --- /dev/null +++ b/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.uischema @@ -0,0 +1,63 @@ +{ + "$schema": "https://schemas.botframework.com/schemas/ui/v1.0/ui.schema", + "form": { + "label": "Prompt for text", + "subtitle": "Text Input", + "helpLink": "https://aka.ms/bfc-ask-for-user-input", + "order": [ + "prompt", + "timeOutInMilliseconds", + "*" + ] + "properties": { + "property": { + "intellisenseScopes": [ + "variable-scopes" + ] + } + } + }, + "menu": { + "label": "Text input | Preview", + "submenu": [ "Silence Detection" ] + }, + "flow": { + "widget": "PromptWidget", + "body": "=action.prompt", + "nowrap": true, + "botAsks": { + "widget": "ActionCard", + "header": { + "widget": "ActionHeader", + "icon": "MessageBot", + "colors": { + "theme": "#EEEAF4", + "icon": "#5C2E91" + } + }, + "body": { + "widget": "LgWidget", + "field": "prompt", + "defaultContent": "" + } + }, + "userInput": { + "widget": "ActionCard", + "header": { + "widget": "ActionHeader", + "disableSDKTitle": true, + "icon": "User", + "menu": "none", + "colors": { + "theme": "#E5F0FF", + "icon": "#0078D4" + } + }, + "body": { + "widget": "LgWidget", + "field": "prompt", + "defaultContent": "" + } + } + } +} diff --git a/packages/Telephony/TelephonyBotComponent.cs b/packages/Telephony/TelephonyBotComponent.cs index ab9cf9e4be..22cf405268 100644 --- a/packages/Telephony/TelephonyBotComponent.cs +++ b/packages/Telephony/TelephonyBotComponent.cs @@ -6,6 +6,7 @@ namespace Microsoft.Bot.Components.Telephony using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs.Declarative; using Microsoft.Bot.Components.Telephony.Actions; + using Microsoft.Bot.Schema; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -18,6 +19,10 @@ public class TelephonyBotComponent : BotComponent public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) { // Conditionals + var middleware = new Middleware.SetSpeakMiddleware(); + services.AddSingleton(middleware); + middleware.addEventReceiver(ActivityTypes.EndOfConversation, TimeoutInput.RemoveTimers); + services.AddSingleton(sp => new DeclarativeType(CallTransfer.Kind)); services.AddSingleton(sp => new DeclarativeType(PauseRecording.Kind)); services.AddSingleton(sp => new DeclarativeType(ResumeRecording.Kind)); @@ -26,6 +31,7 @@ public override void ConfigureServices(IServiceCollection services, IConfigurati services.AddSingleton(sp => new DeclarativeType(BatchTerminationCharacterInput.Kind)); services.AddSingleton(sp => new DeclarativeType(BatchRegexInput.Kind)); services.AddSingleton(sp => new DeclarativeType(TimeoutChoiceInput.Kind)); + services.AddSingleton(sp => new DeclarativeType(TimeoutTextInput.Kind)); services.AddSingleton(sp => new DeclarativeType(SerialNumberInput.Kind)); } } From d8c0e047011513abeecd4bfd0d10856718c2f25f Mon Sep 17 00:00:00 2001 From: Hossein Karimy Date: Tue, 17 May 2022 12:29:45 -0400 Subject: [PATCH 2/5] Removing unused codes --- packages/Telephony/Actions/ITimeoutInput.cs | 24 +--- packages/Telephony/Actions/TimeoutInput.cs | 130 ++++++++++---------- 2 files changed, 68 insertions(+), 86 deletions(-) diff --git a/packages/Telephony/Actions/ITimeoutInput.cs b/packages/Telephony/Actions/ITimeoutInput.cs index 1126f8f30b..ca5ba1abc8 100644 --- a/packages/Telephony/Actions/ITimeoutInput.cs +++ b/packages/Telephony/Actions/ITimeoutInput.cs @@ -8,19 +8,8 @@ namespace Microsoft.Bot.Components.Telephony.Actions { public interface ITimeoutInput { - /// - /// Defines dialog context turn count property value. - /// - const string NoMatchCount= "this.noMatchCount"; - - /// - /// Defines dialog context turn count property value. - /// - const string NoInputCount = "this.noInputCount"; - - /// + // Summary: // Defines dialog context state property value. - /// const string SilenceDetected = "dialog.silenceDetected"; /// @@ -28,16 +17,5 @@ public interface ITimeoutInput /// [JsonProperty("timeOutInMilliseconds")] IntExpression TimeOutInMilliseconds { get; set; } - - /// - /// Gets or sets a value indicating how many times should retry in case of the input didn't match - /// - [JsonProperty("maxNoMatchCount")] - IntExpression MaxNoMatchCount{ get; set; } - /// - /// Gets or sets a value indicating how many times should retry in case of no input provided - /// - [JsonProperty("maxNoInputCount")] - IntExpression MaxNoInputCount{ get; set; } } } diff --git a/packages/Telephony/Actions/TimeoutInput.cs b/packages/Telephony/Actions/TimeoutInput.cs index 935bf4835c..fd90fe13fc 100644 --- a/packages/Telephony/Actions/TimeoutInput.cs +++ b/packages/Telephony/Actions/TimeoutInput.cs @@ -74,99 +74,103 @@ public static async Task ContinueDialogAsync(K inputActivit return Dialog.EndOfTurn; + //if we aren't already complete, go ahead and timeout return await convState.timersState.RunForStatusAsync(timerId, StateStatus.Running, async () => { - var activity = dc.Context.Activity; + //-----------------------------Body--------------- + var activity = dc.Context.Activity; - //we have to manage our timer somehow. - //For starters, complete our existing timer. + //we have to manage our timer somehow. + //For starters, complete our existing timer. - //Handle case where we timed out - var interrupted = dc.State.GetValue(TurnPath.Interrupted, () => false); - if (!interrupted && activity.Type != ActivityTypes.Message && activity.Name == ActivityEventNames.ContinueConversation) + //Handle case where we timed out + var interrupted = dc.State.GetValue(TurnPath.Interrupted, () => false); + if (!interrupted && activity.Type != ActivityTypes.Message && activity.Name == ActivityEventNames.ContinueConversation) + { + string calledTimerId = ""; + //if there is no matched conversation (could be removed by endOfConversations, or there is no called timer (shouldn't happen) or + //the last called Timer is not the same as activity's timer, then don't continue + if (!convState.triggeredTimers.TryDequeue(out calledTimerId) || calledTimerId != timerId) { - string calledTimerId = ""; - //if there is no matched conversation (could be removed by endOfConversations, or there is no called timer (shouldn't happen) or - //the last called Timer is not the same as activity's timer, then don't continue - if (!convState.triggeredTimers.TryDequeue(out calledTimerId) || calledTimerId != timerId) + dc.Services.Get().TrackEvent("Abort Timer routine", new Dictionary { - dc.Services.Get().TrackEvent("Abort Timer routine", - new Dictionary { { "timerId", calledTimerId } }); - return Dialog.EndOfTurn; - } - + {"timerId", calledTimerId} + }); + return Dialog.EndOfTurn; + } - // dc.Services.Get().TrackEvent("Continue Timer", new Dictionary - //{ - // {"timerId", timerId} - //}); - //stop any more incoming event for this activity - convState.timersState.ForceComplete(timerId); + dc.Services.Get().TrackEvent("Continue Timer", new Dictionary + { + {"timerId", timerId} + }); - //Set max turns so that we evaluate the default when we visit the inputdialog. - var oldValue = inputActivity.MaxTurnCount; - inputActivity.MaxTurnCount = 1; + //stop any more incoming event for this activity + convState.timersState.ForceComplete(timerId); - //We need to set interrupted here or it will discard the continueconversation event... - DialogTurnResult result; - try - { - dc.State.SetValue(ITimeoutInput.SilenceDetected, true); - dc.State.SetValue(TurnPath.Interrupted, true); - result = await continueDialogAsync(dc, cancellationToken).ConfigureAwait(false); - } - finally - { - inputActivity.MaxTurnCount = oldValue; - } + //Set max turns so that we evaluate the default when we visit the inputdialog. + var oldValue = inputActivity.MaxTurnCount; + inputActivity.MaxTurnCount = 1; - return result; + //We need to set interrupted here or it will discard the continueconversation event... + DialogTurnResult result; + try + { + dc.State.SetValue(ITimeoutInput.SilenceDetected, true); + dc.State.SetValue(TurnPath.Interrupted, true); + result = await continueDialogAsync(dc, cancellationToken).ConfigureAwait(false); + } + finally + { + inputActivity.MaxTurnCount = oldValue; } - //continue for any other events + return result; + } - //stop any more incoming event for this activity - convState.timersState.ForceComplete(timerId); + //continue for any other events - //check if it is user input - if (activity.Type == ActivityTypes.Message) - { - dc.Services.Get().TrackEvent("User Continue", new Dictionary + //stop any more incoming event for this activity + convState.timersState.ForceComplete(timerId); + + //check if it is user input + if (activity.Type == ActivityTypes.Message) + { + dc.Services.Get().TrackEvent("User Continue", new Dictionary { {"timerId", timerId} }); - //Begin dirty hack to start a timer for the reprompt + //Begin dirty hack to start a timer for the reprompt - //If our input was not valid, restart the timer. - dc.State.SetValue(valueProperty, dc.Context.Activity.Text); //OnRecognizeInput assumes this was already set earlier on in the dialog. We will set it and then unset it to simulate passing an argument to a function... :D + //If our input was not valid, restart the timer. + dc.State.SetValue(valueProperty, dc.Context.Activity.Text); //OnRecognizeInput assumes this was already set earlier on in the dialog. We will set it and then unset it to simulate passing an argument to a function... :D - if (await onRecognizeInputAsync(dc, cancellationToken).ConfigureAwait(false) != InputState.Valid) + if (await onRecognizeInputAsync(dc, cancellationToken).ConfigureAwait(false) != InputState.Valid) + { + var turnCount = dc.State.GetValue(turnCountProperty, () => 0); + if (turnCount < inputActivity.MaxTurnCount?.GetValue(dc.State)) { - var turnCount = dc.State.GetValue(turnCountProperty, () => 0); - if (turnCount < inputActivity.MaxTurnCount?.GetValue(dc.State)) - { - //We are cheating to force this recognition here. Maybe not good? + //We are cheating to force this recognition here. Maybe not good? - //Known bug: Sometimes invalid input gets accepted anyway(due to max turns and defaulting rules), this will start a continuation for a thing it shouldn't. - //Sure do wish EndDialog was available to the adaptive stack. + //Known bug: Sometimes invalid input gets accepted anyway(due to max turns and defaulting rules), this will start a continuation for a thing it shouldn't. + //Sure do wish EndDialog was available to the adaptive stack. - timerId = inputActivity.CreateTimerForConversation(dc, cancellationToken); - await convState.timersState.StartAsync(timerId).ConfigureAwait(false); - } + timerId = inputActivity.CreateTimerForConversation(dc, cancellationToken); + await convState.timersState.StartAsync(timerId).ConfigureAwait(false); } + } - //Clear our the input property after recognition since it will happen again later :D - dc.State.SetValue(valueProperty, null); + //Clear our the input property after recognition since it will happen again later :D + dc.State.SetValue(valueProperty, null); - //End dirty hack - } + //End dirty hack + } - return await continueDialogAsync(dc, cancellationToken).ConfigureAwait(false); - }, + return await continueDialogAsync(dc, cancellationToken).ConfigureAwait(false); + }, () => { dc.Services.Get().TrackEvent("Stop the routine", new Dictionary From 2d8e6f0366d7690dd64f81d4d295af486bb312a5 Mon Sep 17 00:00:00 2001 From: Hossein Karimy Date: Tue, 17 May 2022 13:39:56 -0400 Subject: [PATCH 3/5] update to bot builder 4.16 --- .../Telephony/Microsoft.Bot.Components.Telephony.csproj | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj b/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj index c80e85c4d5..57964c4ea4 100644 --- a/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj +++ b/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj @@ -20,7 +20,7 @@ - + all @@ -56,10 +56,6 @@ - - - - From 2eee3e87fcc897c6938a7a3f9eeff94886d5930f Mon Sep 17 00:00:00 2001 From: Hossein Karimy Date: Tue, 17 May 2022 13:47:39 -0400 Subject: [PATCH 4/5] undo some unwanted changes --- packages/Telephony/Microsoft.Bot.Components.Telephony.csproj | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj b/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj index b1160ad3f5..3b3cc6888c 100644 --- a/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj +++ b/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj @@ -11,7 +11,7 @@ This library implements .NET support for adaptive dialogs with Telephony. This library implements .NET support for adaptive dialogs with Telephony. https://github.com/Microsoft/botframework-components/tree/main/packages/Telephony - False + true ..\..\build\35MSSharedLib1024.snk true content @@ -60,8 +60,6 @@ $(NoWarn),SA0001,SA1649 - False - False From 087a9d7f8d4daf9f8a04aed8efbed9a22275260b Mon Sep 17 00:00:00 2001 From: Hossein Karimy Date: Thu, 19 May 2022 11:40:07 -0400 Subject: [PATCH 5/5] Changes based on PR comments --- packages/Telephony/Actions/TimeoutInput.cs | 8 ++------ .../Telephony/Microsoft.Bot.Components.Telephony.csproj | 6 ++++-- ...SetSpeakMiddleware.cs => ContextReceiverMiddleware.cs} | 4 ++-- .../Schemas/Microsoft.Telephony.TimeoutChoiceInput.schema | 2 +- .../Schemas/Microsoft.Telephony.TimeoutTextInput.schema | 4 ++-- packages/Telephony/TelephonyBotComponent.cs | 2 +- 6 files changed, 12 insertions(+), 14 deletions(-) rename packages/Telephony/Middleware/{SetSpeakMiddleware.cs => ContextReceiverMiddleware.cs} (95%) diff --git a/packages/Telephony/Actions/TimeoutInput.cs b/packages/Telephony/Actions/TimeoutInput.cs index fd90fe13fc..dccf3f7482 100644 --- a/packages/Telephony/Actions/TimeoutInput.cs +++ b/packages/Telephony/Actions/TimeoutInput.cs @@ -28,9 +28,9 @@ public static class TimeoutInput private static ConcurrentDictionary triggeredTimers, IStateMatrix timersState)> conversationStateMatrix = new ConcurrentDictionary triggeredTimers, IStateMatrix timersState)>(); - public static async Task BeginDialogAsync(K inputActivity, DialogContext dc, + public static async Task BeginDialogAsync(T inputActivity, DialogContext dc, Func> baseClassCall, - object options = null, CancellationToken cancellationToken = default(CancellationToken)) where K : InputDialog, ITimeoutInput + object options = null, CancellationToken cancellationToken = default(CancellationToken)) where T : InputDialog, ITimeoutInput { if (options is CancellationToken) { @@ -80,10 +80,6 @@ public static async Task ContinueDialogAsync(K inputActivit //-----------------------------Body--------------- var activity = dc.Context.Activity; - //we have to manage our timer somehow. - //For starters, complete our existing timer. - - //Handle case where we timed out var interrupted = dc.State.GetValue(TurnPath.Interrupted, () => false); if (!interrupted && activity.Type != ActivityTypes.Message && activity.Name == ActivityEventNames.ContinueConversation) diff --git a/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj b/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj index 3b3cc6888c..506fd2d509 100644 --- a/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj +++ b/packages/Telephony/Microsoft.Bot.Components.Telephony.csproj @@ -52,14 +52,16 @@ - - + + $(NoWarn),SA0001,SA1649 + False + False diff --git a/packages/Telephony/Middleware/SetSpeakMiddleware.cs b/packages/Telephony/Middleware/ContextReceiverMiddleware.cs similarity index 95% rename from packages/Telephony/Middleware/SetSpeakMiddleware.cs rename to packages/Telephony/Middleware/ContextReceiverMiddleware.cs index 72247547bb..6f1b59b611 100644 --- a/packages/Telephony/Middleware/SetSpeakMiddleware.cs +++ b/packages/Telephony/Middleware/ContextReceiverMiddleware.cs @@ -15,7 +15,7 @@ namespace Microsoft.Bot.Components.Telephony.Middleware /// /// The middleware that handles all outgoing messages /// - public class SetSpeakMiddleware : IMiddleware + public class ContextReceiverMiddleware : IMiddleware { public delegate void EventReceiverHandler(ITurnContext turnContext); @@ -24,7 +24,7 @@ public class SetSpeakMiddleware : IMiddleware /// /// Initializes a new SetSpeakMiddleware class /// - public SetSpeakMiddleware() + public ContextReceiverMiddleware() { _receivers = new Dictionary>(); } diff --git a/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutChoiceInput.schema b/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutChoiceInput.schema index 9db6c1e4a9..8e91a522fe 100644 --- a/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutChoiceInput.schema +++ b/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutChoiceInput.schema @@ -1,7 +1,7 @@ { "$schema": "https://schemas.botframework.com/schemas/component/v1.0/component.schema", "$role": [ "implements(Microsoft.IDialog)", "extends(Microsoft.InputDialog)" ], - "title": "(Preview) Choice input dialog with silence detection", + "title": "Choice input dialog with silence detection", "description": "Collect information - Pick from a list of choices", "type": "object", "properties": { diff --git a/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.schema b/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.schema index d7cb4e3966..8515dd7d48 100644 --- a/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.schema +++ b/packages/Telephony/Schemas/Microsoft.Telephony.TimeoutTextInput.schema @@ -2,8 +2,8 @@ "$schema": "https://schemas.botframework.com/schemas/component/v1.0/component.schema", "$role": [ "implements(Microsoft.IDialog)", "extends(Microsoft.InputDialog)" ], "type": "object", - "title": "(Preview)1 text input dialog with silence detection", - "description": "getting arbitrary text and detecting silence in case of no entry", + "title": "Text input dialog with silence detection", + "description": "Collection information - Ask for a word or sentence.", "properties": { "timeOutInMilliseconds": { "$ref": "schema:#/definitions/integerExpression", diff --git a/packages/Telephony/TelephonyBotComponent.cs b/packages/Telephony/TelephonyBotComponent.cs index 22cf405268..0020fb8bcd 100644 --- a/packages/Telephony/TelephonyBotComponent.cs +++ b/packages/Telephony/TelephonyBotComponent.cs @@ -19,7 +19,7 @@ public class TelephonyBotComponent : BotComponent public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) { // Conditionals - var middleware = new Middleware.SetSpeakMiddleware(); + var middleware = new Middleware.ContextReceiverMiddleware(); services.AddSingleton(middleware); middleware.addEventReceiver(ActivityTypes.EndOfConversation, TimeoutInput.RemoveTimers);