Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
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
17 changes: 17 additions & 0 deletions Libraries/Microsoft.Teams.Api/Activities/Activity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,23 @@ public virtual Activity AddFeedback(bool value = true)
return this;
}

/// <summary>
/// add a targeted message info entity for prompt preview.
/// If an entity with type "targetedMessageInfo" already exists, it is not added again.
/// </summary>
/// <param name="messageId">the message ID of the targeted message</param>
Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.
[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;
}

/// <summary>
/// add a citation
/// </summary>
Expand Down
11 changes: 11 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,9 @@ 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),
#pragma warning disable ExperimentalTeamsTargeted
"targetedMessageInfo" => element.Deserialize<TargetedMessageInfoEntity>(options),
#pragma warning restore ExperimentalTeamsTargeted
_ => null
};
Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.

Expand Down Expand Up @@ -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);
}
Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.
[Experimental("ExperimentalTeamsTargeted")]
public class TargetedMessageInfoEntity : Entity
Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.
{
[JsonPropertyName("messageId")]
[JsonPropertyOrder(3)]
public required string MessageId { get; set; }

public TargetedMessageInfoEntity() : base("targetedMessageInfo") { }
}
26 changes: 22 additions & 4 deletions Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ 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 is MessageActivity messageActivity && Activity.Recipient?.IsTargeted == true && Activity.Id is not null)
{
messageActivity.AddTargetedMessageInfo(Activity.Id);
Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.
}
#pragma warning restore ExperimentalTeamsTargeted
Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.

var res = await Sender.Send(activity, Ref, CancellationToken);
Comment thread
ShanmathiMayuramKrithivasan marked this conversation as resolved.
await OnActivitySent(res, ToActivityType<IActivity>());
return res;
Expand All @@ -89,10 +98,19 @@ public Task<T> Reply<T>(T activity, CancellationToken cancellationToken = defaul

if (activity is MessageActivity message)
{
message.Text = string.Join("\n", [
Activity.ToQuoteReply(),
message.Text != string.Empty ? $"<p>{message.Text}</p>" : 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 ? $"<p>{message.Text}</p>" : string.Empty
]);
}
}

return Send(activity, cancellationToken);
Expand Down
50 changes: 23 additions & 27 deletions Samples/Samples.TargetedMessages/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 ?? "";
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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);
}

[Fact]
public void AddTargetedMessageInfo_AddsEntity()
{
var activity = new MessageActivity("test");
activity.AddTargetedMessageInfo("12345");

var entity = activity.Entities?.OfType<TargetedMessageInfoEntity>().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<TargetedMessageInfoEntity>().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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "targetedMessageInfo",
"messageId": "1772129782775"
}
Loading
Loading