Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions Libraries/Microsoft.Teams.Api/Activities/Activity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ public partial class Activity : IActivity
[JsonPropertyOrder(130)]
public ChannelData? ChannelData { get; set; }

[JsonIgnore]
public bool IsTargeted { get; set; }

[JsonExtensionData]
public IDictionary<string, object?> Properties { get; set; } = new Dictionary<string, object?>();

Expand Down Expand Up @@ -222,8 +225,14 @@ public virtual Activity WithRelatesTo(ConversationReference value)
}

public virtual Activity WithRecipient(Account value)
{
return WithRecipient(value, false);
}

public virtual Activity WithRecipient(Account value, bool isTargeted)
{
Comment thread
rido-min marked this conversation as resolved.
Recipient = value;
IsTargeted = isTargeted;
return this;
}

Expand Down Expand Up @@ -414,6 +423,11 @@ public Activity Merge(Activity from)
LocalTimestamp ??= from.LocalTimestamp;
AddEntity(from.Entities?.ToArray() ?? []);

if (from.IsTargeted)
{
IsTargeted = true;
}

if (from.ChannelData is not null)
{
WithData(from.ChannelData);
Expand Down
46 changes: 11 additions & 35 deletions Libraries/Microsoft.Teams.Api/Activities/Message/MessageActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,7 @@ public class MessageActivity : Activity
[JsonPropertyName("value")]
[JsonPropertyOrder(43)]
public object? Value { get; set; }

/// <summary>
/// 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.
/// </summary>
[JsonIgnore]
public bool? IsTargeted { get; set; }


Comment thread
rido-min marked this conversation as resolved.
[JsonIgnore]
public bool IsRecipientMentioned
{
Expand Down Expand Up @@ -153,6 +146,16 @@ public MessageActivity AddText(string text)
return this;
}

public override MessageActivity WithRecipient(Account value)
{
return (MessageActivity)base.WithRecipient(value);
}

public override MessageActivity WithRecipient(Account value, bool isTargeted = false)
{
return (MessageActivity)base.WithRecipient(value, isTargeted);
}

public MessageActivity AddAttachment(params Attachment[] value)
{
Attachments ??= [];
Expand Down Expand Up @@ -218,33 +221,6 @@ public MessageActivity AddStreamFinal()
return (MentionEntity?)(Entities ?? []).FirstOrDefault(e => e is MentionEntity mention && mention.Mentioned.Id == accountId);
}

/// <summary>
/// Mark this message as a targeted message visible only to a specific recipient.
/// </summary>
/// <param name="isTargeted">If true, marks this as a targeted message. The recipient will be inferred from the incoming activity context.</param>
/// <returns>This instance for chaining</returns>
/// <remarks>
/// When using true, this must be sent within an activity context (not proactively).
/// For proactive sends, use the overload that accepts an explicit recipient ID.
/// </remarks>
public MessageActivity WithTargetedRecipient(bool isTargeted = true)
{
IsTargeted = isTargeted;
return this;
}

/// <summary>
/// Mark this message as a targeted message visible only to a specific recipient.
/// </summary>
/// <param name="recipientId">The explicit recipient ID.</param>
/// <returns>This instance for chaining</returns>
public MessageActivity WithTargetedRecipient(string recipientId)
{
IsTargeted = true;
Recipient = new Account { Id = recipientId, Name = string.Empty, Role = Role.User };
return this;
}

public MessageActivity Merge(MessageActivity from)
{
base.Merge(from);
Expand Down
2 changes: 1 addition & 1 deletion Libraries/Microsoft.Teams.Apps/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ public async Task<T> Send<T>(string conversationId, T activity, ConversationType
// Validate targeted messages in proactive context
if (activity is MessageActivity messageActivity && messageActivity.IsTargeted == true && messageActivity.Recipient is null)
{
throw new InvalidOperationException("Targeted messages sent proactively must specify an explicit recipient ID using WithTargetedRecipient(recipientId)");
throw new InvalidOperationException("Targeted messages sent proactively must specify an explicit recipient ID. Use WithRecipient(new Account { Id = recipientId }, true) with an explicit recipient.");
}
Comment thread
rido-min marked this conversation as resolved.

var reference = new ConversationReference()
Expand Down
10 changes: 1 addition & 9 deletions Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,7 @@ public partial class Context<TActivity> : IContext<TActivity>
{
public async Task<T> Send<T>(T activity, CancellationToken cancellationToken = default) where T : IActivity
{
// For targeted send, set the recipient if not already set.
// For targeted update (activity.Id exists), we don't update recipient since recipient cannot be changed.
if (activity is MessageActivity messageActivity && messageActivity.IsTargeted == true && activity.Id is null && messageActivity.Recipient is null)
{
messageActivity.Recipient = Activity.From;
}

var token = cancellationToken == default ? CancellationToken : cancellationToken;
var res = await Sender.Send(activity, Ref, token);
var res = await Sender.Send(activity, Ref, CancellationToken);
Comment thread
rido-min marked this conversation as resolved.
await OnActivitySent(res, ToActivityType<IActivity>());
return res;
}
Expand Down
31 changes: 18 additions & 13 deletions Samples/Samples.TargetedMessages/Program.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.Teams.Api;
using Microsoft.Teams.Api.Activities;
using Microsoft.Teams.Apps.Activities;
using Microsoft.Teams.Apps.Extensions;
Expand Down Expand Up @@ -26,22 +27,28 @@

if (text.Contains("send"))
{
// SEND: Create a new targeted message
await context.Send(
new MessageActivity("👋 This is a **targeted message** - only YOU can see this!")
.WithTargetedRecipient(true), cancellationToken);
var members = await context.Api.Conversations.Members.GetAsync(activity.Conversation.Id);

Comment thread
rido-min marked this conversation as resolved.
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"))
{
// UPDATE: Send a targeted message, then update it after 3 seconds
var conversationId = activity.Conversation?.Id ?? "";
var userId = activity.From?.Id ?? "";

var response = await context.Send(
new MessageActivity("📝 This message will be **updated** in 3 seconds...")
.WithTargetedRecipient(true), cancellationToken);
.WithRecipient(context.Activity.From, true), cancellationToken);

if (response?.Id != null)
{
Expand All @@ -53,8 +60,7 @@ await context.Send(

try
{
var updatedMessage = new MessageActivity($"✏️ **Updated!** This message was modified at {DateTime.UtcNow:HH:mm:ss}")
.WithTargetedRecipient(userId);
var updatedMessage = new MessageActivity($"✏️ **Updated!** This message was modified at {DateTime.UtcNow:HH:mm:ss}");
Comment thread
rido-min marked this conversation as resolved.

await context.Api.Conversations.Activities.UpdateTargetedAsync(conversationId, messageId, updatedMessage);

Expand All @@ -76,7 +82,7 @@ await context.Send(

var response = await context.Send(
new MessageActivity("🗑️ This message will be **deleted** in 3 seconds...")
.WithTargetedRecipient(true), cancellationToken);
.WithRecipient(context.Activity.From, true), cancellationToken);

if (response?.Id != null)
{
Expand Down Expand Up @@ -106,7 +112,7 @@ await context.Send(
// REPLY: Send a targeted reply to the user's message
await context.Reply(
new MessageActivity("💬 This is a **targeted reply** - threaded and private!")
.WithTargetedRecipient(true), cancellationToken);
.WithRecipient(context.Activity.From, true), cancellationToken);

context.Log.Info("[REPLY] Sent targeted reply");
}
Expand All @@ -119,12 +125,11 @@ await context.Send(
"- `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" +
"_Targeted messages are only visible to you, even in group chats!_", cancellationToken
);
"_Targeted messages are only visible to you, even in group chats!_", cancellationToken);
}
else
{
await context.Typing();
await context.Typing(null, cancellationToken);
await context.Send($"You said: '{activity.Text}'\n\nType `help` to see available commands.", cancellationToken);
}
});
Expand Down
32 changes: 9 additions & 23 deletions Samples/Samples.TargetedMessages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,8 @@ Targeted messages (also known as "user-specific views" or "private messages in g

## Running the Sample

### Option 1: Using Teams Toolkit (Recommended)

1. Install [Teams Toolkit](https://marketplace.visualstudio.com/items?itemName=TeamsDevApp.ms-teams-vscode-extension) extension in VS Code
2. Open this sample folder in VS Code
3. Press F5 to start debugging - Teams Toolkit will handle tunneling and app registration

### Option 2: Using Dev Tunnels
### Dev Tunnels

1. Install [dev tunnels CLI](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started):
```bash
Expand Down Expand Up @@ -49,6 +44,7 @@ Targeted messages (also known as "user-specific views" or "private messages in g
```json
{
"Teams": {
"TenantId": "your-tenant-id",
"ClientId": "your-bot-app-id",
"ClientSecret": "your-bot-client-secret"
}
Expand All @@ -66,22 +62,12 @@ Targeted messages (also known as "user-specific views" or "private messages in g
- Add the bot to a group chat or channel
- Start chatting!

### Option 3: Using ngrok

1. Install [ngrok](https://ngrok.com/download)

2. Start ngrok:
```bash
ngrok http 3978
```

3. Follow steps 4-7 from Option 2 above, using the ngrok URL

## Commands

| Command | Type | Description |
|---------|------|-------------|
| `send` | Reactive | Create a new targeted message |
| `send` | Reactive | Create targeted messages for participants in a conversation |
| `update` | Reactive | Send a message, then update it after 3 seconds |
| `delete` | Reactive | Send a message, then delete it after 3 seconds |
| `reply` | Reactive | Get a targeted reply (threaded) |
Expand All @@ -94,10 +80,10 @@ Targeted messages (also known as "user-specific views" or "private messages in g
The simplest way to send a targeted message in a reactive context (responding to a user's message):

```csharp
// Target the sender of the incoming message automatically
// Target the sender of the incoming message
await context.Send(
new MessageActivity("Only you can see this!")
.WithTargetedRecipient(true) // Uses Activity.From as recipient
.WithRecipient(context.Activity.From, true)
);
Comment thread
rido-min marked this conversation as resolved.
```

Expand All @@ -110,7 +96,7 @@ When sending proactively (bot-initiated), you must specify the recipient explici
await teams.Send(
conversationId,
new MessageActivity("This is for you specifically!")
.WithTargetedRecipient(userId) // Must provide explicit user ID
.WithRecipient(new Account { Id = userId }, true) // Must provide explicit user ID
);
```

Expand All @@ -120,7 +106,7 @@ Use the API client to update an existing targeted message:

```csharp
var updatedMessage = new MessageActivity("Updated content!")
.WithTargetedRecipient(userId);
.WithRecipient(new Account { Id = userId }, true);

await context.Api.Conversations.Activities.UpdateTargetedAsync(
conversationId,
Expand Down Expand Up @@ -156,8 +142,8 @@ The `MessageActivity.Recipient` property must be set to the target user's accoun

| Scenario | Description | Recipient Setting |
|----------|-------------|-------------------|
| **Reactive** | Bot responds to a user message | `WithTargetedRecipient(true)` - auto-uses `Activity.From` |
| **Proactive** | Bot initiates message (timer, webhook, etc.) | `WithTargetedRecipient(userId)` - must be explicit |
| **Reactive** | Bot responds to a user message | `WithRecipient(context.Activity.From, true)` - uses incoming sender |
| **Proactive** | Bot initiates message (timer, webhook, etc.) | `WithRecipient(new Account { Id = userId }, true)` - must be explicit |

## Limitations

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -475,46 +475,54 @@ public void AddMention_WithCustomTextAndAddTextFalse_DoesNotAddText()
}

[Fact]
public void WithTargetedRecipient_Bool_SetsIsTargeted()
public void WithRecipient_DefaultsToNotTargeted()
{
var activity = new MessageActivity("hello").WithTargetedRecipient(true);
var activity = new MessageActivity("hello").WithRecipient(new Account() { Id = "1" });

Assert.False(activity.IsTargeted);
Assert.NotNull(activity.Recipient);
}
Comment thread
rido-min marked this conversation as resolved.

[Fact]
public void WithRecipient_Bool_SetsIsTargeted()
{
var activity = new MessageActivity("hello").WithRecipient(new Account() { Id = "1" }, true);

Assert.True(activity.IsTargeted);
Assert.Null(activity.Recipient);
Assert.NotNull(activity.Recipient);
}

[Fact]
public void WithTargetedRecipient_String_SetsIsTargetedAndRecipient()
public void WithRecipient_SetsIsTargetedAndRecipient()
{
var activity = new MessageActivity("hello").WithTargetedRecipient("user-123");
var activity = new MessageActivity("hello").WithRecipient(new Account() { Id = "user-123", Name = "user", Role = Role.User }, true);

Assert.True(activity.IsTargeted);
Assert.NotNull(activity.Recipient);
Assert.Equal("user-123", activity.Recipient.Id);
Assert.Equal(string.Empty, activity.Recipient.Name);
Assert.Equal("user", activity.Recipient.Name);
Assert.Equal(Role.User, activity.Recipient.Role);
}

[Fact]
public void WithTargetedRecipient_IsChainable()
public void WithRecipient_MaintainsFluentChaining()
{
// This test ensures that WithRecipient(account) returns MessageActivity, not Activity
// If it returned Activity, the call to AddText would not compile
var activity = new MessageActivity("hello")
.WithImportance(Importance.High)
.WithTargetedRecipient("user-456")
.WithDeliveryMode(DeliveryMode.Notification);
.WithRecipient(new Account() { Id = "user-123" })
.AddText(" world");

Assert.Equal("hello", activity.Text);
Assert.Equal(Importance.High, activity.Importance);
Assert.Equal(DeliveryMode.Notification, activity.DeliveryMode);
Assert.True(activity.IsTargeted);
Assert.Equal("hello world", activity.Text);
Assert.NotNull(activity.Recipient);
Assert.Equal("user-456", activity.Recipient.Id);
Assert.Equal("user-123", activity.Recipient.Id);
Assert.False(activity.IsTargeted);
}

[Fact]
public void JsonSerialize_WithIsTargeted()
{
var activity = new MessageActivity("targeted message").WithTargetedRecipient(true);
var activity = new MessageActivity("targeted message").WithRecipient(new Account() { Id = "user-123" }, true);
activity.Id = "1";
activity.From = new() { Id = "1", Name = "test", Role = Role.User };
activity.Conversation = new() { Id = "1", Type = ConversationType.Personal };
Expand All @@ -532,4 +540,20 @@ public void JsonSerialize_WithIsTargeted()
// Verify the property is still set on the object
Assert.True(activity.IsTargeted);
}

[Fact]
public void Validate_FluentAPI()
{
var msg = new MessageActivity("Hello")
.WithDeliveryMode(DeliveryMode.Notification)
.WithRecipient(new Account() { Id = "user-123", Name = "Test User", Role = Role.User }, true)
.WithImportance(Importance.High);
Comment thread
rido-min marked this conversation as resolved.

Assert.Equal("Hello", msg.Text);
Assert.True(msg.IsTargeted);
Assert.NotNull(msg.Recipient);
Assert.Equal("user-123", msg.Recipient.Id);
Assert.Equal("Test User", msg.Recipient.Name);
Assert.Equal(Role.User, msg.Recipient.Role);
}
}
Loading
Loading