diff --git a/Libraries/Microsoft.Teams.Api/Account.cs b/Libraries/Microsoft.Teams.Api/Account.cs index b4b5637c..ad19a8d2 100644 --- a/Libraries/Microsoft.Teams.Api/Account.cs +++ b/Libraries/Microsoft.Teams.Api/Account.cs @@ -37,6 +37,7 @@ public class Account [JsonPropertyName("isTargeted")] [JsonPropertyOrder(6)] + [JsonInclude] [Experimental("ExperimentalTeamsTargeted")] public bool? IsTargeted { get; internal set; } } diff --git a/Libraries/Microsoft.Teams.Api/Activities/Activity.cs b/Libraries/Microsoft.Teams.Api/Activities/Activity.cs index 6c7dc4a6..a0fad18c 100644 --- a/Libraries/Microsoft.Teams.Api/Activities/Activity.cs +++ b/Libraries/Microsoft.Teams.Api/Activities/Activity.cs @@ -382,6 +382,23 @@ public virtual Activity AddFeedback(bool value = true) return this; } + /// + /// add a targeted message info entity for prompt preview. + /// If an entity with type "targetedMessageInfo" already exists, it is not added again. + /// + /// the message ID of the targeted message + [Experimental("ExperimentalTeamsTargeted")] + public virtual Activity AddTargetedMessageInfo(string messageId) + { + var hasEntity = Entities?.Any(e => e.Type == "targetedMessageInfo") ?? false; + if (!hasEntity) + { + AddEntity(new TargetedMessageInfoEntity { MessageId = messageId }); + } + + return this; + } + /// /// add a citation /// diff --git a/Libraries/Microsoft.Teams.Api/Entities/Entity.cs b/Libraries/Microsoft.Teams.Api/Entities/Entity.cs index abbc5ac8..beb64163 100644 --- a/Libraries/Microsoft.Teams.Api/Entities/Entity.cs +++ b/Libraries/Microsoft.Teams.Api/Entities/Entity.cs @@ -117,6 +117,9 @@ public override bool CanConvert(Type typeToConvert) "message" or "https://schema.org/Message" => (Entity?)element.Deserialize(options), "ProductInfo" => element.Deserialize(options), "streaminfo" => element.Deserialize(options), + #pragma warning disable ExperimentalTeamsTargeted + "targetedMessageInfo" => element.Deserialize(options), + #pragma warning restore ExperimentalTeamsTargeted _ => null }; @@ -161,6 +164,14 @@ public override void Write(Utf8JsonWriter writer, Entity value, JsonSerializerOp return; } + #pragma warning disable ExperimentalTeamsTargeted + if (value is TargetedMessageInfoEntity targetedMessageInfo) + { + JsonSerializer.Serialize(writer, targetedMessageInfo, options); + return; + } + #pragma warning restore ExperimentalTeamsTargeted + JsonSerializer.Serialize(writer, value.ToJsonObject(options), options); } } diff --git a/Libraries/Microsoft.Teams.Api/Entities/TargetedMessageInfoEntity.cs b/Libraries/Microsoft.Teams.Api/Entities/TargetedMessageInfoEntity.cs new file mode 100644 index 00000000..3544d837 --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/Entities/TargetedMessageInfoEntity.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Api.Entities; + +[Experimental("ExperimentalTeamsTargeted")] +public class TargetedMessageInfoEntity : Entity +{ + [JsonPropertyName("messageId")] + [JsonPropertyOrder(3)] + public required string MessageId { get; set; } + + public TargetedMessageInfoEntity() : base("targetedMessageInfo") { } +} diff --git a/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs b/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs index 594a85c2..2bb4d4bc 100644 --- a/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs +++ b/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs @@ -67,6 +67,15 @@ public partial class Context : IContext { public async Task Send(T activity, CancellationToken cancellationToken = default) where T : IActivity { + // Auto-populate targetedMessageInfo entity for prompt preview + // when the incoming activity was a targeted message (reactive flow). + #pragma warning disable ExperimentalTeamsTargeted + if (activity is MessageActivity messageActivity && Activity.Recipient?.IsTargeted == true && Activity.Id is not null) + { + messageActivity.AddTargetedMessageInfo(Activity.Id); + } + #pragma warning restore ExperimentalTeamsTargeted + var res = await Sender.Send(activity, Ref, CancellationToken); await OnActivitySent(res, ToActivityType()); return res; @@ -89,10 +98,19 @@ public Task Reply(T activity, CancellationToken cancellationToken = defaul if (activity is MessageActivity message) { - message.Text = string.Join("\n", [ - Activity.ToQuoteReply(), - message.Text != string.Empty ? $"

