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);
+ }
+}