Add Reactions and TargetedMessage support to Core#338
Add Reactions and TargetedMessage support to Core#338rido-min wants to merge 4 commits intonext/core-api-clientsfrom
Conversation
Introduces ReactionsApi for programmatic message reactions in Teams bots, with AddAsync and DeleteAsync methods. ConversationClient now supports AddReactionAsync and DeleteReactionAsync. Expanded ReactionTypes with more documented types. Demonstrated usage in Program.cs by adding a "cake" reaction to "hello" messages.
Introduces IsTargeted property to CoreActivity for private messages, updates ConversationClient to append isTargetedActivity query string for send/update/delete, and extends CoreActivityBuilder for targeted recipient support. Adds a "tm" command to send private messages to all members. Updates and adds tests for IsTargeted logic and ensures it is not serialized. Also updates .gitignore for .claude/.
Removed checks for Recipient Id and Name in several tests in CoreActivityBuilderTests.cs and CoreActivityTests.cs to streamline test coverage. Other test logic remains unchanged.
| await context.SendActivityAsync( | ||
| TeamsActivity.CreateBuilder() | ||
| .WithText($"Hello {member.Name}!") | ||
| .WithRecipient(member, true) |
| @@ -109,39 +109,54 @@ public class MessageReaction | |||
| public static class ReactionTypes | |||
There was a problem hiding this comment.
I'm not sure if we should keep this "incomplete by definition" list
| }); | ||
|
|
||
| services.AddSingleton<Router>(); | ||
| //services.AddSingleton<Router>(); |
There was a problem hiding this comment.
I got a DI runtime error b/c the Router logger.. adding a note here to have test to verify
| SetConversation(activity.Conversation); | ||
| SetFrom(activity.Recipient); | ||
| SetRecipient(activity.From); | ||
| //SetRecipient(activity.From); |
There was a problem hiding this comment.
I want to callout this important change. AFAIK Recipient was never required, so now making it explicit and allow to set the TM flag.
There was a problem hiding this comment.
is this supposed to be commented out? if so better to remove it?
| Assert.Equal("conv-123", activity.Conversation.Id); | ||
| Assert.Equal("bot-1", activity.From.Id); | ||
| Assert.Equal("Bot", activity.From.Name); | ||
| Assert.Equal("user-1", activity.Recipient.Id); |
There was a problem hiding this comment.
note this deletion, if we decide to keep Recipient in WithConversationReference we should restore these asserts
There was a problem hiding this comment.
Pull request overview
This pull request introduces support for message reactions and targeted messaging in the Teams Bot SDK. The changes add a new ReactionsApi for managing reactions on conversation activities, expand the available reaction types, and enhance message delivery to support targeted (private) messages visible only to specific recipients. The implementation includes both low-level client methods and high-level API abstractions.
Changes:
- Added
ReactionsApiwith methods to add and remove reactions on activities, integrated intoConversationsApi - Implemented targeted messaging support via
IsTargetedproperty onCoreActivity, with query parameter handling inSendActivityAsync,UpdateActivityAsync, andDeleteActivityAsync - Expanded
ReactionTypesconstants to include checkmark, hourglass, pushpin, and exclamation reactions, while removing the unusedplusOnereaction
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs | Added IsTargeted property marked with [JsonIgnore] for SDK-internal routing control |
| core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs | Added WithRecipient(recipient, isTargeted) overload and commented out recipient setting in WithConversationReference |
| core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs | Added query parameter handling for targeted activities and new AddReactionAsync/DeleteReactionAsync methods |
| core/src/Microsoft.Teams.Bot.Apps/Api/ReactionsApi.cs | New API class providing high-level methods for adding and removing reactions |
| core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs | Integrated ReactionsApi as a new property |
| core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivities/MessageReactionActivity.cs | Updated reaction type constants with emoji descriptions and added new types |
| core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs | Commented out Router service registration |
| core/samples/TeamsBot/Program.cs | Added sample code demonstrating reactions and targeted messages |
| core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs | Added tests for IsTargeted property behavior and removed recipient assertions |
| core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs | Added tests for WithRecipient with targeted parameter and removed recipient assertions |
| core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs | Added tests for targeted activity query string handling |
| .gitignore | Added .claude/ directory to ignore list |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| return _client.DeleteReactionAsync( | ||
| activity.Conversation.Id, | ||
| activity.Id, | ||
| reactionType, | ||
| activity.ServiceUrl, | ||
| activity.Recipient.GetAgenticIdentity(), |
There was a problem hiding this comment.
The ReactionsApi uses activity.Recipient.GetAgenticIdentity() for extracting the agentic identity, but this is inconsistent with other API classes in the codebase. MembersApi and ActivitiesApi both use activity.From.GetAgenticIdentity() when dealing with activity context. The bot's identity should come from the From field (which represents the bot), not the Recipient field (which represents the user/target of the activity). This will likely cause authentication issues when the bot attempts to delete reactions.
| return _client.DeleteReactionAsync( | |
| activity.Conversation.Id, | |
| activity.Id, | |
| reactionType, | |
| activity.ServiceUrl, | |
| activity.Recipient.GetAgenticIdentity(), | |
| ArgumentNullException.ThrowIfNull(activity.From); | |
| return _client.DeleteReactionAsync( | |
| activity.Conversation.Id, | |
| activity.Id, | |
| reactionType, | |
| activity.ServiceUrl, | |
| activity.From.GetAgenticIdentity(), |
| SetConversation(activity.Conversation); | ||
| SetFrom(activity.Recipient); | ||
| SetRecipient(activity.From); | ||
| //SetRecipient(activity.From); |
There was a problem hiding this comment.
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.
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using Microsoft.Teams.Bot.Apps.Schema; | ||
| using Microsoft.Teams.Bot.Core; | ||
| using Microsoft.Teams.Bot.Core.Schema; | ||
|
|
||
| namespace Microsoft.Teams.Bot.Apps.Api; | ||
|
|
||
| using CustomHeaders = Dictionary<string, string>; | ||
|
|
||
| /// <summary> | ||
| /// Provides reaction operations for adding and removing reactions on activities in conversations. | ||
| /// </summary> | ||
| public class ReactionsApi | ||
| { | ||
| private readonly ConversationClient _client; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="ReactionsApi"/> class. | ||
| /// </summary> | ||
| /// <param name="conversationClient">The conversation client for reaction operations.</param> | ||
| internal ReactionsApi(ConversationClient conversationClient) | ||
| { | ||
| _client = conversationClient; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Adds a reaction to an activity in a conversation. | ||
| /// </summary> | ||
| /// <param name="conversationId">The ID of the conversation.</param> | ||
| /// <param name="activityId">The ID of the activity to react to.</param> | ||
| /// <param name="reactionType">The type of reaction to add (e.g., "like", "heart", "laugh").</param> | ||
| /// <param name="serviceUrl">The service URL for the conversation.</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 operation.</param> | ||
| /// <returns>A task that represents the asynchronous operation.</returns> | ||
| public Task AddAsync( | ||
| string conversationId, | ||
| string activityId, | ||
| string reactionType, | ||
| Uri serviceUrl, | ||
| AgenticIdentity? agenticIdentity = null, | ||
| CustomHeaders? customHeaders = null, | ||
| CancellationToken cancellationToken = default) | ||
| => _client.AddReactionAsync(conversationId, activityId, reactionType, serviceUrl, agenticIdentity, customHeaders, cancellationToken); | ||
|
|
||
| /// <summary> | ||
| /// Adds a reaction to an activity using activity context. | ||
| /// </summary> | ||
| /// <param name="activity">The activity to react to. Must contain valid Id, Conversation.Id, and ServiceUrl.</param> | ||
| /// <param name="reactionType">The type of reaction to add (e.g., "like", "heart", "laugh").</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 operation.</param> | ||
| /// <returns>A task that represents the asynchronous operation.</returns> | ||
| public Task AddAsync( | ||
| TeamsActivity activity, | ||
| string reactionType, | ||
| CustomHeaders? customHeaders = null, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(activity); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(activity.Id); | ||
| ArgumentNullException.ThrowIfNull(activity.Conversation); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); | ||
| ArgumentNullException.ThrowIfNull(activity.ServiceUrl); | ||
|
|
||
| return _client.AddReactionAsync( | ||
| activity.Conversation.Id, | ||
| activity.Id, | ||
| reactionType, | ||
| activity.ServiceUrl, | ||
| activity.Recipient.GetAgenticIdentity(), | ||
| customHeaders, | ||
| cancellationToken); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Removes a reaction from an activity in a conversation. | ||
| /// </summary> | ||
| /// <param name="conversationId">The ID of the conversation.</param> | ||
| /// <param name="activityId">The ID of the activity to remove the reaction from.</param> | ||
| /// <param name="reactionType">The type of reaction to remove (e.g., "like", "heart", "laugh").</param> | ||
| /// <param name="serviceUrl">The service URL for the conversation.</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 operation.</param> | ||
| /// <returns>A task that represents the asynchronous operation.</returns> | ||
| public Task DeleteAsync( | ||
| string conversationId, | ||
| string activityId, | ||
| string reactionType, | ||
| Uri serviceUrl, | ||
| AgenticIdentity? agenticIdentity = null, | ||
| CustomHeaders? customHeaders = null, | ||
| CancellationToken cancellationToken = default) | ||
| => _client.DeleteReactionAsync(conversationId, activityId, reactionType, serviceUrl, agenticIdentity, customHeaders, cancellationToken); | ||
|
|
||
| /// <summary> | ||
| /// Removes a reaction from an activity using activity context. | ||
| /// </summary> | ||
| /// <param name="activity">The activity to remove the reaction from. Must contain valid Id, Conversation.Id, and ServiceUrl.</param> | ||
| /// <param name="reactionType">The type of reaction to remove (e.g., "like", "heart", "laugh").</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 operation.</param> | ||
| /// <returns>A task that represents the asynchronous operation.</returns> | ||
| public Task DeleteAsync( | ||
| TeamsActivity activity, | ||
| string reactionType, | ||
| CustomHeaders? customHeaders = null, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(activity); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(activity.Id); | ||
| ArgumentNullException.ThrowIfNull(activity.Conversation); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); | ||
| ArgumentNullException.ThrowIfNull(activity.ServiceUrl); | ||
|
|
||
| return _client.DeleteReactionAsync( | ||
| activity.Conversation.Id, | ||
| activity.Id, | ||
| reactionType, | ||
| activity.ServiceUrl, | ||
| activity.Recipient.GetAgenticIdentity(), | ||
| customHeaders, | ||
| cancellationToken); | ||
| } | ||
| } |
There was a problem hiding this comment.
The new ReactionsApi functionality lacks test coverage. Similar API classes like MembersApi and ActivitiesApi have comprehensive test coverage, but there are no tests for AddReactionAsync or DeleteReactionAsync methods in the ConversationClient, and no tests for the ReactionsApi class itself. This is a gap in test coverage for new functionality.
| .Build(), cancellationToken) | ||
| ; | ||
| } |
There was a problem hiding this comment.
The sample code has inconsistent formatting with a semicolon on a separate line (line 35). While this is valid C#, it's unconventional and should be moved to the end of the previous line for consistency with the rest of the codebase.
| .Build(), cancellationToken) | |
| ; | |
| } | |
| .Build(), cancellationToken); | |
| } | |
| activity.Id, | ||
| reactionType, | ||
| activity.ServiceUrl, | ||
| activity.Recipient.GetAgenticIdentity(), |
There was a problem hiding this comment.
The ReactionsApi uses activity.Recipient.GetAgenticIdentity() for extracting the agentic identity, but this is inconsistent with other API classes in the codebase. MembersApi and ActivitiesApi both use activity.From.GetAgenticIdentity() when dealing with activity context. The bot's identity should come from the From field (which represents the bot), not the Recipient field (which represents the user/target of the activity). This will likely cause authentication issues when the bot attempts to add or remove reactions.
| activity.Recipient.GetAgenticIdentity(), | |
| activity.From.GetAgenticIdentity(), |
| }); | ||
|
|
||
| services.AddSingleton<Router>(); | ||
| //services.AddSingleton<Router>(); |
There was a problem hiding this comment.
The Router service registration has been commented out without explanation. This is a critical infrastructure component used throughout the bot application (see TeamsBotApplication.cs which initializes Router in the constructor and uses it in ProcessAsync). Commenting this out breaks the dependency injection and will cause runtime failures when the bot tries to use the Router.
| //services.AddSingleton<Router>(); | |
| services.AddSingleton<Router>(); |
| /// <summary> | ||
| /// Adds a reaction to an activity in 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 react to. Cannot be null or whitespace.</param> | ||
| /// <param name="reactionType">The type of reaction to add (e.g., "like", "heart", "laugh"). Cannot be null or whitespace.</param> | ||
| /// <param name="serviceUrl">The service URL for the conversation. Cannot be null.</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 operation.</param> | ||
| /// <returns>A task that represents the asynchronous operation.</returns> | ||
| /// <exception cref="HttpRequestException">Thrown if the reaction could not be added successfully.</exception> | ||
| public async Task AddReactionAsync(string conversationId, string activityId, string reactionType, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) | ||
| { | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(activityId); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(reactionType); | ||
| ArgumentNullException.ThrowIfNull(serviceUrl); | ||
|
|
||
| string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}/reactions/{reactionType}"; | ||
|
|
||
| logger.LogTrace("Adding reaction at {Url}", url); | ||
|
|
||
| await _botHttpClient.SendAsync( | ||
| HttpMethod.Put, | ||
| url, | ||
| body: null, | ||
| CreateRequestOptions(agenticIdentity, "adding reaction", customHeaders), | ||
| cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Removes a reaction from an activity in 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 remove the reaction from. Cannot be null or whitespace.</param> | ||
| /// <param name="reactionType">The type of reaction to remove (e.g., "like", "heart", "laugh"). Cannot be null or whitespace.</param> | ||
| /// <param name="serviceUrl">The service URL for the conversation. Cannot be null.</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 operation.</param> | ||
| /// <returns>A task that represents the asynchronous operation.</returns> | ||
| /// <exception cref="HttpRequestException">Thrown if the reaction could not be removed successfully.</exception> | ||
| public async Task DeleteReactionAsync(string conversationId, string activityId, string reactionType, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) | ||
| { | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(activityId); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(reactionType); | ||
| ArgumentNullException.ThrowIfNull(serviceUrl); | ||
|
|
||
| string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}/reactions/{reactionType}"; | ||
|
|
||
| logger.LogTrace("Deleting reaction at {Url}", url); | ||
|
|
||
| await _botHttpClient.SendAsync( | ||
| HttpMethod.Delete, | ||
| url, | ||
| body: null, | ||
| CreateRequestOptions(agenticIdentity, "deleting reaction", customHeaders), | ||
| cancellationToken).ConfigureAwait(false); | ||
| } |
There was a problem hiding this comment.
Test coverage is missing for the new AddReactionAsync and DeleteReactionAsync methods. While the targeted message functionality is well tested, the new reaction methods added to ConversationClient do not have corresponding unit tests to verify URL construction, parameter validation, or HTTP method usage.
EchoBot now sends a "Hello TM !" message using TeamsBotApplication's ConversationClient after echoing the user's input. The TeamsActivity is built with correct conversation, recipient, sender, and service URL details. Minor formatting adjustment made in Program.cs with no logic changes.
| return new CoreInvokeResponse(200) | ||
| { | ||
| { | ||
| Type = "application/vnd.microsoft.activity.message", |
There was a problem hiding this comment.
Might need to do a slightly complicated merge with next/core to update invokes
| SetConversation(activity.Conversation); | ||
| SetFrom(activity.Recipient); | ||
| SetRecipient(activity.From); | ||
| //SetRecipient(activity.From); |
There was a problem hiding this comment.
is this supposed to be commented out? if so better to remove it?
| /// 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; } |
This pull request introduces support for message reactions in the Teams bot SDK, along with improvements for targeted messaging. The changes add a new
ReactionsApifor handling reactions, expand the list of supported reaction types, and enhance message delivery to allow targeting specific recipients. The most important updates are grouped below:Message Reaction Support
ReactionsApiclass, enabling bots to add and remove reactions (like, heart, laugh, etc.) on conversation activities. This includes both direct and context-based methods for reaction management. (core/src/Microsoft.Teams.Bot.Apps/Api/ReactionsApi.cs)ReactionsApiintoConversationsApi, exposing it via theReactionsproperty and updating documentation to reflect its availability. (core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs) [1] [2] [3]AddReactionAsyncandDeleteReactionAsyncmethods inConversationClientto support adding/removing reactions through HTTP calls. (core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs)Expanded Reaction Types
ReactionTypesclass to include new reactions (checkmark, hourglass, pushpin, exclamation) and clarified existing reactions with emoji descriptions. Removed the unusedplusOnereaction. (core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivities/MessageReactionActivity.cs)Targeted Messaging Enhancements
IsTargetedproperty toCoreActivityand updated activity cloning to preserve this flag, enabling messages to be directed privately to specific recipients. (core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs) [1] [2]SendActivityAsync,UpdateActivityAsync, andDeleteActivityAsyncmethods inConversationClientto handle targeted activities by appending theisTargetedActivity=truequery parameter when appropriate. (core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs) [1] [2] [3] [4]CoreActivityBuilderto allow specifying targeted recipients via theWithRecipient(recipient, isTargeted)method. (core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs)Sample Usage
core/samples/TeamsBot/Program.cs) to demonstrate reaction usage (adding a "cake" reaction when "hello" is received) and sending targeted messages to conversation members.These changes collectively make it easier for bot developers to manage reactions and deliver targeted messages within Teams conversations.