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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
# local app settings files
appsettings.Local.json

.claude/

# User-specific files
*.rsuser
*.suo
Expand Down
12 changes: 11 additions & 1 deletion core/samples/CompatBot/EchoBot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Teams;
using Microsoft.Bot.Connector;
using Microsoft.Bot.Schema;
using Microsoft.Bot.Schema.Teams;
using Microsoft.Teams.Bot.Apps;
Expand Down Expand Up @@ -40,6 +39,17 @@ protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivi
await turnContext.SendActivityAsync(MessageFactory.Text(replyText, replyText), cancellationToken);
await turnContext.SendActivityAsync(MessageFactory.Text($"Send a proactive message `/api/notify/{turnContext.Activity.Conversation.Id}`"), cancellationToken);

var activity = ((Activity)turnContext.Activity).FromCompatActivity();
TeamsActivity tm = TeamsActivity.CreateBuilder()
.WithConversation(new Conversation { Id = activity.Conversation?.Id! })
.WithText("Hello TM !")
.WithRecipient(activity.From, true)
.WithFrom(activity.Recipient)
.WithServiceUrl(activity.ServiceUrl!)
.Build();

await teamsBotApp.ConversationClient.SendActivityAsync(tm, cancellationToken: cancellationToken);

// TeamsAPXClient provides Teams-specific operations like:
// - FetchTeamDetailsAsync, FetchChannelListAsync
// - FetchMeetingInfoAsync, FetchParticipantAsync, SendMeetingNotificationAsync
Expand Down
8 changes: 8 additions & 0 deletions core/samples/TeamsBot/GlobalSuppressions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.

using System.Diagnostics.CodeAnalysis;

[assembly: SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "<Pending>")]
2 changes: 0 additions & 2 deletions core/samples/TeamsBot/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
teamsApp.OnMessage("(?i)hello", async (context, cancellationToken) =>
{
await context.SendActivityAsync("Hi there! 👋 You said hello!", cancellationToken);

await teamsApp.Api.Conversations.Reactions.AddAsync(context.Activity, "cake", cancellationToken: cancellationToken);
});

// Markdown handler: matches "markdown" (case-insensitive)
Expand Down
38 changes: 36 additions & 2 deletions core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ public async Task<SendActivityResponse> SendActivityAsync(CoreActivity activity,
url += activity.ReplyToId;
}

logger?.LogInformation("Sending activity with type `{Type}` to {Url}", activity.Type, url);
if (activity.IsTargeted)
{
url += url.Contains('?', StringComparison.Ordinal) ? "&isTargetedActivity=true" : "?isTargetedActivity=true";
}

logger?.LogInformation("Sending activity to {Url}", url);

string body = activity.ToJson();

Expand Down Expand Up @@ -88,6 +93,12 @@ public async Task<UpdateActivityResponse> UpdateActivityAsync(string conversatio
ArgumentNullException.ThrowIfNull(activity.ServiceUrl);

string url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}";

if (activity.IsTargeted)
{
url += "?isTargetedActivity=true";
}

string body = activity.ToJson();

logger.LogTrace("Updating activity at {Url}: {Activity}", url, body);
Expand All @@ -112,14 +123,36 @@ public async Task<UpdateActivityResponse> UpdateActivityAsync(string conversatio
/// <param name="cancellationToken">A cancellation token that can be used to cancel the delete operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="HttpRequestException">Thrown if the activity could not be deleted successfully.</exception>
public async Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default)
public Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default)
{
return DeleteActivityAsync(conversationId, activityId, serviceUrl, isTargeted: false, agenticIdentity, customHeaders, cancellationToken);
}

