diff --git a/libraries/Microsoft.Bot.Builder/Teams/TeamsInfo.cs b/libraries/Microsoft.Bot.Builder/Teams/TeamsInfo.cs index ed6ca2f4f1..efd83667eb 100644 --- a/libraries/Microsoft.Bot.Builder/Teams/TeamsInfo.cs +++ b/libraries/Microsoft.Bot.Builder/Teams/TeamsInfo.cs @@ -16,7 +16,7 @@ namespace Microsoft.Bot.Builder.Teams { /// - /// The TeamsInfo + /// The TeamsInfo Test If Build Remote Successful /// provides utility methods for the events and interactions that occur within Microsoft Teams. /// public static class TeamsInfo @@ -29,7 +29,7 @@ public static class TeamsInfo /// The id of the Teams meeting participant. From.AadObjectId will be used if none provided. /// The id of the Teams meeting Tenant. TeamsChannelData.Tenant.Id will be used if none provided. /// Cancellation token. - /// InvalidOperationException will be thrown if meetingId, participantId or tenantId have not been + /// will be thrown if meetingId, participantId or tenantId have not been /// provided, and also cannot be retrieved from turnContext.Activity. /// Team participant channel account. public static async Task GetMeetingParticipantAsync(ITurnContext turnContext, string meetingId = null, string participantId = null, string tenantId = null, CancellationToken cancellationToken = default) @@ -317,6 +317,27 @@ await turnContext.Adapter.CreateConversationAsync( return new Tuple(conversationReference, newActivityId); } + /// + /// Sends a notification to meeting participants. This functionality is available only in teams meeting scoped conversations. + /// + /// Turn context. + /// The notification to send to Teams. + /// The id of the Teams meeting. TeamsChannelData.Meeting.Id will be used if none provided. + /// Cancellation token. + /// InvalidOperationException will be thrown if meetingId or notification have not been + /// provided, and also cannot be retrieved from turnContext.Activity. + /// List of for whom the notification failed. + public static async Task SendMeetingNotificationAsync(ITurnContext turnContext, TeamsMeetingNotification notification, string meetingId = null, CancellationToken cancellationToken = default) + { + meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id ?? throw new InvalidOperationException("This method is only valid within the scope of a MS Teams Meeting."); + notification = notification ?? throw new InvalidOperationException($"{nameof(notification)} is required."); + + using (var teamsClient = GetTeamsConnectorClient(turnContext)) + { + return await teamsClient.Teams.SendMeetingNotificationAsync(meetingId, notification, cancellationToken).ConfigureAwait(false); + } + } + private static async Task> GetMembersAsync(IConnectorClient connectorClient, string conversationId, CancellationToken cancellationToken) { if (conversationId == null) diff --git a/libraries/Microsoft.Bot.Connector/Teams/TeamsOperations.cs b/libraries/Microsoft.Bot.Connector/Teams/TeamsOperations.cs index 418e1898d3..9116668f59 100644 --- a/libraries/Microsoft.Bot.Connector/Teams/TeamsOperations.cs +++ b/libraries/Microsoft.Bot.Connector/Teams/TeamsOperations.cs @@ -287,6 +287,199 @@ public TeamsOperations(TeamsConnectorClient client) return await GetResponseAsync(url, shouldTrace, invocationId, cancellationToken: cancellationToken).ConfigureAwait(false); } + /// + /// Send a teams meeting notification. + /// + /// + /// Send a notification to teams meeting particpants. + /// + /// + /// Teams meeting id. + /// + /// + /// Teams notification object. + /// + /// + /// Headers that will be added to request. + /// + /// + /// The cancellation token. + /// + /// + /// Thrown when the operation returned an invalid status code. + /// + /// + /// Thrown when unable to deserialize the response. + /// + /// + /// Thrown when an input value does not match the expected data type, range or pattern. + /// + /// + /// Thrown when a required parameter is null. + /// + /// + /// A response object containing the response body and response headers. + /// + public async Task> SendMeetingNotificationMessageAsync(string meetingId, TeamsMeetingNotification notification, Dictionary> customHeaders = null, CancellationToken cancellationToken = default(CancellationToken)) + { + if (meetingId == null) + { + throw new ValidationException(ValidationRules.CannotBeNull, nameof(meetingId)); + } + + // Tracing + bool shouldTrace = ServiceClientTracing.IsEnabled; + string invocationId = null; + if (shouldTrace) + { + invocationId = ServiceClientTracing.NextInvocationId.ToString(CultureInfo.InvariantCulture); + Dictionary tracingParameters = new Dictionary(); + tracingParameters.Add("meetingId", meetingId); + tracingParameters.Add("cancellationToken", cancellationToken); + ServiceClientTracing.Enter(invocationId, this, "SendMeetingNotification", tracingParameters); + } + + // Construct URL + var baseUrl = Client.BaseUri.AbsoluteUri; + var url = new System.Uri(new System.Uri(baseUrl + (baseUrl.EndsWith("/", System.StringComparison.InvariantCulture) ? string.Empty : "/")), "v1/meetings/{meetingId}/notification").ToString(); + url = url.Replace("{meetingId}", System.Uri.EscapeDataString(meetingId)); + using var httpRequest = new HttpRequestMessage(); + httpRequest.Method = new HttpMethod("POST"); + httpRequest.RequestUri = new System.Uri(url); + + HttpResponseMessage httpResponse = null; + + // Create HTTP transport objects +#pragma warning disable CA2000 // Dispose objects before losing scope + var result = new HttpOperationResponse(); +#pragma warning restore CA2000 // Dispose objects before losing scope + try + { + // Set Headers + if (customHeaders != null) + { + foreach (var header in customHeaders) + { + if (httpRequest.Headers.Contains(header.Key)) + { + httpRequest.Headers.Remove(header.Key); + } + + httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + // Serialize Request + string requestContent = null; + if (notification != null) + { + requestContent = Rest.Serialization.SafeJsonConvert.SerializeObject(notification, Client.SerializationSettings); + httpRequest.Content = new StringContent(requestContent, System.Text.Encoding.UTF8); + httpRequest.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json; charset=utf-8"); + } + + // Set Credentials + if (Client.Credentials != null) + { + cancellationToken.ThrowIfCancellationRequested(); + await Client.Credentials.ProcessHttpRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); + } + + // Send Request + if (shouldTrace) + { + ServiceClientTracing.SendRequest(invocationId, httpRequest); + } + + cancellationToken.ThrowIfCancellationRequested(); + httpResponse = await Client.HttpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + if (shouldTrace) + { + ServiceClientTracing.ReceiveResponse(invocationId, httpResponse); + } + + HttpStatusCode statusCode = httpResponse.StatusCode; + cancellationToken.ThrowIfCancellationRequested(); + string responseContent = null; + + // Create Result + result.Request = httpRequest; + result.Response = httpResponse; + + if ((int)statusCode == 207) + { + // 207: if the notifications are sent only to parital number of recipients because + // the validation on some recipients’ ids failed or some recipients were not found in the roster. + // In this case, SMBA will return the user MRIs of those failed recipients in a format that was given to a bot + // (ex: if a bot sent encrypted user MRIs, return encrypted one). + + responseContent = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + try + { + result.Body = Rest.Serialization.SafeJsonConvert.DeserializeObject(responseContent, Client.DeserializationSettings); + } + catch (JsonException ex) + { + if (shouldTrace) + { + ServiceClientTracing.Error(invocationId, ex); + } + + throw new SerializationException("Unable to deserialize the response.", responseContent, ex); + } + } + else if ((int)statusCode != 202) + { + // 400: when Meeting Notification request payload validation fails. For instance, + // • Recipients: # of recipients is greater than what the API allows || all of recipients’ user ids were invalid + // • Surface: + // o Surface list is empty or null + // o Surface type is invalid + // o Duplicative surface type exists in one payload + // 401: if the bot token is invalid + // 403: if the bot is not allowed to send the notification. + // In this case, the payload should contain more detail error message. + // There can be many reasons: bot disabled by tenant admin, blocked during live site mitigation, + // the bot does not have a correct RSC permission for a specific surface type, etc + // 404: if a meeting chat is not found || None of the receipients were found in the roster. + + // invalid/unexpected status code + var ex = new HttpOperationException($"Operation returned an invalid status code '{statusCode}'"); + if (httpResponse.Content != null) + { + responseContent = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + } + else + { + responseContent = string.Empty; + } + + ex.Request = new HttpRequestMessageWrapper(httpRequest, requestContent); + ex.Response = new HttpResponseMessageWrapper(httpResponse, responseContent); + if (shouldTrace) + { + ServiceClientTracing.Error(invocationId, ex); + } + + throw ex; + } + } + finally + { + if (httpResponse != null) + { + httpResponse.Dispose(); + } + } + + if (shouldTrace) + { + ServiceClientTracing.Exit(invocationId, result); + } + + return result; + } + private async Task> GetResponseAsync(string url, bool shouldTrace, string invocationId, Dictionary> customHeaders = null, CancellationToken cancellationToken = default(CancellationToken)) { // Create HTTP transport objects diff --git a/libraries/Microsoft.Bot.Connector/Teams/TeamsOperationsExtensions.cs b/libraries/Microsoft.Bot.Connector/Teams/TeamsOperationsExtensions.cs index f53048a595..631e8e21e3 100644 --- a/libraries/Microsoft.Bot.Connector/Teams/TeamsOperationsExtensions.cs +++ b/libraries/Microsoft.Bot.Connector/Teams/TeamsOperationsExtensions.cs @@ -112,5 +112,36 @@ public static partial class TeamsOperationsExtensions throw new InvalidOperationException("TeamsOperations with GetParticipantWithHttpMessagesAsync is required for FetchParticipantAsync."); } } + + /// + /// Sends a notification to participants of a Teams meeting. + /// + /// + /// The operations group for this extension method. + /// + /// + /// Team meeting Id. + /// + /// + /// Team meeting notification. + /// + /// + /// The cancellation token. + /// + /// Information regarding which participant notifications failed. + public static async Task SendMeetingNotificationAsync(this ITeamsOperations operations, string meetingId, TeamsMeetingNotification notification, CancellationToken cancellationToken = default(CancellationToken)) + { + if (operations is TeamsOperations teamsOperations) + { + using (var result = await teamsOperations.SendMeetingNotificationMessageAsync(meetingId, notification, null, cancellationToken).ConfigureAwait(false)) + { + return result.Body; + } + } + else + { + throw new InvalidOperationException("TeamsOperations with SendMeetingNotificationWithHttpMessagesAsync is required for SendMeetingNotificationAsync."); + } + } } } diff --git a/libraries/Microsoft.Bot.Schema/Teams/OnBehalfOf.cs b/libraries/Microsoft.Bot.Schema/Teams/OnBehalfOf.cs new file mode 100644 index 0000000000..844a31d5eb --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/Teams/OnBehalfOf.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Bot.Schema.Teams +{ + using Newtonsoft.Json; + + /// + /// Specifies attribution for notifications. + /// + public partial class OnBehalfOf + { + /// + /// Initializes a new instance of the class. + /// + public OnBehalfOf() + { + CustomInit(); + } + + /// + /// Gets or sets the identification of the item. Default is 0. + /// + /// The item id. + [JsonProperty(PropertyName = "itemId")] + public int ItemId { get; set; } = 0; + + /// + /// Gets or sets the mention type. Default is "person". + /// + /// The mention type. + [JsonProperty(PropertyName = "mentionType")] + public string MentionType { get; set; } = "person"; + + /// + /// Gets or sets message resource identifier (MRI) of the person on whose behalf the message is sent. + /// Message sender name would appear as "[user] through [bot name]". + /// + /// The message resource identifier of the person. + [JsonProperty(PropertyName = "mri")] + public string Mri { get; set; } + + /// + /// Gets or sets name of the person. Used as fallback in case name resolution is unavailable. + /// + /// The name of the person. + [JsonProperty(PropertyName = "displayName")] + public string DisplayName { get; set; } + + /// + /// An initialization method that performs custom operations like setting defaults. + /// + partial void CustomInit(); + } +} diff --git a/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotification.cs b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotification.cs new file mode 100644 index 0000000000..a5048f958b --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotification.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Bot.Schema.Teams +{ + using Newtonsoft.Json; + + /// + /// Specifies meeting notification including channel data, type and value. + /// + public partial class TeamsMeetingNotification + { + /// + /// Initializes a new instance of the class. + /// + public TeamsMeetingNotification() + { + CustomInit(); + } + + /// + /// Gets or sets Activty type. + /// + /// + /// Activity type. + /// + [JsonProperty(PropertyName = "type")] + public string Type { get; set; } = "targetedMeetingNotification"; + + /// + /// Gets or sets Teams meeting notification information. + /// + /// + /// Teams meeting notification information. + /// + [JsonProperty(PropertyName = "value")] + public TeamsMeetingNotificationInfo Value { get; set; } + + /// + /// Gets or sets Teams meeting notification channel data. + /// + /// + /// Teams meeting notification channel data. + /// + [JsonProperty(PropertyName = "channelData")] + public TeamsMeetingNotificationChannelData ChannelData { get; set; } + + /// + /// An initialization method that performs custom operations like setting defaults. + /// + partial void CustomInit(); + } +} diff --git a/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationChannelData.cs b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationChannelData.cs new file mode 100644 index 0000000000..3f4e488231 --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationChannelData.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Bot.Schema.Teams +{ + using System.Collections.Generic; + using Newtonsoft.Json; + + /// + /// Container for list of information for a meeting . + /// + public partial class TeamsMeetingNotificationChannelData + { + /// + /// Initializes a new instance of the class. + /// + public TeamsMeetingNotificationChannelData() + { + CustomInit(); + } + + /// + /// Gets or sets the for user attribution. + /// + /// The meeting notification's . +#pragma warning disable CA2227 // Collection properties should be read only (we can't change this without breaking binary compat)> + [JsonProperty(PropertyName = "OnBehalfOf")] + public IList OnBehalfOf { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only + + /// + /// An initialization method that performs custom operations like setting defaults. + /// + partial void CustomInit(); + } +} diff --git a/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationInfo.cs b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationInfo.cs new file mode 100644 index 0000000000..9917cd94a4 --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationInfo.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Microsoft.Bot.Schema.Teams +{ + /// + /// Specifies the container for what is required to send a meeting notification to recipients. + /// + public partial class TeamsMeetingNotificationInfo + { + /// + /// Initializes a new instance of the class. + /// + public TeamsMeetingNotificationInfo() + { + CustomInit(); + } + + /// + /// Gets or sets the collection of recipients of the notification. + /// + /// + /// The collection of recipients of the notification. + /// + [JsonProperty(PropertyName = "recipients")] +#pragma warning disable CA2227 // Collection properties should be read only (we can't change this without breaking binary compat) + public IList Recipients { get; set; } + + /// + /// Gets or sets the collection of surfaces on which to show the notification. + /// + /// + /// The collection of surfaces on which to show the notification. + /// + [JsonProperty(PropertyName = "surfaces")] + public IList Surfaces { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only + + /// + /// An initialization method that performs custom operations like setting defaults. + /// + + /// + /// An initialization method that performs custom operations like setting defaults. + /// + partial void CustomInit(); + } +} diff --git a/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationOnBehalfOf.cs b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationOnBehalfOf.cs new file mode 100644 index 0000000000..9166a165bd --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationOnBehalfOf.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Bot.Schema.Teams +{ + using Newtonsoft.Json; + + /// + /// Specifies attribution for notifications. + /// + public partial class TeamsMeetingNotificationOnBehalfOf + { + /// + /// Initializes a new instance of the class. + /// + public TeamsMeetingNotificationOnBehalfOf() + { + CustomInit(); + } + + /// + /// Gets or sets the identification of the item. Default is 0. + /// + /// The item id. + [JsonProperty(PropertyName = "itemId")] + public int ItemId { get; set; } = 0; + + /// + /// Gets or sets the mention type. Default is "person". + /// + /// The mention type. + [JsonProperty(PropertyName = "mentionType")] + public string MentionType { get; set; } = "person"; + + /// + /// Gets or sets message resource identifier (MRI) of the person on whose behalf the message is sent. + /// Message sender name would appear as "[user] through [bot name]". + /// + /// The message resource identifier of the person. + [JsonProperty(PropertyName = "mri")] + public string Mri { get; set; } + + /// + /// Gets or sets name of the person. Used as fallback in case name resolution is unavailable. + /// + /// The name of the person. + [JsonProperty(PropertyName = "displayName")] + public string DisplayName { get; set; } + + /// + /// An initialization method that performs custom operations like setting defaults. + /// + partial void CustomInit(); + } +} diff --git a/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationRecipientFailureInfo.cs b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationRecipientFailureInfo.cs new file mode 100644 index 0000000000..7bd3ac9ed8 --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationRecipientFailureInfo.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace Microsoft.Bot.Schema.Teams +{ + /// + /// Information regarding failure to notify a recipient of a . + /// + public class TeamsMeetingNotificationRecipientFailureInfo + { + /// + /// Gets or sets the mri for a recipient failure. + /// + /// The type of this notification container. + [JsonProperty(PropertyName = "recipientMri")] + public string RecipientMri { get; set; } + + /// + /// Gets or sets the error code for a . + /// + /// The error code for a . + [JsonProperty(PropertyName = "errorcode")] + public string ErrorCode { get; set; } + + /// + /// Gets or sets the failure reason for a failure. + /// + /// The reason why a participant failed. + [JsonProperty(PropertyName = "failureReason")] + public string FailureReason { get; set; } + } +} diff --git a/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationRecipientFailureInfos.cs b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationRecipientFailureInfos.cs new file mode 100644 index 0000000000..57a39202c5 --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationRecipientFailureInfos.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Microsoft.Bot.Schema.Teams +{ + /// + /// Container for , which is the result of a + /// failure to notify recipients of a . + /// + public partial class TeamsMeetingNotificationRecipientFailureInfos + { + /// + /// Initializes a new instance of the class. + /// + public TeamsMeetingNotificationRecipientFailureInfos() + { + CustomInit(); + } + + /// + /// Gets or sets the list of . + /// + /// The list of recipients who did not receive a including error information. + [JsonProperty(PropertyName = "recipientsFailureInfo")] +#pragma warning disable CA2227 // Collection properties should be read only (we can't change this without breaking binary compat)> + public IList RecipientsFailureInfo { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only + + /// + /// An initialization method that performs custom operations like setting defaults. + /// + partial void CustomInit(); + } +} diff --git a/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationSurface.cs b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationSurface.cs new file mode 100644 index 0000000000..f81a6c6c19 --- /dev/null +++ b/libraries/Microsoft.Bot.Schema/Teams/TeamsMeetingNotificationSurface.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Bot.Schema.Teams +{ + using Newtonsoft.Json; + + /// + /// Specifies if a notification is to be sent for the mentions. + /// + public partial class TeamsMeetingNotificationSurface + { + /// + /// Initializes a new instance of the class. + /// + public TeamsMeetingNotificationSurface() + { + CustomInit(); + } + + /// + /// Gets or sets the value indicating where the signal will be rendered in the meeting UX. + /// Note: only one instance of surface type is allowed per request. + /// + /// + /// The value indicating where the signal will be rendered in the meeting UX. + /// + [JsonProperty(PropertyName = "surface")] + public string Surface { get; set; } = "meetingStage"; + + /// + /// Gets or sets the content type of this . + /// + /// + /// The content type of this . + /// + [JsonProperty(PropertyName = "contentType")] + public string ContentType { get; set; } = "task"; + + /// + /// Gets or sets the content for this . + /// + /// + /// The content type of this . + /// + [JsonProperty(PropertyName = "content")] + public TaskModuleContinueResponse Content { get; set; } + + /// + /// An initialization method that performs custom operations like setting defaults. + /// + partial void CustomInit(); + } +} diff --git a/tests/Microsoft.Bot.Builder.Tests/Teams/TeamsInfoTests.cs b/tests/Microsoft.Bot.Builder.Tests/Teams/TeamsInfoTests.cs index 3de65e7006..6d6928d3a7 100644 --- a/tests/Microsoft.Bot.Builder.Tests/Teams/TeamsInfoTests.cs +++ b/tests/Microsoft.Bot.Builder.Tests/Teams/TeamsInfoTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -12,7 +13,9 @@ using Microsoft.Bot.Connector.Authentication; using Microsoft.Bot.Schema; using Microsoft.Bot.Schema.Teams; +using Microsoft.Rest; using Moq; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Xunit; @@ -350,6 +353,59 @@ public async Task TestGetMemberNoTeamAsync() await handler.OnTurnAsync(turnContext); } + [Theory] + [InlineData("202")] + [InlineData("207")] + [InlineData("400")] + [InlineData("403")] + public async Task TestSendMeetingNotificationAsync(string statusCode) + { + // 202: accepted + // 207: if the notifications are sent only to parital number of recipients because + // the validation on some recipients’ ids failed or some recipients were not found in the roster. + // • In this case, SMBA will return the user MRIs of those failed recipients in a format that was given to a bot + // (ex: if a bot sent encrypted user MRIs, return encrypted one). + + // 400: when Meeting Notification request payload validation fails. For instance, + // • Recipients: # of recipients is greater than what the API allows || all of recipients’ user ids were invalid + // • Surface: + // o Surface list is empty or null + // o Surface type is invalid + // o Duplicative surface type exists in one payload + // 403: if the bot is not allowed to send the notification. + // In this case, the payload should contain more detail error message. + // There can be many reasons: bot disabled by tenant admin, blocked during live site mitigation, + // the bot does not have a correct RSC permission for a specific surface type, etc + + var baseUri = new Uri("https://test.coffee"); + var customHttpClient = new HttpClient(new RosterHttpMessageHandler()); + + // Set a special base address so then we can make sure the connector client is honoring this http client + customHttpClient.BaseAddress = baseUri; + var connectorClient = new ConnectorClient(new Uri("http://localhost/"), new MicrosoftAppCredentials(string.Empty, string.Empty), customHttpClient); + + var activity = new Activity + { + Type = "targetedMeetingNotification", + Text = "Test-SendMeetingNotificationAsync", + ChannelId = Channels.Msteams, + ServiceUrl = "https://test.coffee", + From = new ChannelAccount() + { + Id = "id-1", + + // Hack for test. use the Name field to pass expected status code to test code + Name = statusCode + }, + Conversation = new ConversationAccount() { Id = "conversation-id" } + }; + + var turnContext = new TurnContext(new SimpleAdapter(), activity); + turnContext.TurnState.Add(connectorClient); + var handler = new TestTeamsActivityHandler(); + await handler.OnTurnAsync(turnContext); + } + private class TestTeamsActivityHandler : TeamsActivityHandler { public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) @@ -382,6 +438,9 @@ public override async Task OnTurnAsync(ITurnContext turnContext, CancellationTok case "Test-GetMeetingInfoAsync": await CallTeamsInfoGetMeetingInfoAsync(turnContext); break; + case "Test-SendMeetingNotificationAsync": + await CallSendMeetingNotificationAsync(turnContext); + break; default: Assert.True(false); break; @@ -457,6 +516,91 @@ private async Task CallTeamsInfoGetMeetingInfoAsync(ITurnContext turnContext) Assert.Equal("meetingConversationId-1", meeting.Conversation.Id); } + private TeamsMeetingNotification GetTeamsMeetingNotification(ChannelAccount from) + { + var recipients = new List { from.Id }; + + if (from.Name == "207") + { + recipients.Add("failingid"); + } + + var surface = new TeamsMeetingNotificationSurface + { + Content = new TaskModuleContinueResponse + { + Value = new TaskModuleTaskInfo + { + Title = "title here", + Height = 3, + Width = 2, + } + } + }; + + var value = new TeamsMeetingNotificationInfo + { + Recipients = recipients, + Surfaces = new[] { surface } + }; + + var obo = new TeamsMeetingNotificationOnBehalfOf + { + DisplayName = from.Name, + Mri = from.Id + }; + + var channelData = new TeamsMeetingNotificationChannelData + { + OnBehalfOf = new[] { obo } + }; + + return new TeamsMeetingNotification + { + Value = value, + ChannelData = channelData + }; + } + + private async Task CallSendMeetingNotificationAsync(ITurnContext turnContext) + { + var from = turnContext.Activity.From; + + try + { + var failedParticipants = await TeamsInfo.SendMeetingNotificationAsync(turnContext, GetTeamsMeetingNotification(from), "meeting-id").ConfigureAwait(false); + + switch (from.Name) + { + case "207": + Assert.Equal("failingid", failedParticipants.RecipientsFailureInfo.First().RecipientMri); + break; + case "202": + Assert.Null(failedParticipants); + break; + default: + throw new InvalidOperationException($"Expected {nameof(HttpOperationException)} with response status code {from.Name}."); + } + } + catch (HttpOperationException ex) + { + Assert.Equal(from.Name, ((int)ex.Response.StatusCode).ToString()); + var errorResponse = JsonConvert.DeserializeObject(ex.Response.Content); + + switch (from.Name) + { + case "400": + Assert.Equal("BadSyntax", errorResponse.Error.Code); + break; + case "403": + Assert.Equal("BotNotInConversationRoster", errorResponse.Error.Code); + break; + default: + throw new InvalidOperationException($"Expected {nameof(HttpOperationException)} with response status code {from.Name}."); + } + } + } + private async Task CallGroupChatGetMembersAsync(ITurnContext turnContext) { var members = (await TeamsInfo.GetMembersAsync(turnContext)).ToArray(); @@ -490,7 +634,7 @@ private async Task CallGetChannelsAsync(ITurnContext turnContext) private class RosterHttpMessageHandler : HttpMessageHandler { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected async override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var response = new HttpResponseMessage(HttpStatusCode.OK); @@ -639,7 +783,41 @@ protected override Task SendAsync(HttpRequestMessage reques response.Content = new StringContent(content.ToString()); } - return Task.FromResult(response); + // Post meeting notification + else if (request.RequestUri.PathAndQuery.EndsWith("v1/meetings/meeting-id/notification")) + { + var responseBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + var notification = JsonConvert.DeserializeObject(responseBody); + var obo = notification.ChannelData.OnBehalfOf.First(); + + // hack displayname as expected status code, for testing + switch (obo.DisplayName) + { + case "207": + var failureInfo = new TeamsMeetingNotificationRecipientFailureInfo { RecipientMri = notification.Value.Recipients.First(r => !r.Equals(obo.Mri, StringComparison.OrdinalIgnoreCase)) }; + var infos = new TeamsMeetingNotificationRecipientFailureInfos + { + RecipientsFailureInfo = new List { failureInfo } + }; + + response.Content = new StringContent(JsonConvert.SerializeObject(infos)); + response.StatusCode = HttpStatusCode.MultiStatus; + break; + case "403": + response.Content = new StringContent("{\"error\":{\"code\":\"BotNotInConversationRoster\"}}"); + response.StatusCode = HttpStatusCode.Forbidden; + break; + case "400": + response.Content = new StringContent("{\"error\":{\"code\":\"BadSyntax\"}}"); + response.StatusCode = HttpStatusCode.BadRequest; + break; + default: + response.StatusCode = HttpStatusCode.Accepted; + break; + } + } + + return response; } }