From 6abe6d66d7d62476c732134f88ae0a4ec44473e3 Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Wed, 30 Apr 2025 15:56:39 -0300 Subject: [PATCH 1/9] Add header propagation feature --- .../SkillDialog.cs | 3 + .../Authentication/BotFrameworkClientImpl.cs | 5 ++ .../Bot.Builder/BotFrameworkClient.cs | 7 ++ .../ConnectorClientEx.cs | 13 ++++ .../HeaderPropagation.cs | 70 +++++++++++++++++++ .../Teams/TeamsHeaderPropagation.cs | 28 ++++++++ .../CloudAdapter.cs | 21 ++++++ .../CloudAdapterTests.cs | 57 ++++++++------- 8 files changed, 175 insertions(+), 29 deletions(-) create mode 100644 libraries/Microsoft.Bot.Connector/HeaderPropagation.cs create mode 100644 libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs diff --git a/libraries/Microsoft.Bot.Builder.Dialogs/SkillDialog.cs b/libraries/Microsoft.Bot.Builder.Dialogs/SkillDialog.cs index 25016a19a1..0ef35f545c 100644 --- a/libraries/Microsoft.Bot.Builder.Dialogs/SkillDialog.cs +++ b/libraries/Microsoft.Bot.Builder.Dialogs/SkillDialog.cs @@ -249,6 +249,9 @@ private async Task SendToSkillAsync(ITurnContext context, Activity act await DialogOptions.ConversationState.SaveChangesAsync(context, true, cancellationToken).ConfigureAwait(false); var skillInfo = DialogOptions.Skill; + + DialogOptions.SkillClient.AddDefaultHeaders(); + var response = await DialogOptions.SkillClient.PostActivityAsync(DialogOptions.BotId, skillInfo.AppId, skillInfo.SkillEndpoint, DialogOptions.SkillHostEndpoint, skillConversationId, activity, cancellationToken).ConfigureAwait(false); // Inspect the skill response status diff --git a/libraries/Microsoft.Bot.Connector/Authentication/BotFrameworkClientImpl.cs b/libraries/Microsoft.Bot.Connector/Authentication/BotFrameworkClientImpl.cs index c86bf857cb..848bf6292b 100644 --- a/libraries/Microsoft.Bot.Connector/Authentication/BotFrameworkClientImpl.cs +++ b/libraries/Microsoft.Bot.Connector/Authentication/BotFrameworkClientImpl.cs @@ -121,6 +121,11 @@ public async override Task> PostActivityAsync(string fromBo } } + public override void AddDefaultHeaders() + { + ConnectorClient.AddDefaultRequestHeaders(_httpClient); + } + protected override void Dispose(bool disposing) { if (_disposed) diff --git a/libraries/Microsoft.Bot.Connector/Bot.Builder/BotFrameworkClient.cs b/libraries/Microsoft.Bot.Connector/Bot.Builder/BotFrameworkClient.cs index 1bec6f7722..05e2851f00 100644 --- a/libraries/Microsoft.Bot.Connector/Bot.Builder/BotFrameworkClient.cs +++ b/libraries/Microsoft.Bot.Connector/Bot.Builder/BotFrameworkClient.cs @@ -45,6 +45,13 @@ public async virtual Task PostActivityAsync(string fromBotId, st /// Async task with optional invokeResponse. public abstract Task> PostActivityAsync(string fromBotId, string toBotId, Uri toUrl, Uri serviceUrl, string conversationId, Activity activity, CancellationToken cancellationToken = default); + /// + /// Allows to add default headers to the HTTP client after the creation of the instance. + /// + public virtual void AddDefaultHeaders() + { + } + /// public void Dispose() { diff --git a/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs b/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs index 40062fe64b..84f9505a27 100644 --- a/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs +++ b/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs @@ -250,6 +250,19 @@ public static void AddDefaultRequestHeaders(HttpClient httpClient) } } + var filteredHeaders = HeaderPropagation.FilterHeaders(); + + if (filteredHeaders != null && filteredHeaders.Count > 0) + { + foreach (var header in filteredHeaders) + { + if (!httpClient.DefaultRequestHeaders.Contains(header.Key)) + { + httpClient.DefaultRequestHeaders.Add(header.Key, header.Value.ToArray()); + } + } + } + httpClient.DefaultRequestHeaders.ExpectContinue = false; var jsonAcceptHeader = new MediaTypeWithQualityHeaderValue("*/*"); diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs new file mode 100644 index 0000000000..6d39a76538 --- /dev/null +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Bot.Connector +{ + /// + /// Class to handle header propagation from incoming request to outgoing request. + /// + public static class HeaderPropagation + { + private static readonly AsyncLocal> _headers = new (); + + private static readonly AsyncLocal> _headersToPropagate = new (); + + /// + /// Gets or sets the headers from an incoming request. + /// + /// . + public static IDictionary Headers + { + get => _headers.Value ?? new Dictionary(); + set => _headers.Value = value; + } + + /// + /// Gets or sets the selected headers for propagation. + /// + /// . + public static IDictionary HeadersToPropagate + { + get => _headersToPropagate.Value ?? new Dictionary(); + set => _headersToPropagate.Value = value; + } + + /// + /// Filters the headers to only include those that are relevant for propagation. + /// + /// The filtered headers. + public static IDictionary FilterHeaders() + { + var filteredHeaders = new Dictionary(); + + if (Headers.TryGetValue("X-Ms-Correlation-Id", out var value)) + { + filteredHeaders.Add("X-Ms-Correlation-Id", value); + } + + foreach (var header in HeadersToPropagate) + { + if (!string.IsNullOrEmpty(header.Value)) + { + filteredHeaders.Add(header.Key, header.Value); + } + else + { + if (Headers.TryGetValue(header.Key, out var headerValue)) + { + filteredHeaders.Add(header.Key, headerValue); + } + } + } + + return filteredHeaders; + } + } +} diff --git a/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs b/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs new file mode 100644 index 0000000000..3c46b5b267 --- /dev/null +++ b/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Bot.Connector.Teams +{ + /// + /// Instantiate this class to set the headers to propagate from incoming request to outgoing request. + /// + public static class TeamsHeaderPropagation + { + /// + /// Set the headers to propagate from incoming request to outgoing request. + /// + public static void SetHeaderPropagation() + { + var headersToPropagate = new Dictionary + { + ["X-Ms-Teams-Id"] = string.Empty, + ["X-Ms-Teams-Custom"] = "Custom-Value" + }; + + HeaderPropagation.HeadersToPropagate = headersToPropagate; + } + } +} diff --git a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs index a94875571b..9104f57e81 100644 --- a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs +++ b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs @@ -14,6 +14,7 @@ using Microsoft.Bot.Connector; using Microsoft.Bot.Connector.Authentication; using Microsoft.Bot.Connector.Streaming.Application; +using Microsoft.Bot.Connector.Teams; using Microsoft.Bot.Schema; using Microsoft.Bot.Streaming; using Microsoft.Extensions.Configuration; @@ -77,6 +78,8 @@ public async Task ProcessAsync(HttpRequest httpRequest, HttpResponse httpRespons _ = httpResponse ?? throw new ArgumentNullException(nameof(httpResponse)); _ = bot ?? throw new ArgumentNullException(nameof(bot)); + FillHeadersDictionary(httpRequest); + try { // Only GET requests for web socket connects are allowed @@ -98,6 +101,12 @@ public async Task ProcessAsync(HttpRequest httpRequest, HttpResponse httpRespons return; } + // Check channel to propagate headers. + if (activity.ChannelId == Channels.Msteams) + { + TeamsHeaderPropagation.SetHeaderPropagation(); + } + // Grab the auth header from the inbound http request var authHeader = httpRequest.Headers["Authorization"]; @@ -206,6 +215,18 @@ protected virtual StreamingConnection CreateWebSocketConnection(WebSocket socket return new WebSocketStreamingConnection(socket, logger); } + private void FillHeadersDictionary(HttpRequest httpRequest) + { + var headers = HeaderPropagation.Headers; + + foreach (var header in httpRequest.Headers) + { + headers.Add(header.Key, header.Value); + } + + HeaderPropagation.Headers = headers; + } + private async Task ConnectAsync(HttpRequest httpRequest, IBot bot, CancellationToken cancellationToken) { Logger.LogInformation($"Received request for web socket connect."); diff --git a/tests/integration/Microsoft.Bot.Builder.Integration.AspNet.Core.Tests/CloudAdapterTests.cs b/tests/integration/Microsoft.Bot.Builder.Integration.AspNet.Core.Tests/CloudAdapterTests.cs index 5e6798c83a..b59145f829 100644 --- a/tests/integration/Microsoft.Bot.Builder.Integration.AspNet.Core.Tests/CloudAdapterTests.cs +++ b/tests/integration/Microsoft.Bot.Builder.Integration.AspNet.Core.Tests/CloudAdapterTests.cs @@ -41,13 +41,13 @@ public class CloudAdapterTests public async Task BasicMessageActivity() { // Arrange - var headerDictionaryMock = new Mock(); - headerDictionaryMock.Setup(h => h[It.Is(v => v == "Authorization")]).Returns(null); - var httpRequestMock = new Mock(); httpRequestMock.Setup(r => r.Method).Returns(HttpMethods.Post); httpRequestMock.Setup(r => r.Body).Returns(CreateMessageActivityStream()); - httpRequestMock.Setup(r => r.Headers).Returns(headerDictionaryMock.Object); + httpRequestMock.Setup(r => r.Headers).Returns(new HeaderDictionary + { + { "Authorization", StringValues.Empty } + }); var httpResponseMock = new Mock(); @@ -66,13 +66,13 @@ public async Task BasicMessageActivity() public async Task InvokeActivity() { // Arrange - var headerDictionaryMock = new Mock(); - headerDictionaryMock.Setup(h => h[It.Is(v => v == "Authorization")]).Returns(null); - var httpRequestMock = new Mock(); httpRequestMock.Setup(r => r.Method).Returns(HttpMethods.Post); httpRequestMock.Setup(r => r.Body).Returns(CreateInvokeActivityStream()); - httpRequestMock.Setup(r => r.Headers).Returns(headerDictionaryMock.Object); + httpRequestMock.Setup(r => r.Headers).Returns(new HeaderDictionary + { + { "Authorization", StringValues.Empty } + }); var response = new MemoryStream(); var httpResponseMock = new Mock(); @@ -389,13 +389,13 @@ public async Task CanContinueConversationOverWebSocketAsync() public async Task MessageActivityWithHttpClient() { // Arrange - var headerDictionaryMock = new Mock(); - headerDictionaryMock.Setup(h => h[It.Is(v => v == "Authorization")]).Returns(null); - var httpRequestMock = new Mock(); httpRequestMock.Setup(r => r.Method).Returns(HttpMethods.Post); httpRequestMock.Setup(r => r.Body).Returns(CreateMessageActivityStream()); - httpRequestMock.Setup(r => r.Headers).Returns(headerDictionaryMock.Object); + httpRequestMock.Setup(r => r.Headers).Returns(new HeaderDictionary + { + { "Authorization", StringValues.Empty } + }); var httpResponseMock = new Mock(); @@ -474,13 +474,13 @@ public async Task BadRequest() public async Task InjectCloudEnvironment() { // Arrange - var headerDictionaryMock = new Mock(); - headerDictionaryMock.Setup(h => h[It.Is(v => v == "Authorization")]).Returns(null); - var httpRequestMock = new Mock(); httpRequestMock.Setup(r => r.Method).Returns(HttpMethods.Post); httpRequestMock.Setup(r => r.Body).Returns(CreateMessageActivityStream()); - httpRequestMock.Setup(r => r.Headers).Returns(headerDictionaryMock.Object); + httpRequestMock.Setup(r => r.Headers).Returns(new HeaderDictionary + { + { "Authorization", StringValues.Empty } + }); var httpResponseMock = new Mock(); @@ -527,13 +527,13 @@ public async Task CloudAdapterProvidesUserTokenClient() string relatesToActivityId = "relatesToActivityId"; string connectionName = "connectionName"; - var headerDictionaryMock = new Mock(); - headerDictionaryMock.Setup(h => h[It.Is(v => v == "Authorization")]).Returns(null); - var httpRequestMock = new Mock(); httpRequestMock.Setup(r => r.Method).Returns(HttpMethods.Post); httpRequestMock.Setup(r => r.Body).Returns(CreateMessageActivityStream(userId, channelId, conversationId, recipientId, relatesToActivityId)); - httpRequestMock.Setup(r => r.Headers).Returns(headerDictionaryMock.Object); + httpRequestMock.Setup(r => r.Headers).Returns(new HeaderDictionary + { + { "Authorization", StringValues.Empty } + }); var httpResponseMock = new Mock(); @@ -609,14 +609,13 @@ public async Task CloudAdapterConnectorFactory() // this is just a basic test to verify the wire-up of a ConnectorFactory in the CloudAdapter // Arrange - - var headerDictionaryMock = new Mock(); - headerDictionaryMock.Setup(h => h[It.Is(v => v == "Authorization")]).Returns(null); - var httpRequestMock = new Mock(); httpRequestMock.Setup(r => r.Method).Returns(HttpMethods.Post); httpRequestMock.Setup(r => r.Body).Returns(CreateMessageActivityStream()); - httpRequestMock.Setup(r => r.Headers).Returns(headerDictionaryMock.Object); + httpRequestMock.Setup(r => r.Headers).Returns(new HeaderDictionary + { + { "Authorization", StringValues.Empty } + }); var httpResponseMock = new Mock(); @@ -809,7 +808,6 @@ public async Task CloudAdapterCreateConversation() public async Task ExpiredTokenShouldThrowUnauthorizedAccessException() { // Arrange - var headerDictionaryMock = new Mock(); // Expired token with removed AppID // This token will be validated against real endpoint https://login.microsoftonline.com/common/discovery/v2.0/keys @@ -826,12 +824,13 @@ public async Task ExpiredTokenShouldThrowUnauthorizedAccessException() // - delete the app var token = "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ii1LSTNROW5OUjdiUm9meG1lWm9YcWJIWkdldyIsImtpZCI6Ii1LSTNROW5OUjdiUm9meG1lWm9YcWJIWkdldyJ9.eyJhdWQiOiJodHRwczovL2FwaS5ib3RmcmFtZXdvcmsuY29tIiwiaXNzIjoiaHR0cHM6Ly9zdHMud2luZG93cy5uZXQvZDZkNDk0MjAtZjM5Yi00ZGY3LWExZGMtZDU5YTkzNTg3MWRiLyIsImlhdCI6MTY5Mjg3MDMwMiwibmJmIjoxNjkyODcwMzAyLCJleHAiOjE2OTI5NTcwMDIsImFpbyI6IkUyRmdZUGhhdFZ6czVydGFFYTlWbDN2ZnIyQ2JBZ0E9IiwiYXBwaWQiOiIxNWYwMTZmZS00ODhjLTQwZTktOWNiZS00Yjk0OGY5OGUyMmMiLCJhcHBpZGFjciI6IjEiLCJpZHAiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC9kNmQ0OTQyMC1mMzliLTRkZjctYTFkYy1kNTlhOTM1ODcxZGIvIiwicmgiOiIwLkFXNEFJSlRVMXB2ejkwMmgzTldhazFoeDIwSXpMWTBwejFsSmxYY09EcS05RnJ4dUFBQS4iLCJ0aWQiOiJkNmQ0OTQyMC1mMzliLTRkZjctYTFkYy1kNTlhOTM1ODcxZGIiLCJ1dGkiOiJkenVwa1dWd2FVT2x1RldkbnlvLUFBIiwidmVyIjoiMS4wIn0.sbQH997Q2GDKiiYd6l5MIz_XNfXypJd6zLY9xjtvEgXMBB0x0Vu3fv9W0nM57_ZipQiZDTZuSQA5BE30KBBwU-ZVqQ7MgiTkmE9eF6Ngie_5HwSr9xMK3EiDghHiOP9pIj3oEwGOSyjR5L9n-7tLSdUbKVyV14nS8OQtoPd1LZfoZI3e7tVu3vx8Lx3KzudanXX8Vz7RKaYndj3RyRi4wEN5hV9ab40d7fQsUzygFd5n_PXC2rs0OhjZJzjCOTC0VLQEn1KwiTkSH1E-OSzkrMltn1sbhD2tv_H-4rqQd51vAEJ7esC76qQjz_pfDRLs6T2jvJyhd5MZrN_MT0TqlA"; - headerDictionaryMock.Setup(h => h[It.Is(v => v == "Authorization")]).Returns((_) => token); - var httpRequestMock = new Mock(); httpRequestMock.Setup(r => r.Method).Returns(HttpMethods.Post); httpRequestMock.Setup(r => r.Body).Returns(CreateInvokeActivityStream()); - httpRequestMock.Setup(r => r.Headers).Returns(headerDictionaryMock.Object); + httpRequestMock.Setup(r => r.Headers).Returns(new HeaderDictionary + { + { "Authorization", token } + }); var response = new MemoryStream(); var httpResponseMock = new Mock().SetupAllProperties(); From 9dd18d5bd020943360cb7d1fe0dea902ba76a145 Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Tue, 6 May 2025 11:47:27 -0300 Subject: [PATCH 2/9] Propagate headers in proactive scenarios --- .../Microsoft.Bot.Builder/CloudAdapterBase.cs | 2 +- .../ConnectorClientEx.cs | 8 +- .../HeaderPropagation.cs | 25 +++--- .../Teams/TeamsHeaderPropagation.cs | 9 +- .../CloudAdapter.cs | 83 ++++++++++++++++--- 5 files changed, 98 insertions(+), 29 deletions(-) diff --git a/libraries/Microsoft.Bot.Builder/CloudAdapterBase.cs b/libraries/Microsoft.Bot.Builder/CloudAdapterBase.cs index ad10362487..fb05222b65 100644 --- a/libraries/Microsoft.Bot.Builder/CloudAdapterBase.cs +++ b/libraries/Microsoft.Bot.Builder/CloudAdapterBase.cs @@ -259,7 +259,7 @@ protected virtual ConnectorFactory GetStreamingConnectorFactory(Activity activit /// The method to call for the resulting bot turn. /// Cancellation token. /// A task that represents the work queued to execute. - protected async Task ProcessProactiveAsync(ClaimsIdentity claimsIdentity, Activity continuationActivity, string audience, BotCallbackHandler callback, CancellationToken cancellationToken) + protected virtual async Task ProcessProactiveAsync(ClaimsIdentity claimsIdentity, Activity continuationActivity, string audience, BotCallbackHandler callback, CancellationToken cancellationToken) { Logger.LogInformation($"ProcessProactiveAsync for Conversation Id: {continuationActivity.Conversation.Id}"); diff --git a/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs b/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs index 84f9505a27..fc70db850a 100644 --- a/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs +++ b/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; @@ -9,6 +10,7 @@ using System.Runtime.Versioning; using System.Text.RegularExpressions; using Microsoft.Bot.Connector.Authentication; +using Microsoft.Extensions.Primitives; using Microsoft.Rest; using Newtonsoft.Json.Serialization; @@ -250,11 +252,11 @@ public static void AddDefaultRequestHeaders(HttpClient httpClient) } } - var filteredHeaders = HeaderPropagation.FilterHeaders(); + var headersToPropagate = HeaderPropagation.HeadersToPropagate; - if (filteredHeaders != null && filteredHeaders.Count > 0) + if (headersToPropagate != null && headersToPropagate.Count > 0) { - foreach (var header in filteredHeaders) + foreach (var header in headersToPropagate) { if (!httpClient.DefaultRequestHeaders.Contains(header.Key)) { diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs index 6d39a76538..5cb470e840 100644 --- a/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs @@ -12,7 +12,7 @@ namespace Microsoft.Bot.Connector /// public static class HeaderPropagation { - private static readonly AsyncLocal> _headers = new (); + private static readonly AsyncLocal> _requestHeaders = new (); private static readonly AsyncLocal> _headersToPropagate = new (); @@ -20,10 +20,10 @@ public static class HeaderPropagation /// Gets or sets the headers from an incoming request. /// /// . - public static IDictionary Headers + public static IDictionary RequestHeaders { - get => _headers.Value ?? new Dictionary(); - set => _headers.Value = value; + get => _requestHeaders.Value ?? new Dictionary(); + set => _requestHeaders.Value = value; } /// @@ -37,29 +37,30 @@ public static IDictionary HeadersToPropagate } /// - /// Filters the headers to only include those that are relevant for propagation. + /// Filters the request's headers to only include those that are relevant for propagation. /// + /// The headers to filter. /// The filtered headers. - public static IDictionary FilterHeaders() + public static IDictionary FilterHeaders(IDictionary headerFilter) { var filteredHeaders = new Dictionary(); - if (Headers.TryGetValue("X-Ms-Correlation-Id", out var value)) + if (RequestHeaders.TryGetValue("X-Ms-Correlation-Id", out var value)) { filteredHeaders.Add("X-Ms-Correlation-Id", value); } - foreach (var header in HeadersToPropagate) + foreach (var filter in headerFilter) { - if (!string.IsNullOrEmpty(header.Value)) + if (!string.IsNullOrEmpty(filter.Value)) { - filteredHeaders.Add(header.Key, header.Value); + filteredHeaders.Add(filter.Key, filter.Value); } else { - if (Headers.TryGetValue(header.Key, out var headerValue)) + if (RequestHeaders.TryGetValue(filter.Key, out var headerValue)) { - filteredHeaders.Add(header.Key, headerValue); + filteredHeaders.Add(filter.Key, headerValue); } } } diff --git a/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs b/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs index 3c46b5b267..eeb14d23f7 100644 --- a/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs +++ b/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs @@ -12,9 +12,10 @@ namespace Microsoft.Bot.Connector.Teams public static class TeamsHeaderPropagation { /// - /// Set the headers to propagate from incoming request to outgoing request. + /// Returns the headers to propagate from incoming request to outgoing request. /// - public static void SetHeaderPropagation() + /// . + public static Dictionary GetHeadersToPropagate() { var headersToPropagate = new Dictionary { @@ -22,7 +23,9 @@ public static void SetHeaderPropagation() ["X-Ms-Teams-Custom"] = "Custom-Value" }; - HeaderPropagation.HeadersToPropagate = headersToPropagate; + return headersToPropagate; + + // HeaderPropagation.HeadersToPropagate = headersToPropagate; } } } diff --git a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs index 9104f57e81..6f53ea8871 100644 --- a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs +++ b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.WebSockets; @@ -19,6 +21,7 @@ using Microsoft.Bot.Streaming; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; namespace Microsoft.Bot.Builder.Integration.AspNet.Core { @@ -28,6 +31,7 @@ namespace Microsoft.Bot.Builder.Integration.AspNet.Core public class CloudAdapter : CloudAdapterBase, IBotFrameworkHttpAdapter { private readonly ConcurrentDictionary _streamingConnections = new ConcurrentDictionary(); + private readonly IStorage _storage; /// /// Initializes a new instance of the class. (Public cloud. No auth. For testing.) @@ -42,11 +46,14 @@ public CloudAdapter() /// /// The this adapter should use. /// The implementation this adapter should use. + /// The implementation this adapter should use for header propagation. public CloudAdapter( BotFrameworkAuthentication botFrameworkAuthentication, - ILogger logger = null) + ILogger logger = null, + IStorage storage = null) : base(botFrameworkAuthentication, logger) { + _storage = storage ?? new MemoryStorage(); } /// @@ -55,12 +62,15 @@ public CloudAdapter( /// The instance. /// The this adapter should use. /// The implementation this adapter should use. + /// The implementation this adapter should use for header propagation. public CloudAdapter( IConfiguration configuration, IHttpClientFactory httpClientFactory = null, - ILogger logger = null) + ILogger logger = null, + IStorage storage = null) : this(new ConfigurationBotFrameworkAuthentication(configuration, httpClientFactory: httpClientFactory, logger: logger), logger) { + _storage = storage ?? new MemoryStorage(); } /// @@ -78,8 +88,6 @@ public async Task ProcessAsync(HttpRequest httpRequest, HttpResponse httpRespons _ = httpResponse ?? throw new ArgumentNullException(nameof(httpResponse)); _ = bot ?? throw new ArgumentNullException(nameof(bot)); - FillHeadersDictionary(httpRequest); - try { // Only GET requests for web socket connects are allowed @@ -101,10 +109,22 @@ public async Task ProcessAsync(HttpRequest httpRequest, HttpResponse httpRespons return; } - // Check channel to propagate headers. - if (activity.ChannelId == Channels.Msteams) + var filteredHeaders = GetPropagationHeaders(httpRequest, activity); + + // Store headers in memory to be retrieved in case of proactive messages. + if (activity.Conversation?.Id != null) { - TeamsHeaderPropagation.SetHeaderPropagation(); + var serializedHeaders = filteredHeaders.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToArray()); + + var storageKey = $"headers-{activity.Conversation.Id}"; + var storageData = new Dictionary + { + { storageKey, serializedHeaders } + }; + + await _storage.WriteAsync(storageData, cancellationToken); } // Grab the auth header from the inbound http request @@ -215,16 +235,59 @@ protected virtual StreamingConnection CreateWebSocketConnection(WebSocket socket return new WebSocketStreamingConnection(socket, logger); } - private void FillHeadersDictionary(HttpRequest httpRequest) + /// + protected override async Task ProcessProactiveAsync(ClaimsIdentity claimsIdentity, Activity continuationActivity, string audience, BotCallbackHandler callback, CancellationToken cancellationToken) + { + // Retrieve the headers from IStorage + if (continuationActivity.Conversation?.Id != null) + { + var storageKey = $"headers-{continuationActivity.Conversation.Id}"; + var storedData = await _storage.ReadAsync([storageKey], cancellationToken); + + if (storedData.TryGetValue(storageKey, out var headersObject) && headersObject is Dictionary serializedHeaders) + { + var headers = serializedHeaders.ToDictionary( + kvp => kvp.Key, + kvp => new StringValues(kvp.Value)); + + HeaderPropagation.HeadersToPropagate = headers; + } + } + + await base.ProcessProactiveAsync(claimsIdentity, continuationActivity, audience, callback, cancellationToken); + } + + /// + /// Get the headers to propagate from the the incoming request. + /// + /// The incoming request to get the headers from. + /// The activity contained in the request. + /// The headers to be propagated to outgoing requests. + private IDictionary GetPropagationHeaders(HttpRequest httpRequest, IActivity activity) { - var headers = HeaderPropagation.Headers; + // Read the headers from the request. + var headers = new Dictionary(); foreach (var header in httpRequest.Headers) { headers.Add(header.Key, header.Value); } - HeaderPropagation.Headers = headers; + HeaderPropagation.RequestHeaders = headers; + + // Look for the selected filters to propagate. + var teamsHeaders = new Dictionary(); + + if (activity.ChannelId == Channels.Msteams) + { + teamsHeaders = TeamsHeaderPropagation.GetHeadersToPropagate(); + } + + var filteredHeaders = HeaderPropagation.FilterHeaders(teamsHeaders); + + HeaderPropagation.HeadersToPropagate = filteredHeaders; + + return filteredHeaders; } private async Task ConnectAsync(HttpRequest httpRequest, IBot bot, CancellationToken cancellationToken) From 33867e8dc646080b016b55575fa62d96b9d50df3 Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Thu, 15 May 2025 17:43:27 -0300 Subject: [PATCH 3/9] Add append and override operations --- .../HeaderPropagation.cs | 38 +++++--- .../HeaderPropagationEntry.cs | 62 ++++++++++++ .../HeaderPropagationEntryCollection.cs | 97 +++++++++++++++++++ .../Teams/TeamsHeaderPropagation.cs | 17 ++-- .../CloudAdapter.cs | 2 +- 5 files changed, 195 insertions(+), 21 deletions(-) create mode 100644 libraries/Microsoft.Bot.Connector/HeaderPropagationEntry.cs create mode 100644 libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs index 5cb470e840..cc307ad2f1 100644 --- a/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs @@ -41,26 +41,42 @@ public static IDictionary HeadersToPropagate /// /// The headers to filter. /// The filtered headers. - public static IDictionary FilterHeaders(IDictionary headerFilter) + public static IDictionary FilterHeaders(HeaderPropagationEntryCollection headerFilter) { + // We propagate the X-Ms-Correlation-Id header by default. + headerFilter.Propagate("X-Ms-Correlation-Id"); + var filteredHeaders = new Dictionary(); - if (RequestHeaders.TryGetValue("X-Ms-Correlation-Id", out var value)) + foreach (var filter in headerFilter.Entries) { - filteredHeaders.Add("X-Ms-Correlation-Id", value); - } - - foreach (var filter in headerFilter) - { - if (!string.IsNullOrEmpty(filter.Value)) + if (RequestHeaders.TryGetValue(filter.Key, out var value)) { - filteredHeaders.Add(filter.Key, filter.Value); + switch (filter.Action) + { + case HeaderPropagationEntryAction.Add: + break; + case HeaderPropagationEntryAction.Append: + filteredHeaders.Add(filter.Key, string.Concat(value, filter.Value)); + break; + case HeaderPropagationEntryAction.Override: + filteredHeaders.Add(filter.Key, filter.Value); + break; + case HeaderPropagationEntryAction.Propagate: + filteredHeaders.Add(filter.Key, value); + break; + } } else { - if (RequestHeaders.TryGetValue(filter.Key, out var headerValue)) + switch (filter.Action) { - filteredHeaders.Add(filter.Key, headerValue); + case HeaderPropagationEntryAction.Add: + filteredHeaders.Add(filter.Key, filter.Value); + break; + case HeaderPropagationEntryAction.Override: + filteredHeaders.Add(filter.Key, filter.Value); + break; } } } diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagationEntry.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntry.cs new file mode 100644 index 0000000000..8e774987c2 --- /dev/null +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntry.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.Serialization; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Bot.Connector +{ + /// + /// Represents the action of the header entry. + /// + public enum HeaderPropagationEntryAction + { + /// + /// Adds a new header entry to the outgoing request. + /// + [EnumMember(Value = "add")] + Add, + + /// + /// Appends a new header value to an existing key in the outgoing request. + /// + [EnumMember(Value = "append")] + Append, + + /// + /// Propagates the header entry from the incoming request to the outgoing request without modifications. + /// + [EnumMember(Value = "propagate")] + Propagate, + + /// + /// Overrides an existing header entry in the outgoing request. + /// + [EnumMember(Value = "override")] + Override + } + + /// + /// Represents a single header entry used for header propagation. + /// + public class HeaderPropagationEntry + { + /// + /// Gets or sets the key of the header entry. + /// + /// Key of the header entry. + public string Key { get; set; } = string.Empty; + + /// + /// Gets or sets the value of the header entry. + /// + /// Value of the header entry. + public StringValues Value { get; set; } = new StringValues(string.Empty); + + /// + /// Gets or sets the action of the header entry (Add, Append, Override or Propagate). + /// + /// Action of the header entry. + public HeaderPropagationEntryAction Action { get; set; } = HeaderPropagationEntryAction.Propagate; + } +} diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs new file mode 100644 index 0000000000..aded6f5f10 --- /dev/null +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Primitives; + +#pragma warning disable SA1010 // OpeningSquareBracketsMustBeSpacedCorrectly + +namespace Microsoft.Bot.Connector +{ + /// + /// Represents a collection of all the header entries configured to be propagated to outgoing requests. + /// + public class HeaderPropagationEntryCollection + { + private readonly Dictionary _entries = []; + + /// + /// Gets the collection of header entries to be propagated to outgoing requests. + /// + /// The collection of header entries. + public List Entries => [.. _entries.Select(x => x.Value)]; + + /// + /// Attempts to add a new header entry to the collection. + /// + /// + /// If the key already exists, it will be ignored. + /// + /// The key of the element to add. + /// The value to add for the specified key. + public void Add(string key, StringValues value) + { + _entries[key] = new HeaderPropagationEntry + { + Key = key, + Value = value, + Action = HeaderPropagationEntryAction.Add + }; + } + + /// + /// Appends a new header value to an existing key. + /// + /// + /// If the key does not exist, it will be ignored. + /// + /// The key of the element to append the value. + /// The value to append for the specified key. + public void Append(string key, StringValues value) + { + _entries[key] = new HeaderPropagationEntry + { + Key = key, + Value = value, + Action = HeaderPropagationEntryAction.Append + }; + } + + /// + /// Propagates the incoming request header value to outgoing requests without modifications. + /// + /// + /// If the key does not exist, it will be ignored. + /// + /// The key of the element to propagate. + public void Propagate(string key) + { + _entries[key] = new HeaderPropagationEntry + { + Key = key, + Action = HeaderPropagationEntryAction.Propagate + }; + } + + /// + /// Overrides the header value of an existing key. + /// + /// + /// If the key does not exist, it will add it. + /// + /// The key of the element to override. + /// The value to override in the specified key. + public void Override(string key, StringValues value) + { + _entries[key] = new HeaderPropagationEntry + { + Key = key, + Value = value, + Action = HeaderPropagationEntryAction.Override + }; + } + } +} + +#pragma warning restore SA1010 // OpeningSquareBracketsMustBeSpacedCorrectly diff --git a/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs b/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs index eeb14d23f7..30feb581b1 100644 --- a/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs +++ b/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.Collections.Generic; using Microsoft.Extensions.Primitives; namespace Microsoft.Bot.Connector.Teams @@ -15,17 +14,17 @@ public static class TeamsHeaderPropagation /// Returns the headers to propagate from incoming request to outgoing request. /// /// . - public static Dictionary GetHeadersToPropagate() + public static HeaderPropagationEntryCollection GetHeadersToPropagate() { - var headersToPropagate = new Dictionary - { - ["X-Ms-Teams-Id"] = string.Empty, - ["X-Ms-Teams-Custom"] = "Custom-Value" - }; + // Propagate headers to the outgoing request by adding them to the HeaderPropagationEntryCollection. For example: + var headersToPropagate = new HeaderPropagationEntryCollection(); + + headersToPropagate.Propagate("X-Ms-Teams-Id"); + headersToPropagate.Add("X-Ms-Teams-Custom", new StringValues("Custom-Value")); + headersToPropagate.Append("X-Ms-Teams-Channel", new StringValues("-SubChannel-Id")); + headersToPropagate.Override("X-Ms-Other", new StringValues("new-value")); return headersToPropagate; - - // HeaderPropagation.HeadersToPropagate = headersToPropagate; } } } diff --git a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs index 6f53ea8871..87c2f4ea56 100644 --- a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs +++ b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs @@ -276,7 +276,7 @@ private IDictionary GetPropagationHeaders(HttpRequest http HeaderPropagation.RequestHeaders = headers; // Look for the selected filters to propagate. - var teamsHeaders = new Dictionary(); + var teamsHeaders = new HeaderPropagationEntryCollection(); if (activity.ChannelId == Channels.Msteams) { From 5846559107d8403603626185ca3e12b64edd5994 Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Fri, 16 May 2025 12:03:29 -0300 Subject: [PATCH 4/9] Use TeamsHeaderPropagation as an example --- .../Microsoft.Bot.Connector/ConnectorClientEx.cs | 2 -- .../Microsoft.Bot.Connector/HeaderPropagation.cs | 8 ++++---- .../HeaderPropagationEntry.cs | 2 +- .../HeaderPropagationEntryCollection.cs | 2 +- .../Teams/TeamsHeaderPropagation.cs | 13 +++++++------ .../CloudAdapter.cs | 15 ++++++++------- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs b/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs index fc70db850a..6b2a5275f5 100644 --- a/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs +++ b/libraries/Microsoft.Bot.Connector/ConnectorClientEx.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System; -using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; @@ -10,7 +9,6 @@ using System.Runtime.Versioning; using System.Text.RegularExpressions; using Microsoft.Bot.Connector.Authentication; -using Microsoft.Extensions.Primitives; using Microsoft.Rest; using Newtonsoft.Json.Serialization; diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs index cc307ad2f1..a387bc6db0 100644 --- a/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs @@ -19,7 +19,7 @@ public static class HeaderPropagation /// /// Gets or sets the headers from an incoming request. /// - /// . + /// The headers from an incoming request. public static IDictionary RequestHeaders { get => _requestHeaders.Value ?? new Dictionary(); @@ -29,7 +29,7 @@ public static IDictionary RequestHeaders /// /// Gets or sets the selected headers for propagation. /// - /// . + /// The selected headers for propagation. public static IDictionary HeadersToPropagate { get => _headersToPropagate.Value ?? new Dictionary(); @@ -37,9 +37,9 @@ public static IDictionary HeadersToPropagate } /// - /// Filters the request's headers to only include those that are relevant for propagation. + /// Filters the request's headers to include only those relevant for propagation. /// - /// The headers to filter. + /// The chosen headers to propagate. /// The filtered headers. public static IDictionary FilterHeaders(HeaderPropagationEntryCollection headerFilter) { diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagationEntry.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntry.cs index 8e774987c2..b5a5300cc2 100644 --- a/libraries/Microsoft.Bot.Connector/HeaderPropagationEntry.cs +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntry.cs @@ -7,7 +7,7 @@ namespace Microsoft.Bot.Connector { /// - /// Represents the action of the header entry. + /// Represents the action to perform with the header entry. /// public enum HeaderPropagationEntryAction { diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs index aded6f5f10..f8ef9ec0a1 100644 --- a/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs @@ -10,7 +10,7 @@ namespace Microsoft.Bot.Connector { /// - /// Represents a collection of all the header entries configured to be propagated to outgoing requests. + /// Represents a collection of the header entries configured to be propagated to outgoing requests. /// public class HeaderPropagationEntryCollection { diff --git a/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs b/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs index 30feb581b1..55a907c7c3 100644 --- a/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs +++ b/libraries/Microsoft.Bot.Connector/Teams/TeamsHeaderPropagation.cs @@ -13,16 +13,17 @@ public static class TeamsHeaderPropagation /// /// Returns the headers to propagate from incoming request to outgoing request. /// - /// . + /// The collection of headers to propagate. public static HeaderPropagationEntryCollection GetHeadersToPropagate() { - // Propagate headers to the outgoing request by adding them to the HeaderPropagationEntryCollection. For example: + // Propagate headers to the outgoing request by adding them to the HeaderPropagationEntryCollection. + // For example: var headersToPropagate = new HeaderPropagationEntryCollection(); - headersToPropagate.Propagate("X-Ms-Teams-Id"); - headersToPropagate.Add("X-Ms-Teams-Custom", new StringValues("Custom-Value")); - headersToPropagate.Append("X-Ms-Teams-Channel", new StringValues("-SubChannel-Id")); - headersToPropagate.Override("X-Ms-Other", new StringValues("new-value")); + //headersToPropagate.Propagate("X-Ms-Teams-Id"); + //headersToPropagate.Add("X-Ms-Teams-Custom", new StringValues("Custom-Value")); + //headersToPropagate.Append("X-Ms-Teams-Channel", new StringValues("-SubChannel-Id")); + //headersToPropagate.Override("X-Ms-Other", new StringValues("new-value")); return headersToPropagate; } diff --git a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs index 87c2f4ea56..14ce1cf9e1 100644 --- a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs +++ b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs @@ -275,15 +275,16 @@ private IDictionary GetPropagationHeaders(HttpRequest http HeaderPropagation.RequestHeaders = headers; - // Look for the selected filters to propagate. - var teamsHeaders = new HeaderPropagationEntryCollection(); + // Look for the selected headers to propagate. + var headersCollection = new HeaderPropagationEntryCollection(); - if (activity.ChannelId == Channels.Msteams) - { - teamsHeaders = TeamsHeaderPropagation.GetHeadersToPropagate(); - } + // TODO: If a channel implements a static class to configure header propagation, add it to this block. + //if (activity.ChannelId == Channels.Msteams) + //{ + // headersCollection = TeamsHeaderPropagation.GetHeadersToPropagate(); + //} - var filteredHeaders = HeaderPropagation.FilterHeaders(teamsHeaders); + var filteredHeaders = HeaderPropagation.FilterHeaders(headersCollection); HeaderPropagation.HeadersToPropagate = filteredHeaders; From d5b743ba611ee2a88f1460c852007c422845085a Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Fri, 16 May 2025 14:57:33 -0300 Subject: [PATCH 5/9] Apply feedback --- libraries/Microsoft.Bot.Connector/HeaderPropagation.cs | 9 +++++---- .../HeaderPropagationEntryCollection.cs | 9 +++------ .../CloudAdapter.cs | 4 ++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs index a387bc6db0..70c957a547 100644 --- a/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagation.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Threading; using Microsoft.Extensions.Primitives; @@ -22,7 +23,7 @@ public static class HeaderPropagation /// The headers from an incoming request. public static IDictionary RequestHeaders { - get => _requestHeaders.Value ?? new Dictionary(); + get => _requestHeaders.Value ??= new Dictionary(StringComparer.OrdinalIgnoreCase); set => _requestHeaders.Value = value; } @@ -32,7 +33,7 @@ public static IDictionary RequestHeaders /// The selected headers for propagation. public static IDictionary HeadersToPropagate { - get => _headersToPropagate.Value ?? new Dictionary(); + get => _headersToPropagate.Value ??= new Dictionary(StringComparer.OrdinalIgnoreCase); set => _headersToPropagate.Value = value; } @@ -46,7 +47,7 @@ public static IDictionary FilterHeaders(HeaderPropagationE // We propagate the X-Ms-Correlation-Id header by default. headerFilter.Propagate("X-Ms-Correlation-Id"); - var filteredHeaders = new Dictionary(); + var filteredHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var filter in headerFilter.Entries) { @@ -57,7 +58,7 @@ public static IDictionary FilterHeaders(HeaderPropagationE case HeaderPropagationEntryAction.Add: break; case HeaderPropagationEntryAction.Append: - filteredHeaders.Add(filter.Key, string.Concat(value, filter.Value)); + filteredHeaders[filter.Key] = StringValues.Concat(value, filter.Value); break; case HeaderPropagationEntryAction.Override: filteredHeaders.Add(filter.Key, filter.Value); diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs index f8ef9ec0a1..f09f1ea7c5 100644 --- a/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs @@ -1,12 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.Primitives; -#pragma warning disable SA1010 // OpeningSquareBracketsMustBeSpacedCorrectly - namespace Microsoft.Bot.Connector { /// @@ -14,13 +13,13 @@ namespace Microsoft.Bot.Connector /// public class HeaderPropagationEntryCollection { - private readonly Dictionary _entries = []; + private readonly Dictionary _entries = new (StringComparer.OrdinalIgnoreCase); /// /// Gets the collection of header entries to be propagated to outgoing requests. /// /// The collection of header entries. - public List Entries => [.. _entries.Select(x => x.Value)]; + public List Entries => _entries.Values.ToList(); /// /// Attempts to add a new header entry to the collection. @@ -93,5 +92,3 @@ public void Override(string key, StringValues value) } } } - -#pragma warning restore SA1010 // OpeningSquareBracketsMustBeSpacedCorrectly diff --git a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs index 14ce1cf9e1..24c1b3f145 100644 --- a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs +++ b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs @@ -266,11 +266,11 @@ protected override async Task ProcessProactiveAsync(ClaimsIdentity claimsIdentit private IDictionary GetPropagationHeaders(HttpRequest httpRequest, IActivity activity) { // Read the headers from the request. - var headers = new Dictionary(); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var header in httpRequest.Headers) { - headers.Add(header.Key, header.Value); + headers[header.Key] = header.Value; } HeaderPropagation.RequestHeaders = headers; From 4a6dc0dd5cd40734a5fbf8a1034e40a16c645dd9 Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Mon, 26 May 2025 09:55:28 -0300 Subject: [PATCH 6/9] Improve use of Storage --- .../CloudAdapter.cs | 98 +++++++++++++++---- 1 file changed, 80 insertions(+), 18 deletions(-) diff --git a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs index 24c1b3f145..817edb4f54 100644 --- a/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs +++ b/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/CloudAdapter.cs @@ -111,20 +111,10 @@ public async Task ProcessAsync(HttpRequest httpRequest, HttpResponse httpRespons var filteredHeaders = GetPropagationHeaders(httpRequest, activity); - // Store headers in memory to be retrieved in case of proactive messages. if (activity.Conversation?.Id != null) { - var serializedHeaders = filteredHeaders.ToDictionary( - kvp => kvp.Key, - kvp => kvp.Value.ToArray()); - - var storageKey = $"headers-{activity.Conversation.Id}"; - var storageData = new Dictionary - { - { storageKey, serializedHeaders } - }; - - await _storage.WriteAsync(storageData, cancellationToken); + // Store headers to be retrieved in case of proactive messages. + StoreFilteredHeaders(filteredHeaders, activity.Conversation.Id, cancellationToken); } // Grab the auth header from the inbound http request @@ -135,6 +125,12 @@ public async Task ProcessAsync(HttpRequest httpRequest, HttpResponse httpRespons // Write the response, potentially serializing the InvokeResponse await HttpHelper.WriteResponseAsync(httpResponse, invokeResponse).ConfigureAwait(false); + + if (activity.Type == ActivityTypes.EndOfConversation && activity.Conversation?.Id != null) + { + // Delete stored headers to avoid memory bloat. + DeleteStoredHeaders(activity.Conversation.Id, cancellationToken); + } } else { @@ -242,15 +238,36 @@ protected override async Task ProcessProactiveAsync(ClaimsIdentity claimsIdentit if (continuationActivity.Conversation?.Id != null) { var storageKey = $"headers-{continuationActivity.Conversation.Id}"; - var storedData = await _storage.ReadAsync([storageKey], cancellationToken); + var readAttempts = 3; + var delay = TimeSpan.FromMilliseconds(100); - if (storedData.TryGetValue(storageKey, out var headersObject) && headersObject is Dictionary serializedHeaders) + while (readAttempts > 0) { - var headers = serializedHeaders.ToDictionary( - kvp => kvp.Key, - kvp => new StringValues(kvp.Value)); + try + { + var storedData = await _storage.ReadAsync([storageKey], cancellationToken).ConfigureAwait(false); + + if (storedData.TryGetValue(storageKey, out var headersObject) && headersObject is Dictionary serializedHeaders) + { + var headers = serializedHeaders.ToDictionary( + kvp => kvp.Key, + kvp => new StringValues(kvp.Value)); - HeaderPropagation.HeadersToPropagate = headers; + HeaderPropagation.HeadersToPropagate = headers; + break; + } + else + { + // No headers found, retry. + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + readAttempts--; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to read headers from storage."); + readAttempts--; + } } } @@ -321,6 +338,51 @@ private async Task ConnectAsync(HttpRequest httpRequest, IBot bot, CancellationT } } + private void StoreFilteredHeaders(IDictionary filteredHeaders, string conversationId, CancellationToken cancellationToken) + { + var serializedHeaders = filteredHeaders.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToArray()); + + var storageKey = $"headers-{conversationId}"; + var storageData = new Dictionary + { + { storageKey, serializedHeaders } + }; + + // fire and forget the write operation to avoid blocking the request. + _ = Task.Run( + async () => + { + try + { + await _storage.WriteAsync(storageData, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to write headers to storage."); + } + }, cancellationToken); + } + + private void DeleteStoredHeaders(string conversationId, CancellationToken cancellationToken) + { + // fire and forget the delete operation to avoid blocking the request. + _ = Task.Run( + async () => + { + try + { + var storageKey = $"headers-{conversationId}"; + await _storage.DeleteAsync([storageKey], cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete headers after EndOfConversation"); + } + }, cancellationToken); + } + private class StreamingActivityProcessor : IStreamingActivityProcessor, IDisposable { private readonly AuthenticateRequestResult _authenticateRequestResult; From 5bb0e8849c776bcb92a7239899b86f0d0d8e31e9 Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Mon, 26 May 2025 17:31:02 -0300 Subject: [PATCH 7/9] Fix unit test in CloudAdapterTests --- .../CloudAdapterTests.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/integration/Microsoft.Bot.Builder.Integration.AspNet.Core.Tests/CloudAdapterTests.cs b/tests/integration/Microsoft.Bot.Builder.Integration.AspNet.Core.Tests/CloudAdapterTests.cs index b59145f829..6d1a788bd9 100644 --- a/tests/integration/Microsoft.Bot.Builder.Integration.AspNet.Core.Tests/CloudAdapterTests.cs +++ b/tests/integration/Microsoft.Bot.Builder.Integration.AspNet.Core.Tests/CloudAdapterTests.cs @@ -353,7 +353,7 @@ public async Task CanContinueConversationOverWebSocketAsync() var nullUrlProcessRequest = adapter.ProcessAsync(nullUrlHttpRequest.Object, nullUrlHttpResponse.Object, bot.Object, CancellationToken.None); var processRequest = adapter.ProcessAsync(httpRequest.Object, httpResponse.Object, bot.Object, CancellationToken.None); - var validContinuation = adapter.ContinueConversationAsync( + await adapter.ContinueConversationAsync( authResult.ClaimsIdentity, validActivity, (turn, cancellationToken) => @@ -368,8 +368,8 @@ public async Task CanContinueConversationOverWebSocketAsync() }, CancellationToken.None); - var invalidContinuation = adapter.ContinueConversationAsync( - authResult.ClaimsIdentity, invalidActivity, (turn, cancellationToken) => Task.CompletedTask, CancellationToken.None); + await Assert.ThrowsAsync(() => adapter.ContinueConversationAsync( + authResult.ClaimsIdentity, invalidActivity, (turn, cancellationToken) => Task.CompletedTask, CancellationToken.None)); continueConversationWaiter.Set(); await nullUrlProcessRequest; @@ -378,11 +378,6 @@ public async Task CanContinueConversationOverWebSocketAsync() // Assert Assert.True(processRequest.IsCompletedSuccessfully); Assert.True(verifiedValidContinuation); - Assert.True(validContinuation.IsCompletedSuccessfully); - Assert.Null(validContinuation.Exception); - Assert.True(invalidContinuation.IsFaulted); - Assert.NotEmpty(invalidContinuation.Exception.InnerExceptions); - Assert.True(invalidContinuation.Exception.InnerExceptions[0] is ApplicationException); } [Fact] From f0e3ee7ccc383e9174b59688fddb888aad499ab3 Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Fri, 30 May 2025 15:06:56 -0300 Subject: [PATCH 8/9] Add header propagation tests --- .../HeaderPropagationEntryCollection.cs | 10 +- .../HeaderPropagationTests.cs | 116 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/Microsoft.Bot.Connector.Tests/HeaderPropagationTests.cs diff --git a/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs index f09f1ea7c5..4b66c85b17 100644 --- a/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs +++ b/libraries/Microsoft.Bot.Connector/HeaderPropagationEntryCollection.cs @@ -49,10 +49,18 @@ public void Add(string key, StringValues value) /// The value to append for the specified key. public void Append(string key, StringValues value) { + StringValues newValue; + + if (_entries.TryGetValue(key, out var entry)) + { + // If the key already exists, append the new value to the existing one. + newValue = StringValues.Concat(entry.Value, value); + } + _entries[key] = new HeaderPropagationEntry { Key = key, - Value = value, + Value = !StringValues.IsNullOrEmpty(newValue) ? newValue : value, Action = HeaderPropagationEntryAction.Append }; } diff --git a/tests/Microsoft.Bot.Connector.Tests/HeaderPropagationTests.cs b/tests/Microsoft.Bot.Connector.Tests/HeaderPropagationTests.cs new file mode 100644 index 0000000000..1cbe5f238b --- /dev/null +++ b/tests/Microsoft.Bot.Connector.Tests/HeaderPropagationTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.Bot.Connector.Tests +{ + [Collection("Non-Parallel Collection")] // Ensure this test runs in a single-threaded context to avoid issues with static dictionary. + public class HeaderPropagationTests + { + public HeaderPropagationTests() + { + HeaderPropagation.HeadersToPropagate = new Dictionary(); + } + + [Fact] + public void HeaderPropagationContext_ShouldFilterHeaders() + { + // Arrange + HeaderPropagation.RequestHeaders = new Dictionary + { + { "x-custom-header-1", new StringValues("Value-1") }, + { "x-custom-header-2", new StringValues("Value-2") }, + { "x-custom-header-3", new StringValues("Value-3") } + }; + + var headersToPropagate = new HeaderPropagationEntryCollection(); + + headersToPropagate.Add("x-custom-header", "custom-value"); + headersToPropagate.Propagate("x-custom-header-1"); + headersToPropagate.Override("x-custom-header-2", "new-value"); + headersToPropagate.Append("x-custom-header-3", "extra-value"); + + // Act + var filteredHeaders = HeaderPropagation.FilterHeaders(headersToPropagate); + + // Assert + Assert.Equal(4, filteredHeaders.Count); + Assert.Equal("custom-value", filteredHeaders["x-custom-header"]); + Assert.Equal("Value-1", filteredHeaders["x-custom-header-1"]); + Assert.Equal("new-value", filteredHeaders["x-custom-header-2"]); + Assert.Equal("Value-3,extra-value", filteredHeaders["x-custom-header-3"]); + } + + [Fact] + public void HeaderPropagationContext_ShouldAppendMultipleValues() + { + // Arrange + HeaderPropagation.RequestHeaders = new Dictionary + { + { "User-Agent", new StringValues("Value-1") } + }; + + var headersToPropagate = new HeaderPropagationEntryCollection(); + + headersToPropagate.Append("User-Agent", "extra-value-1"); + headersToPropagate.Append("User-Agent", "extra-value-2"); + + // Act + var filteredHeaders = HeaderPropagation.FilterHeaders(headersToPropagate); + + // Assert + Assert.Single(filteredHeaders); + Assert.Equal("Value-1,extra-value-1,extra-value-2", filteredHeaders["User-Agent"]); + } + + [Fact] + public void HeaderPropagationContext_MultipleAdd_ShouldKeepLastValue() + { + // Arrange + HeaderPropagation.RequestHeaders = new Dictionary(); + + var headersToPropagate = new HeaderPropagationEntryCollection(); + + headersToPropagate.Add("x-custom-header-1", "value-1"); + headersToPropagate.Add("x-custom-header-1", "value-2"); + + // Act + var filteredHeaders = HeaderPropagation.FilterHeaders(headersToPropagate); + + // Assert + Assert.Single(filteredHeaders); + Assert.Equal("value-2", filteredHeaders["x-custom-header-1"]); + } + + [Fact] + public void HeaderPropagationContext_MultipleOverride_ShouldKeepLastValue() + { + // Arrange + HeaderPropagation.RequestHeaders = new Dictionary + { + { "x-custom-header-1", new StringValues("Value-1") } + }; + + var headersToPropagate = new HeaderPropagationEntryCollection(); + headersToPropagate.Override("x-custom-header-1", "new-value-1"); + headersToPropagate.Override("x-custom-header-1", "new-value-2"); + + // Act + var filteredHeaders = HeaderPropagation.FilterHeaders(headersToPropagate); + + // Assert + Assert.Single(filteredHeaders); + Assert.Equal("new-value-2", filteredHeaders["x-custom-header-1"]); + } + } + + [CollectionDefinition("Non-Parallel Collection", DisableParallelization = true)] +#pragma warning disable SA1402 // File may only contain a single type + public class NonParallelCollectionDefinition + { + } +#pragma warning restore SA1402 // File may only contain a single type +} From a3a11b1f71118813cfdf2a6508f6d153b1280d83 Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Fri, 30 May 2025 15:20:07 -0300 Subject: [PATCH 9/9] Fix tests names --- .../HeaderPropagationTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Microsoft.Bot.Connector.Tests/HeaderPropagationTests.cs b/tests/Microsoft.Bot.Connector.Tests/HeaderPropagationTests.cs index 1cbe5f238b..5f2207734a 100644 --- a/tests/Microsoft.Bot.Connector.Tests/HeaderPropagationTests.cs +++ b/tests/Microsoft.Bot.Connector.Tests/HeaderPropagationTests.cs @@ -16,7 +16,7 @@ public HeaderPropagationTests() } [Fact] - public void HeaderPropagationContext_ShouldFilterHeaders() + public void HeaderPropagation_ShouldFilterHeaders() { // Arrange HeaderPropagation.RequestHeaders = new Dictionary @@ -45,7 +45,7 @@ public void HeaderPropagationContext_ShouldFilterHeaders() } [Fact] - public void HeaderPropagationContext_ShouldAppendMultipleValues() + public void HeaderPropagation_ShouldAppendMultipleValues() { // Arrange HeaderPropagation.RequestHeaders = new Dictionary @@ -67,7 +67,7 @@ public void HeaderPropagationContext_ShouldAppendMultipleValues() } [Fact] - public void HeaderPropagationContext_MultipleAdd_ShouldKeepLastValue() + public void HeaderPropagation_MultipleAdd_ShouldKeepLastValue() { // Arrange HeaderPropagation.RequestHeaders = new Dictionary(); @@ -86,7 +86,7 @@ public void HeaderPropagationContext_MultipleAdd_ShouldKeepLastValue() } [Fact] - public void HeaderPropagationContext_MultipleOverride_ShouldKeepLastValue() + public void HeaderPropagation_MultipleOverride_ShouldKeepLastValue() { // Arrange HeaderPropagation.RequestHeaders = new Dictionary