/// <summary>
/// Deletes an existing activity from a conversation.
/// </summary>
/// <param name="conversationId">The ID of the conversation. Cannot be null or whitespace.</param>
/// <param name="activityId">The ID of the activity to delete. Cannot be null or whitespace.</param>
/// <param name="serviceUrl">The service URL for the conversation. Cannot be null.</param>
/// <param name="isTargeted">If true, deletes a targeted activity.</param>
/// <param name="agenticIdentity">Optional agentic identity for authentication.</param>
/// <param name="customHeaders">Optional custom headers to include in the request.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the delete operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="HttpRequestException">Thrown if the activity could not be deleted successfully.</exception>
public async Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, bool isTargeted, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
ArgumentException.ThrowIfNullOrWhiteSpace(activityId);
ArgumentNullException.ThrowIfNull(serviceUrl);

string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}";

if (isTargeted)
{
url += "?isTargetedActivity=true";
}

logger.LogTrace("Deleting activity at {Url}", url);

await _botHttpClient.SendAsync(
Expand Down Expand Up @@ -150,6 +183,7 @@ await DeleteActivityAsync(
activity.Conversation.Id,
activity.Id,
activity.ServiceUrl,
activity.IsTargeted,
activity.From?.GetAgenticIdentity(),
customHeaders,
cancellationToken).ConfigureAwait(false);
Expand Down
6 changes: 6 additions & 0 deletions core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ public class CoreActivity
/// </summary>
[JsonPropertyName("recipient")] public ConversationAccount? Recipient { 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; }
Copy link
Collaborator

Choose a reason for hiding this comment

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

@copilot will JsonIgnore ignore when deserializing?

@rido-min ignoring means we would have to manually support some how setting this field in BF SDK activity.

Copy link
Member Author

Choose a reason for hiding this comment

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

see my comment in #349 (comment)

/// <summary>
/// Gets or sets the conversation in which this activity is taking place.
/// </summary>
[JsonPropertyName("conversation")] public Conversation? Conversation { get; set; }
Expand Down Expand Up @@ -153,6 +158,7 @@ protected CoreActivity(CoreActivity activity)
Attachments = activity.Attachments;
Properties = activity.Properties;
Value = activity.Value;
IsTargeted = activity.IsTargeted;
}

/// <summary>
Expand Down
14 changes: 13 additions & 1 deletion core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public TBuilder WithConversationReference(TActivity activity)
WithChannelId(activity.ChannelId);
SetConversation(activity.Conversation);
SetFrom(activity.Recipient);
SetRecipient(activity.From);
//SetRecipient(activity.From);
Copy link
Member Author

Choose a reason for hiding this comment

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

I want to callout this important change. AFAIK Recipient was never required, so now making it explicit and allow to set the TM flag.

Copy link
Collaborator

Choose a reason for hiding this comment

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

is this supposed to be commented out? if so better to remove it?

Copy link
Member Author

Choose a reason for hiding this comment

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

I keep this commented while we decide what to do with the Recipient

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The recipient is being commented out without explanation, breaking the behavior of WithConversationReference. This method is intended to swap the From and Recipient fields when creating a reply (the bot becomes From, and the original sender becomes Recipient). By commenting out the SetRecipient call, replies created using this method will not have a recipient, which could break targeting and routing. This change appears intentional given the PR's focus on targeted messaging, but needs clarification on why the recipient is being removed when creating conversation references.

Copilot uses AI. Check for mistakes.

if (!string.IsNullOrEmpty(activity.Id))
{
Expand Down Expand Up @@ -156,8 +156,20 @@ public TBuilder WithFrom(ConversationAccount? from)
/// <param name="recipient">The recipient account.</param>
/// <returns>The builder instance for chaining.</returns>
public TBuilder WithRecipient(ConversationAccount? recipient)
{
return WithRecipient(recipient, false);
}

/// <summary>
/// Sets the recipient account information and optionally marks this as a targeted message.
/// </summary>
/// <param name="recipient">The recipient account.</param>
/// <param name="isTargeted">If true, marks this as a targeted message visible only to the specified recipient.</param>
/// <returns>The builder instance for chaining.</returns>
public TBuilder WithRecipient(ConversationAccount? recipient, bool isTargeted)
{
SetRecipient(recipient);
_activity.IsTargeted = isTargeted;
return (TBuilder)this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,178 @@ public async Task SendActivityAsync_ConstructsCorrectUrl()
Assert.Equal(HttpMethod.Post, capturedRequest.Method);
}

[Fact]
public async Task SendActivityAsync_WithIsTargeted_AppendsQueryString()
{
HttpRequestMessage? capturedRequest = null;
Mock<HttpMessageHandler> mockHttpMessageHandler = new();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("{\"id\":\"activity123\"}")
});

