diff --git a/samples/InteractingWithMessagesBot/Program.cs b/samples/InteractingWithMessagesBot/Program.cs index 0a387987..2d591213 100644 --- a/samples/InteractingWithMessagesBot/Program.cs +++ b/samples/InteractingWithMessagesBot/Program.cs @@ -24,17 +24,16 @@ await context.SendAsync( **Interacting with Messages** **Quoting:** - - `quote reply` - auto-quote your message + - `quote reply` - quote your incoming message - `quote message` - quote a previously sent message - - `quote add` - compose a quote with the message builder - `quote batch` - combine multiple quotes - - `quote manual` - combine a quote and text manually **Threading:** - - `thread reply` - send a reactive threaded reply - - `thread send` - send to the same thread without quoting + - `default send` - send to the same thread without quoting - `thread proactive` - send a proactive threaded reply - - `thread manual` - construct a threaded conversation ID manually + - `thread proactive quote` - explicitly place and quote a threaded reply + - `thread proactive targeted` - send a proactive targeted threaded reply + - `thread proactive targeted quote` - send a proactive targeted threaded reply with a quote **Reactions:** - `reaction add ` - add a reaction to your message @@ -57,7 +56,7 @@ Quote or react to one of my messages to see the corresponding inbound event. string text = context.Activity.TextWithoutMentions ?? ""; if (Regex.IsMatch( text, - @"(?i)^(help|quote (reply|message|add|batch|manual)|thread (send|reply|proactive|manual)|reaction (add \S+|remove \S+|proactive))$")) + @"(?i)^(help|quote (reply|message|batch)|default send|thread proactive( quote| targeted( quote)?)?|reaction (add \S+|remove \S+|proactive))$")) { return; } diff --git a/samples/InteractingWithMessagesBot/QuotingHandlers.cs b/samples/InteractingWithMessagesBot/QuotingHandlers.cs index f2a508b9..f34ade27 100644 --- a/samples/InteractingWithMessagesBot/QuotingHandlers.cs +++ b/samples/InteractingWithMessagesBot/QuotingHandlers.cs @@ -12,7 +12,10 @@ internal static void Register(TeamsBotApplication teamsApp) { teamsApp.OnMessage("(?i)^quote reply$", async (context, cancellationToken) => { - await context.ReplyAsync("Thanks for your message! This reply auto-quotes it.", cancellationToken); + await context.SendAsync( + new MessageActivityInput() + .AddQuote(context.Activity.Id!, "Thanks for your message!"), + cancellationToken); }); teamsApp.OnMessage("(?i)^quote message$", async (context, cancellationToken) => @@ -21,23 +24,10 @@ internal static void Register(TeamsBotApplication teamsApp) "The meeting has been moved to 3 PM tomorrow.", cancellationToken); if (sent?.Id != null) - { - await context.QuoteAsync( - sent.Id, - "Just to confirm - does the new time work for everyone?", - cancellationToken); - } - }); - - teamsApp.OnMessage("(?i)^quote add$", async (context, cancellationToken) => - { - SendActivityResponse? sent = await context.SendAsync( - "Please review the latest PR before end of day.", - cancellationToken); - if (sent?.Id != null) { await context.SendAsync( - new MessageActivityInput().AddQuote(sent.Id, "Done! Left my comments on the PR."), + new MessageActivityInput() + .AddQuote(sent.Id, "Just to confirm - does the new time work for everyone?"), cancellationToken); } }); @@ -63,21 +53,6 @@ await context.SendAsync( await context.SendAsync(message, cancellationToken); } }); - - teamsApp.OnMessage("(?i)^quote manual$", async (context, cancellationToken) => - { - SendActivityResponse? sent = await context.SendAsync( - "Deployment to staging is complete.", - cancellationToken); - if (sent?.Id != null) - { - await context.SendAsync( - new MessageActivityInput() - .AddQuote(sent.Id) - .AddText(" Verified - all smoke tests passing."), - cancellationToken); - } - }); } internal static async Task HandleQuotedMessageAsync( diff --git a/samples/InteractingWithMessagesBot/README.md b/samples/InteractingWithMessagesBot/README.md index e50e25d3..8abad6d7 100644 --- a/samples/InteractingWithMessagesBot/README.md +++ b/samples/InteractingWithMessagesBot/README.md @@ -4,7 +4,7 @@ Demonstrates quoting, threading, and reactions in one bot while keeping each con in a separate handler class. - `QuotingHandlers.cs` - quoted-message metadata and quote composition -- `ThreadingHandlers.cs` - reactive, proactive, and manually constructed threads +- `ThreadingHandlers.cs` - default and explicit thread placement - `ReactionHandlers.cs` - reactions on inbound messages and a proactive reaction flow - `Program.cs` - app setup, handler registration, and help @@ -14,21 +14,20 @@ in a separate handler class. | Command | Behavior | |---------|----------| -| `quote reply` | `context.ReplyAsync()` auto-quotes the inbound message | -| `quote message` | `context.QuoteAsync()` quotes a previously sent message by ID | -| `quote add` | `AddQuote()` composes a quote with a response | +| `quote reply` | `MessageActivityInput.AddQuote()` quotes the inbound message | +| `quote message` | `MessageActivityInput.AddQuote()` quotes a previously sent message by ID | | `quote batch` | Combines multiple quotes with mixed responses | -| `quote manual` | Combines `AddQuote()` and `AddText()` manually | | *(quote a message)* | Displays the quoted-message metadata | ### Threading | Command | Behavior | |---------|----------| -| `thread reply` | `teamsApp.ReplyAsync()` sends a reactive threaded reply | -| `thread send` | `context.SendAsync()` sends to the same thread without quoting | -| `thread proactive` | `teamsApp.ReplyAsync()` sends a proactive threaded reply | -| `thread manual` | `ToThreadedConversationId()` and `teamsApp.SendAsync()` provide manual control | +| `default send` | `context.SendAsync()` uses the default placement for the current scope without quoting | +| `thread proactive` | `GetProactiveThreadReference()` resolves placement and `teamsApp.ReplyAsync()` sends a proactive threaded reply | +| `thread proactive quote` | `teamsApp.ReplyAsync()` explicitly places a reply and `AddQuote()` quotes the inbound message | +| `thread proactive targeted` | `teamsApp.ReplyAsync()` sends a proactive targeted reply through the explicit reply endpoint | +| `thread proactive targeted quote` | `teamsApp.ReplyAsync()` sends a proactive targeted reply with an explicit quote | ### Reactions diff --git a/samples/InteractingWithMessagesBot/ThreadingHandlers.cs b/samples/InteractingWithMessagesBot/ThreadingHandlers.cs index a0bbe97d..167b15b5 100644 --- a/samples/InteractingWithMessagesBot/ThreadingHandlers.cs +++ b/samples/InteractingWithMessagesBot/ThreadingHandlers.cs @@ -2,57 +2,63 @@ // Licensed under the MIT License. using Microsoft.Teams.Apps; -using Microsoft.Teams.Core; -using Microsoft.Teams.Core.Schema; +using Microsoft.Teams.Apps.Schema; internal static class ThreadingHandlers { internal static void Register(TeamsBotApplication teamsApp) { - teamsApp.OnMessage("(?i)^thread send$", async (context, cancellationToken) => + teamsApp.OnMessage("(?i)^default send$", async (context, cancellationToken) => { await context.SendAsync("This is sent to the same thread, without quoting.", cancellationToken); }); - teamsApp.OnMessage("(?i)^thread reply$", async (context, cancellationToken) => + teamsApp.OnMessage("(?i)^thread proactive$", async (context, cancellationToken) => { - (string conversationId, string threadRootId) = GetThreadReference(context.Activity); + (string conversationId, string threadRootId) = context.Activity.GetProactiveThreadReference(); await teamsApp.ReplyAsync( conversationId, threadRootId, - "This is a threaded reply to your message.", + "This is a proactive threaded reply using teamsApp.ReplyAsync().", cancellationToken: cancellationToken); }); - teamsApp.OnMessage("(?i)^thread proactive$", async (context, cancellationToken) => + teamsApp.OnMessage("(?i)^thread proactive quote$", async (context, cancellationToken) => { - (string conversationId, string threadRootId) = GetThreadReference(context.Activity); + (string conversationId, string threadRootId) = context.Activity.GetProactiveThreadReference(); await teamsApp.ReplyAsync( conversationId, threadRootId, - "This is a proactive threaded reply using teamsApp.ReplyAsync().", + new MessageActivityInput().AddQuote( + context.Activity.Id!, + "This is explicitly placed in the thread and quotes your message."), cancellationToken: cancellationToken); }); - teamsApp.OnMessage("(?i)^thread manual$", async (context, cancellationToken) => + teamsApp.OnMessage("(?i)^thread proactive targeted$", async (context, cancellationToken) => { - (string conversationId, string threadRootId) = GetThreadReference(context.Activity); - string threadId = ConversationExtensions.ToThreadedConversationId(conversationId, threadRootId); - await teamsApp.SendAsync( - threadId, - "This was sent using ToThreadedConversationId() + teamsApp.SendAsync() for manual control.", + ArgumentNullException.ThrowIfNull(context.Activity.From); + (string conversationId, string threadRootId) = context.Activity.GetProactiveThreadReference(); + await teamsApp.ReplyAsync( + conversationId, + threadRootId, + new MessageActivityInput() + .WithText("This proactive targeted message uses the explicit reply endpoint.") + .WithRecipient(context.Activity.From, isTargeted: true), cancellationToken: cancellationToken); }); - } - private static (string ConversationId, string ThreadRootId) GetThreadReference(CoreActivity activity) - { - ArgumentNullException.ThrowIfNull(activity.Conversation); - ArgumentException.ThrowIfNullOrEmpty(activity.Id); - - string conversationId = activity.Conversation.Id; - string[] threadParts = conversationId.Split(";messageid="); - string threadRootId = threadParts.Length > 1 ? threadParts[1] : activity.Id; - return (conversationId, threadRootId); + teamsApp.OnMessage("(?i)^thread proactive targeted quote$", async (context, cancellationToken) => + { + ArgumentNullException.ThrowIfNull(context.Activity.From); + (string conversationId, string threadRootId) = context.Activity.GetProactiveThreadReference(); + await teamsApp.ReplyAsync( + conversationId, + threadRootId, + new MessageActivityInput() + .AddQuote(context.Activity.Id!, "This proactive targeted reply quotes your message.") + .WithRecipient(context.Activity.From, isTargeted: true), + cancellationToken: cancellationToken); + }); } } diff --git a/samples/M365ExtensionsBot/MyTeamsBot.cs b/samples/M365ExtensionsBot/MyTeamsBot.cs index 88d855bb..84e7c6bb 100644 --- a/samples/M365ExtensionsBot/MyTeamsBot.cs +++ b/samples/M365ExtensionsBot/MyTeamsBot.cs @@ -47,7 +47,9 @@ public MyTeamsBot(ApiClient api, IHttpContextAccessor accessor, ILogger { - await context.ReplyAsync("Quoting your message!", ct); + await context.SendAsync( + new MessageActivityInput().AddQuote(context.Activity.Id!, "Quoting your message!"), + ct); }); this.OnMessage("targeted", async (context, ct) => diff --git a/samples/TargetedMessages/Program.cs b/samples/TargetedMessages/Program.cs index 5b28a748..e7bab83e 100644 --- a/samples/TargetedMessages/Program.cs +++ b/samples/TargetedMessages/Program.cs @@ -22,15 +22,14 @@ await context.SendAsync(reply, cancellationToken); }); -// Targeted reply to the inbound message: same wire format as send, but goes through -// Context.Reply which prepends a quoted reference to the inbound message. +// Targeted message that explicitly quotes the inbound message. teamsApp.OnMessage("(?i)^test reply$", async (context, cancellationToken) => { MessageActivityInput reply = new MessageActivityInput() - .WithText("🔒 Targeted reply visible only to you.") + .AddQuote(context.Activity.Id!, "🔒 Targeted reply visible only to you.") .WithRecipient(context.Activity.From!, isTargeted: true) ; - await context.ReplyAsync(reply, cancellationToken); + await context.SendAsync(reply, cancellationToken); }); // Send → Update a targeted message after 3 seconds. diff --git a/src/Microsoft.Teams.Apps/Clients/ActivityClient.cs b/src/Microsoft.Teams.Apps/Clients/ActivityClient.cs index 653c0e3f..b6a95a0c 100644 --- a/src/Microsoft.Teams.Apps/Clients/ActivityClient.cs +++ b/src/Microsoft.Teams.Apps/Clients/ActivityClient.cs @@ -83,8 +83,7 @@ public Task UpdateAsync(string conversationId, string id public Task ReplyAsync(string conversationId, string id, TeamsActivityInput activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - activity.ReplyToId = id; - return SendCoreAsync(conversationId, activity, isTargeted: false, additionalHeaders, cancellationToken); + return _client.ReplyToActivityAsync(conversationId, id, activity, _serviceUrl, isTargeted: false, requestContext: AgenticContext, customHeaders: additionalHeaders, cancellationToken: cancellationToken); } /// @@ -95,8 +94,7 @@ public Task UpdateAsync(string conversationId, string id { ArgumentNullException.ThrowIfNull(activity); CoreActivityInput input = CoreActivityInput.FromActivity(activity); - input.ReplyToId = id; - return SendCoreAsync(conversationId, input, isTargeted: false, additionalHeaders, cancellationToken); + return _client.ReplyToActivityAsync(conversationId, id, input, _serviceUrl, isTargeted: false, requestContext: AgenticContext, customHeaders: additionalHeaders, cancellationToken: cancellationToken); } /// @@ -117,6 +115,15 @@ public Task DeleteAsync(string conversationId, string id, Dictionary + /// Reply to an existing activity with a targeted activity visible only to the specified recipient. + /// + public Task ReplyTargetedAsync(string conversationId, string id, TeamsActivityInput activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + return _client.ReplyToActivityAsync(conversationId, id, activity, _serviceUrl, isTargeted: true, requestContext: AgenticContext, customHeaders: additionalHeaders, cancellationToken: cancellationToken); + } + /// /// Update an existing targeted activity in a conversation. /// diff --git a/src/Microsoft.Teams.Apps/Clients/ConversationApiClient.cs b/src/Microsoft.Teams.Apps/Clients/ConversationApiClient.cs index f45a59fe..3c54059c 100644 --- a/src/Microsoft.Teams.Apps/Clients/ConversationApiClient.cs +++ b/src/Microsoft.Teams.Apps/Clients/ConversationApiClient.cs @@ -87,8 +87,7 @@ public Task UpdateActivityAsync(string conversationId, s public Task ReplyToActivityAsync(string conversationId, string id, TeamsActivityInput activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - activity.ReplyToId = id; - return _client.SendActivityAsync(conversationId, activity, _serviceUrl, isTargeted: false, requestContext: AgenticContext, customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.ReplyToActivityAsync(conversationId, id, activity, _serviceUrl, isTargeted: false, requestContext: AgenticContext, customHeaders: additionalHeaders, cancellationToken: cancellationToken); } /// @@ -109,6 +108,15 @@ public Task DeleteActivityAsync(string conversationId, string id, Dictionary + /// Reply to an existing activity with a targeted activity visible only to the specified recipient. + /// + public Task ReplyToTargetedActivityAsync(string conversationId, string id, TeamsActivityInput activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + return _client.ReplyToActivityAsync(conversationId, id, activity, _serviceUrl, isTargeted: true, requestContext: AgenticContext, customHeaders: additionalHeaders, cancellationToken: cancellationToken); + } + /// /// Update an existing targeted activity in a conversation. /// diff --git a/src/Microsoft.Teams.Apps/Context.cs b/src/Microsoft.Teams.Apps/Context.cs index e69a91f0..72f9d452 100644 --- a/src/Microsoft.Teams.Apps/Context.cs +++ b/src/Microsoft.Teams.Apps/Context.cs @@ -8,6 +8,7 @@ using Microsoft.Teams.Apps.Schema.Entities; using Microsoft.Teams.Apps.State; using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; namespace Microsoft.Teams.Apps; @@ -108,30 +109,31 @@ internal Context CreateDerivedContext(TNew activity) where TNew : Te // ==================== Convenience Send/Reply/Typing ==================== /// - /// Sends a text message as a threaded reply to the current activity. When the inbound activity - /// has an id, the response auto-quotes it (rendered as a quote bubble above the response in Teams); - /// otherwise sends without quoting. + /// Sends a text message that quotes the current activity when it has an ID. /// /// The text to send. /// A cancellation token. /// The response from the send operation. + [Obsolete("Use SendAsync. To quote a message, add the quote to a MessageActivityInput with AddQuote and pass it to SendAsync.")] public Task ReplyAsync(string text, CancellationToken cancellationToken = default) - => ReplyAsync(new MessageActivityInput().WithText(text), cancellationToken); + => SendQuotedReplyAsync(new MessageActivityInput().WithText(text), cancellationToken); /// - /// Sends an activity to the conversation. When the inbound activity has an id, the response - /// auto-quotes it (rendered as a quote bubble above the response in Teams). Otherwise sends - /// without quoting. To send without quoting unconditionally, use . + /// Sends an activity that quotes the current activity when it has an ID. /// /// The activity to send. /// A cancellation token. /// The response from the send operation. + [Obsolete("Use SendAsync. To quote a message, add the quote to the MessageActivityInput with AddQuote before passing it to SendAsync.")] public Task ReplyAsync(MessageActivityInput activity, CancellationToken cancellationToken = default) + => SendQuotedReplyAsync(activity, cancellationToken); + + private Task SendQuotedReplyAsync(MessageActivityInput activity, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(activity); if (!string.IsNullOrWhiteSpace(Activity.Id)) { - return QuoteAsync(Activity.Id, activity, cancellationToken); + activity.PrependQuote(Activity.Id); } return SendAsync(activity, cancellationToken); @@ -159,25 +161,27 @@ internal Context CreateDerivedContext(TNew activity) where TNew : Te /// The response text, appended to the quoted message placeholder. /// Optional cancellation token. /// The response from sending the activity. + [Obsolete("Add the quote to a MessageActivityInput with AddQuote and pass it to SendAsync.")] public Task QuoteAsync(string messageId, string text, CancellationToken cancellationToken = default) - => QuoteAsync(messageId, new MessageActivityInput().WithText(text), cancellationToken); + => SendQuotedAsync(messageId, new MessageActivityInput().WithText(text), cancellationToken); /// /// Send a message to the conversation with a quoted message reference prepended to the text. /// Teams renders the quoted message as a preview bubble above the response text. /// /// The ID of the message to quote. - /// The activity to send. For , a quote placeholder for messageId is prepended to its text. Other activity types are sent as-is without quoting. + /// The activity to send. A quote placeholder for messageId is prepended to its text. /// Optional cancellation token. /// The response from sending the activity. + [Obsolete("Add the quote to the MessageActivityInput with AddQuote before passing it to SendAsync.")] public Task QuoteAsync(string messageId, MessageActivityInput activity, CancellationToken cancellationToken = default) + => SendQuotedAsync(messageId, activity, cancellationToken); + + private Task SendQuotedAsync(string messageId, MessageActivityInput activity, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(activity); ArgumentException.ThrowIfNullOrWhiteSpace(messageId); - if (activity is MessageActivityInput message) - { - message.PrependQuote(messageId); - } + activity.PrependQuote(messageId); return SendAsync(activity, cancellationToken); } @@ -199,23 +203,25 @@ internal Context CreateDerivedContext(TNew activity) where TNew : Te } /// - [Obsolete("Use ReplyAsync instead.")] + [Obsolete("Use SendAsync. To quote a message, add the quote to a MessageActivityInput with AddQuote and pass it to SendAsync.")] public Task Reply(string text, CancellationToken cancellationToken = default) - => ReplyAsync(text, cancellationToken); + => SendQuotedReplyAsync(new MessageActivityInput().WithText(text), cancellationToken); /// - [Obsolete("Use ReplyAsync with a TeamsActivityInput built via new MessageActivityInput() instead.")] + [Obsolete("Use SendAsync. To quote a message, add the quote to a MessageActivityInput with AddQuote and pass it to SendAsync.")] public Task Reply(MessageActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); string conversationId = Activity.Conversation?.Id ?? throw new InvalidOperationException("Activity.Conversation.Id is required to send an activity."); -#pragma warning disable CS0618 // routing an inbound activity through the obsolete client overload if (!string.IsNullOrWhiteSpace(Activity.Id)) { - return Api.Conversations.Activities.ReplyAsync(conversationId, Activity.Id!, activity, cancellationToken: cancellationToken); +#pragma warning disable CS0618 // preserving the obsolete auto-quote behavior + activity.PrependQuote(Activity.Id); +#pragma warning restore CS0618 } +#pragma warning disable CS0618 // routing an inbound activity through the obsolete client overload return Api.Conversations.Activities.CreateAsync(conversationId, activity, cancellationToken: cancellationToken); #pragma warning restore CS0618 } @@ -231,19 +237,20 @@ public TeamsStreamingWriter Stream() => TeamsStreamingWriter.CreateFromContext(this); /// - [Obsolete("Use QuoteAsync instead.")] + [Obsolete("Add the quote to a MessageActivityInput with AddQuote and pass it to SendAsync.")] public Task Quote(string messageId, string text, CancellationToken cancellationToken = default) - => QuoteAsync(messageId, text, cancellationToken); + => SendQuotedAsync(messageId, new MessageActivityInput().WithText(text), cancellationToken); /// - [Obsolete("Use QuoteAsync with a TeamsActivityInput built via new MessageActivityInput() instead.")] + [Obsolete("Add the quote to a MessageActivityInput with AddQuote and pass it to SendAsync.")] public Task Quote(string messageId, MessageActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); string conversationId = Activity.Conversation?.Id ?? throw new InvalidOperationException("Activity.Conversation.Id is required to send an activity."); -#pragma warning disable CS0618 // routing an inbound activity through the obsolete client overload - return Api.Conversations.Activities.ReplyAsync(conversationId, messageId, activity, cancellationToken: cancellationToken); +#pragma warning disable CS0618 // preserving the obsolete quote behavior and outbound activity overload + activity.PrependQuote(messageId); + return Api.Conversations.Activities.CreateAsync(conversationId, activity, cancellationToken: cancellationToken); #pragma warning restore CS0618 } @@ -288,6 +295,15 @@ public TeamsStreamingWriter Stream() TargetedMessageInfoEntityExtensions.AddToActivity(activity, Activity.Id); } + string? threadRootId = Activity.GetDefaultThreadId(); + if (threadRootId is not null) + { + string baseConversationId = Activity.Conversation!.ThreadId(); + return isTargeted + ? Api.Conversations.ReplyToTargetedActivityAsync(baseConversationId, threadRootId, activity, cancellationToken: cancellationToken) + : Api.Conversations.ReplyToActivityAsync(baseConversationId, threadRootId, activity, cancellationToken: cancellationToken); + } + if (!isTargeted) { return Api.Conversations.CreateActivityAsync(conversationId, activity, cancellationToken: cancellationToken); diff --git a/src/Microsoft.Teams.Apps/Schema/TeamsActivityExtensions.cs b/src/Microsoft.Teams.Apps/Schema/TeamsActivityExtensions.cs new file mode 100644 index 00000000..91aff6c8 --- /dev/null +++ b/src/Microsoft.Teams.Apps/Schema/TeamsActivityExtensions.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Schema; + +/// +/// Threading helpers for Teams activities. +/// +public static class TeamsActivityExtensions +{ + private const string LegacyThreadMarker = ";messageid="; + + /// + /// Gets the base conversation ID and thread root ID needed to send a proactive threaded reply. + /// + /// + /// The thread root is resolved from typed channel data first, then from a legacy + /// ;messageid= conversation ID suffix, and finally from the inbound activity ID. + /// + /// The inbound Teams activity. + /// The base conversation ID and thread root ID. + public static (string ConversationId, string ThreadRootId) GetProactiveThreadReference(this TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(activity.Conversation); + + string? threadRootId = GetExplicitThreadId(activity); + if (string.IsNullOrWhiteSpace(threadRootId)) + { + ArgumentException.ThrowIfNullOrEmpty(activity.Id); + threadRootId = activity.Id; + } + + return (activity.Conversation.ThreadId(), threadRootId); + } + + /// + /// Gets the thread root ID used by default for a reactive reply. + /// + /// + /// The thread root is resolved from typed channel data first, then from a legacy + /// ;messageid= conversation ID suffix. For a channel root activity, the inbound + /// activity ID is used. Group-chat and personal root activities return . + /// + /// The inbound Teams activity. + /// The default thread root ID, or for an unthreaded activity. + public static string? GetDefaultThreadId(this TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + + string? threadRootId = GetExplicitThreadId(activity); + if (!string.IsNullOrWhiteSpace(threadRootId)) + { + return threadRootId; + } + + bool isChannel = activity.Conversation?.ConversationType?.Equals(ConversationTypes.Channel) ?? false; + return isChannel && !string.IsNullOrWhiteSpace(activity.Id) ? activity.Id : null; + } + + private static string? GetExplicitThreadId(TeamsActivity activity) + { + if (!string.IsNullOrWhiteSpace(activity.ChannelData?.Thread?.Id)) + { + return activity.ChannelData.Thread.Id; + } + + string? conversationId = activity.Conversation?.Id; + if (string.IsNullOrEmpty(conversationId)) + { + return null; + } + + int markerIndex = conversationId.IndexOf(LegacyThreadMarker, StringComparison.OrdinalIgnoreCase); + if (markerIndex < 0) + { + return null; + } + + string threadRootId = conversationId[(markerIndex + LegacyThreadMarker.Length)..]; + return string.IsNullOrWhiteSpace(threadRootId) ? null : threadRootId; + } +} diff --git a/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs b/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs index af5f9ff1..2197b8be 100644 --- a/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs +++ b/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs @@ -64,6 +64,28 @@ public class AppInfo [JsonPropertyName("version")] public string? Version { get; set; } } +/// +/// Identifies the root of the thread containing an inbound activity. +/// +public sealed class TeamsChannelDataThread +{ + /// + /// Creates thread information for deserialization. + /// + /// The root activity ID. + [JsonConstructor] + public TeamsChannelDataThread(string? id) + { + Id = id; + } + + /// + /// Gets the root activity ID. + /// + [JsonPropertyName("id")] + public string? Id { get; } +} + /// /// Represents Teams-specific channel data. /// @@ -91,6 +113,13 @@ public TeamsChannelData() /// [JsonPropertyName("teamsTeamId")] public string? TeamsTeamId { get; set; } + /// + /// Gets information about the thread containing this inbound activity. + /// + [JsonPropertyName("thread")] + [JsonInclude] + public TeamsChannelDataThread? Thread { get; internal set; } + /// /// Gets or sets the channel information associated with this entity. /// diff --git a/src/Microsoft.Teams.Apps/TeamsBotApplication.cs b/src/Microsoft.Teams.Apps/TeamsBotApplication.cs index 7385f9fd..9b731bbe 100644 --- a/src/Microsoft.Teams.Apps/TeamsBotApplication.cs +++ b/src/Microsoft.Teams.Apps/TeamsBotApplication.cs @@ -10,6 +10,7 @@ using Microsoft.Teams.Apps.Schema; using Microsoft.Teams.Apps.State; using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Http; using Microsoft.Teams.Core.Schema; namespace Microsoft.Teams.Apps; @@ -211,7 +212,7 @@ public TeamsBotApplication( /// /// Sends a text message proactively to a conversation. /// - /// The conversation ID to send to. For channel threads, include ;messageid=. + /// The base conversation ID to send to. /// The text to send. /// The service URL. If null, uses the last-seen service URL from an incoming activity. /// The agentic identity for user-delegated token acquisition. Extract from the inbound activity's via . @@ -229,7 +230,7 @@ public TeamsBotApplication( /// Sends an activity proactively to a conversation. When the activity carries a recipient marked as /// targeted (), it is sent as a targeted message visible only to that recipient. /// - /// The conversation ID to send to. For channel threads, include ;messageid=. + /// The base conversation ID to send to. /// The activity to send. /// The service URL. If null, uses the last-seen service URL from an incoming activity. /// The agentic identity for user-delegated token acquisition. Extract from the inbound activity's via . @@ -242,6 +243,18 @@ public TeamsBotApplication( Uri resolvedUrl = serviceUrl ?? _lastServiceUrl ?? throw new InvalidOperationException("No service URL available. Either pass a serviceUrl parameter or ensure the bot has received at least one activity."); + if (TryParseLegacyThreadedConversationId(conversationId, out string baseConversationId, out string threadRootId)) + { + return ConversationClient.ReplyToActivityAsync( + baseConversationId, + threadRootId, + activity, + resolvedUrl, + isTargeted: activity.Recipient?.IsTargeted ?? false, + requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), + cancellationToken: cancellationToken); + } + return SendActivityAsync( conversationId, activity, @@ -253,7 +266,6 @@ public TeamsBotApplication( /// /// Sends a text message proactively as a threaded reply. - /// Constructs a threaded conversation ID from the conversation ID and message ID. /// /// The conversation ID. /// The thread root message ID. @@ -264,13 +276,18 @@ public TeamsBotApplication( /// The response from the send operation. public Task ReplyAsync(string conversationId, string messageId, string text, Uri? serviceUrl = null, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) { - string threadedConversationId = ConversationExtensions.ToThreadedConversationId(conversationId, messageId); - return SendAsync(threadedConversationId, text, serviceUrl, agenticIdentity, cancellationToken); + return ReplyAsync( + conversationId, + messageId, + new MessageActivityInput().WithText(text), + serviceUrl, + agenticIdentity, + cancellationToken); } /// - /// Sends an activity proactively as a threaded reply. - /// Constructs a threaded conversation ID from the conversation ID and message ID. + /// Sends an activity proactively as a threaded reply. When the activity carries a targeted recipient, + /// the reply is visible only to that recipient. /// /// The conversation ID. /// The thread root message ID. @@ -281,8 +298,21 @@ public TeamsBotApplication( /// The response from the send operation. public Task ReplyAsync(string conversationId, string messageId, TeamsActivityInput activity, Uri? serviceUrl = null, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) { - string threadedConversationId = ConversationExtensions.ToThreadedConversationId(conversationId, messageId); - return SendAsync(threadedConversationId, activity, serviceUrl, agenticIdentity, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ValidateThreadRootId(messageId); + ArgumentNullException.ThrowIfNull(activity); + + Uri resolvedUrl = serviceUrl ?? _lastServiceUrl + ?? throw new InvalidOperationException("No service URL available. Either pass a serviceUrl parameter or ensure the bot has received at least one activity."); + string baseConversationId = conversationId.Split(';')[0]; + return ConversationClient.ReplyToActivityAsync( + baseConversationId, + messageId, + activity, + resolvedUrl, + isTargeted: activity.Recipient?.IsTargeted ?? false, + requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), + cancellationToken: cancellationToken); } /// @@ -297,6 +327,17 @@ public TeamsBotApplication( ArgumentNullException.ThrowIfNull(activity); Uri resolvedUrl = serviceUrl ?? _lastServiceUrl ?? throw new InvalidOperationException("No service URL available. Either pass a serviceUrl parameter or ensure the bot has received at least one activity."); + if (TryParseLegacyThreadedConversationId(conversationId, out string baseConversationId, out string threadRootId)) + { + return ConversationClient.ReplyToActivityAsync( + baseConversationId, + threadRootId, + CoreActivityInput.FromActivity(activity), + resolvedUrl, + requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), + cancellationToken: cancellationToken); + } + return SendActivityAsync(conversationId, CoreActivityInput.FromActivity(activity), resolvedUrl, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); } @@ -310,10 +351,46 @@ public TeamsBotApplication( public Task Reply(string conversationId, string messageId, TeamsActivity activity, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - string threadedConversationId = ConversationExtensions.ToThreadedConversationId(conversationId, messageId); + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ValidateThreadRootId(messageId); Uri resolvedUrl = _lastServiceUrl ?? throw new InvalidOperationException("No service URL available. Either pass a serviceUrl parameter or ensure the bot has received at least one activity."); - return SendActivityAsync(threadedConversationId, CoreActivityInput.FromActivity(activity), resolvedUrl, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); + return ConversationClient.ReplyToActivityAsync( + conversationId.Split(';')[0], + messageId, + CoreActivityInput.FromActivity(activity), + resolvedUrl, + isTargeted: activity.Recipient?.IsTargeted ?? false, + requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), + cancellationToken: cancellationToken); + } + + private static void ValidateThreadRootId(string messageId) + { + if (string.IsNullOrEmpty(messageId) || !ulong.TryParse(messageId, out ulong parsed) || parsed == 0) + { + throw new ArgumentException($"Invalid messageId \"{messageId}\": must be a non-zero numeric value", nameof(messageId)); + } + } + + private static bool TryParseLegacyThreadedConversationId(string conversationId, out string baseConversationId, out string threadRootId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + + const string marker = ";messageid="; + int markerIndex = conversationId.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (markerIndex < 0) + { + baseConversationId = conversationId; + threadRootId = string.Empty; + return false; + } + + baseConversationId = conversationId[..markerIndex]; + threadRootId = conversationId[(markerIndex + marker.Length)..]; + ArgumentException.ThrowIfNullOrWhiteSpace(baseConversationId); + ValidateThreadRootId(threadRootId); + return true; } /// diff --git a/src/Microsoft.Teams.Core/ConversationClient.cs b/src/Microsoft.Teams.Core/ConversationClient.cs index 2fc10143..756586f9 100644 --- a/src/Microsoft.Teams.Core/ConversationClient.cs +++ b/src/Microsoft.Teams.Core/ConversationClient.cs @@ -82,6 +82,50 @@ public class ConversationClient(HttpClient httpClient, ILogger + /// Sends an activity as a reply beneath an existing root activity. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the root activity to reply beneath. Cannot be null or whitespace. + /// The activity to send. Cannot be null. + /// The service URL for the conversation. Cannot be null. + /// When true, the activity is sent as a targeted message. + /// Optional per-request properties used as a fallback for authentication context. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the send operation. + /// The response containing the ID of the sent activity, or . + public virtual async Task ReplyToActivityAsync(string conversationId, string activityId, CoreActivityInput activity, Uri serviceUrl, bool isTargeted = false, BotRequestContext? requestContext = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}"; + if (isTargeted) + { + url += "?isTargetedActivity=true"; + } + + string body = activity.ToJson(); + return await ExecuteConversationClientAsync( + serviceUrl, + Telemetry.ClientOperations.SendActivity, + async span => + { + span?.SetTag(Telemetry.Tags.ConversationId, conversationId); + span?.SetTag(Telemetry.Tags.ActivityId, activityId); + span?.SetTag(Telemetry.Tags.ActivityType, activity.Type); + SendActivityResponse? response = await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(requestContext, "replying to activity", customHeaders), + cancellationToken).ConfigureAwait(false); + return response; + }).ConfigureAwait(false); + } + /// /// Updates an existing activity in a conversation. /// diff --git a/src/Microsoft.Teams.Core/Schema/ConversationExtensions.cs b/src/Microsoft.Teams.Core/Schema/ConversationExtensions.cs index 98182af3..4ee5a810 100644 --- a/src/Microsoft.Teams.Core/Schema/ConversationExtensions.cs +++ b/src/Microsoft.Teams.Core/Schema/ConversationExtensions.cs @@ -9,7 +9,7 @@ namespace Microsoft.Teams.Core.Schema; public static class ConversationExtensions { /// - /// The thread root portion of the conversation ID, with any ;messageid= suffix stripped. + /// The base conversation ID, with any legacy ;messageid= suffix stripped. /// public static string ThreadId(this Conversation conversation) { @@ -26,6 +26,7 @@ public static string ThreadId(this Conversation conversation) /// the conversation to thread into (e.g. 19:abc@thread.skype) /// the thread root message ID (must be a non-zero numeric string) /// the threaded conversation ID (e.g. 19:abc@thread.skype;messageid=123) + [Obsolete("Thread placement is endpoint-based. Use the base conversation ID with ReplyToActivityAsync instead.")] public static string ToThreadedConversationId(string conversationId, string messageId) { if (string.IsNullOrEmpty(conversationId)) diff --git a/test/Microsoft.Teams.Apps.UnitTests/ConversationApiClientTests.cs b/test/Microsoft.Teams.Apps.UnitTests/ConversationApiClientTests.cs new file mode 100644 index 00000000..d17667a3 --- /dev/null +++ b/test/Microsoft.Teams.Apps.UnitTests/ConversationApiClientTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Apps.Clients; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Http; +using Microsoft.Teams.Core.Schema; +using Moq; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class ConversationApiClientTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReplyMethods_SetExpectedTargetedFlag(bool isTargeted) + { + Mock conversationClient = new( + new HttpClient(), + NullLogger.Instance); + bool? capturedIsTargeted = null; + conversationClient + .Setup(c => c.ReplyToActivityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>( + (_, _, _, _, targeted, _, _, _) => capturedIsTargeted = targeted) + .ReturnsAsync(new SendActivityResponse { Id = "reply-id" }); + + ConversationApiClient client = new( + new Uri("https://test.service.url/"), + conversationClient.Object); + MessageActivityInput activity = new MessageActivityInput().WithText("hello"); + + if (isTargeted) + { + await client.ReplyToTargetedActivityAsync("conversation-id", "root-id", activity); + } + else + { + await client.ReplyToActivityAsync("conversation-id", "root-id", activity); + } + + Assert.Equal(isTargeted, capturedIsTargeted); + } +} diff --git a/test/Microsoft.Teams.Apps.UnitTests/PromptPreviewTests.cs b/test/Microsoft.Teams.Apps.UnitTests/PromptPreviewTests.cs index 3a5a05c8..4918bb41 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/PromptPreviewTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/PromptPreviewTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; @@ -136,6 +137,85 @@ public async Task SendActivityAsync_Succeeds_WhenTargetedMessage_InGroupChat() Assert.NotNull(captured.Value); } + [Fact] + public async Task SendActivityAsync_InChannel_RoutesToReplyEndpoint() + { + TestHarness harness = CreateHarness(); + string? capturedConversationId = null; + string? capturedRootId = null; + SetupReplyCapture(harness, (conversationId, rootId) => + { + capturedConversationId = conversationId; + capturedRootId = rootId; + }); + MessageActivity inbound = BuildInbound(targetedInbound: false, inboundId: "1772129782775", convType: ConversationTypes.Channel); + Context ctx = new(harness.App, inbound); + + await ctx.SendAsync("hello"); + + Assert.Equal("conv-1", capturedConversationId); + Assert.Equal("1772129782775", capturedRootId); + } + + [Fact] + public async Task SendActivityAsync_WithChannelDataThread_RoutesToReplyEndpoint() + { + TestHarness harness = CreateHarness(); + string? capturedRootId = null; + SetupReplyCapture(harness, (_, rootId) => capturedRootId = rootId); + MessageActivity inbound = BuildInbound(targetedInbound: false, inboundId: "reply-id", convType: ConversationTypes.GroupChat); + inbound.ChannelData = JsonSerializer.Deserialize("{\"thread\":{\"id\":\"1772129782775\"}}"); + Context ctx = new(harness.App, inbound); + + await ctx.SendAsync("hello"); + + Assert.Equal("1772129782775", capturedRootId); + } + + [Fact] + public async Task SendActivityAsync_TargetedWithChannelDataThread_UsesTargetedReplyEndpoint() + { + TestHarness harness = CreateHarness(); + string? capturedRootId = null; + bool? capturedIsTargeted = null; + SetupReplyCapture( + harness, + (_, rootId) => capturedRootId = rootId, + isTargeted => capturedIsTargeted = isTargeted); + MessageActivity inbound = BuildInbound(targetedInbound: false, inboundId: "reply-id", convType: ConversationTypes.GroupChat); + inbound.ChannelData = JsonSerializer.Deserialize("{\"thread\":{\"id\":\"1772129782775\"}}"); + Context ctx = new(harness.App, inbound); + + await ctx.SendAsync( + new MessageActivityInput() + .WithText("targeted reply") + .WithRecipient(inbound.From!, isTargeted: true)); + + Assert.Equal("1772129782775", capturedRootId); + Assert.True(capturedIsTargeted); + } + + [Fact] + public async Task SendActivityAsync_WithLegacyThreadedConversationId_UsesBaseIdAndReplyEndpoint() + { + TestHarness harness = CreateHarness(); + string? capturedConversationId = null; + string? capturedRootId = null; + SetupReplyCapture(harness, (conversationId, rootId) => + { + capturedConversationId = conversationId; + capturedRootId = rootId; + }); + MessageActivity inbound = BuildInbound(targetedInbound: false, inboundId: "reply-id", convType: ConversationTypes.GroupChat); + inbound.Conversation!.Id = "conv-1;messageid=1772129782775"; + Context ctx = new(harness.App, inbound); + + await ctx.SendAsync("hello"); + + Assert.Equal("conv-1", capturedConversationId); + Assert.Equal("1772129782775", capturedRootId); + } + // ==================== Helpers ==================== private sealed class CaptureSlot @@ -183,6 +263,30 @@ private static CaptureSlot SetupCapture(TestHarness harness) return slot; } + private static void SetupReplyCapture( + TestHarness harness, + Action capture, + Action? captureIsTargeted = null) + { + harness.MockConversationClient + .Setup(c => c.ReplyToActivityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>( + (conversationId, rootId, _, _, isTargeted, _, _, _) => + { + capture(conversationId, rootId); + captureIsTargeted?.Invoke(isTargeted); + }) + .ReturnsAsync(new SendActivityResponse { Id = "sent-id" }); + } + private sealed class TestHarness { public required TeamsBotApplication App { get; init; } diff --git a/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityExtensionsTests.cs b/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityExtensionsTests.cs new file mode 100644 index 00000000..d32117a9 --- /dev/null +++ b/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityExtensionsTests.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class TeamsActivityExtensionsTests +{ + [Fact] + public void GetProactiveThreadReference_PrefersTypedThreadId() + { + MessageActivity activity = BuildActivity( + "base-conversation;messageid=legacy-root", + "inbound-id", + ConversationTypes.GroupChat, + "typed-root"); + + (string conversationId, string threadRootId) = activity.GetProactiveThreadReference(); + + Assert.Equal("base-conversation", conversationId); + Assert.Equal("typed-root", threadRootId); + } + + [Fact] + public void GetProactiveThreadReference_UsesLegacyThreadId() + { + MessageActivity activity = BuildActivity( + "base-conversation;messageid=legacy-root", + "inbound-id", + ConversationTypes.GroupChat); + + (string conversationId, string threadRootId) = activity.GetProactiveThreadReference(); + + Assert.Equal("base-conversation", conversationId); + Assert.Equal("legacy-root", threadRootId); + } + + [Fact] + public void GetProactiveThreadReference_FallsBackToInboundActivityId() + { + MessageActivity activity = BuildActivity( + "base-conversation", + "inbound-id", + ConversationTypes.GroupChat); + + (string conversationId, string threadRootId) = activity.GetProactiveThreadReference(); + + Assert.Equal("base-conversation", conversationId); + Assert.Equal("inbound-id", threadRootId); + } + + [Fact] + public void GetProactiveThreadReference_ThrowsWhenFallbackActivityIdIsMissing() + { + MessageActivity activity = BuildActivity( + "base-conversation", + null, + ConversationTypes.GroupChat); + + Assert.Throws(() => activity.GetProactiveThreadReference()); + } + + [Fact] + public void GetDefaultThreadId_PrefersTypedThreadId() + { + MessageActivity activity = BuildActivity( + "base-conversation;messageid=legacy-root", + "inbound-id", + ConversationTypes.Channel, + "typed-root"); + + Assert.Equal("typed-root", activity.GetDefaultThreadId()); + } + + [Fact] + public void GetDefaultThreadId_UsesLegacyThreadId() + { + MessageActivity activity = BuildActivity( + "base-conversation;messageid=legacy-root", + "inbound-id", + ConversationTypes.GroupChat); + + Assert.Equal("legacy-root", activity.GetDefaultThreadId()); + } + + [Fact] + public void GetDefaultThreadId_UsesInboundActivityIdForChannelRoot() + { + MessageActivity activity = BuildActivity( + "base-conversation", + "inbound-id", + ConversationTypes.Channel); + + Assert.Equal("inbound-id", activity.GetDefaultThreadId()); + } + + [Theory] + [InlineData("groupChat")] + [InlineData("personal")] + public void GetDefaultThreadId_ReturnsNullForUnthreadedChatRoot(string conversationType) + { + MessageActivity activity = BuildActivity( + "base-conversation", + "inbound-id", + new ConversationType(conversationType)); + + Assert.Null(activity.GetDefaultThreadId()); + } + + [Obsolete] + private static MessageActivity BuildActivity( + string conversationId, + string? activityId, + ConversationType conversationType, + string? threadId = null) + { + MessageActivity activity = new("test") + { + Id = activityId, + Conversation = new TeamsConversation + { + Id = conversationId, + ConversationType = conversationType, + }, + }; + + if (threadId is not null) + { + activity.ChannelData = JsonSerializer.Deserialize( + $"{{\"thread\":{{\"id\":\"{threadId}\"}}}}"); + } + + return activity; + } +} diff --git a/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationTests.cs b/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationTests.cs index 2f9ce287..63a400c5 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationTests.cs @@ -5,7 +5,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Apps.Clients; +using Microsoft.Teams.Apps.Schema; using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Http; using Microsoft.Teams.Core.Schema; using Moq; @@ -40,6 +42,113 @@ await Assert.ThrowsAsync(() => app.ReplyAsync("", "1680000000000", "hello")); } + [Fact] + public async Task Reply_Proactive_UsesReplyEndpointWithoutThreadedConversationId() + { + (TeamsBotApplication app, Mock conversationClient) = CreateAppWithConversationClient(); + string? capturedConversationId = null; + string? capturedRootId = null; + conversationClient + .Setup(c => c.ReplyToActivityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>( + (conversationId, rootId, _, _, _, _, _, _) => + { + capturedConversationId = conversationId; + capturedRootId = rootId; + }) + .ReturnsAsync(new SendActivityResponse { Id = "reply-id" }); + + await app.ReplyAsync( + "19:abc@thread.skype;messageid=old", + "1680000000000", + "hello", + new Uri("https://test.service.url/")); + + Assert.Equal("19:abc@thread.skype", capturedConversationId); + Assert.Equal("1680000000000", capturedRootId); + } + + [Fact] + public async Task Reply_Proactive_WithTargetedRecipient_UsesTargetedReplyEndpoint() + { + (TeamsBotApplication app, Mock conversationClient) = CreateAppWithConversationClient(); + bool? capturedIsTargeted = null; + conversationClient + .Setup(c => c.ReplyToActivityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>( + (_, _, _, _, isTargeted, _, _, _) => capturedIsTargeted = isTargeted) + .ReturnsAsync(new SendActivityResponse { Id = "reply-id" }); + + await app.ReplyAsync( + "19:abc@thread.skype", + "1680000000000", + new MessageActivityInput() + .WithText("hello") + .WithRecipient(new TeamsChannelAccount { Id = "user-1" }, isTargeted: true), + new Uri("https://test.service.url/")); + + Assert.True(capturedIsTargeted); + } + + [Fact] + public async Task Send_Proactive_WithLegacyThreadedId_UsesReplyEndpoint() + { + (TeamsBotApplication app, Mock conversationClient) = CreateAppWithConversationClient(); + string? capturedConversationId = null; + string? capturedRootId = null; + conversationClient + .Setup(c => c.ReplyToActivityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>( + (conversationId, rootId, _, _, _, _, _, _) => + { + capturedConversationId = conversationId; + capturedRootId = rootId; + }) + .ReturnsAsync(new SendActivityResponse { Id = "reply-id" }); + + await app.SendAsync( + "19:abc@thread.skype;messageid=1680000000000", + new MessageActivityInput().WithText("hello"), + new Uri("https://test.service.url/")); + + Assert.Equal("19:abc@thread.skype", capturedConversationId); + Assert.Equal("1680000000000", capturedRootId); + conversationClient.Verify( + c => c.SendActivityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + [Fact] public void HasMatchingRoute_ReturnsTrueForRegisteredInvokeHandler() { @@ -51,6 +160,9 @@ public void HasMatchingRoute_ReturnsTrueForRegisteredInvokeHandler() } private static TeamsBotApplication CreateApp() + => CreateAppWithConversationClient().App; + + private static (TeamsBotApplication App, Mock ConversationClient) CreateAppWithConversationClient() { Mock mockUserTokenClient = new( new HttpClient(), @@ -66,10 +178,12 @@ private static TeamsBotApplication CreateApp() mockConversationClient.Object, mockUserTokenClient.Object); - return new TeamsBotApplication( + TeamsBotApplication app = new( apiClient, new HttpContextAccessor(), NullLogger.Instance, new TeamsBotApplicationOptions { AppId = "test-app-id" }); + + return (app, mockConversationClient); } } diff --git a/test/Microsoft.Teams.Apps.UnitTests/TeamsChannelDataDeserializationTests.cs b/test/Microsoft.Teams.Apps.UnitTests/TeamsChannelDataDeserializationTests.cs index 0eb20289..05b9325a 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/TeamsChannelDataDeserializationTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/TeamsChannelDataDeserializationTests.cs @@ -14,6 +14,15 @@ namespace Microsoft.Teams.Apps.UnitTests; /// public class TeamsChannelDataDeserializationTests { + [Fact] + public void Deserialize_ThreadRoot() + { + TeamsChannelData? channelData = JsonSerializer.Deserialize( + "{\"thread\":{\"id\":\"1772129782775\"}}"); + + Assert.Equal("1772129782775", channelData?.Thread?.Id); + } + [Theory] [InlineData("{\"app\":{}}")] [InlineData("{\"channel\":{}}")] diff --git a/test/Microsoft.Teams.Core.UnitTests/ConversationClientTests.cs b/test/Microsoft.Teams.Core.UnitTests/ConversationClientTests.cs index 8e1948db..8834e57b 100644 --- a/test/Microsoft.Teams.Core.UnitTests/ConversationClientTests.cs +++ b/test/Microsoft.Teams.Core.UnitTests/ConversationClientTests.cs @@ -131,6 +131,85 @@ public async Task SendActivityAsync_ConstructsCorrectUrl() Assert.Equal(HttpMethod.Post, capturedRequest.Method); } + [Fact] + public async Task ReplyToActivityAsync_ConstructsReplyEndpoint() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"reply123\"}") + }); + + ConversationClient conversationClient = new(new HttpClient(mockHttpMessageHandler.Object)); + + SendActivityResponse? result = await conversationClient.ReplyToActivityAsync( + "conv/123", + "root/456", + CoreActivityInput.CreateBuilder().WithType(ActivityType.Message).Build(), + new Uri("https://test.service.url/")); + + Assert.Equal("reply123", result?.Id); + Assert.NotNull(capturedRequest); + Assert.Equal( + "https://test.service.url/v3/conversations/conv%2F123/activities/root%2F456", + capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, capturedRequest.Method); + } + + [Fact] + public async Task ReplyToActivityAsync_WithIsTargeted_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"reply123\"}") + }); + + ConversationClient conversationClient = new(new HttpClient(mockHttpMessageHandler.Object)); + + await conversationClient.ReplyToActivityAsync( + "conv123", + "root456", + CoreActivityInput.CreateBuilder().WithType(ActivityType.Message).Build(), + new Uri("https://test.service.url/"), + isTargeted: true); + + Assert.Equal( + "https://test.service.url/v3/conversations/conv123/activities/root456?isTargetedActivity=true", + capturedRequest?.RequestUri?.ToString()); + } + + [Fact] + public async Task ReplyToActivityAsync_RejectsEmptyRootId() + { + ConversationClient conversationClient = new(new HttpClient()); + + await Assert.ThrowsAsync(() => + conversationClient.ReplyToActivityAsync( + "conv123", + "", + CoreActivityInput.CreateBuilder().WithType(ActivityType.Message).Build(), + new Uri("https://test.service.url/"))); + } + [Fact] public async Task SendActivityAsync_WithIsTargeted_AppendsQueryString() {