From 00ea4ca352ac8aef3b0cf098af341136d37735b5 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 18 Mar 2022 17:34:40 -0500 Subject: [PATCH 1/3] Add support for search invoke --- .../Microsoft.Bot.Builder/ActivityHandler.cs | 89 ++++++++++++++++++- .../SearchInvokeOptions.cs | 31 +++++++ .../SearchInvokeResponse.cs | 14 +++ .../Microsoft.Bot.Schema/SearchInvokeTypes.cs | 33 +++++++ .../Microsoft.Bot.Schema/SearchInvokeValue.cs | 55 ++++++++++++ .../ActivityHandlerTests.cs | 31 +++++++ .../SearchQueryTests.cs | 67 ++++++++++++++ 7 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 libraries/Microsoft.Bot.Schema/SearchInvokeOptions.cs create mode 100644 libraries/Microsoft.Bot.Schema/SearchInvokeResponse.cs create mode 100644 libraries/Microsoft.Bot.Schema/SearchInvokeTypes.cs create mode 100644 libraries/Microsoft.Bot.Schema/SearchInvokeValue.cs create mode 100644 tests/Microsoft.Bot.Schema.Tests/SearchQueryTests.cs diff --git a/libraries/Microsoft.Bot.Builder/ActivityHandler.cs b/libraries/Microsoft.Bot.Builder/ActivityHandler.cs index 6089885218..d18bfb54d1 100644 --- a/libraries/Microsoft.Bot.Builder/ActivityHandler.cs +++ b/libraries/Microsoft.Bot.Builder/ActivityHandler.cs @@ -454,6 +454,10 @@ protected virtual async Task OnInvokeActivityAsync(ITurnContext< { switch (turnContext.Activity.Name) { + case "application/search": + var searchInvokeValue = GetSearchInvokeValue(turnContext.Activity); + return CreateInvokeResponse(await OnSearchInvokeAsync(turnContext, searchInvokeValue, cancellationToken).ConfigureAwait(false)); + case "adaptiveCard/action": var invokeValue = GetAdaptiveCardInvokeValue(turnContext.Activity); return CreateInvokeResponse(await OnAdaptiveCardInvokeAsync(turnContext, invokeValue, cancellationToken).ConfigureAwait(false)); @@ -516,6 +520,25 @@ protected virtual Task OnAdaptiveCardInvokeAsync(ITu throw new InvokeResponseException(HttpStatusCode.NotImplemented); } + /// + /// Invoked when the bot is sent an 'invoke' activity having name of 'application/search'. + /// + /// A strongly-typed context object for this turn. + /// A stringly-typed object from the incoming activity's Value. + /// A cancellation token that can be used by other objects + /// or threads to receive notice of cancellation. + /// A task that represents the work queued to execute. + /// + /// When the + /// method receives an Invoke with a of `application/search`, + /// it calls this method. The Activity.Value must be a well formed . + /// + /// + protected virtual Task OnSearchInvokeAsync(ITurnContext turnContext, SearchInvokeValue invokeValue, CancellationToken cancellationToken) + { + throw new InvokeResponseException(HttpStatusCode.NotImplemented); + } + /// /// Override this in a derived class to provide logic specific to /// activities, such as the conversational logic. @@ -706,7 +729,69 @@ protected virtual Task OnUnrecognizedActivityTypeAsync(ITurnContext turnContext, return Task.CompletedTask; } - private AdaptiveCardInvokeValue GetAdaptiveCardInvokeValue(IInvokeActivity activity) + private static SearchInvokeValue GetSearchInvokeValue(IInvokeActivity activity) + { + if (activity.Value == null) + { + var response = CreateAdaptiveCardInvokeErrorResponse(HttpStatusCode.BadRequest, "BadRequest", "Missing value property for search"); + throw new InvokeResponseException(HttpStatusCode.BadRequest, response); + } + + var obj = activity.Value as JObject; + if (obj == null) + { + var response = CreateAdaptiveCardInvokeErrorResponse(HttpStatusCode.BadRequest, "BadRequest", "Value property is not properly formed for search"); + throw new InvokeResponseException(HttpStatusCode.BadRequest, response); + } + + SearchInvokeValue invokeValue = null; + + try + { + invokeValue = obj.ToObject(); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch +#pragma warning restore CA1031 // Do not catch general exception types + { + var errorResponse = CreateAdaptiveCardInvokeErrorResponse(HttpStatusCode.BadRequest, "BadRequest", "Value property is not valid for search"); + throw new InvokeResponseException(HttpStatusCode.BadRequest, errorResponse); + } + + ValidateSearchInvokeValue(invokeValue, activity.ChannelId); + return invokeValue; + } + + private static void ValidateSearchInvokeValue(SearchInvokeValue searchInvokeValue, string channelId) + { + string missingField = null; + + if (string.IsNullOrEmpty(searchInvokeValue.Kind)) + { + // Teams does not always send the 'kind' field. Default to 'search'. + if (Connector.Channels.Msteams.Equals(channelId, StringComparison.OrdinalIgnoreCase)) + { + searchInvokeValue.Kind = SearchInvokeTypes.Search; + } + else + { + missingField = nameof(searchInvokeValue.Kind); + } + } + + if (string.IsNullOrEmpty(searchInvokeValue.QueryText)) + { + missingField = nameof(searchInvokeValue.QueryText); + } + + if (missingField != null) + { + var errorResponse = CreateAdaptiveCardInvokeErrorResponse(HttpStatusCode.BadRequest, "BadRequest", $"Missing {missingField} property for search"); + throw new InvokeResponseException(HttpStatusCode.BadRequest, errorResponse); + } + } + + private static AdaptiveCardInvokeValue GetAdaptiveCardInvokeValue(IInvokeActivity activity) { if (activity.Value == null) { @@ -750,7 +835,7 @@ private AdaptiveCardInvokeValue GetAdaptiveCardInvokeValue(IInvokeActivity activ return invokeValue; } - private AdaptiveCardInvokeResponse CreateAdaptiveCardInvokeErrorResponse(HttpStatusCode statusCode, string code, string message) + private static AdaptiveCardInvokeResponse CreateAdaptiveCardInvokeErrorResponse(HttpStatusCode statusCode, string code, string message) { return new AdaptiveCardInvokeResponse() { diff --git a/libraries/Microsoft.Bot.Schema/SearchInvokeOptions.cs b/libraries/Microsoft.Bot.Schema/SearchInvokeOptions.cs new file mode 100644 index 0000000000..f4c395149c --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/SearchInvokeOptions.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace Microsoft.Bot.Schema +{ + /// + /// Defines the query options in the for Invoke activity with Name of 'application/search'. + /// + public class SearchInvokeOptions + { + /// + /// Gets or sets the the starting reference number from which ordered search results should be returned. + /// + /// + /// The the starting reference number from which ordered search results should be returned. + /// + [JsonProperty("skip")] + public int Skip { get; set; } + + /// + /// Gets or sets the number of search results that should be returned. + /// + /// + /// The number of search results that should be returned. + /// + [JsonProperty("top")] + public int Top { get; set; } + } +} diff --git a/libraries/Microsoft.Bot.Schema/SearchInvokeResponse.cs b/libraries/Microsoft.Bot.Schema/SearchInvokeResponse.cs new file mode 100644 index 0000000000..ebec1cb3b6 --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/SearchInvokeResponse.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace Microsoft.Bot.Schema +{ + /// + /// Defines the structure that is returned as the result of an Invoke activity with Name of 'application/search'. + /// + public class SearchInvokeResponse : AdaptiveCardInvokeResponse + { + } +} diff --git a/libraries/Microsoft.Bot.Schema/SearchInvokeTypes.cs b/libraries/Microsoft.Bot.Schema/SearchInvokeTypes.cs new file mode 100644 index 0000000000..9fcad0f1ac --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/SearchInvokeTypes.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Bot.Schema +{ + /// + /// Defines values for SearchInvokeTypes. See . + /// + public static class SearchInvokeTypes + { + /// + /// The type for Search. + /// Implies a standard, paginated search operation that expects + /// one or more templated results to be returned. + /// + public const string Search = "search"; + + /// + /// The type for bot SearchAnswer. + /// Implies a simpler search that does not include pagination, + /// and most typically only returns a single search result. + /// + public const string SearchAnswer = "searchAnswer"; + + /// + /// The type for Typeahead. + /// Implies a search for a small set of values, most often used + /// for dynamic auto-complete or type-ahead UI controls. + /// This search supports pagination. + /// + public const string Typeahead = "typeahead"; + } +} diff --git a/libraries/Microsoft.Bot.Schema/SearchInvokeValue.cs b/libraries/Microsoft.Bot.Schema/SearchInvokeValue.cs new file mode 100644 index 0000000000..c176ccf7ce --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/SearchInvokeValue.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace Microsoft.Bot.Schema +{ + /// + /// Defines the structure that arrives in the Activity.Value for Invoke activity + /// with Name of 'application/search'. + /// + public class SearchInvokeValue + { + /// + /// Gets or sets the kind for the search invoke value. + /// Must be either search, searchAnswer, or typeahead. + /// . + /// + /// + /// The kind for this search invoke action value. + /// + [JsonProperty("kind")] + public string Kind { get; set; } + + /// + /// Gets or sets the query text for the search invoke value. + /// + /// + /// The query text of this search invoke action value. + /// + [JsonProperty("queryText")] + public string QueryText { get; set; } + + /// + /// Gets or sets the for this search invoke. + /// + /// + /// The for this search invoke. + /// + [JsonProperty("queryOptions")] + public SearchInvokeOptions QueryOptions { get; set; } + + /// + /// Gets or sets the context information about the query. Such as the UI + /// control that issued the query. The type of the context field is object + /// and is dependent on the kind field. For search and searchAnswers, + /// there is no defined context value. + /// + /// + /// The context information about the query. + /// + [JsonProperty("context")] + public object Context { get; set; } + } +} diff --git a/tests/Microsoft.Bot.Builder.Tests/ActivityHandlerTests.cs b/tests/Microsoft.Bot.Builder.Tests/ActivityHandlerTests.cs index 975143f3cf..217b7d9749 100644 --- a/tests/Microsoft.Bot.Builder.Tests/ActivityHandlerTests.cs +++ b/tests/Microsoft.Bot.Builder.Tests/ActivityHandlerTests.cs @@ -558,6 +558,31 @@ public async Task TestOnAdaptiveCardInvokeAsync() Assert.Equal("OnAdaptiveCardInvokeAsync", bot.Record[1]); } + [Fact] + public async Task TestOnSearchInvokeAsync() + { + var value = JObject.FromObject(new SearchInvokeValue { Kind = SearchInvokeTypes.Search, QueryText = "bot" }); + + // Arrange + var activity = new Activity + { + Type = ActivityTypes.Invoke, + Name = "application/search", + Value = value + }; + + var turnContext = new TurnContext(new TestInvokeAdapter(), activity); + + // Act + var bot = new TestActivityHandler(); + await ((IBot)bot).OnTurnAsync(turnContext); + + // Assert + Assert.Equal(2, bot.Record.Count); + Assert.Equal("OnInvokeActivityAsync", bot.Record[0]); + Assert.Equal("OnSearchQueryInvokeAsync", bot.Record[1]); + } + [Fact] public async Task TestCommandActivityType() { @@ -818,6 +843,12 @@ protected override Task OnAdaptiveCardInvokeAsync(IT Record.Add(MethodBase.GetCurrentMethod().Name); return Task.FromResult(new AdaptiveCardInvokeResponse()); } + + protected override Task OnSearchInvokeAsync(ITurnContext turnContext, SearchInvokeValue invokeValue, CancellationToken cancellationToken) + { + Record.Add(MethodBase.GetCurrentMethod().Name); + return Task.FromResult(new SearchInvokeResponse()); + } } private class TestDelegatingTurnContextActivityHandler : ActivityHandler diff --git a/tests/Microsoft.Bot.Schema.Tests/SearchQueryTests.cs b/tests/Microsoft.Bot.Schema.Tests/SearchQueryTests.cs new file mode 100644 index 0000000000..f65d0cc65c --- /dev/null +++ b/tests/Microsoft.Bot.Schema.Tests/SearchQueryTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.Bot.Schema.Tests +{ + public class SearchQueryTests + { + [Fact] + public void SearchInvokeValue() + { + var kind = SearchInvokeTypes.Typeahead; + var context = "Microsoft.Graph"; + var options = new SearchInvokeOptions { Skip = 10, Top = 5 }; + var text = "the query text"; + + var value = new SearchInvokeValue() + { + Kind = kind, + Context = context, + QueryOptions = options, + QueryText = text + }; + + Assert.Equal(kind, value.Kind); + Assert.Equal(context, value.Context); + Assert.Equal(options, value.QueryOptions); + Assert.Equal(text, value.QueryText); + } + + [Fact] + public void SearchInvokeResponse() + { + var statusCode = 200; + var type = "myType"; + var value = new { }; + + var res = new SearchInvokeResponse() + { + StatusCode = statusCode, + Type = type, + Value = value, + }; + + Assert.Equal(statusCode, res.StatusCode); + Assert.Equal(type, res.Type); + Assert.Equal(value, res.Value); + } + + [Fact] + public void SearchQueryOptions() + { + int skip = 10; + int top = 5; + + var options = new SearchInvokeOptions + { + Skip = skip, + Top = top + }; + + Assert.Equal(skip, options.Skip); + Assert.Equal(top, options.Top); + } + } +} From 6265ebcf9a12068994f7a4d5f3f4283fbc01a494 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 18 Mar 2022 18:03:29 -0500 Subject: [PATCH 2/3] Fix TestOnSearchInvokeAsync --- tests/Microsoft.Bot.Builder.Tests/ActivityHandlerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Microsoft.Bot.Builder.Tests/ActivityHandlerTests.cs b/tests/Microsoft.Bot.Builder.Tests/ActivityHandlerTests.cs index 217b7d9749..1684657e6f 100644 --- a/tests/Microsoft.Bot.Builder.Tests/ActivityHandlerTests.cs +++ b/tests/Microsoft.Bot.Builder.Tests/ActivityHandlerTests.cs @@ -580,7 +580,7 @@ public async Task TestOnSearchInvokeAsync() // Assert Assert.Equal(2, bot.Record.Count); Assert.Equal("OnInvokeActivityAsync", bot.Record[0]); - Assert.Equal("OnSearchQueryInvokeAsync", bot.Record[1]); + Assert.Equal("OnSearchInvokeAsync", bot.Record[1]); } [Fact] From 079705824a50332673802a4ed682b0c177069965 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 18 Mar 2022 18:06:33 -0500 Subject: [PATCH 3/3] Fix strongly-typed spelling --- libraries/Microsoft.Bot.Builder/ActivityHandler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/Microsoft.Bot.Builder/ActivityHandler.cs b/libraries/Microsoft.Bot.Builder/ActivityHandler.cs index d18bfb54d1..c6178b4dfa 100644 --- a/libraries/Microsoft.Bot.Builder/ActivityHandler.cs +++ b/libraries/Microsoft.Bot.Builder/ActivityHandler.cs @@ -505,7 +505,7 @@ protected virtual Task OnSignInInvokeAsync(ITurnContext turnCon /// Invoked when the bot is sent an Adaptive Card Action Execute. /// /// A strongly-typed context object for this turn. - /// A stringly-typed object from the incoming activity's Value. + /// A strongly-typed object from the incoming activity's Value. /// A cancellation token that can be used by other objects /// or threads to receive notice of cancellation. /// A task that represents the work queued to execute. @@ -524,7 +524,7 @@ protected virtual Task OnAdaptiveCardInvokeAsync(ITu /// Invoked when the bot is sent an 'invoke' activity having name of 'application/search'. /// /// A strongly-typed context object for this turn. - /// A stringly-typed object from the incoming activity's Value. + /// A strongly-typed object from the incoming activity's Value. /// A cancellation token that can be used by other objects /// or threads to receive notice of cancellation. /// A task that represents the work queued to execute.