HttpClient httpClient = new(mockHttpMessageHandler.Object);
ConversationClient conversationClient = new(httpClient);

CoreActivity activity = new()
{
Type = ActivityType.Message,
Conversation = new Conversation { Id = "conv123" },
ServiceUrl = new Uri("https://test.service.url/"),
IsTargeted = true
};

await conversationClient.SendActivityAsync(activity);

Assert.NotNull(capturedRequest);
Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString());
}

[Fact]
public async Task SendActivityAsync_WithIsTargetedFalse_DoesNotAppendQueryString()
{
HttpRequestMessage? capturedRequest = null;
Mock<HttpMessageHandler> mockHttpMessageHandler = new();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("{\"id\":\"activity123\"}")
});

HttpClient httpClient = new(mockHttpMessageHandler.Object);
ConversationClient conversationClient = new(httpClient);

CoreActivity activity = new()
{
Type = ActivityType.Message,
Conversation = new Conversation { Id = "conv123" },
ServiceUrl = new Uri("https://test.service.url/"),
IsTargeted = false
};

await conversationClient.SendActivityAsync(activity);

Assert.NotNull(capturedRequest);
Assert.DoesNotContain("isTargetedActivity", capturedRequest.RequestUri?.ToString());
}

[Fact]
public async Task UpdateActivityAsync_WithIsTargeted_AppendsQueryString()
{
HttpRequestMessage? capturedRequest = null;
Mock<HttpMessageHandler> mockHttpMessageHandler = new();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("{\"id\":\"activity123\"}")
});

HttpClient httpClient = new(mockHttpMessageHandler.Object);
ConversationClient conversationClient = new(httpClient, NullLogger<ConversationClient>.Instance);

CoreActivity activity = new()
{
Type = ActivityType.Message,
ServiceUrl = new Uri("https://test.service.url/"),
IsTargeted = true
};

await conversationClient.UpdateActivityAsync("conv123", "activity123", activity);

Assert.NotNull(capturedRequest);
Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString());
Assert.Equal(HttpMethod.Put, capturedRequest.Method);
}

[Fact]
public async Task DeleteActivityAsync_WithIsTargeted_AppendsQueryString()
{
HttpRequestMessage? capturedRequest = null;
Mock<HttpMessageHandler> mockHttpMessageHandler = new();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK
});

HttpClient httpClient = new(mockHttpMessageHandler.Object);
ConversationClient conversationClient = new(httpClient, NullLogger<ConversationClient>.Instance);

await conversationClient.DeleteActivityAsync(
"conv123",
"activity123",
new Uri("https://test.service.url/"),
isTargeted: true);

Assert.NotNull(capturedRequest);
Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString());
Assert.Equal(HttpMethod.Delete, capturedRequest.Method);
}

[Fact]
public async Task DeleteActivityAsync_WithActivity_UsesIsTargetedProperty()
{
HttpRequestMessage? capturedRequest = null;
Mock<HttpMessageHandler> mockHttpMessageHandler = new();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK
});

HttpClient httpClient = new(mockHttpMessageHandler.Object);
ConversationClient conversationClient = new(httpClient, NullLogger<ConversationClient>.Instance);

CoreActivity activity = new()
{
Id = "activity123",
Type = ActivityType.Message,
Conversation = new Conversation { Id = "conv123" },
ServiceUrl = new Uri("https://test.service.url/"),
IsTargeted = true
};

await conversationClient.DeleteActivityAsync(activity);

Assert.NotNull(capturedRequest);
Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString());
Assert.Equal(HttpMethod.Delete, capturedRequest.Method);
}

[Fact]
public async Task SendActivityAsync_WithReplyToId_AppendsReplyToIdToUrl()
{
Expand Down
Loading
Loading