diff --git a/packages/Telephony/Actions/BatchFixedLengthInput.cs b/packages/Telephony/Actions/BatchFixedLengthInput.cs index 4f29fa889b..ba96682769 100644 --- a/packages/Telephony/Actions/BatchFixedLengthInput.cs +++ b/packages/Telephony/Actions/BatchFixedLengthInput.cs @@ -66,16 +66,23 @@ public int BatchLength } /// - public override Task ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken = default) + public async override Task ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken = default) { if ((dc.Context.Activity.Type == ActivityTypes.Message) && (Regex.Match(dc.Context.Activity.Text, _dtmfCharacterRegex).Success || dc.State.GetValue(TurnPath.Interrupted, () => false))) { - return base.ContinueDialogAsync(dc, cancellationToken); + return await base.ContinueDialogAsync(dc, cancellationToken).ConfigureAwait(false); } else { - return Task.FromResult(new DialogTurnResult(DialogTurnStatus.Waiting)); + if (dc.Context.Activity.Name == ActivityEventNames.ContinueConversation) + { + return await EndDialogAsync(dc, cancellationToken).ConfigureAwait(false); + } + else + { + return new DialogTurnResult(DialogTurnStatus.Waiting); + } } } } diff --git a/packages/Telephony/Actions/BatchRegexInput.cs b/packages/Telephony/Actions/BatchRegexInput.cs index 138243d3da..57401a42a3 100644 --- a/packages/Telephony/Actions/BatchRegexInput.cs +++ b/packages/Telephony/Actions/BatchRegexInput.cs @@ -4,12 +4,15 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using System.Security.Claims; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using AdaptiveExpressions.Properties; +using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.Dialogs.Adaptive; +using Microsoft.Bot.Components.Telephony.Common; using Microsoft.Bot.Schema; using Newtonsoft.Json; @@ -23,6 +26,8 @@ public class BatchRegexInput : Dialog [JsonProperty("$kind")] public const string Kind = "Microsoft.Telephony.BatchRegexInput"; protected const string AggregationDialogMemory = "this.aggregation"; + private const string TimerId = "this.TimerId"; + private static IStateMatrix stateMatrix = new LatchingStateMatrix(); /// /// Initializes a new instance of the class. @@ -85,9 +90,36 @@ public BatchRegexInput([CallerFilePath] string sourceFilePath = "", [CallerLineN [JsonProperty("interruptionMask")] public StringExpression InterruptionMask { get; set; } + /// + /// 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; } + + /// + /// Gets or sets the default value for the input dialog when a Timeout is reached. + /// + /// + /// Value or expression which evaluates to a value. + /// + [JsonProperty("defaultValue")] + public ValueExpression DefaultValue { get; set; } + + /// + /// Gets or sets the activity template to send when a Timeout is reached and the default value is used. + /// + /// + /// An activity template. + /// + [JsonProperty("defaultValueResponse")] + public ITemplate DefaultValueResponse { get; set; } + /// public override async Task BeginDialogAsync(DialogContext dc, object options = null, CancellationToken cancellationToken = default(CancellationToken)) { + //start a timer that will continue this conversation + await InitTimeoutTimerAsync(dc, cancellationToken).ConfigureAwait(false); + return await PromptUserAsync(dc, cancellationToken).ConfigureAwait(false); } @@ -120,6 +152,18 @@ public override async Task ContinueDialogAsync(DialogContext d } else { + //If we didn't timeout then we have to manage our timer somehow. + //For starters, complete our existing timer. + string timerId = dc.State.GetValue(TimerId); + + if (timerId != null) + { + await stateMatrix.CompleteAsync(timerId).ConfigureAwait(false); + + // Restart the timeout timer + await InitTimeoutTimerAsync(dc, cancellationToken).ConfigureAwait(false); + } + //else, save the updated aggregation and end the turn dc.State.SetValue(AggregationDialogMemory, existingAggregation); return new DialogTurnResult(DialogTurnStatus.Waiting); @@ -158,6 +202,30 @@ protected override async Task OnPreBubbleEventAsync(DialogContext dc, Dial return false; } + protected async Task EndDialogAsync(DialogContext dc, CancellationToken cancellationToken) + { + // Set the default value to the output property and send the default value response to the user + if (this.DefaultValue != null) + { + var (value, error) = this.DefaultValue.TryGetValue(dc.State); + if (this.DefaultValueResponse != null) + { + var response = await this.DefaultValueResponse.BindAsync(dc, cancellationToken: cancellationToken).ConfigureAwait(false); + if (response != null) + { + await dc.Context.SendActivityAsync(response, cancellationToken).ConfigureAwait(false); + } + } + + // Set output property + dc.State.SetValue(this.Property.GetValue(dc.State), value); + + return await dc.EndDialogAsync(value, cancellationToken).ConfigureAwait(false); + } + + return await dc.EndDialogAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + } + private async Task PromptUserAsync(DialogContext dc, CancellationToken cancellationToken = default(CancellationToken)) { //Do we already have a value stored? This would happen in the interruption case, a case in which we are looping over ourselves, or maybe we had a fatal error and had to restart the dialog tree @@ -198,5 +266,43 @@ protected override async Task OnPreBubbleEventAsync(DialogContext dc, Dial return new DialogTurnResult(DialogTurnStatus.Waiting); } + + private void CreateTimerForConversation(DialogContext dc, int timeout, string timerId, CancellationToken cancellationToken) + { + var adapter = dc.Context.Adapter; + var conversationReference = dc.Context.Activity.GetConversationReference(); + var identity = dc.Context.TurnState.Get("BotIdentity"); + var audience = dc.Context.TurnState.Get(BotAdapter.OAuthScopeKey); + + //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 we aren't already complete, go ahead and timeout + await stateMatrix.RunForStatusAsync(timerId, StateStatus.Running, async () => + { + await adapter.ContinueConversationAsync( + identity, + conversationReference, + audience, + BotWithLookup.OnTurn, //Leverage dirty hack to achieve Bot lookup from component + cancellationToken).ConfigureAwait(false); + }).ConfigureAwait(false); + }); + } + + private async Task InitTimeoutTimerAsync(DialogContext dc, CancellationToken cancellationToken) + { + var timeout = this.TimeOutInMilliseconds?.GetValue(dc.State) ?? 0; + + if (timeout > 0) + { + var timerId = Guid.NewGuid().ToString(); + CreateTimerForConversation(dc, timeout, timerId, cancellationToken); + await stateMatrix.StartAsync(timerId).ConfigureAwait(false); + dc.State.SetValue(TimerId, timerId); + } + } } } \ No newline at end of file diff --git a/packages/Telephony/Actions/BatchTerminationCharacterInput.cs b/packages/Telephony/Actions/BatchTerminationCharacterInput.cs index 559508cc6f..877617923f 100644 --- a/packages/Telephony/Actions/BatchTerminationCharacterInput.cs +++ b/packages/Telephony/Actions/BatchTerminationCharacterInput.cs @@ -60,16 +60,23 @@ public string TerminationCharacter } /// - public override Task ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken = default) + public override async Task ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken = default) { - if ((dc.Context.Activity.Type == ActivityTypes.Message) && - (Regex.Match(dc.Context.Activity.Text, _dtmfCharacterRegex).Success || dc.State.GetValue(TurnPath.Interrupted, () => false))) + if ((dc.Context.Activity.Type == ActivityTypes.Message) && + (Regex.Match(dc.Context.Activity.Text, _dtmfCharacterRegex).Success || dc.State.GetValue(TurnPath.Interrupted, () => false))) { - return base.ContinueDialogAsync(dc, cancellationToken); + return await base.ContinueDialogAsync(dc, cancellationToken).ConfigureAwait(false); } else { - return Task.FromResult(new DialogTurnResult(DialogTurnStatus.Waiting)); + if (dc.Context.Activity.Name == ActivityEventNames.ContinueConversation) + { + return await EndDialogAsync(dc, cancellationToken).ConfigureAwait(false); + } + else + { + return new DialogTurnResult(DialogTurnStatus.Waiting); + } } } } diff --git a/packages/Telephony/Readme.md b/packages/Telephony/Readme.md index bdb78892b8..01b3024e24 100644 --- a/packages/Telephony/Readme.md +++ b/packages/Telephony/Readme.md @@ -120,6 +120,8 @@ The Stop Recording action stops recording of the conversation. Note that it is n ## **Aggregate DTMF Input (n)** Prompts the user for multiple inputs that are aggregated until a specified character length is met or exceeded. Speech, DTMF inputs, and chat provided characters can all be used to provide input, but any inputs that aren't the characters 1,2,3,4,5,6,7,8,9,0,#,*, or some combination of said characters are dropped. +When the Timeout parameter is set an integer greater than 0, a timer will be set whenever the Aggreate DTMF Input(n) node begins. This timer will be reset whenever the user responds to the bot, until the expected batch length is met. +In case the timeout is reached, the dialog will end and if the Default Value is set, its value will be assigned to the Property field. Also, a response can be sent to the user using the Default value Response field. #### Parameters * Batch Length @@ -127,16 +129,18 @@ Speech, DTMF inputs, and chat provided characters can all be used to provide inp * Prompt * AllowInterruptions * AlwaysPrompt +* Timeout +* Default Value +* Default Value Response #### Usage * After started, each input the user sends will be appended to the last message until the user provides a number of characters equal to or greater than the batch length. #### Dialog Flow -* The dialog will only end and continue to the next dialog when the batch length is reached. +* The dialog will only end and continue to the next dialog when the batch length is reached or the timeout is reached. * If AllowInterruptions is true, the parent dialog will receive non-digit input and can handle it as an intent. * After the interruption is handled, control flow will resume with this dialog. If AlwaysPrompt is set to true, the dialog will attempt to start over, otherwise it will end this dialog without setting the output property. -* Best practice recommendation when using interruptions is to validate that the output property has been set and handle the case in which it is and isn't set.' - +* Best practice recommendation when using interruptions is to validate that the output property has been set and handle the case in which it is and isn't set. #### Failures * In the event that an exception occurs within the dialog, the dialog will end and the normal exception flow can be followed. @@ -145,6 +149,8 @@ Speech, DTMF inputs, and chat provided characters can all be used to provide inp ## **Aggregate DTMF Input (#)** Prompts the user for multiple inputs that are aggregated until the termination string is received. Speech, DTMF inputs, and chat provided characters can all be used to provide input, but any inputs that aren't the characters 1,2,3,4,5,6,7,8,9,0,#,*, or some combination of said characters are dropped. +When the Timeout parameter is set an integer greater than 0, a timer will be set whenever the Aggreate DTMF Input(n) node begins. This timer will be reset whenever the user responds to the bot, until the expected batch length is met. +In case the timeout is reached, the dialog will end and if the Default Value is set, its value will be assigned to the Property field. Also, a response can be sent to the user using the Default value Response field. #### Parameters * Termination Character @@ -152,15 +158,18 @@ Speech, DTMF inputs, and chat provided characters can all be used to provide inp * Prompt * AllowInterruptions * AlwaysPrompt +* Timeout +* Default Value +* Default Value Response #### Usage * After started, each input the user sends will be appended to the last message until the user sends the provided termination character #### Dialog Flow -* The dialog will only end and continue to the next dialog when the termination character is sent. +* The dialog will only end and continue to the next dialog when the termination character is sent or the timeout is reached. * If AllowInterruptions is true, the parent dialog will receive non-digit input and can handle it as an intent. * After the interruption is handled, control flow will resume with this dialog. If AlwaysPrompt is set to true, the dialog will attempt to start over, otherwise it will end this dialog without setting the output property. -* Best practice recommendation when using interruptions is to validate that the output property has been set and handle the case in which it is and isn't set.' +* Best practice recommendation when using interruptions is to validate that the output property has been set and handle the case in which it is and isn't set. #### Failures * In the event that an exception occurs within the dialog, the dialog will end and the normal exception flow can be followed. diff --git a/packages/Telephony/Schemas/Microsoft.Telephony.BatchFixedLengthInput.schema b/packages/Telephony/Schemas/Microsoft.Telephony.BatchFixedLengthInput.schema index a591bfc4f8..f72b5af1ab 100644 --- a/packages/Telephony/Schemas/Microsoft.Telephony.BatchFixedLengthInput.schema +++ b/packages/Telephony/Schemas/Microsoft.Telephony.BatchFixedLengthInput.schema @@ -49,6 +49,30 @@ "$ref": "schema:#/definitions/booleanExpression", "title": "Always Prompt", "description": "When true, if batch is interrupted, it will attempt to restart the batch rather than abandon it." + }, + "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 a timeout is reached.", + "examples": [ + "hello world", + "Hello ${user.name}", + "=concat(user.firstname, user.lastName)" + ] + }, + "defaultValueResponse": { + "$kind": "Microsoft.IActivityTemplate", + "title": "Default value response", + "description": "Message to send when a Timeout has been reached and the default value is selected as the value." } }, "$policies": { diff --git a/packages/Telephony/Schemas/Microsoft.Telephony.BatchFixedLengthInput.uischema b/packages/Telephony/Schemas/Microsoft.Telephony.BatchFixedLengthInput.uischema index a329253eac..1f81cf8dd6 100644 --- a/packages/Telephony/Schemas/Microsoft.Telephony.BatchFixedLengthInput.uischema +++ b/packages/Telephony/Schemas/Microsoft.Telephony.BatchFixedLengthInput.uischema @@ -9,6 +9,9 @@ "property", "allowInterruptions", "alwaysPrompt", + "timeOutInMilliseconds", + "defaultValue", + "defaultValueResponse", "*" ], "properties": { diff --git a/packages/Telephony/Schemas/Microsoft.Telephony.BatchRegexInput.schema b/packages/Telephony/Schemas/Microsoft.Telephony.BatchRegexInput.schema index 3fdb84df79..92433c066a 100644 --- a/packages/Telephony/Schemas/Microsoft.Telephony.BatchRegexInput.schema +++ b/packages/Telephony/Schemas/Microsoft.Telephony.BatchRegexInput.schema @@ -57,6 +57,30 @@ "$ref": "schema:#/definitions/booleanExpression", "title": "Always Prompt", "description": "When true, if batch is interrupted, it will attempt to restart the batch rather than abandon it." + }, + "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 a timeout is reached.", + "examples": [ + "hello world", + "Hello ${user.name}", + "=concat(user.firstname, user.lastName)" + ] + }, + "defaultValueResponse": { + "$kind": "Microsoft.IActivityTemplate", + "title": "Default value response", + "description": "Message to send when a Timeout has been reached and the default value is selected as the value." } }, "$policies": { diff --git a/packages/Telephony/Schemas/Microsoft.Telephony.BatchRegexInput.uischema b/packages/Telephony/Schemas/Microsoft.Telephony.BatchRegexInput.uischema index f3a8ddf0c2..02bc872bf4 100644 --- a/packages/Telephony/Schemas/Microsoft.Telephony.BatchRegexInput.uischema +++ b/packages/Telephony/Schemas/Microsoft.Telephony.BatchRegexInput.uischema @@ -9,6 +9,9 @@ "property", "allowInterruptions", "alwaysPrompt", + "timeOutInMilliseconds", + "defaultValue", + "defaultValueResponse", "*" ], "properties": { @@ -36,6 +39,21 @@ "intellisenseScopes": [ "variable-scopes" ] + }, + "timeOutInMilliseconds": { + "intellisenseScopes": [ + "variable-scopes" + ] + }, + "defaultValue": { + "intellisenseScopes": [ + "variable-scopes" + ] + }, + "defaultValueResponse": { + "intellisenseScopes": [ + "variable-scopes" + ] } } }, diff --git a/packages/Telephony/Schemas/Microsoft.Telephony.BatchTerminationCharacterInput.schema b/packages/Telephony/Schemas/Microsoft.Telephony.BatchTerminationCharacterInput.schema index 62c89f2f1b..0151b636a1 100644 --- a/packages/Telephony/Schemas/Microsoft.Telephony.BatchTerminationCharacterInput.schema +++ b/packages/Telephony/Schemas/Microsoft.Telephony.BatchTerminationCharacterInput.schema @@ -49,6 +49,30 @@ "$ref": "schema:#/definitions/booleanExpression", "title": "Always Prompt", "description": "When true, if batch is interrupted, it will attempt to restart the batch rather than abandon it." + }, + "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 a timeout is reached.", + "examples": [ + "hello world", + "Hello ${user.name}", + "=concat(user.firstname, user.lastName)" + ] + }, + "defaultValueResponse": { + "$kind": "Microsoft.IActivityTemplate", + "title": "Default value response", + "description": "Message to send when a Timeout has been reached and the default value is selected as the value." } },"$policies": { "interactive": true diff --git a/packages/Telephony/Schemas/Microsoft.Telephony.BatchTerminationCharacterInput.uischema b/packages/Telephony/Schemas/Microsoft.Telephony.BatchTerminationCharacterInput.uischema index 1e563f0217..d2eafed60f 100644 --- a/packages/Telephony/Schemas/Microsoft.Telephony.BatchTerminationCharacterInput.uischema +++ b/packages/Telephony/Schemas/Microsoft.Telephony.BatchTerminationCharacterInput.uischema @@ -9,6 +9,9 @@ "property", "allowInterruptions", "alwaysPrompt", + "timeOutInMilliseconds", + "defaultValue", + "defaultValueResponse", "*" ], "properties": { diff --git a/tests/unit/packages/Microsoft.Bot.Components.Telephony.Tests/BatchInputTests.cs b/tests/unit/packages/Microsoft.Bot.Components.Telephony.Tests/BatchInputTests.cs index 8c79d2a1b6..fc8d9aade7 100644 --- a/tests/unit/packages/Microsoft.Bot.Components.Telephony.Tests/BatchInputTests.cs +++ b/tests/unit/packages/Microsoft.Bot.Components.Telephony.Tests/BatchInputTests.cs @@ -1,11 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.Collections.Generic; -using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Builder.Dialogs.Adaptive.Templates; +using Microsoft.Bot.Builder.Dialogs.Memory; +using Microsoft.Bot.Builder.Dialogs.Memory.Scopes; +using Microsoft.Bot.Components.Telephony.Actions; using Microsoft.Bot.Connector; using Microsoft.Bot.Schema; using Microsoft.Extensions.Configuration; +using Moq; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Xunit; namespace Microsoft.Bot.Components.Telephony.Tests @@ -74,6 +82,30 @@ await TestUtils.RunTestScript( .Build()); } + + [Fact] + public async Task BatchInput_Termination_WithTimeoutTriggered() + { + // Setup + var mockDefaultValue = "test value"; + var mockActivityText = "activity text"; + var mockTurnContext = new Mock(); + var dc = GetDialogContext(mockTurnContext); + + var batchFixedLengthInput = new BatchTerminationCharacterInput(); + batchFixedLengthInput.Property = "turn.result"; + batchFixedLengthInput.DefaultValue = mockDefaultValue; + batchFixedLengthInput.DefaultValueResponse = new ActivityTemplate(mockActivityText); + + // Act + var dialogTurnResult = await batchFixedLengthInput.ContinueDialogAsync(dc); + + // Assert + Assert.Equal(mockDefaultValue, dialogTurnResult.Result); + Assert.Equal(mockDefaultValue, dc.State.GetValue("turn.result", () => string.Empty)); + mockTurnContext.Verify(ctx => ctx.SendActivityAsync(It.Is(act => act.Text == mockActivityText), It.IsAny()), Times.Exactly(1)); + } + [Fact] public async Task BatchInput_FixedLength_WithTangent_InterruptionEnabled() { @@ -114,6 +146,29 @@ public async Task BatchInput_FixedLength_InterruptionIgnoredForMaskedDigits() await TestUtils.RunTestScript(_resourceExplorerFixture.ResourceExplorer, adapterChannel: Channels.Telephony); } + [Fact] + public async Task BatchInput_FixedLength_WithTimeoutTriggered() + { + // Setup + var mockDefaultValue = "test value"; + var mockActivityText = "activity text"; + var mockTurnContext = new Mock(); + var dc = GetDialogContext(mockTurnContext); + + var batchFixedLengthInput = new BatchFixedLengthInput(); + batchFixedLengthInput.Property = "turn.result"; + batchFixedLengthInput.DefaultValue = mockDefaultValue; + batchFixedLengthInput.DefaultValueResponse = new ActivityTemplate(mockActivityText); + + // Act + var dialogTurnResult = await batchFixedLengthInput.ContinueDialogAsync(dc); + + // Assert + Assert.Equal(mockDefaultValue, dialogTurnResult.Result); + Assert.Equal(mockDefaultValue, dc.State.GetValue("turn.result", () => string.Empty)); + mockTurnContext.Verify(ctx => ctx.SendActivityAsync(It.Is(act => act.Text == mockActivityText), It.IsAny()), Times.Exactly(1)); + } + [Fact] public async Task BatchInput_Regex_WithTangent_InterruptionEnabled() { @@ -154,6 +209,32 @@ public async Task BatchInput_Regex_InterruptionIgnoredForMaskedDigits() await TestUtils.RunTestScript(_resourceExplorerFixture.ResourceExplorer, adapterChannel: Channels.Telephony); } + private DialogContext GetDialogContext(Mock turnContext) + { + var configuration = new DialogStateManagerConfiguration + { + MemoryScopes = new List { new ThisMemoryScope(), new TurnMemoryScope() } + }; + + var turnState = new TurnContextStateCollection(); + turnState.Add(configuration); + + turnContext + .SetupGet(ctx => ctx.Activity) + .Returns(new Activity { Type = ActivityTypes.Event, Name = ActivityEventNames.ContinueConversation }); + turnContext + .Setup(ctx => ctx.SendActivityAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ResourceResponse())); + + turnContext + .SetupGet(ctx => ctx.TurnState) + .Returns(turnState); + + var dc = new DialogContext(new DialogSet(), turnContext.Object, new DialogState()); + dc.Stack.Add(new DialogInstance { Id = "DialogInstanceId" }); + + return dc; + } } }