diff --git a/.gitignore b/.gitignore index 26d20f91..e89a149a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ # local app settings files appsettings.Local.json +.claude/ + # User-specific files *.rsuser *.suo diff --git a/core/samples/CompatBot/EchoBot.cs b/core/samples/CompatBot/EchoBot.cs index ee1116bb..8b820d6f 100644 --- a/core/samples/CompatBot/EchoBot.cs +++ b/core/samples/CompatBot/EchoBot.cs @@ -3,7 +3,6 @@ using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Teams; -using Microsoft.Bot.Connector; using Microsoft.Bot.Schema; using Microsoft.Bot.Schema.Teams; using Microsoft.Teams.Bot.Apps; @@ -40,6 +39,17 @@ protected override async Task OnMessageActivityAsync(ITurnContext { await context.SendActivityAsync("Hi there! 👋 You said hello!", cancellationToken); - - await teamsApp.Api.Conversations.Reactions.AddAsync(context.Activity, "cake", cancellationToken: cancellationToken); }); // Markdown handler: matches "markdown" (case-insensitive) diff --git a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs index be9838bd..b9f28e41 100644 --- a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs +++ b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs @@ -56,7 +56,12 @@ public async Task SendActivityAsync(CoreActivity activity, url += activity.ReplyToId; } - logger?.LogInformation("Sending activity with type `{Type}` to {Url}", activity.Type, url); + if (activity.IsTargeted) + { + url += url.Contains('?', StringComparison.Ordinal) ? "&isTargetedActivity=true" : "?isTargetedActivity=true"; + } + + logger?.LogInformation("Sending activity to {Url}", url); string body = activity.ToJson(); @@ -88,6 +93,12 @@ public async Task UpdateActivityAsync(string conversatio ArgumentNullException.ThrowIfNull(activity.ServiceUrl); string url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}"; + + if (activity.IsTargeted) + { + url += "?isTargetedActivity=true"; + } + string body = activity.ToJson(); logger.LogTrace("Updating activity at {Url}: {Activity}", url, body); @@ -112,7 +123,24 @@ public async Task UpdateActivityAsync(string conversatio /// A cancellation token that can be used to cancel the delete operation. /// A task that represents the asynchronous operation. /// Thrown if the activity could not be deleted successfully. - public async Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + public Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + return DeleteActivityAsync(conversationId, activityId, serviceUrl, isTargeted: false, agenticIdentity, customHeaders, cancellationToken); + } + + /// + /// Deletes an existing activity from a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to delete. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// If true, deletes a targeted activity. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the delete operation. + /// A task that represents the asynchronous operation. + /// Thrown if the activity could not be deleted successfully. + public async Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, bool isTargeted, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); ArgumentException.ThrowIfNullOrWhiteSpace(activityId); @@ -120,6 +148,11 @@ public async Task DeleteActivityAsync(string conversationId, string activityId, string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}"; + if (isTargeted) + { + url += "?isTargetedActivity=true"; + } + logger.LogTrace("Deleting activity at {Url}", url); await _botHttpClient.SendAsync( @@ -150,6 +183,7 @@ await DeleteActivityAsync( activity.Conversation.Id, activity.Id, activity.ServiceUrl, + activity.IsTargeted, activity.From?.GetAgenticIdentity(), customHeaders, cancellationToken).ConfigureAwait(false); diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs index 0cc30d63..e392a542 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs @@ -58,6 +58,11 @@ public class CoreActivity /// [JsonPropertyName("recipient")] public ConversationAccount? Recipient { get; set; } /// + /// Indicates if this is a targeted message visible only to a specific recipient. + /// Used internally by the SDK for routing - not serialized to the service. + /// + [JsonIgnore] public bool IsTargeted { get; set; } + /// /// Gets or sets the conversation in which this activity is taking place. /// [JsonPropertyName("conversation")] public Conversation? Conversation { get; set; } @@ -153,6 +158,7 @@ protected CoreActivity(CoreActivity activity) Attachments = activity.Attachments; Properties = activity.Properties; Value = activity.Value; + IsTargeted = activity.IsTargeted; } /// diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs index cf65c86f..8674f4d0 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs @@ -47,7 +47,7 @@ public TBuilder WithConversationReference(TActivity activity) WithChannelId(activity.ChannelId); SetConversation(activity.Conversation); SetFrom(activity.Recipient); - SetRecipient(activity.From); + //SetRecipient(activity.From); if (!string.IsNullOrEmpty(activity.Id)) { @@ -156,8 +156,20 @@ public TBuilder WithFrom(ConversationAccount? from) /// The recipient account. /// The builder instance for chaining. public TBuilder WithRecipient(ConversationAccount? recipient) + { + return WithRecipient(recipient, false); + } + + /// + /// Sets the recipient account information and optionally marks this as a targeted message. + /// + /// The recipient account. + /// If true, marks this as a targeted message visible only to the specified recipient. + /// The builder instance for chaining. + public TBuilder WithRecipient(ConversationAccount? recipient, bool isTargeted) { SetRecipient(recipient); + _activity.IsTargeted = isTargeted; return (TBuilder)this; } diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs index 89efedf2..53b30dcf 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs @@ -171,6 +171,178 @@ public async Task SendActivityAsync_ConstructsCorrectUrl() Assert.Equal(HttpMethod.Post, capturedRequest.Method); } + [Fact] + public async Task SendActivityAsync_WithIsTargeted_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/"), + IsTargeted = true + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task SendActivityAsync_WithIsTargetedFalse_DoesNotAppendQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/"), + IsTargeted = false + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.DoesNotContain("isTargetedActivity", capturedRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task UpdateActivityAsync_WithIsTargeted_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/"), + IsTargeted = true + }; + + await conversationClient.UpdateActivityAsync("conv123", "activity123", activity); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Put, capturedRequest.Method); + } + + [Fact] + public async Task DeleteActivityAsync_WithIsTargeted_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + await conversationClient.DeleteActivityAsync( + "conv123", + "activity123", + new Uri("https://test.service.url/"), + isTargeted: true); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Delete, capturedRequest.Method); + } + + [Fact] + public async Task DeleteActivityAsync_WithActivity_UsesIsTargetedProperty() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + CoreActivity activity = new() + { + Id = "activity123", + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/"), + IsTargeted = true + }; + + await conversationClient.DeleteActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Delete, capturedRequest.Method); + } + [Fact] public async Task SendActivityAsync_WithReplyToId_AppendsReplyToIdToUrl() { diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs index 39669430..07f615b4 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs @@ -480,4 +480,67 @@ public void IntegrationTest_CreateComplexActivity() Assert.Equal("conv-001", activity.Conversation?.Id); Assert.NotNull(activity.ChannelData); } + + [Fact] + public void WithRecipient_DefaultsToNotTargeted() + { + CoreActivity activity = new CoreActivityBuilder() + .WithRecipient(new ConversationAccount { Id = "user-123" }) + .Build(); + + Assert.False(activity.IsTargeted); + Assert.NotNull(activity.Recipient); + Assert.Equal("user-123", activity.Recipient.Id); + } + + [Fact] + public void WithRecipient_WithIsTargetedTrue_SetsIsTargeted() + { + CoreActivity activity = new CoreActivityBuilder() + .WithRecipient(new ConversationAccount { Id = "user-123" }, true) + .Build(); + + Assert.True(activity.IsTargeted); + Assert.NotNull(activity.Recipient); + Assert.Equal("user-123", activity.Recipient.Id); + } + + [Fact] + public void WithRecipient_WithIsTargetedFalse_DoesNotSetIsTargeted() + { + CoreActivity activity = new CoreActivityBuilder() + .WithRecipient(new ConversationAccount { Id = "user-123" }, false) + .Build(); + + Assert.False(activity.IsTargeted); + Assert.NotNull(activity.Recipient); + Assert.Equal("user-123", activity.Recipient.Id); + } + + [Fact] + public void WithRecipient_Targeted_MaintainsFluentChaining() + { + CoreActivityBuilder builder = new(); + + CoreActivityBuilder result = builder.WithRecipient(new ConversationAccount { Id = "user-123" }, true); + + Assert.Same(builder, result); + } + + [Fact] + public void WithRecipient_Targeted_CanChainWithOtherMethods() + { + CoreActivity activity = new CoreActivityBuilder() + .WithType(ActivityType.Message) + .WithRecipient(new ConversationAccount { Id = "user-123", Name = "Test User" }, true) + .WithChannelId("msteams") + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.True(activity.IsTargeted); + Assert.NotNull(activity.Recipient); + Assert.Equal("user-123", activity.Recipient.Id); + Assert.Equal("Test User", activity.Recipient.Name); + Assert.Equal("msteams", activity.ChannelId); + } } diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs index d853c41a..88006d82 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs @@ -346,4 +346,53 @@ public async Task DeserializeInvokeWithValueAsync() Assert.Equal("value1", act.Value["key1"]?.GetValue()); Assert.Equal(2, act.Value["key2"]?.GetValue()); } + + [Fact] + public void IsTargeted_DefaultsToFalse() + { + CoreActivity activity = new(); + + Assert.False(activity.IsTargeted); + } + + [Fact] + public void IsTargeted_CanBeSetToTrue() + { + CoreActivity activity = new() + { + IsTargeted = true + }; + + Assert.True(activity.IsTargeted); + } + + [Fact] + public void IsTargeted_IsNotSerializedToJson() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + IsTargeted = true + }; + + string json = activity.ToJson(); + + Assert.DoesNotContain("isTargeted", json, StringComparison.OrdinalIgnoreCase); + Assert.True(activity.IsTargeted); // Property still holds value + } + + [Fact] + public void IsTargeted_IsNotDeserializedFromJson() + { + string json = """ + { + "type": "message", + "isTargeted": true + } + """; + + CoreActivity activity = CoreActivity.FromJsonString(json); + + Assert.False(activity.IsTargeted); // Should default to false since JsonIgnore + } }