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 6b8da45e..4f59a5de 100644 --- a/core/samples/CompatBot/EchoBot.cs +++ b/core/samples/CompatBot/EchoBot.cs @@ -9,9 +9,9 @@ using Microsoft.Teams.Bot.Core.Schema; using Microsoft.Bot.Schema; using Microsoft.Bot.Schema.Teams; +using Newtonsoft.Json.Linq; using Microsoft.Teams.Bot.Apps; using Microsoft.Teams.Bot.Apps.Schema; -using Newtonsoft.Json.Linq; namespace CompatBot; @@ -40,6 +40,17 @@ protected override async Task OnMessageActivityAsync(ITurnContext +{ + var members = await teamsApp.Api.Conversations.Members.GetAllAsync(context.Activity, cancellationToken: cancellationToken); + foreach (var member in members) + { + await context.SendActivityAsync( + TeamsActivity.CreateBuilder() + .WithText($"Hello {member.Name}!") + .WithRecipient(member, true) + .Build(), cancellationToken) + ; + } + await context.SendActivityAsync($"Sent a private message to {members.Count} member(s) of the conversation!", cancellationToken); + +}); + // Markdown handler: matches "markdown" (case-insensitive) teamsApp.OnMessage("(?i)markdown", async (context, cancellationToken) => { @@ -146,7 +162,7 @@ [Visit Microsoft](https://www.microsoft.com) await context.SendActivityAsync(reply, cancellationToken); return new CoreInvokeResponse(200) - { + { Type = "application/vnd.microsoft.activity.message", Body = "Invokes are great !!" }; diff --git a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs index aa86f985..a901386b 100644 --- a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs +++ b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs @@ -51,6 +51,11 @@ public async Task SendActivityAsync(CoreActivity activity, url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{convId}/activities"; } + if (activity.IsTargeted) + { + url += url.Contains('?', StringComparison.Ordinal) ? "&isTargetedActivity=true" : "?isTargetedActivity=true"; + } + logger?.LogInformation("Sending activity to {Url}", url); string body = activity.ToJson(); @@ -83,6 +88,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); @@ -107,7 +118,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); @@ -115,6 +143,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( @@ -145,6 +178,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 1bd1a5b6..daca5df1 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs @@ -57,6 +57,12 @@ public class CoreActivity /// Gets or sets the account that should receive this activity. /// [JsonPropertyName("recipient")] public ConversationAccount Recipient { get; set; } = new(); + + /// + /// 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. /// @@ -150,6 +156,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 d05e635e..624cc038 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); return (TBuilder)this; } @@ -140,8 +140,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 212d4927..85c4c3c6 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Bot.Core.Schema; using Moq; using Moq.Protected; @@ -168,4 +170,176 @@ public async Task SendActivityAsync_ConstructsCorrectUrl() Assert.Equal("https://test.service.url/v3/conversations/conv123/activities/", capturedRequest.RequestUri?.ToString()); 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); + } } diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs index c3f62fd0..0e9cfba7 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs @@ -338,8 +338,6 @@ public void WithConversationReference_AppliesConversationReference() Assert.Equal("conv-123", activity.Conversation.Id); Assert.Equal("bot-1", activity.From.Id); Assert.Equal("Bot", activity.From.Name); - Assert.Equal("user-1", activity.Recipient.Id); - Assert.Equal("User One", activity.Recipient.Name); } [Fact] @@ -360,8 +358,6 @@ public void WithConversationReference_SwapsFromAndRecipient() Assert.Equal("bot-id", replyActivity.From.Id); Assert.Equal("Bot", replyActivity.From.Name); - Assert.Equal("user-id", replyActivity.Recipient.Id); - Assert.Equal("User", replyActivity.Recipient.Name); } [Fact] @@ -424,7 +420,6 @@ public void WithConversationReference_ChainedWithOtherMethods_MaintainsFluentInt Assert.Equal(ActivityType.Message, activity.Type); Assert.Equal("bot-1", activity.From.Id); - Assert.Equal("user-1", activity.Recipient.Id); } [Fact] @@ -480,4 +475,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 36835f3b..5a0d30c7 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs @@ -293,8 +293,6 @@ public void CreateReply() Assert.Equal("conversation1", reply.Conversation.Id); Assert.Equal("bot1", reply.From.Id); Assert.Equal("Bot One", reply.From.Name); - Assert.Equal("user1", reply.Recipient.Id); - Assert.Equal("User One", reply.Recipient.Name); } [Fact] @@ -346,4 +344,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 + } }