Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Libraries/Microsoft.Teams.Api/Account.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class Account

[JsonPropertyName("isTargeted")]
[JsonPropertyOrder(6)]
[JsonInclude]
[Experimental("ExperimentalTeamsTargeted")]
public bool? IsTargeted { get; internal set; }
}
Expand Down
7 changes: 7 additions & 0 deletions Libraries/Microsoft.Teams.Api/Entities/Entity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ public override bool CanConvert(Type typeToConvert)
"message" or "https://schema.org/Message" => (Entity?)element.Deserialize<IMessageEntity>(options),
"ProductInfo" => element.Deserialize<ProductInfoEntity>(options),
"streaminfo" => element.Deserialize<StreamInfoEntity>(options),
"targetedMessageInfo" => element.Deserialize<TargetedMessageInfoEntity>(options),
_ => null
};

Expand Down Expand Up @@ -161,6 +162,12 @@ public override void Write(Utf8JsonWriter writer, Entity value, JsonSerializerOp
return;
}

if (value is TargetedMessageInfoEntity targetedMessageInfo)
{
JsonSerializer.Serialize(writer, targetedMessageInfo, options);
return;
}

JsonSerializer.Serialize(writer, value.ToJsonObject(options), options);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Text.Json.Serialization;

namespace Microsoft.Teams.Api.Entities;

public class TargetedMessageInfoEntity : Entity
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new entity depends on Account.IsTargeted, which is marked [Experimental("ExperimentalTeamsTargeted")] (Account.cs:40). Other Targeted-Message surfaces carry the same attribute — ActivityClient.cs:103,128,151, MessageActivity.cs:155, Activity.cs:234. The auto-populate call site in Context.Send.cs wraps the usage in #pragma warning disable ExperimentalTeamsTargeted, which itself indicates the feature is still experimental.

For consistency with the rest of the Targeted-Message API surface, consider marking the class (and/or MessageId) [Experimental("ExperimentalTeamsTargeted")] so consumers opt in explicitly until the feature stabilizes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

{
[JsonPropertyName("messageId")]
[JsonPropertyOrder(3)]
public required string MessageId { get; set; }

public TargetedMessageInfoEntity() : base("targetedMessageInfo") { }
}
15 changes: 15 additions & 0 deletions Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using Microsoft.Teams.Api.Activities;
using Microsoft.Teams.Api.Entities;

namespace Microsoft.Teams.Apps;

Expand Down Expand Up @@ -67,6 +68,20 @@ public partial class Context<TActivity> : IContext<TActivity>
{
public async Task<T> Send<T>(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.Recipient?.IsTargeted == true && Activity.Id is not null)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard is inside the generic Send<T> where T : IActivity, so this fires for every activity type — TypingActivity, EndOfConversationActivity, etc. — not just messages. APX only consumes targetedMessageInfo inside MessageEncoder (gated on EnablePromptPreview), so non-message sends during a targeted session will carry a stray entity that APX silently ignores.

Not a correctness bug, but wasted payload + log noise. Consider narrowing the guard:

if (activity is MessageActivity && Activity.Recipient?.IsTargeted == true && Activity.Id is not null)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

{
var hasEntity = activity.Entities?.Any(e => e is TargetedMessageInfoEntity) ?? false;
if (!hasEntity)
{
activity.Entities ??= new List<IEntity>();
Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.
Outdated
activity.Entities.Add(new TargetedMessageInfoEntity { MessageId = Activity.Id });
}
}
#pragma warning restore ExperimentalTeamsTargeted

var res = await Sender.Send(activity, Ref, CancellationToken);
Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.
await OnActivitySent(res, ToActivityType<IActivity>());
return res;
Expand Down
31 changes: 30 additions & 1 deletion Samples/Samples.TargetedMessages/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.Teams.Api;
using Microsoft.Teams.Api.Activities;
using Microsoft.Teams.Api.Entities;
using Microsoft.Teams.Apps.Activities;
using Microsoft.Teams.Apps.Extensions;
using Microsoft.Teams.Plugins.AspNetCore.DevTools.Extensions;
Expand Down Expand Up @@ -116,6 +117,32 @@ await context.Reply(

context.Log.Info("[REPLY] Sent targeted reply");
}
else if (text.Contains("preview"))
{
// PROMPT PREVIEW (reactive): The SDK auto-populates the targetedMessageInfo
// entity when the incoming activity is a targeted message. The reply is sent
// as a targeted message with a collapsible preview of the original prompt.
await context.Send(
new MessageActivity("📋 Here is the information you requested - only you can see this, with prompt preview!")
.WithRecipient(context.Activity.From, true),
cancellationToken);

context.Log.Info("[PREVIEW] Sent targeted reply with auto-populated prompt preview");
}
else if (text.Contains("preview-proactive"))
{
Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.
// PROMPT PREVIEW (proactive): The developer manually includes the
// targetedMessageInfo entity with the targeted message ID.
var targetedMessageId = activity.Id;

await context.Send(
new MessageActivity("📋 Here is the proactive result - with prompt preview!")
.WithRecipient(context.Activity.From, true)
.AddEntity(new TargetedMessageInfoEntity { MessageId = targetedMessageId }),
cancellationToken);

context.Log.Info("[PREVIEW-PROACTIVE] Sent targeted reply with manually attached prompt preview");
}
else if (text.Contains("help"))
{
await context.Send(
Expand All @@ -124,7 +151,9 @@ await context.Send(
"- `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" +
"- `reply` - Get a targeted reply (threaded)\n" +
"- `preview` - Reply with auto-populated prompt preview (reactive)\n" +
"- `preview-proactive` - Reply with manually attached prompt preview (proactive)\n\n" +
"_Targeted messages are only visible to you, even in group chats!_", cancellationToken);
}
else
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Text.Json;

using Microsoft.Teams.Api.Entities;

namespace Microsoft.Teams.Api.Tests.Entities;

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<TargetedMessageInfoEntity>(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<Entity>(json);

Assert.NotNull(entity);
Assert.IsType<TargetedMessageInfoEntity>(entity);

var targeted = (TargetedMessageInfoEntity)entity;
Assert.Equal("targetedMessageInfo", targeted.Type);
Assert.Equal("1772129782775", targeted.MessageId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "targetedMessageInfo",
"messageId": "1772129782775"
}
124 changes: 124 additions & 0 deletions Tests/Microsoft.Teams.Apps.Tests/Activities/PromptPreviewTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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;

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<TestPlugin>(_token, incomingActivity);

Assert.NotNull(sentActivity);
Assert.NotNull(sentActivity!.Entities);

var targetedEntity = sentActivity.Entities!.OfType<TargetedMessageInfoEntity>().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<TestPlugin>(_token, incomingActivity);

Assert.NotNull(sentActivity);
Assert.NotNull(sentActivity!.Entities);

var targetedEntity = sentActivity.Entities!.OfType<TargetedMessageInfoEntity>().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<TestPlugin>(_token, incomingActivity);

Assert.NotNull(sentActivity);
var targetedEntity = sentActivity!.Entities?.OfType<TargetedMessageInfoEntity>().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<TestPlugin>(_token, incomingActivity);

Assert.NotNull(sentActivity);
Assert.NotNull(sentActivity!.Entities);

var targetedEntities = sentActivity.Entities!.OfType<TargetedMessageInfoEntity>().ToList();
Assert.Single(targetedEntities);
// The developer-provided entity should be preserved, not overwritten
Assert.Equal("9999", targetedEntities[0].MessageId);
}
}
Loading