Skip to content
This repository was archived by the owner on Jan 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 88 additions & 3 deletions libraries/Microsoft.Bot.Builder/ActivityHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,10 @@ protected virtual async Task<InvokeResponse> 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));
Expand Down Expand Up @@ -501,7 +505,7 @@ protected virtual Task OnSignInInvokeAsync(ITurnContext<IInvokeActivity> turnCon
/// Invoked when the bot is sent an Adaptive Card Action Execute.
/// </summary>
/// <param name="turnContext">A strongly-typed context object for this turn.</param>
/// <param name="invokeValue">A stringly-typed object from the incoming activity's Value.</param>
/// <param name="invokeValue">A strongly-typed object from the incoming activity's Value.</param>
/// <param name="cancellationToken">A cancellation token that can be used by other objects
/// or threads to receive notice of cancellation.</param>
/// <returns>A task that represents the work queued to execute.</returns>
Expand All @@ -516,6 +520,25 @@ protected virtual Task<AdaptiveCardInvokeResponse> OnAdaptiveCardInvokeAsync(ITu
throw new InvokeResponseException(HttpStatusCode.NotImplemented);
}

/// <summary>
/// Invoked when the bot is sent an 'invoke' activity having name of 'application/search'.
/// </summary>
/// <param name="turnContext">A strongly-typed context object for this turn.</param>
/// <param name="invokeValue">A strongly-typed object from the incoming activity's Value.</param>
/// <param name="cancellationToken">A cancellation token that can be used by other objects
/// or threads to receive notice of cancellation.</param>
/// <returns>A task that represents the work queued to execute.</returns>
/// <remarks>
/// When the <see cref="OnInvokeActivityAsync(ITurnContext{IInvokeActivity}, CancellationToken)"/>
/// method receives an Invoke with a <see cref="IInvokeActivity.Name"/> of `application/search`,
/// it calls this method. The Activity.Value must be a well formed <see cref="SearchInvokeValue"/>.
/// </remarks>
/// <seealso cref="OnInvokeActivityAsync(ITurnContext{IInvokeActivity}, CancellationToken)"/>
protected virtual Task<SearchInvokeResponse> OnSearchInvokeAsync(ITurnContext<IInvokeActivity> turnContext, SearchInvokeValue invokeValue, CancellationToken cancellationToken)
{
throw new InvokeResponseException(HttpStatusCode.NotImplemented);
}

/// <summary>
/// Override this in a derived class to provide logic specific to
/// <see cref="ActivityTypes.EndOfConversation"/> activities, such as the conversational logic.
Expand Down Expand Up @@ -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<SearchInvokeValue>();
}
#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);

@johnataylor johnataylor Mar 21, 2022 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: isn't nameof going to give you the C# identifier whereas what you really want in the error message is the JSON property name. OK this is perhaps a nit because the only difference in this case at the current time is the casing.

I'm assuming the json property attribute doesn't override the nameof result (can you even do that?)

}

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)
{
Expand Down Expand Up @@ -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()
{
Expand Down
31 changes: 31 additions & 0 deletions libraries/Microsoft.Bot.Schema/SearchInvokeOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Newtonsoft.Json;

namespace Microsoft.Bot.Schema
{
/// <summary>
/// Defines the query options in the <see cref="SearchInvokeValue"/> for Invoke activity with Name of 'application/search'.
/// </summary>
public class SearchInvokeOptions
{
/// <summary>
/// Gets or sets the the starting reference number from which ordered search results should be returned.
/// </summary>
/// <value>
/// The the starting reference number from which ordered search results should be returned.
/// </value>
[JsonProperty("skip")]
public int Skip { get; set; }

/// <summary>
/// Gets or sets the number of search results that should be returned.
/// </summary>
/// <value>
/// The number of search results that should be returned.
/// </value>
[JsonProperty("top")]
public int Top { get; set; }
}
}
14 changes: 14 additions & 0 deletions libraries/Microsoft.Bot.Schema/SearchInvokeResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Newtonsoft.Json;

namespace Microsoft.Bot.Schema
{
/// <summary>
/// Defines the structure that is returned as the result of an Invoke activity with Name of 'application/search'.
/// </summary>
public class SearchInvokeResponse : AdaptiveCardInvokeResponse
{
}
}
33 changes: 33 additions & 0 deletions libraries/Microsoft.Bot.Schema/SearchInvokeTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

namespace Microsoft.Bot.Schema
{
/// <summary>
/// Defines values for SearchInvokeTypes. See <see cref="SearchInvokeValue"/>.
/// </summary>
public static class SearchInvokeTypes
{
/// <summary>
/// The type for Search.
/// Implies a standard, paginated search operation that expects
/// one or more templated results to be returned.
/// </summary>
public const string Search = "search";

/// <summary>
/// The type for bot SearchAnswer.
/// Implies a simpler search that does not include pagination,
/// and most typically only returns a single search result.
/// </summary>
public const string SearchAnswer = "searchAnswer";

/// <summary>
/// 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.
/// </summary>
public const string Typeahead = "typeahead";
}
}
55 changes: 55 additions & 0 deletions libraries/Microsoft.Bot.Schema/SearchInvokeValue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Newtonsoft.Json;

namespace Microsoft.Bot.Schema
{
/// <summary>
/// Defines the structure that arrives in the Activity.Value for Invoke activity
/// with Name of 'application/search'.
/// </summary>
public class SearchInvokeValue
{
/// <summary>
/// Gets or sets the kind for the search invoke value.
/// Must be either search, searchAnswer, or typeahead.
/// <see cref="SearchInvokeTypes"/>.
/// </summary>
/// <value>
/// The kind for this search invoke action value.
/// </value>
[JsonProperty("kind")]
public string Kind { get; set; }

/// <summary>
/// Gets or sets the query text for the search invoke value.
/// </summary>
/// <value>
/// The query text of this search invoke action value.
/// </value>
[JsonProperty("queryText")]
public string QueryText { get; set; }

/// <summary>
/// Gets or sets the <see cref="SearchInvokeOptions"/> for this search invoke.
/// </summary>
/// <value>
/// The <see cref="SearchInvokeOptions"/> for this search invoke.
/// </value>
[JsonProperty("queryOptions")]
public SearchInvokeOptions QueryOptions { get; set; }

/// <summary>
/// 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.
/// </summary>
/// <value>
/// The context information about the query.
/// </value>
[JsonProperty("context")]
public object Context { get; set; }
}
}
31 changes: 31 additions & 0 deletions tests/Microsoft.Bot.Builder.Tests/ActivityHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("OnSearchInvokeAsync", bot.Record[1]);
}

[Fact]
public async Task TestCommandActivityType()
{
Expand Down Expand Up @@ -818,6 +843,12 @@ protected override Task<AdaptiveCardInvokeResponse> OnAdaptiveCardInvokeAsync(IT
Record.Add(MethodBase.GetCurrentMethod().Name);
return Task.FromResult(new AdaptiveCardInvokeResponse());
}

protected override Task<SearchInvokeResponse> OnSearchInvokeAsync(ITurnContext<IInvokeActivity> turnContext, SearchInvokeValue invokeValue, CancellationToken cancellationToken)
{
Record.Add(MethodBase.GetCurrentMethod().Name);
return Task.FromResult(new SearchInvokeResponse());
}
}

private class TestDelegatingTurnContextActivityHandler : ActivityHandler
Expand Down
67 changes: 67 additions & 0 deletions tests/Microsoft.Bot.Schema.Tests/SearchQueryTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}