{message.Text}

" : string.Empty - ]); + // Skip quoted reply when incoming activity is targeted — + // prompt preview owns the preview surface for targeted messages. + #pragma warning disable ExperimentalTeamsTargeted + var isTargeted = Activity.Recipient?.IsTargeted == true && Activity.Id is not null; + #pragma warning restore ExperimentalTeamsTargeted + + if (!isTargeted) + { + message.Text = string.Join("\n", [ + Activity.ToQuoteReply(), + message.Text != string.Empty ? $"

{message.Text}

" : string.Empty + ]); + } } return Send(activity, cancellationToken); diff --git a/Samples/Samples.TargetedMessages/Program.cs b/Samples/Samples.TargetedMessages/Program.cs index 9c5a7111..a2d756e6 100644 --- a/Samples/Samples.TargetedMessages/Program.cs +++ b/Samples/Samples.TargetedMessages/Program.cs @@ -5,6 +5,8 @@ using Microsoft.Teams.Plugins.AspNetCore.DevTools.Extensions; using Microsoft.Teams.Plugins.AspNetCore.Extensions; +#pragma warning disable ExperimentalTeamsTargeted + var builder = WebApplication.CreateBuilder(args); builder.AddTeams().AddTeamsDevTools(); var app = builder.Build(); @@ -25,23 +27,7 @@ context.Log.Info($"[MESSAGE] Received: {text}"); - if (text.Contains("send")) - { - var members = await context.Api.Conversations.Members.GetAsync(activity.Conversation.Id, cancellationToken); - - foreach (var member in members) - { - context.Log.Info($"[MEMBER] {member.Name} (ID: {member.Id})"); - - // SEND: Create a new targeted message - await context.Send( - new MessageActivity($"👋 {member.Name} This is a **targeted message** - only YOU can see this!") - .WithRecipient(new Account() { Id = member.Id, Name = member.Name, Role = Role.User }, true), cancellationToken); - } - - context.Log.Info($"[SEND] Sent targeted message"); - } - else if (text.Contains("update")) + if (text.Contains("update")) { // UPDATE: Send a targeted message, then update it after 3 seconds var conversationId = activity.Conversation?.Id ?? ""; @@ -107,24 +93,34 @@ await context.Send( context.Log.Info($"[DELETE] Scheduled delete in 3 seconds"); } - else if (text.Contains("reply")) + else if (text.Contains("public")) { - // REPLY: Send a targeted reply to the user's message - await context.Reply( - new MessageActivity("💬 This is a **targeted reply** - threaded and private!") - .WithRecipient(context.Activity.From, true), cancellationToken); + // PUBLIC: Send a public message visible to everyone in the chat. + await context.Send( + new MessageActivity("📋 Here is the public result — everyone can see this!"), + cancellationToken); + + context.Log.Info("[PUBLIC] Sent public message"); + } + else if (text.Contains("send")) + { + // SEND: Send a targeted message visible only to the sender. + await context.Send( + new MessageActivity("👋 This is a **targeted message** — only YOU can see this!") + .WithRecipient(context.Activity.From, true), + cancellationToken); - context.Log.Info("[REPLY] Sent targeted reply"); + context.Log.Info("[SEND] Sent targeted message"); } else if (text.Contains("help")) { await context.Send( "**🎯 Targeted Messages Demo**\n\n" + "**Commands:**\n" + - "- `send` - Send a targeted message (only you see it)\n" + - "- `update` - Send a message, then update it after 3 seconds\n" + - "- `delete` - Send a message, then delete it after 3 seconds\n" + - "- `reply` - Get a targeted reply (threaded)\n\n" + + "- `send` - Send a targeted message (only visible to you)\n" + + "- `update` - Send a targeted message, then update it after 3 seconds\n" + + "- `delete` - Send a targeted message, then delete it after 3 seconds\n" + + "- `public` - Send a public reply (visible to all)\n\n" + "_Targeted messages are only visible to you, even in group chats!_", cancellationToken); } else diff --git a/Tests/Microsoft.Teams.Api.Tests/Entities/TargetedMessageInfoEntityTests.cs b/Tests/Microsoft.Teams.Api.Tests/Entities/TargetedMessageInfoEntityTests.cs new file mode 100644 index 00000000..5a53bf58 --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Entities/TargetedMessageInfoEntityTests.cs @@ -0,0 +1,111 @@ +using System.Text.Json; + +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Entities; + +namespace Microsoft.Teams.Api.Tests.Entities; + +#pragma warning disable ExperimentalTeamsTargeted +public class TargetedMessageInfoEntityTests +{ + [Fact] + public void TargetedMessageInfoEntity_JsonSerialize() + { + var entity = new TargetedMessageInfoEntity() + { + MessageId = "1772129782775" + }; + + var json = JsonSerializer.Serialize(entity, new JsonSerializerOptions() + { + WriteIndented = true, + IndentSize = 2, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + Assert.Equal(File.ReadAllText( + @"../../../Json/Entities/TargetedMessageInfoEntity.json" + ), json); + } + + [Fact] + public void TargetedMessageInfoEntity_JsonSerialize_Derived() + { + Entity entity = new TargetedMessageInfoEntity() + { + MessageId = "1772129782775" + }; + + var json = JsonSerializer.Serialize(entity, new JsonSerializerOptions() + { + WriteIndented = true, + IndentSize = 2, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + Assert.Equal(File.ReadAllText( + @"../../../Json/Entities/TargetedMessageInfoEntity.json" + ), json); + } + + [Fact] + public void TargetedMessageInfoEntity_JsonDeserialize() + { + var json = File.ReadAllText(@"../../../Json/Entities/TargetedMessageInfoEntity.json"); + var entity = JsonSerializer.Deserialize(json); + + Assert.NotNull(entity); + Assert.Equal("targetedMessageInfo", entity.Type); + Assert.Equal("1772129782775", entity.MessageId); + } + + [Fact] + public void TargetedMessageInfoEntity_JsonDeserialize_Derived() + { + var json = File.ReadAllText(@"../../../Json/Entities/TargetedMessageInfoEntity.json"); + var entity = JsonSerializer.Deserialize(json); + + Assert.NotNull(entity); + Assert.IsType(entity); + + var targeted = (TargetedMessageInfoEntity)entity; + Assert.Equal("targetedMessageInfo", targeted.Type); + Assert.Equal("1772129782775", targeted.MessageId); + } + + [Fact] + public void AddTargetedMessageInfo_AddsEntity() + { + var activity = new MessageActivity("test"); + activity.AddTargetedMessageInfo("12345"); + + var entity = activity.Entities?.OfType().SingleOrDefault(); + Assert.NotNull(entity); + Assert.Equal("12345", entity!.MessageId); + } + + [Fact] + public void AddTargetedMessageInfo_DoesNotDuplicate_WhenConcreteEntityExists() + { + var activity = new MessageActivity("test") + .AddEntity(new TargetedMessageInfoEntity { MessageId = "9999" }); + + activity.AddTargetedMessageInfo("12345"); + + var entities = activity.Entities!.OfType().ToList(); + Assert.Single(entities); + Assert.Equal("9999", entities[0].MessageId); + } + + [Fact] + public void AddTargetedMessageInfo_DoesNotDuplicate_WhenGenericEntityWithMatchingType() + { + var activity = new MessageActivity("test") + .AddEntity(new Entity("targetedMessageInfo")); + + activity.AddTargetedMessageInfo("12345"); + + var entities = activity.Entities!.Where(e => e.Type == "targetedMessageInfo").ToList(); + Assert.Single(entities); + } +} diff --git a/Tests/Microsoft.Teams.Api.Tests/Json/Entities/TargetedMessageInfoEntity.json b/Tests/Microsoft.Teams.Api.Tests/Json/Entities/TargetedMessageInfoEntity.json new file mode 100644 index 00000000..f5f64f1d --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Json/Entities/TargetedMessageInfoEntity.json @@ -0,0 +1,4 @@ +{ + "type": "targetedMessageInfo", + "messageId": "1772129782775" +} \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Apps.Tests/Activities/PromptPreviewTests.cs b/Tests/Microsoft.Teams.Apps.Tests/Activities/PromptPreviewTests.cs new file mode 100644 index 00000000..68249953 --- /dev/null +++ b/Tests/Microsoft.Teams.Apps.Tests/Activities/PromptPreviewTests.cs @@ -0,0 +1,125 @@ +using Microsoft.Teams.Api; +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Auth; +using Microsoft.Teams.Api.Entities; +using Microsoft.Teams.Apps.Activities; +using Microsoft.Teams.Apps.Testing.Plugins; + +namespace Microsoft.Teams.Apps.Tests.Activities; + +#pragma warning disable ExperimentalTeamsTargeted +public class PromptPreviewTests +{ + private readonly App _app = new(); + private readonly IToken _token = Globals.Token; + + public PromptPreviewTests() + { + _app.AddPlugin(new TestPlugin()); + } + + [Fact] + public async Task Send_AutoPopulates_TargetedMessageInfoEntity_WhenIncomingIsTargeted() + { + IActivity? sentActivity = null; + + _app.OnMessage(async (context, cancellationToken) => + { + sentActivity = await context.Send("Here is the result!", cancellationToken); + }); + + // Simulate an incoming targeted message (bot's Recipient.IsTargeted = true) + var incomingActivity = new MessageActivity("summarize") + .WithId("1772129782775") + .WithFrom(new Account() { Id = "user1", Name = "User" }) + .WithRecipient(new Account() { Id = "bot1", Name = "Bot" }, true); + + await _app.Process(_token, incomingActivity); + + Assert.NotNull(sentActivity); + Assert.NotNull(sentActivity!.Entities); + + var targetedEntity = sentActivity.Entities!.OfType().SingleOrDefault(); + Assert.NotNull(targetedEntity); + Assert.Equal("1772129782775", targetedEntity!.MessageId); + } + + [Fact] + public async Task Reply_AutoPopulates_TargetedMessageInfoEntity_WhenIncomingIsTargeted() + { + IActivity? sentActivity = null; + + _app.OnMessage(async (context, cancellationToken) => + { + sentActivity = await context.Reply("Here is the result!", cancellationToken); + }); + + var incomingActivity = new MessageActivity("summarize") + .WithId("1772129782775") + .WithFrom(new Account() { Id = "user1", Name = "User" }) + .WithConversation(new Api.Conversation() { Id = "conv1" }) + .WithRecipient(new Account() { Id = "bot1", Name = "Bot" }, true); + + await _app.Process(_token, incomingActivity); + + Assert.NotNull(sentActivity); + Assert.NotNull(sentActivity!.Entities); + + var targetedEntity = sentActivity.Entities!.OfType().SingleOrDefault(); + Assert.NotNull(targetedEntity); + Assert.Equal("1772129782775", targetedEntity!.MessageId); + } + + [Fact] + public async Task Send_DoesNotAdd_TargetedMessageInfoEntity_WhenNotTargeted() + { + IActivity? sentActivity = null; + + _app.OnMessage(async (context, cancellationToken) => + { + sentActivity = await context.Send("Hello!", cancellationToken); + }); + + // Normal (non-targeted) incoming message + var incomingActivity = new MessageActivity("hello") + .WithId("123456") + .WithRecipient(new Account() { Id = "bot1", Name = "Bot" }); + + await _app.Process(_token, incomingActivity); + + Assert.NotNull(sentActivity); + var targetedEntity = sentActivity!.Entities?.OfType().SingleOrDefault(); + Assert.Null(targetedEntity); + } + + [Fact] + public async Task Send_DoesNotDuplicate_TargetedMessageInfoEntity_WhenAlreadyPresent() + { + IActivity? sentActivity = null; + + _app.OnMessage(async (context, cancellationToken) => + { + // Developer manually adds the entity (proactive-like scenario) + var activity = new MessageActivity("Result") + .AddEntity(new TargetedMessageInfoEntity { MessageId = "9999" }); + + sentActivity = await context.Send(activity, cancellationToken); + }); + + // Incoming activity is targeted + var incomingActivity = new MessageActivity("summarize") + .WithId("1772129782775") + .WithFrom(new Account() { Id = "user1", Name = "User" }) + .WithRecipient(new Account() { Id = "bot1", Name = "Bot" }, true); + + await _app.Process(_token, incomingActivity); + + Assert.NotNull(sentActivity); + Assert.NotNull(sentActivity!.Entities); + + var targetedEntities = sentActivity.Entities!.OfType().ToList(); + Assert.Single(targetedEntities); + // The developer-provided entity should be preserved, not overwritten + Assert.Equal("9999", targetedEntities[0].MessageId); + } +}