diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f17c774a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Agent Instructions + +## XML documentation + +- Prefer `` for linkable XML doc references, including explicit overloads like ``; use `...` only for inline code formatting. + +## Pull requests + +- If a change breaks a public-facing API or behavior, call it out explicitly in the PR/review description. diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/ActivityClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/ActivityClient.cs index 1cf236fa..8a1601fe 100644 --- a/core/src/Microsoft.Teams.Apps/Api/Clients/ActivityClient.cs +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/ActivityClient.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; +using Microsoft.Teams.Apps.Schema; using Microsoft.Teams.Core; using Microsoft.Teams.Core.Http; using Microsoft.Teams.Core.Schema; @@ -19,52 +20,72 @@ public class ActivityClient { private readonly CoreConversationClient _client; private readonly Uri _serviceUrl; + private readonly AgenticIdentity? _defaultAgenticIdentity; - internal ActivityClient(Uri serviceUrl, CoreConversationClient client) + internal ActivityClient(Uri serviceUrl, CoreConversationClient client, AgenticIdentity? defaultAgenticIdentity = null) { _serviceUrl = serviceUrl; _client = client; + _defaultAgenticIdentity = defaultAgenticIdentity; } /// /// Create a new activity in a conversation. /// - public Task CreateAsync(string conversationId, CoreActivity activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task CreateAsync(string conversationId, CoreActivity activity, RequestOptions options = default, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - activity.ServiceUrl ??= _serviceUrl; + activity.ServiceUrl ??= options.ServiceUrl ?? _serviceUrl; activity.Conversation ??= new Conversation(conversationId); - return _client.SendActivityAsync(activity, customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.SendActivityAsync(activity, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken); } /// /// Update an existing activity in a conversation. /// - public Task UpdateAsync(string conversationId, string id, CoreActivity activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task UpdateAsync(string conversationId, string id, CoreActivity activity, RequestOptions options = default, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - activity.ServiceUrl ??= _serviceUrl; - return _client.UpdateActivityAsync(conversationId, id, activity, requestContext: BotRequestContext.FromActivity(activity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + activity.ServiceUrl ??= options.ServiceUrl ?? _serviceUrl; + BotRequestContext? requestContext = BotRequestContext.Merge( + options.ToBotRequestContext(_defaultAgenticIdentity), + BotRequestContext.FromActivity(activity)); + return _client.UpdateActivityAsync(conversationId, id, activity, requestContext: requestContext, cancellationToken: cancellationToken); } /// /// Reply to an existing activity in a conversation. /// - public Task ReplyAsync(string conversationId, string id, CoreActivity activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task ReplyAsync(string conversationId, string id, CoreActivity activity, RequestOptions options = default, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); activity.ReplyToId = id; - activity.ServiceUrl ??= _serviceUrl; + activity.ServiceUrl ??= options.ServiceUrl ?? _serviceUrl; activity.Conversation ??= new Conversation(conversationId); - return _client.SendActivityAsync(activity, customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.SendActivityAsync(activity, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken); } /// /// Delete an activity from a conversation. /// - public Task DeleteAsync(string conversationId, string id, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task DeleteAsync(string conversationId, string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + => DeleteAsync(conversationId, id, new RequestOptions { AgenticIdentity = agenticIdentity }, cancellationToken); + + /// + /// Delete an activity from a conversation. + /// + public Task DeleteAsync(string conversationId, string id, RequestOptions options, CancellationToken cancellationToken = default) { - return _client.DeleteActivityAsync(conversationId, id, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.DeleteActivityAsync(conversationId, id, options.ServiceUrl ?? _serviceUrl, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken); + } + + /// + /// Get members of a specific activity. + /// + public async Task> GetMembersAsync(string conversationId, string id, RequestOptions options = default, CancellationToken cancellationToken = default) + { + IList members = await _client.GetActivityMembersAsync(conversationId, id, options.ServiceUrl ?? _serviceUrl, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken).ConfigureAwait(false); + return [.. members.Select(m => TeamsChannelAccount.FromChannelAccount(m))]; } /// @@ -72,35 +93,44 @@ public Task DeleteAsync(string conversationId, string id, AgenticIdentity? agent /// Targeted activities are only visible to the specified recipient. /// [Experimental("ExperimentalTeamsTargeted")] - public Task CreateTargetedAsync(string conversationId, CoreActivity activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task CreateTargetedAsync(string conversationId, CoreActivity activity, RequestOptions options = default, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - activity.ServiceUrl ??= _serviceUrl; + activity.ServiceUrl ??= options.ServiceUrl ?? _serviceUrl; activity.Conversation ??= new Conversation(conversationId); // Ensure recipient is marked as targeted if (activity.Recipient is not null) { activity.Recipient.IsTargeted = true; } - return _client.SendActivityAsync(activity, customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.SendActivityAsync(activity, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken); } /// /// Update an existing targeted activity in a conversation. /// [Experimental("ExperimentalTeamsTargeted")] - public Task UpdateTargetedAsync(string conversationId, string id, CoreActivity activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task UpdateTargetedAsync(string conversationId, string id, CoreActivity activity, RequestOptions options = default, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - activity.ServiceUrl ??= _serviceUrl; - return _client.UpdateTargetedActivityAsync(conversationId, id, activity, requestContext: BotRequestContext.FromActivity(activity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + activity.ServiceUrl ??= options.ServiceUrl ?? _serviceUrl; + BotRequestContext? requestContext = BotRequestContext.Merge( + options.ToBotRequestContext(_defaultAgenticIdentity), + BotRequestContext.FromActivity(activity)); + return _client.UpdateTargetedActivityAsync(conversationId, id, activity, requestContext: requestContext, cancellationToken: cancellationToken); } /// /// Delete a targeted activity from a conversation. /// - public Task DeleteTargetedAsync(string conversationId, string id, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task DeleteTargetedAsync(string conversationId, string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + => DeleteTargetedAsync(conversationId, id, new RequestOptions { AgenticIdentity = agenticIdentity }, cancellationToken); + + /// + /// Delete a targeted activity from a conversation. + /// + public Task DeleteTargetedAsync(string conversationId, string id, RequestOptions options, CancellationToken cancellationToken = default) { - return _client.DeleteTargetedActivityAsync(conversationId, id, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.DeleteTargetedActivityAsync(conversationId, id, options.ServiceUrl ?? _serviceUrl, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken); } } diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/ApiClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/ApiClient.cs index 2d17636a..933a6524 100644 --- a/core/src/Microsoft.Teams.Apps/Api/Clients/ApiClient.cs +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/ApiClient.cs @@ -3,7 +3,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Teams.Apps.Schema; using Microsoft.Teams.Core.Http; +using Microsoft.Teams.Core.Schema; using CoreConversationClient = Microsoft.Teams.Core.ConversationClient; using CoreUserTokenClient = Microsoft.Teams.Core.UserTokenClient; @@ -18,9 +20,9 @@ namespace Microsoft.Teams.Apps.Api.Clients; /// This client can be constructed in two ways: /// /// -/// DI-friendly (no serviceUrl) — Use -/// and call per-request to create a scoped instance. -/// Fully initialized — Use +/// DI-friendly (no serviceUrl) — Use ApiClient(HttpClient, ConversationClient, UserTokenClient, ILogger) +/// and call , , or per-request to create a scoped instance. +/// Fully initialized — Use ApiClient(Uri, HttpClient, ConversationClient, UserTokenClient, ILogger) /// when the service URL is known upfront. /// /// @@ -32,10 +34,12 @@ public class ApiClient internal CoreUserTokenClient UserTokenClient { get; } + internal AgenticIdentity? DefaultAgenticIdentity { get; } + /// /// The service URL used by this client. /// Null when constructed without a service URL (DI-friendly constructor). - /// Call to create a scoped instance with a service URL. + /// Call or to create a scoped instance with a service URL. /// public virtual Uri ServiceUrl { get; } @@ -64,7 +68,7 @@ public class ApiClient /// /// Creates a new without a service URL (DI-friendly). - /// Use to create a scoped instance bound to a specific service URL. + /// Use to create a scoped instance bound to a specific service URL. /// /// An configured with authentication (e.g., via DI with BotAuthenticationHandler). /// The core conversation client for conversation/activity/member operations. @@ -80,6 +84,7 @@ internal ApiClient(HttpClient httpClient, CoreConversationClient conversationCli _http = new BotHttpClient(httpClient, logger); ConversationClient = conversationClient; UserTokenClient = userTokenClient; + DefaultAgenticIdentity = null; // ServiceUrl-dependent sub-clients require ForServiceUrl() before use ServiceUrl = null!; @@ -96,7 +101,8 @@ internal ApiClient(HttpClient httpClient, CoreConversationClient conversationCli /// The core conversation client for conversation/activity/member operations. /// The core user token client for sign-in and token operations. /// Optional logger. - internal ApiClient(Uri serviceUrl, HttpClient httpClient, CoreConversationClient conversationClient, CoreUserTokenClient userTokenClient, ILogger? logger = null) + /// Optional default agentic identity for service URL-bound sub-clients. + internal ApiClient(Uri serviceUrl, HttpClient httpClient, CoreConversationClient conversationClient, CoreUserTokenClient userTokenClient, ILogger? logger = null, AgenticIdentity? defaultAgenticIdentity = null) { ArgumentNullException.ThrowIfNull(serviceUrl); ArgumentNullException.ThrowIfNull(httpClient); @@ -106,22 +112,26 @@ internal ApiClient(Uri serviceUrl, HttpClient httpClient, CoreConversationClient _http = new BotHttpClient(httpClient, logger); ConversationClient = conversationClient; UserTokenClient = userTokenClient; + DefaultAgenticIdentity = defaultAgenticIdentity; ServiceUrl = serviceUrl; - Conversations = new ConversationApiClient(serviceUrl, conversationClient); - Teams = new TeamClient(serviceUrl.ToString(), _http); - Meetings = new MeetingClient(serviceUrl.ToString(), _http); + Conversations = new ConversationApiClient(serviceUrl, conversationClient, DefaultAgenticIdentity); + Teams = new TeamClient(serviceUrl.ToString(), _http, DefaultAgenticIdentity); + Meetings = new MeetingClient(serviceUrl.ToString(), _http, DefaultAgenticIdentity); } - // Private constructor for ForServiceUrl — shares BotHttpClient, ConversationClient, and UserTokenClient - private ApiClient(BotHttpClient http, CoreConversationClient conversationClient, CoreUserTokenClient userTokenClient, Uri serviceUrl) + // Private constructor for scoped clients — shares BotHttpClient, ConversationClient, and UserTokenClient + private ApiClient(BotHttpClient http, CoreConversationClient conversationClient, CoreUserTokenClient userTokenClient, RequestOptions options) { + Uri serviceUrl = options.ServiceUrl ?? throw new InvalidOperationException("RequestOptions.ServiceUrl is required to scope the Api client."); + _http = http; ConversationClient = conversationClient; UserTokenClient = userTokenClient; + DefaultAgenticIdentity = options.AgenticIdentity; ServiceUrl = serviceUrl; - Conversations = new ConversationApiClient(serviceUrl, conversationClient); - Teams = new TeamClient(serviceUrl.ToString(), http); - Meetings = new MeetingClient(serviceUrl.ToString(), http); + Conversations = new ConversationApiClient(serviceUrl, conversationClient, DefaultAgenticIdentity); + Teams = new TeamClient(serviceUrl.ToString(), http, DefaultAgenticIdentity); + Meetings = new MeetingClient(serviceUrl.ToString(), http, DefaultAgenticIdentity); } /// @@ -133,6 +143,33 @@ private ApiClient(BotHttpClient http, CoreConversationClient conversationClient, public virtual ApiClient ForServiceUrl(Uri serviceUrl) { ArgumentNullException.ThrowIfNull(serviceUrl); - return new ApiClient(_http, ConversationClient, UserTokenClient, serviceUrl); + return ForRequestOptions(new RequestOptions { ServiceUrl = serviceUrl }); + } + + /// + /// Creates a new with defaults from the request options. + /// + /// The request options. is required. + /// A new bound to the request options. + /// Thrown when is not provided. + public virtual ApiClient ForRequestOptions(RequestOptions options) + { + return new ApiClient(_http, ConversationClient, UserTokenClient, options); + } + + /// + /// Creates a new with defaults from the inbound activity. + /// + /// The inbound activity that supplies defaults for this scope. + /// A new with defaults from the inbound activity. + /// Thrown when does not include required API defaults. + public virtual ApiClient ForInboundActivity(TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return ForRequestOptions(new RequestOptions + { + ServiceUrl = activity.ServiceUrl ?? throw new InvalidOperationException("Activity.ServiceUrl is required to use the Api client."), + AgenticIdentity = activity.Recipient?.GetAgenticIdentity() + }); } } diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs index 26b49bd0..df6f2471 100644 --- a/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs @@ -20,6 +20,7 @@ public class ConversationApiClient { private readonly CoreConversationClient _client; private readonly Uri _serviceUrl; + private readonly AgenticIdentity? _defaultAgenticIdentity; /// /// Client for activity operations. @@ -39,14 +40,15 @@ public class ConversationApiClient [Obsolete("Use ConversationApiClient.AddReactionAsync and ConversationApiClient.DeleteReactionAsync instead.")] public ReactionClient Reactions { get; } - internal ConversationApiClient(Uri serviceUrl, CoreConversationClient client) + internal ConversationApiClient(Uri serviceUrl, CoreConversationClient client, AgenticIdentity? defaultAgenticIdentity = null) { _serviceUrl = serviceUrl; _client = client; + _defaultAgenticIdentity = defaultAgenticIdentity; #pragma warning disable CS0618 // Suppress obsolete warnings for backward-compatible initialization - Activities = new ActivityClient(serviceUrl, client); - Members = new MemberClient(serviceUrl, client); - Reactions = new ReactionClient(serviceUrl, client); + Activities = new ActivityClient(serviceUrl, client, defaultAgenticIdentity); + Members = new MemberClient(serviceUrl, client, defaultAgenticIdentity); + Reactions = new ReactionClient(serviceUrl, client, defaultAgenticIdentity); #pragma warning restore CS0618 } @@ -55,7 +57,7 @@ internal ConversationApiClient(Uri serviceUrl, CoreConversationClient client) /// public Task CreateAsync(ConversationParameters request, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) { - return _client.CreateConversationAsync(request, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.CreateConversationAsync(request, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); } #region Activity Methods @@ -63,42 +65,60 @@ public Task CreateAsync(ConversationParameters reque /// /// Create a new activity in a conversation. /// - public Task CreateActivityAsync(string conversationId, CoreActivity activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task CreateActivityAsync(string conversationId, CoreActivity activity, RequestOptions options = default, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - activity.ServiceUrl ??= _serviceUrl; + activity.ServiceUrl ??= options.ServiceUrl ?? _serviceUrl; activity.Conversation ??= new Conversation(conversationId); - return _client.SendActivityAsync(activity, customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.SendActivityAsync(activity, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken); } /// /// Update an existing activity in a conversation. /// - public Task UpdateActivityAsync(string conversationId, string id, CoreActivity activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task UpdateActivityAsync(string conversationId, string id, CoreActivity activity, RequestOptions options = default, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - activity.ServiceUrl ??= _serviceUrl; - return _client.UpdateActivityAsync(conversationId, id, activity, requestContext: BotRequestContext.FromActivity(activity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + activity.ServiceUrl ??= options.ServiceUrl ?? _serviceUrl; + BotRequestContext? requestContext = BotRequestContext.Merge( + options.ToBotRequestContext(_defaultAgenticIdentity), + BotRequestContext.FromActivity(activity)); + return _client.UpdateActivityAsync(conversationId, id, activity, requestContext: requestContext, cancellationToken: cancellationToken); } /// /// Reply to an existing activity in a conversation. /// - public Task ReplyToActivityAsync(string conversationId, string id, CoreActivity activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task ReplyToActivityAsync(string conversationId, string id, CoreActivity activity, RequestOptions options = default, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); activity.ReplyToId = id; - activity.ServiceUrl ??= _serviceUrl; + activity.ServiceUrl ??= options.ServiceUrl ?? _serviceUrl; activity.Conversation ??= new Conversation(conversationId); - return _client.SendActivityAsync(activity, customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.SendActivityAsync(activity, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken); } /// /// Delete an activity from a conversation. /// - public Task DeleteActivityAsync(string conversationId, string id, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task DeleteActivityAsync(string conversationId, string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + => DeleteActivityAsync(conversationId, id, new RequestOptions { AgenticIdentity = agenticIdentity }, cancellationToken); + + /// + /// Delete an activity from a conversation. + /// + public Task DeleteActivityAsync(string conversationId, string id, RequestOptions options, CancellationToken cancellationToken = default) + { + return _client.DeleteActivityAsync(conversationId, id, options.ServiceUrl ?? _serviceUrl, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken); + } + + /// + /// Get members of a specific activity. + /// + public async Task> GetActivityMembersAsync(string conversationId, string id, RequestOptions options = default, CancellationToken cancellationToken = default) { - return _client.DeleteActivityAsync(conversationId, id, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + IList members = await _client.GetActivityMembersAsync(conversationId, id, options.ServiceUrl ?? _serviceUrl, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken).ConfigureAwait(false); + return [.. members.Select(m => TeamsChannelAccount.FromChannelAccount(m))]; } /// @@ -106,35 +126,44 @@ public Task DeleteActivityAsync(string conversationId, string id, AgenticIdentit /// Targeted activities are only visible to the specified recipient. /// [Experimental("ExperimentalTeamsTargeted")] - public Task CreateTargetedActivityAsync(string conversationId, CoreActivity activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task CreateTargetedActivityAsync(string conversationId, CoreActivity activity, RequestOptions options = default, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - activity.ServiceUrl ??= _serviceUrl; + activity.ServiceUrl ??= options.ServiceUrl ?? _serviceUrl; activity.Conversation ??= new Conversation(conversationId); if (activity.Recipient is not null) { activity.Recipient.IsTargeted = true; } - return _client.SendActivityAsync(activity, customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.SendActivityAsync(activity, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken); } /// /// Update an existing targeted activity in a conversation. /// [Experimental("ExperimentalTeamsTargeted")] - public Task UpdateTargetedActivityAsync(string conversationId, string id, CoreActivity activity, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task UpdateTargetedActivityAsync(string conversationId, string id, CoreActivity activity, RequestOptions options = default, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - activity.ServiceUrl ??= _serviceUrl; - return _client.UpdateTargetedActivityAsync(conversationId, id, activity, requestContext: BotRequestContext.FromActivity(activity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + activity.ServiceUrl ??= options.ServiceUrl ?? _serviceUrl; + BotRequestContext? requestContext = BotRequestContext.Merge( + options.ToBotRequestContext(_defaultAgenticIdentity), + BotRequestContext.FromActivity(activity)); + return _client.UpdateTargetedActivityAsync(conversationId, id, activity, requestContext: requestContext, cancellationToken: cancellationToken); } /// /// Delete a targeted activity from a conversation. /// - public Task DeleteTargetedActivityAsync(string conversationId, string id, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) + public Task DeleteTargetedActivityAsync(string conversationId, string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + => DeleteTargetedActivityAsync(conversationId, id, new RequestOptions { AgenticIdentity = agenticIdentity }, cancellationToken); + + /// + /// Delete a targeted activity from a conversation. + /// + public Task DeleteTargetedActivityAsync(string conversationId, string id, RequestOptions options, CancellationToken cancellationToken = default) { - return _client.DeleteTargetedActivityAsync(conversationId, id, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.DeleteTargetedActivityAsync(conversationId, id, options.ServiceUrl ?? _serviceUrl, requestContext: options.ToBotRequestContext(_defaultAgenticIdentity), cancellationToken: cancellationToken); } #endregion @@ -147,7 +176,7 @@ public Task DeleteTargetedActivityAsync(string conversationId, string id, Agenti [Obsolete("Use GetMembersPagedAsync instead.")] public async Task> GetMembersAsync(string conversationId, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) { - IList members = await _client.GetConversationMembersAsync(conversationId, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken).ConfigureAwait(false); + IList members = await _client.GetConversationMembersAsync(conversationId, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken).ConfigureAwait(false); return [.. members.Select(m => TeamsChannelAccount.FromChannelAccount(m))]; } @@ -167,7 +196,7 @@ public async Task GetMembersPagedAsync( _serviceUrl, pageSize, continuationToken, - requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), + requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken).ConfigureAwait(false); PagedTeamsMembersResult result = new(); @@ -226,7 +255,7 @@ public async IAsyncEnumerable GetAllMembersAsync( /// public Task GetMemberByIdAsync(string conversationId, string memberId, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) where T : ChannelAccount { - return _client.GetConversationMemberAsync(conversationId, memberId, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.GetConversationMemberAsync(conversationId, memberId, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); } /// @@ -253,7 +282,7 @@ public Task GetMemberByIdAsync(string conversationId, string memberId, Age /// A to observe while waiting for the task to complete. public Task AddReactionAsync(string conversationId, string activityId, string reactionType, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) { - return _client.AddReactionAsync(conversationId, activityId, reactionType, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.AddReactionAsync(conversationId, activityId, reactionType, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); } /// @@ -267,7 +296,7 @@ public Task AddReactionAsync(string conversationId, string activityId, string re /// A to observe while waiting for the task to complete. public Task DeleteReactionAsync(string conversationId, string activityId, string reactionType, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) { - return _client.DeleteReactionAsync(conversationId, activityId, reactionType, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.DeleteReactionAsync(conversationId, activityId, reactionType, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); } #endregion diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/MeetingClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/MeetingClient.cs index 43485ee3..c8bdd4d5 100644 --- a/core/src/Microsoft.Teams.Apps/Api/Clients/MeetingClient.cs +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/MeetingClient.cs @@ -14,11 +14,13 @@ public class MeetingClient { private readonly BotHttpClient _http; private readonly string _serviceUrl; + private readonly AgenticIdentity? _defaultAgenticIdentity; - internal MeetingClient(string serviceUrl, BotHttpClient http) + internal MeetingClient(string serviceUrl, BotHttpClient http, AgenticIdentity? defaultAgenticIdentity = null) { _serviceUrl = serviceUrl.TrimEnd('/'); _http = http; + _defaultAgenticIdentity = defaultAgenticIdentity; } /// @@ -27,7 +29,7 @@ internal MeetingClient(string serviceUrl, BotHttpClient http) public async Task GetByIdAsync(string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) { string url = $"{_serviceUrl}/v1/meetings/{Uri.EscapeDataString(id)}"; - return await _http.SendAsync(HttpMethod.Get, url, body: null, options: CreateRequestOptions(agenticIdentity), cancellationToken).ConfigureAwait(false); + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: new BotRequestOptions { RequestContext = BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity) }, cancellationToken).ConfigureAwait(false); } /// @@ -36,11 +38,8 @@ internal MeetingClient(string serviceUrl, BotHttpClient http) public async Task GetParticipantAsync(string meetingId, string id, string tenantId, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) { string url = $"{_serviceUrl}/v1/meetings/{Uri.EscapeDataString(meetingId)}/participants/{Uri.EscapeDataString(id)}?tenantId={Uri.EscapeDataString(tenantId)}"; - return await _http.SendAsync(HttpMethod.Get, url, body: null, options: CreateRequestOptions(agenticIdentity), cancellationToken).ConfigureAwait(false); + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: new BotRequestOptions { RequestContext = BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity) }, cancellationToken).ConfigureAwait(false); } - - private static BotRequestOptions? CreateRequestOptions(AgenticIdentity? agenticIdentity) => - agenticIdentity is null ? null : new() { RequestContext = BotRequestContext.FromAgenticIdentity(agenticIdentity) }; } /// diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/MemberClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/MemberClient.cs index cfd05b82..bb8d23fd 100644 --- a/core/src/Microsoft.Teams.Apps/Api/Clients/MemberClient.cs +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/MemberClient.cs @@ -20,11 +20,13 @@ public class MemberClient { private readonly CoreConversationClient _client; private readonly Uri _serviceUrl; + private readonly AgenticIdentity? _defaultAgenticIdentity; - internal MemberClient(Uri serviceUrl, CoreConversationClient client) + internal MemberClient(Uri serviceUrl, CoreConversationClient client, AgenticIdentity? defaultAgenticIdentity = null) { _serviceUrl = serviceUrl; _client = client; + _defaultAgenticIdentity = defaultAgenticIdentity; } /// @@ -33,7 +35,7 @@ internal MemberClient(Uri serviceUrl, CoreConversationClient client) [Obsolete("Use GetPagedAsync instead.")] public async Task> GetAsync(string conversationId, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) { - IList members = await _client.GetConversationMembersAsync(conversationId, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken).ConfigureAwait(false); + IList members = await _client.GetConversationMembersAsync(conversationId, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken).ConfigureAwait(false); return [.. members.Select(m => TeamsChannelAccount.FromChannelAccount(m))]; } @@ -53,7 +55,7 @@ public async Task GetPagedAsync( _serviceUrl, pageSize, continuationToken, - requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), + requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken).ConfigureAwait(false); PagedTeamsMembersResult result = new(); @@ -112,7 +114,7 @@ public async IAsyncEnumerable GetAllAsync( /// public Task GetByIdAsync(string conversationId, string memberId, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) where T : ChannelAccount { - return _client.GetConversationMemberAsync(conversationId, memberId, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.GetConversationMemberAsync(conversationId, memberId, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); } /// diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/ReactionClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/ReactionClient.cs index 88419ec9..3b6e64ed 100644 --- a/core/src/Microsoft.Teams.Apps/Api/Clients/ReactionClient.cs +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/ReactionClient.cs @@ -17,11 +17,13 @@ public class ReactionClient { private readonly CoreConversationClient _client; private readonly Uri _serviceUrl; + private readonly AgenticIdentity? _defaultAgenticIdentity; - internal ReactionClient(Uri serviceUrl, CoreConversationClient client) + internal ReactionClient(Uri serviceUrl, CoreConversationClient client, AgenticIdentity? defaultAgenticIdentity = null) { _serviceUrl = serviceUrl; _client = client; + _defaultAgenticIdentity = defaultAgenticIdentity; } /// @@ -35,7 +37,7 @@ internal ReactionClient(Uri serviceUrl, CoreConversationClient client) /// A to observe while waiting for the task to complete. public Task AddAsync(string conversationId, string activityId, string reactionType, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) { - return _client.AddReactionAsync(conversationId, activityId, reactionType, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.AddReactionAsync(conversationId, activityId, reactionType, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); } /// @@ -49,6 +51,6 @@ public Task AddAsync(string conversationId, string activityId, string reactionTy /// A to observe while waiting for the task to complete. public Task DeleteAsync(string conversationId, string activityId, string reactionType, AgenticIdentity? agenticIdentity = null, Dictionary? additionalHeaders = null, CancellationToken cancellationToken = default) { - return _client.DeleteReactionAsync(conversationId, activityId, reactionType, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); + return _client.DeleteReactionAsync(conversationId, activityId, reactionType, _serviceUrl, requestContext: BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity), customHeaders: additionalHeaders, cancellationToken: cancellationToken); } } diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/RequestOptions.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/RequestOptions.cs new file mode 100644 index 00000000..1a3c5e73 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/RequestOptions.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core.Http; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Options for Apps API requests that need per-call settings beyond the required method parameters. +/// +public readonly record struct RequestOptions +{ + /// + /// Gets the service URL for this request. + /// + public Uri? ServiceUrl { get; init; } + + /// + /// Gets the agentic identity to authenticate as for this request. + /// + public AgenticIdentity? AgenticIdentity { get; init; } + + internal BotRequestContext? ToBotRequestContext(AgenticIdentity? defaultAgenticIdentity = null) + => BotRequestContext.FromAgenticIdentity(AgenticIdentity ?? defaultAgenticIdentity); + + internal BotRequestOptions? ToBotRequestOptions(AgenticIdentity? defaultAgenticIdentity = null) + => ToBotRequestContext(defaultAgenticIdentity) is { } requestContext + ? new BotRequestOptions { RequestContext = requestContext } + : null; +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/TeamClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/TeamClient.cs index 4b3dd905..5e9de7ed 100644 --- a/core/src/Microsoft.Teams.Apps/Api/Clients/TeamClient.cs +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/TeamClient.cs @@ -15,11 +15,13 @@ public class TeamClient { private readonly BotHttpClient _http; private readonly string _serviceUrl; + private readonly AgenticIdentity? _defaultAgenticIdentity; - internal TeamClient(string serviceUrl, BotHttpClient http) + internal TeamClient(string serviceUrl, BotHttpClient http, AgenticIdentity? defaultAgenticIdentity = null) { _serviceUrl = serviceUrl.TrimEnd('/'); _http = http; + _defaultAgenticIdentity = defaultAgenticIdentity; } /// @@ -28,7 +30,7 @@ internal TeamClient(string serviceUrl, BotHttpClient http) public async Task GetByIdAsync(string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) { string url = $"{_serviceUrl}/v3/teams/{Uri.EscapeDataString(id)}"; - return await _http.SendAsync(HttpMethod.Get, url, body: null, options: CreateRequestOptions(agenticIdentity), cancellationToken).ConfigureAwait(false); + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: new BotRequestOptions { RequestContext = BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity) }, cancellationToken).ConfigureAwait(false); } /// @@ -37,13 +39,10 @@ internal TeamClient(string serviceUrl, BotHttpClient http) public async Task?> GetConversationsAsync(string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) { string url = $"{_serviceUrl}/v3/teams/{Uri.EscapeDataString(id)}/conversations"; - ConversationListResponse? response = await _http.SendAsync(HttpMethod.Get, url, body: null, options: CreateRequestOptions(agenticIdentity), cancellationToken).ConfigureAwait(false); + ConversationListResponse? response = await _http.SendAsync(HttpMethod.Get, url, body: null, options: new BotRequestOptions { RequestContext = BotRequestContext.FromAgenticIdentity(agenticIdentity ?? _defaultAgenticIdentity) }, cancellationToken).ConfigureAwait(false); return response?.Conversations; } - private static BotRequestOptions? CreateRequestOptions(AgenticIdentity? agenticIdentity) => - agenticIdentity is null ? null : new() { RequestContext = BotRequestContext.FromAgenticIdentity(agenticIdentity) }; - private sealed class ConversationListResponse { [JsonPropertyName("conversations")] diff --git a/core/src/Microsoft.Teams.Apps/Context.cs b/core/src/Microsoft.Teams.Apps/Context.cs index 53851f38..a4bc2b45 100644 --- a/core/src/Microsoft.Teams.Apps/Context.cs +++ b/core/src/Microsoft.Teams.Apps/Context.cs @@ -48,8 +48,7 @@ public class Context(TeamsBotApplication botApplication, TActivity ac /// /// Gets the scoped to the current activity's service URL. /// - public ApiClient Api => _api ??= TeamsBotApplication.Api.ForServiceUrl( - Activity.ServiceUrl ?? throw new InvalidOperationException("Activity.ServiceUrl is required to use the Api client.")); + public ApiClient Api => _api ??= TeamsBotApplication.Api.ForInboundActivity(Activity); // ==================== Turn State ==================== diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs index 9f9bc60c..38eaef2b 100644 --- a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs @@ -115,7 +115,11 @@ public static IServiceCollection AddTeamsBotApplication(this IServiceColle BotConfig botConfig = BotConfig.Resolve(services, sectionName); // Register TeamsBotApplicationOptions - TeamsBotApplicationOptions teamsOptions = new() { AppId = botConfig.ClientId }; + TeamsBotApplicationOptions teamsOptions = new() + { + AppId = botConfig.ClientId, + TenantId = string.IsNullOrWhiteSpace(botConfig.TenantId) ? null : botConfig.TenantId + }; configure?.Invoke(teamsOptions); services.AddSingleton(teamsOptions); diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs index f273c8e9..9116b424 100644 --- a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs @@ -21,6 +21,7 @@ namespace Microsoft.Teams.Apps; public class TeamsBotApplication : BotApplication { private readonly TurnStateLoader? _stateLoader; + private readonly string? _tenantId; private Uri? _lastServiceUrl; /// @@ -112,6 +113,7 @@ public TeamsBotApplication( options) { _stateLoader = stateLoader; + _tenantId = string.IsNullOrWhiteSpace(options?.TenantId) ? null : options.TenantId; Api = teamsApiClient; Logger = logger; Router = new Router(logger); @@ -217,6 +219,7 @@ public TeamsBotApplication( AgenticAppId = agenticIdentity.AgenticAppId, AgenticUserId = agenticIdentity.AgenticUserId, AgenticAppBlueprintId = agenticIdentity.AgenticAppBlueprintId, + TenantId = agenticIdentity.TenantId, }); } @@ -241,6 +244,33 @@ public TeamsBotApplication( return SendAsync(threadedConversationId, text, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); } + /// + /// Creates an agentic identity using this application's defaults. + /// + /// + /// The agentic app blueprint ID defaults to . + /// The tenant ID defaults to the configured when available; + /// otherwise it remains null unless explicitly provided. + /// + /// The agentic application ID. + /// The agentic user ID. + /// Optional tenant ID of the agent instance for multi-tenant applications. Overrides the configured tenant ID when provided. + /// Optional agentic application blueprint ID. Defaults to this application's AppId. + /// An populated with application defaults. + public AgenticIdentity GetAgenticIdentity(string agenticAppId, string agenticUserId, string? tenantId = null, string? agenticAppBlueprintId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agenticAppId); + ArgumentException.ThrowIfNullOrWhiteSpace(agenticUserId); + + return new AgenticIdentity + { + AgenticAppId = agenticAppId, + AgenticUserId = agenticUserId, + TenantId = tenantId ?? _tenantId, + AgenticAppBlueprintId = agenticAppBlueprintId ?? AppId + }; + } + /// [Obsolete("Use SendAsync instead.")] public Task Send(string conversationId, string text, Uri? serviceUrl = null, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs index 1bc1ca16..56362971 100644 --- a/core/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs @@ -15,6 +15,11 @@ public sealed class TeamsBotApplicationOptions : BotApplicationOptions { internal List OAuthFlows { get; } = []; + /// + /// Gets or sets the configured tenant ID. Used as the default tenant for agentic identities created by . + /// + public string? TenantId { get; set; } + /// /// Register an OAuth flow with the given connection name and optional configuration. /// diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/AgenticIdentityDefaultsTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/AgenticIdentityDefaultsTests.cs new file mode 100644 index 00000000..c27f6668 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/AgenticIdentityDefaultsTests.cs @@ -0,0 +1,529 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Http; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class AgenticIdentityDefaultsTests +{ + private static readonly Uri ServiceUrl = new("https://smba.trafficmanager.net/amer/"); + + [Fact] + public async Task ContextApi_UsesRecipientAgenticIdentityAsDefault() + { + CapturingHttpMessageHandler handler = new(); + TeamsBotApplication app = CreateApp(handler); + AgenticIdentity expected = DefaultIdentity(); + Context context = new( + app, + new TeamsActivity + { + Type = TeamsActivityTypes.Message, + ServiceUrl = ServiceUrl, + Recipient = new TeamsChannelAccount + { + AgenticAppId = expected.AgenticAppId, + AgenticUserId = expected.AgenticUserId, + AgenticAppBlueprintId = expected.AgenticAppBlueprintId, + TenantId = expected.TenantId + } + }); + + await context.Api.Conversations.GetMembersPagedAsync("conversation-id"); + + CapturedRequest request = Assert.Single(handler.Requests); + AssertIdentity(expected, request.AgenticIdentity); + } + + [Fact] + public async Task ContextApi_ExplicitIdentityOverridesRecipientDefault() + { + CapturingHttpMessageHandler handler = new(); + TeamsBotApplication app = CreateApp(handler); + AgenticIdentity explicitIdentity = ExplicitIdentity(); + Context context = new( + app, + new TeamsActivity + { + Type = TeamsActivityTypes.Message, + ServiceUrl = ServiceUrl, + Recipient = new TeamsChannelAccount + { + AgenticAppId = "default-agentic-app-id", + AgenticUserId = "default-agentic-user-id", + AgenticAppBlueprintId = "default-agentic-blueprint-id", + TenantId = "default-tenant-id" + } + }); + + await context.Api.Conversations.GetMemberByIdAsync("conversation-id", "member-id", explicitIdentity); + + CapturedRequest request = Assert.Single(handler.Requests); + AssertIdentity(explicitIdentity, request.AgenticIdentity); + } + + [Fact] + public async Task ScopedApi_ServiceUrlBoundClientsUseDefaultIdentity() + { + CapturingHttpMessageHandler handler = new(); + AgenticIdentity expected = DefaultIdentity(); + ApiClient api = CreateApiClient(handler).ForRequestOptions(new RequestOptions + { + ServiceUrl = ServiceUrl, + AgenticIdentity = expected + }); + + await api.Conversations.CreateAsync(new ConversationParameters()); + await api.Conversations.GetMemberByIdAsync("conversation-id", "member-id"); + await api.Conversations.AddReactionAsync("conversation-id", "activity-id", "like"); + await api.Teams.GetByIdAsync("team-id"); + await api.Meetings.GetByIdAsync("meeting-id"); + + Assert.Equal(5, handler.Requests.Count); + Assert.All(handler.Requests, request => AssertIdentity(expected, request.AgenticIdentity)); + } + + [Fact] + public async Task ScopedApi_ExplicitIdentityOverridesDefaultIdentity() + { + CapturingHttpMessageHandler handler = new(); + AgenticIdentity explicitIdentity = ExplicitIdentity(); + ApiClient api = CreateApiClient(handler).ForRequestOptions(new RequestOptions + { + ServiceUrl = ServiceUrl, + AgenticIdentity = DefaultIdentity() + }); + + await api.Meetings.GetParticipantAsync("meeting-id", "participant-id", "tenant-id", explicitIdentity); + + CapturedRequest request = Assert.Single(handler.Requests); + AssertIdentity(explicitIdentity, request.AgenticIdentity); + } + + [Fact] + public async Task ForInboundActivity_UsesServiceUrlAndRecipientAgenticIdentity() + { + CapturingHttpMessageHandler handler = new(); + AgenticIdentity expected = DefaultIdentity(); + ApiClient api = CreateApiClient(handler).ForInboundActivity(new TeamsActivity + { + ServiceUrl = ServiceUrl, + Recipient = new TeamsChannelAccount + { + AgenticAppId = expected.AgenticAppId, + AgenticUserId = expected.AgenticUserId, + AgenticAppBlueprintId = expected.AgenticAppBlueprintId, + TenantId = expected.TenantId + } + }); + + await api.Conversations.GetMembersPagedAsync("conversation-id"); + + Assert.Equal(ServiceUrl, api.ServiceUrl); + CapturedRequest request = Assert.Single(handler.Requests); + AssertIdentity(expected, request.AgenticIdentity); + } + + [Fact] + public async Task ForRequestOptions_UsesServiceUrlAndAgenticIdentity() + { + CapturingHttpMessageHandler handler = new(); + AgenticIdentity expected = DefaultIdentity(); + ApiClient api = CreateApiClient(handler).ForRequestOptions(new RequestOptions + { + ServiceUrl = ServiceUrl, + AgenticIdentity = expected + }); + + await api.Conversations.GetMembersPagedAsync("conversation-id"); + + Assert.Equal(ServiceUrl, api.ServiceUrl); + CapturedRequest request = Assert.Single(handler.Requests); + AssertIdentity(expected, request.AgenticIdentity); + } + + [Fact] + public void ForInboundActivity_ThrowsWhenServiceUrlIsMissing() + { + ApiClient api = CreateApiClient(new CapturingHttpMessageHandler()); + + InvalidOperationException exception = Assert.Throws(() => api.ForInboundActivity(new TeamsActivity())); + + Assert.Equal("Activity.ServiceUrl is required to use the Api client.", exception.Message); + } + + [Fact] + public void ForRequestOptions_ThrowsWhenServiceUrlIsMissing() + { + ApiClient api = CreateApiClient(new CapturingHttpMessageHandler()); + + InvalidOperationException exception = Assert.Throws(() => api.ForRequestOptions(new RequestOptions())); + + Assert.Equal("RequestOptions.ServiceUrl is required to scope the Api client.", exception.Message); + } + + [Fact] + public async Task ConversationApi_ActivityMethodsUseDefaultIdentityWhenExplicitIdentityIsMissing() + { + CapturingHttpMessageHandler handler = new(); + AgenticIdentity expected = DefaultIdentity(); + ConversationApiClient conversations = CreateApiClient(handler).ForRequestOptions(new RequestOptions + { + ServiceUrl = ServiceUrl, + AgenticIdentity = expected + }).Conversations; + + await conversations.CreateActivityAsync("conversation-id", CreateActivity()); + await conversations.UpdateActivityAsync("conversation-id", "activity-id", CreateActivity()); + await conversations.ReplyToActivityAsync("conversation-id", "activity-id", CreateActivity()); + await conversations.DeleteActivityAsync("conversation-id", "activity-id"); + await conversations.GetActivityMembersAsync("conversation-id", "activity-id"); + await conversations.CreateTargetedActivityAsync("conversation-id", CreateTargetedActivity()); + await conversations.UpdateTargetedActivityAsync("conversation-id", "activity-id", CreateActivity()); + await conversations.DeleteTargetedActivityAsync("conversation-id", "activity-id"); + + Assert.Equal(8, handler.Requests.Count); + Assert.All(handler.Requests, request => AssertIdentity(expected, request.AgenticIdentity)); + } + + [Fact] + public async Task ConversationApi_ActivityMethodsExplicitIdentityOverridesDefaultIdentity() + { + CapturingHttpMessageHandler handler = new(); + AgenticIdentity explicitIdentity = ExplicitIdentity(); + ConversationApiClient conversations = CreateApiClient(handler).ForRequestOptions(new RequestOptions + { + ServiceUrl = ServiceUrl, + AgenticIdentity = DefaultIdentity() + }).Conversations; + RequestOptions options = CreateRequestOptions(explicitIdentity); + + await conversations.CreateActivityAsync("conversation-id", CreateActivity(), options); + await conversations.UpdateActivityAsync("conversation-id", "activity-id", CreateActivity(), options); + await conversations.ReplyToActivityAsync("conversation-id", "activity-id", CreateActivity(), options); + await conversations.DeleteActivityAsync("conversation-id", "activity-id", options); + await conversations.GetActivityMembersAsync("conversation-id", "activity-id", options); + await conversations.CreateTargetedActivityAsync("conversation-id", CreateTargetedActivity(), options); + await conversations.UpdateTargetedActivityAsync("conversation-id", "activity-id", CreateActivity(), options); + await conversations.DeleteTargetedActivityAsync("conversation-id", "activity-id", options); + + Assert.Equal(8, handler.Requests.Count); + Assert.All(handler.Requests, request => AssertIdentity(explicitIdentity, request.AgenticIdentity)); + } + + [Fact] + public async Task ConversationApi_ActivityFromIdentityOverridesFallbackIdentity() + { + CapturingHttpMessageHandler handler = new(); + AgenticIdentity fromIdentity = new() + { + AgenticAppId = "from-agentic-app-id", + AgenticUserId = "from-agentic-user-id", + AgenticAppBlueprintId = "from-agentic-blueprint-id", + TenantId = "from-tenant-id" + }; + ConversationApiClient conversations = CreateApiClient(handler).ForRequestOptions(new RequestOptions + { + ServiceUrl = ServiceUrl, + AgenticIdentity = DefaultIdentity() + }).Conversations; + RequestOptions options = CreateRequestOptions(ExplicitIdentity()); + + await conversations.CreateActivityAsync("conversation-id", CreateActivity(fromIdentity), options); + await conversations.UpdateActivityAsync("conversation-id", "activity-id", CreateActivity(fromIdentity), options); + + Assert.Equal(2, handler.Requests.Count); + Assert.All(handler.Requests, request => + { + AssertIdentity(fromIdentity, request.AgenticIdentity); + Assert.Equal("from-bot-id", request.BotAppId); + }); + } + + [Fact] + public async Task ConversationApi_ActivityRequestOptionsServiceUrlOverridesScopedServiceUrl() + { + CapturingHttpMessageHandler handler = new(); + Uri serviceUrl = new("https://override.example.com/"); + ConversationApiClient conversations = CreateApiClient(handler).ForRequestOptions(new RequestOptions + { + ServiceUrl = ServiceUrl, + AgenticIdentity = DefaultIdentity() + }).Conversations; + + await conversations.CreateActivityAsync("conversation-id", CreateActivity(), new RequestOptions { ServiceUrl = serviceUrl }); + + CapturedRequest request = Assert.Single(handler.Requests); + Assert.StartsWith(serviceUrl.ToString(), request.Url, StringComparison.Ordinal); + } + + [Fact] + public async Task ActivityClient_UsesDefaultIdentityWhenExplicitIdentityIsMissing() + { + CapturingHttpMessageHandler handler = new(); + AgenticIdentity expected = DefaultIdentity(); +#pragma warning disable CS0618 // Verifies backward-compatible obsolete wrapper behavior. + ActivityClient activities = CreateApiClient(handler).ForRequestOptions(new RequestOptions + { + ServiceUrl = ServiceUrl, + AgenticIdentity = expected + }).Conversations.Activities; +#pragma warning restore CS0618 + + await activities.CreateAsync("conversation-id", CreateActivity()); + await activities.UpdateAsync("conversation-id", "activity-id", CreateActivity()); + await activities.ReplyAsync("conversation-id", "activity-id", CreateActivity()); + await activities.DeleteAsync("conversation-id", "activity-id"); + await activities.GetMembersAsync("conversation-id", "activity-id"); + await activities.CreateTargetedAsync("conversation-id", CreateTargetedActivity()); + await activities.UpdateTargetedAsync("conversation-id", "activity-id", CreateActivity()); + await activities.DeleteTargetedAsync("conversation-id", "activity-id"); + + Assert.Equal(8, handler.Requests.Count); + Assert.All(handler.Requests, request => AssertIdentity(expected, request.AgenticIdentity)); + } + + [Fact] + public async Task ActivityClient_ExplicitIdentityOverridesDefaultIdentity() + { + CapturingHttpMessageHandler handler = new(); + AgenticIdentity explicitIdentity = ExplicitIdentity(); +#pragma warning disable CS0618 // Verifies backward-compatible obsolete wrapper behavior. + ActivityClient activities = CreateApiClient(handler).ForRequestOptions(new RequestOptions + { + ServiceUrl = ServiceUrl, + AgenticIdentity = DefaultIdentity() + }).Conversations.Activities; +#pragma warning restore CS0618 + RequestOptions options = CreateRequestOptions(explicitIdentity); + + await activities.CreateAsync("conversation-id", CreateActivity(), options); + await activities.UpdateAsync("conversation-id", "activity-id", CreateActivity(), options); + await activities.ReplyAsync("conversation-id", "activity-id", CreateActivity(), options); + await activities.DeleteAsync("conversation-id", "activity-id", options); + await activities.GetMembersAsync("conversation-id", "activity-id", options); + await activities.CreateTargetedAsync("conversation-id", CreateTargetedActivity(), options); + await activities.UpdateTargetedAsync("conversation-id", "activity-id", CreateActivity(), options); + await activities.DeleteTargetedAsync("conversation-id", "activity-id", options); + + Assert.Equal(8, handler.Requests.Count); + Assert.All(handler.Requests, request => AssertIdentity(explicitIdentity, request.AgenticIdentity)); + } + + [Fact] + public async Task ActivityClient_ActivityFromIdentityOverridesFallbackIdentity() + { + CapturingHttpMessageHandler handler = new(); + AgenticIdentity fromIdentity = new() + { + AgenticAppId = "from-agentic-app-id", + AgenticUserId = "from-agentic-user-id", + AgenticAppBlueprintId = "from-agentic-blueprint-id", + TenantId = "from-tenant-id" + }; +#pragma warning disable CS0618 // Verifies backward-compatible obsolete wrapper behavior. + ActivityClient activities = CreateApiClient(handler).ForRequestOptions(new RequestOptions + { + ServiceUrl = ServiceUrl, + AgenticIdentity = DefaultIdentity() + }).Conversations.Activities; +#pragma warning restore CS0618 + RequestOptions options = CreateRequestOptions(ExplicitIdentity()); + + await activities.CreateAsync("conversation-id", CreateActivity(fromIdentity), options); + await activities.UpdateAsync("conversation-id", "activity-id", CreateActivity(fromIdentity), options); + + Assert.Equal(2, handler.Requests.Count); + Assert.All(handler.Requests, request => + { + AssertIdentity(fromIdentity, request.AgenticIdentity); + Assert.Equal("from-bot-id", request.BotAppId); + }); + } + + [Fact] + public void GetAgenticIdentity_DefaultsBlueprintAndTenant() + { + TeamsBotApplication app = CreateApp(options: new TeamsBotApplicationOptions + { + AppId = "app-id", + TenantId = "tenant-id" + }); + + AgenticIdentity identity = app.GetAgenticIdentity("agentic-app-id", "agentic-user-id"); + + Assert.Equal("agentic-app-id", identity.AgenticAppId); + Assert.Equal("agentic-user-id", identity.AgenticUserId); + Assert.Equal("app-id", identity.AgenticAppBlueprintId); + Assert.Equal("tenant-id", identity.TenantId); + } + + [Fact] + public void GetAgenticIdentity_ExplicitValuesOverrideDefaults() + { + TeamsBotApplication app = CreateApp(options: new TeamsBotApplicationOptions + { + AppId = "app-id", + TenantId = "tenant-id" + }); + + AgenticIdentity identity = app.GetAgenticIdentity( + "agentic-app-id", + "agentic-user-id", + tenantId: "explicit-tenant-id", + agenticAppBlueprintId: "explicit-blueprint-id"); + + Assert.Equal("explicit-blueprint-id", identity.AgenticAppBlueprintId); + Assert.Equal("explicit-tenant-id", identity.TenantId); + } + + [Fact] + public void GetAgenticIdentity_LeavesTenantNullWhenUnconfigured() + { + TeamsBotApplication app = CreateApp(options: new TeamsBotApplicationOptions { AppId = "app-id" }); + + AgenticIdentity identity = app.GetAgenticIdentity("agentic-app-id", "agentic-user-id"); + + Assert.Equal("app-id", identity.AgenticAppBlueprintId); + Assert.Null(identity.TenantId); + } + + private static TeamsBotApplication CreateApp(CapturingHttpMessageHandler? handler = null, TeamsBotApplicationOptions? options = null) + => new( + CreateApiClient(handler ?? new CapturingHttpMessageHandler()), + new HttpContextAccessor(), + NullLogger.Instance, + options ?? new TeamsBotApplicationOptions { AppId = "app-id" }); + + private static ApiClient CreateApiClient(CapturingHttpMessageHandler handler) + { + HttpClient httpClient = new(handler); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + UserTokenClient userTokenClient = new(new HttpClient(), new ConfigurationBuilder().Build(), NullLogger.Instance); + return new ApiClient(httpClient, conversationClient, userTokenClient); + } + + private static CoreActivity CreateActivity(AgenticIdentity? fromIdentity = null) + { + CoreActivity activity = new() + { + Type = ActivityType.Message + }; + + if (fromIdentity is not null) + { + activity.From = new ChannelAccount + { + Id = "28:from-bot-id", + AgenticAppId = fromIdentity.AgenticAppId, + AgenticUserId = fromIdentity.AgenticUserId, + AgenticAppBlueprintId = fromIdentity.AgenticAppBlueprintId, + TenantId = fromIdentity.TenantId + }; + } + + return activity; + } + + private static CoreActivity CreateTargetedActivity() + => new() + { + Type = ActivityType.Message, + Recipient = new ChannelAccount { Id = "recipient-id" } + }; + + private static AgenticIdentity DefaultIdentity() + => new() + { + AgenticAppId = "default-agentic-app-id", + AgenticUserId = "default-agentic-user-id", + AgenticAppBlueprintId = "default-agentic-blueprint-id", + TenantId = "default-tenant-id" + }; + + private static AgenticIdentity ExplicitIdentity() + => new() + { + AgenticAppId = "explicit-agentic-app-id", + AgenticUserId = "explicit-agentic-user-id", + AgenticAppBlueprintId = "explicit-agentic-blueprint-id", + TenantId = "explicit-tenant-id" + }; + + private static RequestOptions CreateRequestOptions(AgenticIdentity agenticIdentity) + => new() { AgenticIdentity = agenticIdentity }; + + private static void AssertIdentity(AgenticIdentity expected, AgenticIdentity? actual) + { + Assert.NotNull(actual); + Assert.Equal(expected.AgenticAppId, actual!.AgenticAppId); + Assert.Equal(expected.AgenticUserId, actual.AgenticUserId); + Assert.Equal(expected.AgenticAppBlueprintId, actual.AgenticAppBlueprintId); + Assert.Equal(expected.TenantId, actual.TenantId); + } + + private sealed class CapturingHttpMessageHandler : HttpMessageHandler + { + private static readonly HttpRequestOptionsKey AgenticIdentityKey = new(BotRequestContext.AgenticIdentityKey); + private static readonly HttpRequestOptionsKey BotAppIdKey = new(BotRequestContext.BotAppIdKey); + + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Options.TryGetValue(AgenticIdentityKey, out object? agenticIdentity); + request.Options.TryGetValue(BotAppIdKey, out object? botAppId); + Requests.Add(new CapturedRequest( + request.Method, + request.RequestUri?.ToString() ?? string.Empty, + agenticIdentity as AgenticIdentity, + botAppId as string)); + + return Task.FromResult(CreateResponse(request)); + } + + private static HttpResponseMessage CreateResponse(HttpRequestMessage request) + { + string path = request.RequestUri?.AbsolutePath ?? string.Empty; + string json = path.Contains("/pagedmembers", StringComparison.Ordinal) + ? "{\"members\":[{\"id\":\"member-id\"}]}" + : path.EndsWith("/members", StringComparison.Ordinal) + ? "[{\"id\":\"member-id\"}]" + : path.Contains("/members/", StringComparison.Ordinal) + ? "{\"id\":\"member-id\"}" + : path.EndsWith("/conversations", StringComparison.Ordinal) && request.Method == HttpMethod.Post + ? "{\"id\":\"conversation-id\"}" + : path.Contains("/activities", StringComparison.Ordinal) && request.Method != HttpMethod.Delete + ? "{\"id\":\"activity-id\"}" + : path.Contains("/teams/", StringComparison.Ordinal) && path.EndsWith("/conversations", StringComparison.Ordinal) + ? "{\"conversations\":[]}" + : path.Contains("/teams/", StringComparison.Ordinal) + ? "{\"id\":\"team-id\"}" + : path.Contains("/meetings/", StringComparison.Ordinal) && path.Contains("/participants/", StringComparison.Ordinal) + ? "{\"user\":{\"id\":\"participant-id\"}}" + : path.Contains("/meetings/", StringComparison.Ordinal) + ? "{\"id\":\"meeting-id\"}" + : string.Empty; + + HttpResponseMessage response = new(HttpStatusCode.OK); + if (json.Length > 0) + { + response.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + + return response; + } + } + + private sealed record CapturedRequest(HttpMethod Method, string Url, AgenticIdentity? AgenticIdentity, string? BotAppId); +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationHostingExtensionsTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationHostingExtensionsTests.cs index 7acabc0d..9369c813 100644 --- a/core/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationHostingExtensionsTests.cs +++ b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationHostingExtensionsTests.cs @@ -42,6 +42,7 @@ public void AddTeamsBotApplication_RegistersAllRequiredServices() Assert.NotNull(serviceProvider.GetRequiredService()); TeamsBotApplicationOptions options = serviceProvider.GetRequiredService(); Assert.Equal("teams-bundle-client-id", options.AppId); + Assert.Equal("teams-bundle-tenant-id", options.TenantId); BotApplicationOptions botOptions = serviceProvider.GetRequiredService(); Assert.Equal("teams-bundle-client-id", botOptions.AppId);