Skip to content
Draft
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
13 changes: 12 additions & 1 deletion core/samples/CompatBot/EchoBot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
using Microsoft.Teams.Bot.Core.Schema;
using Microsoft.Bot.Schema;
using Microsoft.Bot.Schema.Teams;
using Newtonsoft.Json.Linq;
using Microsoft.Teams.Bot.Apps;
using Microsoft.Teams.Bot.Apps.Schema;
using Newtonsoft.Json.Linq;

namespace CompatBot;

Expand Down Expand Up @@ -40,6 +40,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
18 changes: 17 additions & 1 deletion core/samples/TeamsBot/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@
await teamsApp.Api.Conversations.Reactions.AddAsync(context.Activity, "cake", cancellationToken: cancellationToken);
});

teamsApp.OnMessage("(?i)tm", async (context, cancellationToken) =>
{
var members = await teamsApp.Api.Conversations.Members.GetAllAsync(context.Activity, cancellationToken: cancellationToken);
foreach (var member in members)
{
await context.SendActivityAsync(
TeamsActivity.CreateBuilder()
.WithText($"Hello {member.Name}!")
.WithRecipient(member, true)
.Build(), cancellationToken)
;
}
await context.SendActivityAsync($"Sent a private message to {members.Count} member(s) of the conversation!", cancellationToken);

});

// Markdown handler: matches "markdown" (case-insensitive)
teamsApp.OnMessage("(?i)markdown", async (context, cancellationToken) =>
{
Expand Down Expand Up @@ -146,7 +162,7 @@ [Visit Microsoft](https://www.microsoft.com)
await context.SendActivityAsync(reply, cancellationToken);

return new CoreInvokeResponse(200)
{
{
Type = "application/vnd.microsoft.activity.message",
Body = "Invokes are great !!"
};
Expand Down
36 changes: 35 additions & 1 deletion core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ public async Task<SendActivityResponse> SendActivityAsync(CoreActivity activity,
url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{convId}/activities";
}

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 @@ -83,6 +88,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 @@ -107,14 +118,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 @@ -145,6 +178,7 @@ await DeleteActivityAsync(
activity.Conversation.Id,
activity.Id,
activity.ServiceUrl,
activity.IsTargeted,
activity.From.GetAgenticIdentity(),
customHeaders,
cancellationToken).ConfigureAwait(false);
Expand Down
7 changes: 7 additions & 0 deletions core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ public class CoreActivity
/// Gets or sets the account that should receive this activity.
/// </summary>
[JsonPropertyName("recipient")] public ConversationAccount Recipient { get; set; } = new();

/// <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; }
/// <summary>
/// Gets or sets the conversation in which this activity is taking place.
/// </summary>
Expand Down Expand Up @@ -150,6 +156,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);

return (TBuilder)this;
}
Expand Down Expand Up @@ -140,8 +140,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 @@ -2,6 +2,8 @@
// Licensed under the MIT License.

using System.Net;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Teams.Bot.Core.Schema;
using Moq;
using Moq.Protected;
Expand Down Expand Up @@ -168,4 +170,176 @@ public async Task SendActivityAsync_ConstructsCorrectUrl()
Assert.Equal("https://test.service.url/v3/conversations/conv123/activities/", capturedRequest.RequestUri?.ToString());
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);
}
}
Loading