From 61449e8ebb02293a9fa13d840a3980aac3d7438c Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 16 Apr 2026 16:47:40 -0700 Subject: [PATCH 01/22] wip, migrated new apiclient --- core/core.slnx | 2 +- .../Api/ActivitiesApi.cs | 222 -------- .../Microsoft.Teams.Bot.Apps/Api/BatchApi.cs | 358 ------------- .../Api/Clients/ActivityClient.cs | 98 ++++ .../Api/Clients/ApiClient.cs | 83 +++ .../Api/Clients/BotClient.cs | 22 + .../Api/Clients/BotSignInClient.cs | 58 ++ .../Api/Clients/BotTokenClient.cs | 25 + .../Api/Clients/MeetingClient.cs | 143 +++++ .../Api/Clients/MemberClient.cs | 49 ++ .../Api/Clients/ReactionClient.cs | 47 ++ .../Api/Clients/TeamClient.cs | 40 ++ .../Api/Clients/UserClient.cs | 22 + .../Api/Clients/V3ConversationClient.cs | 63 +++ .../Api/Clients/V3UserTokenClient.cs | 127 +++++ .../Api/ConversationsApi.cs | 69 --- .../Api/MeetingsApi.cs | 166 ------ .../Api/MembersApi.cs | 266 --------- .../Microsoft.Teams.Bot.Apps/Api/README.md | 77 --- .../Api/ReactionsApi.cs | 90 ---- .../Microsoft.Teams.Bot.Apps/Api/TeamsApi.cs | 65 --- .../Api/TeamsOperationsApi.cs | 109 ---- .../Api/UserTokenApi.cs | 278 ---------- .../Microsoft.Teams.Bot.Apps/Api/UsersApi.cs | 32 -- .../TeamsApiClient.Models.cs | 478 ----------------- .../TeamsApiClient.cs | 442 --------------- .../TeamsBotApplication.HostingExtensions.cs | 4 +- .../TeamsBotApplication.cs | 11 +- .../CompatActivity.cs | 36 +- .../CompatAdapter.cs | 4 +- .../CompatTeamsInfo.Models.cs | 258 ++++----- .../CompatTeamsInfo.cs | 505 +++++++++--------- .../CompatAdapterTests.cs | 13 +- .../CompatBotAdapterTests.cs | 10 +- 34 files changed, 1203 insertions(+), 3069 deletions(-) delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/ActivitiesApi.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/BatchApi.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotTokenClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MeetingClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/TeamClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/MeetingsApi.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/MembersApi.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/README.md delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/ReactionsApi.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/TeamsApi.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/TeamsOperationsApi.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/UserTokenApi.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/UsersApi.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.Models.cs delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.cs diff --git a/core/core.slnx b/core/core.slnx index f7ad70c9..12934ad3 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -44,6 +44,6 @@ - + diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/ActivitiesApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/ActivitiesApi.cs deleted file mode 100644 index 80e1d551..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/ActivitiesApi.cs +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Schema; - -namespace Microsoft.Teams.Bot.Apps.Api; - -using CustomHeaders = Dictionary; - -/// -/// Provides activity operations for sending, updating, and deleting activities in conversations. -/// -public class ActivitiesApi -{ - private readonly ConversationClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// The conversation client for activity operations. - internal ActivitiesApi(ConversationClient conversationClient) - { - _client = conversationClient; - } - - // TODO: Resolve comment https://github.com/microsoft/teams.net/pull/334/changes#r2824918487 - - /// - /// Sends an activity to a conversation. - /// - /// The activity to send. Must contain valid conversation and service URL information. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the response with the ID of the sent activity, or null if the response has no body. - public Task SendAsync( - CoreActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.SendActivityAsync(activity, customHeaders, cancellationToken); - - /// - /// Updates an existing activity in a conversation. - /// - /// The ID of the conversation. - /// The ID of the activity to update. - /// The updated activity data. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the response with the ID of the updated activity. - public Task UpdateAsync( - string conversationId, - string activityId, - CoreActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.UpdateActivityAsync(conversationId, activityId, activity, customHeaders, cancellationToken); - - /// - /// Updates an existing targeted activity in a conversation. - /// - /// The ID of the conversation. - /// The ID of the activity to update. - /// The updated activity data. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the response with the ID of the updated activity. - public Task UpdateTargetedAsync( - string conversationId, - string activityId, - CoreActivity activity, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.UpdateTargetedActivityAsync(conversationId, activityId, activity, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Deletes an existing targeted activity from a conversation. - /// - /// The ID of the conversation. - /// The ID of the activity to delete. - /// The service URL for the conversation. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - public Task DeleteTargetedAsync( - string conversationId, - string activityId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.DeleteTargetedActivityAsync(conversationId, activityId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Deletes an existing activity from a conversation. - /// - /// The ID of the conversation. - /// The ID of the activity to delete. - /// The service URL for the conversation. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - public Task DeleteAsync( - string conversationId, - string activityId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.DeleteActivityAsync(conversationId, activityId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Deletes an existing activity from a conversation using activity context. - /// - /// The activity to delete. Must contain valid Id, Conversation.Id, and ServiceUrl. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - public Task DeleteAsync( - CoreActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.DeleteActivityAsync(activity, customHeaders, cancellationToken); - - /// - /// Uploads and sends historic activities to a conversation. - /// - /// The ID of the conversation. - /// The transcript containing the historic activities. - /// The service URL for the conversation. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the response with a resource ID. - public Task SendHistoryAsync( - string conversationId, - Transcript transcript, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.SendConversationHistoryAsync(conversationId, transcript, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Uploads and sends historic activities to a conversation using activity context. - /// - /// The activity providing conversation context. Must contain valid Conversation.Id and ServiceUrl. - /// The transcript containing the historic activities. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the response with a resource ID. - public Task SendHistoryAsync( - TeamsActivity activity, - Transcript transcript, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.Conversation); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.SendConversationHistoryAsync( - activity.Conversation.Id, - transcript, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Gets the members of a specific activity. - /// - /// The ID of the conversation. - /// The ID of the activity. - /// The service URL for the conversation. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains a list of conversation members. - public Task> GetMembersAsync( - string conversationId, - string activityId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.GetActivityMembersAsync(conversationId, activityId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets the members of a specific activity using activity context. - /// - /// The activity to get members for. Must contain valid Id, Conversation.Id, and ServiceUrl. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains a list of members for the activity. - public Task> GetMembersAsync( - TeamsActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.Id); - ArgumentNullException.ThrowIfNull(activity.Conversation); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.GetActivityMembersAsync( - activity.Conversation.Id, - activity.Id, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/BatchApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/BatchApi.cs deleted file mode 100644 index bb2c6846..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/BatchApi.cs +++ /dev/null @@ -1,358 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core.Schema; - -namespace Microsoft.Teams.Bot.Apps.Api; - -using CustomHeaders = Dictionary; - -/// -/// Provides batch messaging operations for sending messages to multiple recipients. -/// -public class BatchApi -{ - private readonly TeamsApiClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// The Teams API client for batch operations. - internal BatchApi(TeamsApiClient teamsApiClient) - { - _client = teamsApiClient; - } - - /// - /// Sends a message to a list of Teams users. - /// - /// The activity to send. - /// The list of team members to send the message to. - /// The ID of the tenant. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - public Task SendToUsersAsync( - CoreActivity activity, - IList teamsMembers, - string tenantId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.SendMessageToListOfUsersAsync(activity, teamsMembers, tenantId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Sends a message to a list of Teams users using activity context. - /// - /// The activity to send. - /// The list of team members to send the message to. - /// The activity providing service URL, tenant ID, and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - public Task SendToUsersAsync( - CoreActivity activity, - IList teamsMembers, - TeamsActivity contextActivity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(contextActivity); - ArgumentNullException.ThrowIfNull(contextActivity.ServiceUrl); - - string? tenantId = contextActivity.ChannelData?.Tenant?.Id; - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId, "contextActivity.ChannelData.Tenant.Id"); - return _client.SendMessageToListOfUsersAsync( - activity, - teamsMembers, - tenantId, - contextActivity.ServiceUrl, - contextActivity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Sends a message to all users in a tenant. - /// - /// The activity to send. - /// The ID of the tenant. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - public Task SendToTenantAsync( - CoreActivity activity, - string tenantId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.SendMessageToAllUsersInTenantAsync(activity, tenantId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Sends a message to all users in a tenant using activity context. - /// - /// The activity to send. - /// The activity providing service URL, tenant ID, and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - public Task SendToTenantAsync( - CoreActivity activity, - TeamsActivity contextActivity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(contextActivity); - ArgumentNullException.ThrowIfNull(contextActivity.ServiceUrl); - - string? tenantId = contextActivity.ChannelData?.Tenant?.Id; - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId, "contextActivity.ChannelData.Tenant.Id"); - return _client.SendMessageToAllUsersInTenantAsync( - activity, - tenantId, - contextActivity.ServiceUrl, - contextActivity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Sends a message to all users in a team. - /// - /// The activity to send. - /// The ID of the team. - /// The ID of the tenant. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - public Task SendToTeamAsync( - CoreActivity activity, - string teamId, - string tenantId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.SendMessageToAllUsersInTeamAsync(activity, teamId, tenantId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Sends a message to all users in a team using activity context. - /// - /// The activity to send. - /// The ID of the team. - /// The activity providing service URL, tenant ID, and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - public Task SendToTeamAsync( - CoreActivity activity, - string teamId, - TeamsActivity contextActivity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(contextActivity); - ArgumentNullException.ThrowIfNull(contextActivity.ServiceUrl); - - string? tenantId = contextActivity.ChannelData?.Tenant?.Id; - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId, "contextActivity.ChannelData.Tenant.Id"); - return _client.SendMessageToAllUsersInTeamAsync( - activity, - teamId, - tenantId, - contextActivity.ServiceUrl, - contextActivity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Sends a message to a list of Teams channels. - /// - /// The activity to send. - /// The list of channels to send the message to. - /// The ID of the tenant. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - public Task SendToChannelsAsync( - CoreActivity activity, - IList channelMembers, - string tenantId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.SendMessageToListOfChannelsAsync(activity, channelMembers, tenantId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Sends a message to a list of Teams channels using activity context. - /// - /// The activity to send. - /// The list of channels to send the message to. - /// The activity providing service URL, tenant ID, and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - public Task SendToChannelsAsync( - CoreActivity activity, - IList channelMembers, - TeamsActivity contextActivity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(contextActivity); - ArgumentNullException.ThrowIfNull(contextActivity.ServiceUrl); - - string? tenantId = contextActivity.ChannelData?.Tenant?.Id; - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId, "contextActivity.ChannelData.Tenant.Id"); - return _client.SendMessageToListOfChannelsAsync( - activity, - channelMembers, - tenantId, - contextActivity.ServiceUrl, - contextActivity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Gets the state of a batch operation. - /// - /// The ID of the operation. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation state. - public Task GetStateAsync( - string operationId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.GetOperationStateAsync(operationId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets the state of a batch operation using activity context. - /// - /// The ID of the operation. - /// The activity providing service URL and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation state. - public Task GetStateAsync( - string operationId, - TeamsActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - return _client.GetOperationStateAsync( - operationId, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Gets the failed entries of a batch operation. - /// - /// The ID of the operation. - /// The service URL for the Teams service. - /// Optional continuation token for pagination. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the failed entries. - public Task GetFailedEntriesAsync( - string operationId, - Uri serviceUrl, - string? continuationToken = null, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.GetPagedFailedEntriesAsync(operationId, serviceUrl, continuationToken, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets the failed entries of a batch operation using activity context. - /// - /// The ID of the operation. - /// The activity providing service URL and identity context. - /// Optional continuation token for pagination. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the failed entries. - public Task GetFailedEntriesAsync( - string operationId, - TeamsActivity activity, - string? continuationToken = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - return _client.GetPagedFailedEntriesAsync( - operationId, - activity.ServiceUrl, - continuationToken, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Cancels a batch operation. - /// - /// The ID of the operation to cancel. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - public Task CancelAsync( - string operationId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.CancelOperationAsync(operationId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Cancels a batch operation using activity context. - /// - /// The ID of the operation to cancel. - /// The activity providing service URL and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - public Task CancelAsync( - string operationId, - TeamsActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.CancelOperationAsync( - operationId, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs new file mode 100644 index 00000000..1a1816f5 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Http; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for creating, updating, and deleting activities in a conversation. +/// +public class ActivityClient +{ + private readonly BotHttpClient _http; + private readonly string _serviceUrl; + + internal ActivityClient(string serviceUrl, BotHttpClient http) + { + _serviceUrl = serviceUrl.TrimEnd('/'); + _http = http; + } + + /// + /// Create a new activity in a conversation. + /// + public async Task CreateAsync(string conversationId, CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities"; + string body = activity.ToJson(); + return await _http.SendAsync(HttpMethod.Post, url, body, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Update an existing activity in a conversation. + /// + public async Task UpdateAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(id)}"; + string body = activity.ToJson(); + return await _http.SendAsync(HttpMethod.Put, url, body, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Reply to an existing activity in a conversation. + /// + public async Task ReplyAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + activity.ReplyToId = id; + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(id)}"; + string body = activity.ToJson(); + return await _http.SendAsync(HttpMethod.Post, url, body, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Delete an activity from a conversation. + /// + public async Task DeleteAsync(string conversationId, string id, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(id)}"; + await _http.SendAsync(HttpMethod.Delete, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Create a new targeted activity in a conversation. + /// Targeted activities are only visible to the specified recipient. + /// + public async Task CreateTargetedAsync(string conversationId, CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities?isTargetedActivity=true"; + string body = activity.ToJson(); + return await _http.SendAsync(HttpMethod.Post, url, body, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Update an existing targeted activity in a conversation. + /// + public async Task UpdateTargetedAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(id)}?isTargetedActivity=true"; + string body = activity.ToJson(); + return await _http.SendAsync(HttpMethod.Put, url, body, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Delete a targeted activity from a conversation. + /// + public async Task DeleteTargetedAsync(string conversationId, string id, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(id)}?isTargetedActivity=true"; + await _http.SendAsync(HttpMethod.Delete, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs new file mode 100644 index 00000000..b690d403 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Core.Http; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Top-level API client that provides access to all Teams Bot API sub-clients. +/// +public class ApiClient +{ + private readonly BotHttpClient _http; + + /// + /// The service URL used by this client. + /// + public Uri ServiceUrl { get; } + + /// + /// Client for bot-level operations (token, sign-in). + /// + public BotClient Bots { get; } + + /// + /// Client for conversation operations (activities, members, reactions). + /// + public V3ConversationClient Conversations { get; } + + /// + /// Client for user-level operations (token). + /// + public UserClient Users { get; } + + /// + /// Client for team operations. + /// + public TeamClient Teams { get; } + + /// + /// Client for meeting operations. + /// + public MeetingClient Meetings { get; } + + /// + /// Creates a new instance. + /// + /// The Bot Framework service URL. + /// An configured with authentication (e.g., via DI with BotAuthenticationHandler). + /// Optional logger. + /// Optional token API endpoint override. Defaults to https://token.botframework.com. + public ApiClient(Uri serviceUrl, HttpClient httpClient, ILogger? logger = null, string tokenApiEndpoint = "https://token.botframework.com") + { + ArgumentNullException.ThrowIfNull(serviceUrl); + ArgumentNullException.ThrowIfNull(httpClient); + + string serviceUrlString = serviceUrl.ToString(); + ServiceUrl = serviceUrl; + _http = new BotHttpClient(httpClient, logger); + Bots = new BotClient(_http, tokenApiEndpoint); + Conversations = new V3ConversationClient(serviceUrlString, _http); + Users = new UserClient(_http, tokenApiEndpoint); + Teams = new TeamClient(serviceUrlString, _http); + Meetings = new MeetingClient(serviceUrlString, _http); + } + + /// + /// Creates a copy of an existing with the same configuration. + /// + public ApiClient(ApiClient client) + { + ArgumentNullException.ThrowIfNull(client); + + ServiceUrl = client.ServiceUrl; + _http = client._http; + Bots = client.Bots; + Conversations = client.Conversations; + Users = client.Users; + Teams = client.Teams; + Meetings = client.Meetings; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotClient.cs new file mode 100644 index 00000000..cc17b120 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotClient.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Http; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for bot-level operations, including the sign-in sub-client. +/// +public class BotClient +{ + /// + /// Client for bot sign-in operations. + /// + public BotSignInClient SignIn { get; } + + internal BotClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") + { + SignIn = new BotSignInClient(http, tokenApiEndpoint); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs new file mode 100644 index 00000000..bac4efea --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Http; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for bot sign-in operations. +/// +public class BotSignInClient +{ + private readonly BotHttpClient _http; + private readonly string _tokenApiEndpoint; + + internal BotSignInClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") + { + _http = http; + _tokenApiEndpoint = tokenApiEndpoint.TrimEnd('/'); + } + + /// + /// Get the sign-in URL for a connection. + /// + public async Task GetUrlAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + { + List queryParams = [$"state={Uri.EscapeDataString(state)}"]; + + if (!string.IsNullOrEmpty(codeChallenge)) + queryParams.Add($"code_challenge={Uri.EscapeDataString(codeChallenge)}"); + if (emulatorUrl is not null) + queryParams.Add($"emulatorUrl={Uri.EscapeDataString(emulatorUrl.ToString())}"); + if (finalRedirect is not null) + queryParams.Add($"finalRedirect={Uri.EscapeDataString(finalRedirect.ToString())}"); + + string url = $"{_tokenApiEndpoint}/api/botsignin/GetSignInUrl?{string.Join("&", queryParams)}"; + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Get the sign-in resource for a connection. + /// + public async Task GetResourceAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + { + List queryParams = [$"state={Uri.EscapeDataString(state)}"]; + + if (!string.IsNullOrEmpty(codeChallenge)) + queryParams.Add($"code_challenge={Uri.EscapeDataString(codeChallenge)}"); + if (emulatorUrl is not null) + queryParams.Add($"emulatorUrl={Uri.EscapeDataString(emulatorUrl.ToString())}"); + if (finalRedirect is not null) + queryParams.Add($"finalRedirect={Uri.EscapeDataString(finalRedirect.ToString())}"); + + string url = $"{_tokenApiEndpoint}/api/botsignin/GetSignInResource?{string.Join("&", queryParams)}"; + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotTokenClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotTokenClient.cs new file mode 100644 index 00000000..175c9fd5 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotTokenClient.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for bot token operations. +/// +/// +/// In the core SDK, bot authentication is handled transparently by BotAuthenticationHandler, +/// which automatically acquires and attaches tokens to HTTP requests. This client exposes the +/// well-known token scopes for scenarios that need explicit scope references. +/// +public static class BotTokenClient +{ + /// + /// The default Bot Framework API scope. + /// + public static readonly string BotScope = "https://api.botframework.com/.default"; + + /// + /// The Microsoft Graph API scope. + /// + public static readonly string GraphScope = "https://graph.microsoft.com/.default"; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MeetingClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MeetingClient.cs new file mode 100644 index 00000000..72d50261 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MeetingClient.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Http; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for retrieving meeting information and participants. +/// +public class MeetingClient +{ + private readonly BotHttpClient _http; + private readonly string _serviceUrl; + + internal MeetingClient(string serviceUrl, BotHttpClient http) + { + _serviceUrl = serviceUrl.TrimEnd('/'); + _http = http; + } + + /// + /// Get a meeting by its ID. + /// + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v1/meetings/{Uri.EscapeDataString(id)}"; + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Get a participant in a meeting. + /// + public async Task GetParticipantAsync(string meetingId, string id, string tenantId, 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: null, cancellationToken).ConfigureAwait(false); + } +} + +/// +/// General information about a Teams meeting. +/// +public class Meeting +{ + /// + /// Unique identifier representing a meeting. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The specific details of a Teams meeting. + /// + [JsonPropertyName("details")] + public MeetingDetails? Details { get; set; } + + /// + /// The conversation for the meeting. + /// + [JsonPropertyName("conversation")] + public Conversation? Conversation { get; set; } + + /// + /// The organizer's user information. + /// + [JsonPropertyName("organizer")] + public ConversationAccount? Organizer { get; set; } +} + +/// +/// The specific details of a Teams meeting. +/// +public class MeetingDetails +{ + /// + /// The meeting's Id, encoded as a BASE64 string. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The meeting's type. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The URL used to join the meeting. + /// + [JsonPropertyName("joinUrl")] + public Uri? JoinUrl { get; set; } + + /// + /// The title of the meeting. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } +} + +/// +/// Meeting participant information. +/// +public class MeetingParticipant +{ + /// + /// The participant's user information. + /// + [JsonPropertyName("user")] + public ConversationAccount? User { get; set; } + + /// + /// Information about the associated meeting. + /// + [JsonPropertyName("meeting")] + public MeetingInfo? Meeting { get; set; } + + /// + /// The conversation associated with this participant. + /// + [JsonPropertyName("conversation")] + public Conversation? Conversation { get; set; } +} + +/// +/// Represents information about a participant's role and status within a meeting. +/// +public class MeetingInfo +{ + /// + /// The role associated with the participant. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// Whether the user is currently in a meeting. + /// + [JsonPropertyName("inMeeting")] + public bool? InMeeting { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs new file mode 100644 index 00000000..0db11af8 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Http; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for managing conversation members. +/// +public class MemberClient +{ + private readonly BotHttpClient _http; + private readonly string _serviceUrl; + + internal MemberClient(string serviceUrl, BotHttpClient http) + { + _serviceUrl = serviceUrl.TrimEnd('/'); + _http = http; + } + + /// + /// Get all members of a conversation. + /// + public async Task?> GetAsync(string conversationId, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/members"; + return await _http.SendAsync>(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Get a specific member of a conversation by ID. + /// + public async Task GetByIdAsync(string conversationId, string memberId, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/members/{Uri.EscapeDataString(memberId)}"; + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Remove a member from a conversation. + /// + public async Task DeleteAsync(string conversationId, string memberId, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/members/{Uri.EscapeDataString(memberId)}"; + await _http.SendAsync(HttpMethod.Delete, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs new file mode 100644 index 00000000..4ceea16c --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Http; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for managing reactions on activities in a conversation. +/// +public class ReactionClient +{ + private readonly BotHttpClient _http; + private readonly string _serviceUrl; + + internal ReactionClient(string serviceUrl, BotHttpClient http) + { + _serviceUrl = serviceUrl.TrimEnd('/'); + _http = http; + } + + /// + /// Adds a reaction on an activity in a conversation. + /// + /// The conversation id. + /// The id of the activity to react to. + /// The reaction type (for example: "like", "heart", "laugh", etc.). + /// A to observe while waiting for the task to complete. + public async Task AddAsync(string conversationId, string activityId, string reactionType, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}/reactions/{Uri.EscapeDataString(reactionType)}"; + await _http.SendAsync(HttpMethod.Put, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Removes a reaction from an activity in a conversation. + /// + /// The conversation id. + /// The id of the activity the reaction is on. + /// The reaction type to remove (for example: "like", "heart", "laugh", etc.). + /// A to observe while waiting for the task to complete. + public async Task DeleteAsync(string conversationId, string activityId, string reactionType, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}/reactions/{Uri.EscapeDataString(reactionType)}"; + await _http.SendAsync(HttpMethod.Delete, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/TeamClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/TeamClient.cs new file mode 100644 index 00000000..5ef7efd9 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/TeamClient.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Http; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for retrieving team information and channels. +/// +public class TeamClient +{ + private readonly BotHttpClient _http; + private readonly string _serviceUrl; + + internal TeamClient(string serviceUrl, BotHttpClient http) + { + _serviceUrl = serviceUrl.TrimEnd('/'); + _http = http; + } + + /// + /// Get a team by its ID. + /// + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/teams/{Uri.EscapeDataString(id)}"; + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Get the channels (conversations) for a team. + /// + public async Task?> GetConversationsAsync(string id, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/teams/{Uri.EscapeDataString(id)}/conversations"; + return await _http.SendAsync>(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs new file mode 100644 index 00000000..6e6c5ceb --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Http; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for user-level operations, including the token sub-client. +/// +public class UserClient +{ + /// + /// Client for user token operations. + /// + public V3UserTokenClient Token { get; } + + internal UserClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") + { + Token = new V3UserTokenClient(http, tokenApiEndpoint); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs new file mode 100644 index 00000000..65cd2568 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Http; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for managing conversations, exposing sub-clients for activities, members, and reactions. +/// +public class V3ConversationClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private readonly BotHttpClient _http; + private readonly string _serviceUrl; + + /// + /// The service URL for this conversation client. + /// + internal string ServiceUrlString => _serviceUrl; + + /// + /// Client for activity operations. + /// + public ActivityClient Activities { get; } + + /// + /// Client for member operations. + /// + public MemberClient Members { get; } + + /// + /// Client for reaction operations. + /// + public ReactionClient Reactions { get; } + + internal V3ConversationClient(string serviceUrl, BotHttpClient http) + { + _serviceUrl = serviceUrl.TrimEnd('/'); + _http = http; + Activities = new ActivityClient(serviceUrl, http); + Members = new MemberClient(serviceUrl, http); + Reactions = new ReactionClient(serviceUrl, http); + } + + /// + /// Create a new conversation. + /// + public async Task CreateAsync(ConversationParameters request, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/conversations"; + string body = JsonSerializer.Serialize(request, JsonOptions); + return await _http.SendAsync(HttpMethod.Post, url, body, null, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs new file mode 100644 index 00000000..aafa7c5f --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Http; + +namespace Microsoft.Teams.Bot.Apps.Api.Clients; + +/// +/// Client for user token operations. +/// +public class V3UserTokenClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly BotHttpClient _http; + private readonly string _tokenApiEndpoint; + + internal V3UserTokenClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") + { + _http = http; + _tokenApiEndpoint = tokenApiEndpoint.TrimEnd('/'); + } + + /// + /// Get a user token for a connection. + /// + public async Task GetAsync(string userId, string connectionName, string channelId, string? code = null, CancellationToken cancellationToken = default) + { + List queryParams = + [ + $"userId={Uri.EscapeDataString(userId)}", + $"connectionName={Uri.EscapeDataString(connectionName)}", + $"channelId={Uri.EscapeDataString(channelId)}" + ]; + + if (!string.IsNullOrEmpty(code)) + queryParams.Add($"code={Uri.EscapeDataString(code)}"); + + string url = $"{_tokenApiEndpoint}/api/usertoken/GetToken?{string.Join("&", queryParams)}"; + + return await _http.SendAsync( + HttpMethod.Get, url, body: null, + new BotRequestOptions { ReturnNullOnNotFound = true }, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Get AAD tokens for specified resources. + /// + public async Task?> GetAadAsync(string userId, string connectionName, string channelId, IList? resourceUrls = null, CancellationToken cancellationToken = default) + { + List queryParams = + [ + $"userId={Uri.EscapeDataString(userId)}", + $"connectionName={Uri.EscapeDataString(connectionName)}", + $"channelId={Uri.EscapeDataString(channelId)}" + ]; + + string url = $"{_tokenApiEndpoint}/api/usertoken/GetAadTokens?{string.Join("&", queryParams)}"; + var body = new { resourceUrls = resourceUrls ?? new List() }; + string bodyJson = JsonSerializer.Serialize(body, JsonOptions); + + return await _http.SendAsync>( + HttpMethod.Post, url, bodyJson, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Get the token status for a user's connections. + /// + public async Task?> GetStatusAsync(string userId, string channelId, string? includeFilter = null, CancellationToken cancellationToken = default) + { + List queryParams = + [ + $"userId={Uri.EscapeDataString(userId)}", + $"channelId={Uri.EscapeDataString(channelId)}" + ]; + + if (!string.IsNullOrEmpty(includeFilter)) + queryParams.Add($"includeFilter={Uri.EscapeDataString(includeFilter)}"); + + string url = $"{_tokenApiEndpoint}/api/usertoken/GetTokenStatus?{string.Join("&", queryParams)}"; + return await _http.SendAsync>( + HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sign a user out of a connection. + /// + public async Task SignOutAsync(string userId, string connectionName, string channelId, CancellationToken cancellationToken = default) + { + List queryParams = + [ + $"userId={Uri.EscapeDataString(userId)}", + $"connectionName={Uri.EscapeDataString(connectionName)}", + $"channelId={Uri.EscapeDataString(channelId)}" + ]; + + string url = $"{_tokenApiEndpoint}/api/usertoken/SignOut?{string.Join("&", queryParams)}"; + await _http.SendAsync(HttpMethod.Delete, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Exchange a token for another token. + /// + public async Task ExchangeAsync(string userId, string connectionName, string channelId, string exchangeToken, CancellationToken cancellationToken = default) + { + List queryParams = + [ + $"userId={Uri.EscapeDataString(userId)}", + $"connectionName={Uri.EscapeDataString(connectionName)}", + $"channelId={Uri.EscapeDataString(channelId)}" + ]; + + string url = $"{_tokenApiEndpoint}/api/usertoken/exchange?{string.Join("&", queryParams)}"; + var body = new { token = exchangeToken }; + string bodyJson = JsonSerializer.Serialize(body, JsonOptions); + + return await _http.SendAsync( + HttpMethod.Post, url, bodyJson, null, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs deleted file mode 100644 index 23857583..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Schema; - -namespace Microsoft.Teams.Bot.Apps.Api; - -using CustomHeaders = Dictionary; - -/// -/// Provides conversation-related operations. -/// -/// -/// This class serves as a container for conversation-specific sub-APIs: -/// -/// - Activity operations (send, update, delete, history) -/// - Member operations (get, delete) -/// - Reaction operations (add, delete) -/// -/// -public class ConversationsApi -{ - private readonly ConversationClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// The conversation client for conversation operations. - internal ConversationsApi(ConversationClient conversationClient) - { - _client = conversationClient; - Activities = new ActivitiesApi(conversationClient); - Members = new MembersApi(conversationClient); - Reactions = new ReactionsApi(conversationClient); - } - - /// - /// Gets the activities API for sending, updating, and deleting activities. - /// - public ActivitiesApi Activities { get; } - - /// - /// Gets the members API for managing conversation members. - /// - public MembersApi Members { get; } - - /// - /// Gets the reactions API for adding and removing reactions on activities. - /// - public ReactionsApi Reactions { get; } - - /// - /// Creates a new conversation. - /// - /// The parameters for creating the conversation. Cannot be null. - /// The service URL for the bot. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the conversation resource response with the conversation ID. - public Task CreateAsync( - ConversationParameters parameters, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.CreateConversationAsync(parameters, serviceUrl, agenticIdentity, customHeaders, cancellationToken); -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/MeetingsApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/MeetingsApi.cs deleted file mode 100644 index 02b58ba6..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/MeetingsApi.cs +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core.Schema; - -namespace Microsoft.Teams.Bot.Apps.Api; - -using CustomHeaders = Dictionary; - -/// -/// Provides meeting operations for managing Teams meetings. -/// -public class MeetingsApi -{ - private readonly TeamsApiClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// The Teams API client for meeting operations. - internal MeetingsApi(TeamsApiClient teamsApiClient) - { - _client = teamsApiClient; - } - - /// - /// Gets information about a meeting. - /// - /// The ID of the meeting, encoded as a BASE64 string. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the meeting information. - public Task GetByIdAsync( - string meetingId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.FetchMeetingInfoAsync(meetingId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets information about a meeting using activity context. - /// - /// The ID of the meeting, encoded as a BASE64 string. - /// The activity providing service URL and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the meeting information. - public Task GetByIdAsync( - string meetingId, - TeamsActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.FetchMeetingInfoAsync( - meetingId, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Gets details for a meeting participant. - /// - /// The ID of the meeting. - /// The ID of the participant. - /// The ID of the tenant. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the participant details. - public Task GetParticipantAsync( - string meetingId, - string participantId, - string tenantId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.FetchParticipantAsync(meetingId, participantId, tenantId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets details for a meeting participant using activity context. - /// - /// The ID of the meeting. - /// The ID of the participant. - /// The activity providing service URL, tenant ID, and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the participant details. - public Task GetParticipantAsync( - string meetingId, - string participantId, - TeamsActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - string? tenantId = activity.ChannelData?.Tenant?.Id; - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId, "activity.ChannelData.Tenant.Id"); - return _client.FetchParticipantAsync( - meetingId, - participantId, - tenantId, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Sends a notification to meeting participants. - /// - /// The ID of the meeting. - /// The notification to send. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains information about failed recipients. - public Task SendNotificationAsync( - string meetingId, - TargetedMeetingNotification notification, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.SendMeetingNotificationAsync(meetingId, notification, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Sends a notification to meeting participants using activity context. - /// - /// The ID of the meeting. - /// The notification to send. - /// The activity providing service URL and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains information about failed recipients. - public Task SendNotificationAsync( - string meetingId, - TargetedMeetingNotification notification, - TeamsActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.SendMeetingNotificationAsync( - meetingId, - notification, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/MembersApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/MembersApi.cs deleted file mode 100644 index 2a734145..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/MembersApi.cs +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Schema; - -namespace Microsoft.Teams.Bot.Apps.Api; - -using CustomHeaders = Dictionary; - -/// -/// Provides member operations for managing conversation members. -/// -public class MembersApi -{ - private readonly ConversationClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// The conversation client for member operations. - internal MembersApi(ConversationClient conversationClient) - { - _client = conversationClient; - } - - /// - /// Gets all members of a conversation. - /// - /// The ID of the conversation. - /// The service URL for the conversation. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains a list of conversation members. - public Task> GetAllAsync( - string conversationId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.GetConversationMembersAsync(conversationId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets all members of a conversation using activity context. - /// - /// The activity providing conversation context. Must contain valid Conversation.Id and ServiceUrl. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains a list of conversation members. - public Task> GetAllAsync( - TeamsActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - ArgumentNullException.ThrowIfNull(activity.Conversation); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); - - return _client.GetConversationMembersAsync( - activity.Conversation.Id, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Gets a specific member of a conversation. - /// - /// The type of conversation account to return. Must inherit from . - /// The ID of the conversation. - /// The ID of the user to retrieve. - /// The service URL for the conversation. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the conversation member. - public Task GetByIdAsync( - string conversationId, - string userId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) where T : ConversationAccount - => _client.GetConversationMemberAsync(conversationId, userId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets a specific member of a conversation using activity context. - /// - /// The type of conversation account to return. Must inherit from . - /// The activity providing conversation context. Must contain valid Conversation.Id and ServiceUrl. - /// The ID of the user to retrieve. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the conversation member. - public Task GetByIdAsync( - TeamsActivity activity, - string userId, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) where T : ConversationAccount - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.Conversation); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.GetConversationMemberAsync( - activity.Conversation.Id, - userId, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Gets a specific member of a conversation. - /// - /// The ID of the conversation. - /// The ID of the user to retrieve. - /// The service URL for the conversation. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the conversation member. - public Task GetByIdAsync( - string conversationId, - string userId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.GetConversationMemberAsync(conversationId, userId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets a specific member of a conversation using activity context. - /// - /// The activity providing conversation context. Must contain valid Conversation.Id and ServiceUrl. - /// The ID of the user to retrieve. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the conversation member. - public Task GetByIdAsync( - TeamsActivity activity, - string userId, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.Conversation); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.GetConversationMemberAsync( - activity.Conversation.Id, - userId, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Gets members of a conversation one page at a time. - /// - /// The ID of the conversation. - /// The service URL for the conversation. - /// Optional page size for the number of members to retrieve. - /// Optional continuation token for pagination. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains a page of members and an optional continuation token. - public Task GetPagedAsync( - string conversationId, - Uri serviceUrl, - int? pageSize = null, - string? continuationToken = null, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.GetConversationPagedMembersAsync(conversationId, serviceUrl, pageSize, continuationToken, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets members of a conversation one page at a time using activity context. - /// - /// The activity providing conversation context. Must contain valid Conversation.Id and ServiceUrl. - /// Optional page size for the number of members to retrieve. - /// Optional continuation token for pagination. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains a page of members and an optional continuation token. - public Task GetPagedAsync( - TeamsActivity activity, - int? pageSize = null, - string? continuationToken = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.Conversation); - ArgumentNullException.ThrowIfNull(activity.Conversation.Id); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.GetConversationPagedMembersAsync( - activity.Conversation.Id, - activity.ServiceUrl, - pageSize, - continuationToken, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Deletes a member from a conversation. - /// - /// The ID of the conversation. - /// The ID of the member to delete. - /// The service URL for the conversation. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - /// If the deleted member was the last member of the conversation, the conversation is also deleted. - public Task DeleteAsync( - string conversationId, - string memberId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.DeleteConversationMemberAsync(conversationId, memberId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Deletes a member from a conversation using activity context. - /// - /// The activity providing conversation context. Must contain valid Conversation.Id and ServiceUrl. - /// The ID of the member to delete. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - /// If the deleted member was the last member of the conversation, the conversation is also deleted. - public Task DeleteAsync( - TeamsActivity activity, - string memberId, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.Conversation); - ArgumentNullException.ThrowIfNull(activity.Conversation.Id); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.DeleteConversationMemberAsync( - activity.Conversation.Id, - memberId, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/README.md b/core/src/Microsoft.Teams.Bot.Apps/Api/README.md deleted file mode 100644 index 7aa8404b..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# TeamsApi REST Endpoint Mapping - -This document maps the `TeamsApi` facade methods to their underlying REST endpoints. - -## Conversations - -### Activities - -| Facade Method | HTTP Method | REST Endpoint | -|---------------|-------------|---------------| -| `Api.Conversations.Activities.SendAsync` | POST | `/v3/conversations/{conversationId}/activities/` | -| `Api.Conversations.Activities.UpdateAsync` | PUT | `/v3/conversations/{conversationId}/activities/{activityId}` | -| `Api.Conversations.Activities.DeleteAsync` | DELETE | `/v3/conversations/{conversationId}/activities/{activityId}` | -| `Api.Conversations.Activities.SendHistoryAsync` | POST | `/v3/conversations/{conversationId}/activities/history` | -| `Api.Conversations.Activities.GetMembersAsync` | GET | `/v3/conversations/{conversationId}/activities/{activityId}/members` | - -### Members - -| Facade Method | HTTP Method | REST Endpoint | -|---------------|-------------|---------------| -| `Api.Conversations.Members.GetAllAsync` | GET | `/v3/conversations/{conversationId}/members` | -| `Api.Conversations.Members.GetByIdAsync` | GET | `/v3/conversations/{conversationId}/members/{userId}` | -| `Api.Conversations.Members.GetPagedAsync` | GET | `/v3/conversations/{conversationId}/pagedmembers` | -| `Api.Conversations.Members.DeleteAsync` | DELETE | `/v3/conversations/{conversationId}/members/{memberId}` | - -## Users - -### Token - -| Facade Method | HTTP Method | REST Endpoint | Base URL | -|---------------|-------------|---------------|----------| -| `Api.Users.Token.GetAsync` | GET | `/api/usertoken/GetToken` | `token.botframework.com` | -| `Api.Users.Token.ExchangeAsync` | POST | `/api/usertoken/exchange` | `token.botframework.com` | -| `Api.Users.Token.SignOutAsync` | DELETE | `/api/usertoken/SignOut` | `token.botframework.com` | -| `Api.Users.Token.GetAadTokensAsync` | POST | `/api/usertoken/GetAadTokens` | `token.botframework.com` | -| `Api.Users.Token.GetStatusAsync` | GET | `/api/usertoken/GetTokenStatus` | `token.botframework.com` | -| `Api.Users.Token.GetSignInResourceAsync` | GET | `/api/botsignin/GetSignInResource` | `token.botframework.com` | - -## Teams - -| Facade Method | HTTP Method | REST Endpoint | -|---------------|-------------|---------------| -| `Api.Teams.GetByIdAsync` | GET | `/v3/teams/{teamId}` | -| `Api.Teams.GetChannelsAsync` | GET | `/v3/teams/{teamId}/conversations` | - -## Meetings - -| Facade Method | HTTP Method | REST Endpoint | -|---------------|-------------|---------------| -| `Api.Meetings.GetByIdAsync` | GET | `/v1/meetings/{meetingId}` | -| `Api.Meetings.GetParticipantAsync` | GET | `/v1/meetings/{meetingId}/participants/{participantId}?tenantId={tenantId}` | -| `Api.Meetings.SendNotificationAsync` | POST | `/v1/meetings/{meetingId}/notification` | - -## Batch - -### Send Operations - -| Facade Method | HTTP Method | REST Endpoint | -|---------------|-------------|---------------| -| `Api.Batch.SendToUsersAsync` | POST | `/v3/batch/conversation/users/` | -| `Api.Batch.SendToTenantAsync` | POST | `/v3/batch/conversation/tenant/` | -| `Api.Batch.SendToTeamAsync` | POST | `/v3/batch/conversation/team/` | -| `Api.Batch.SendToChannelsAsync` | POST | `/v3/batch/conversation/channels/` | - -### Operation Management - -| Facade Method | HTTP Method | REST Endpoint | -|---------------|-------------|---------------| -| `Api.Batch.GetStateAsync` | GET | `/v3/batch/conversation/{operationId}` | -| `Api.Batch.GetFailedEntriesAsync` | GET | `/v3/batch/conversation/failedentries/{operationId}` | -| `Api.Batch.CancelAsync` | DELETE | `/v3/batch/conversation/{operationId}` | - -## Notes - -- All endpoints under `Conversations`, `Teams`, `Meetings`, and `Batch` use the service URL from the activity context (e.g., `https://smba.trafficmanager.net/teams/`). -- All endpoints under `Users.Token` use the Bot Framework Token Service URL (`https://token.botframework.com`). -- Path parameters in `{braces}` are URL-encoded when constructing the request. diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/ReactionsApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/ReactionsApi.cs deleted file mode 100644 index 4a8bc9d0..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/ReactionsApi.cs +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core; - -namespace Microsoft.Teams.Bot.Apps.Api; - -using CustomHeaders = Dictionary; - -/// -/// Provides reaction operations for adding and removing reactions on activities in conversations. -/// -public class ReactionsApi -{ - private readonly ConversationClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// The conversation client for reaction operations. - internal ReactionsApi(ConversationClient conversationClient) - { - _client = conversationClient; - } - - /// - /// Adds a reaction to an activity using activity context. - /// - /// The activity to react to. Must contain valid Id, Conversation.Id, and ServiceUrl. - /// The ID of the activity to react to. This is separate from activity.Id to allow reacting to a different activity than the one in context if needed. - /// The type of reaction to add (e.g., "like", "heart", "laugh"). - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - public Task AddAsync( - TeamsActivity activity, - string activityId, - string reactionType, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentException.ThrowIfNullOrWhiteSpace(activityId); - ArgumentNullException.ThrowIfNull(activity.Conversation); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.AddReactionAsync( - activity.Conversation.Id, - activityId, - reactionType, - activity.ServiceUrl, - activity.Recipient?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Removes a reaction from an activity using activity context. - /// - /// The activity to remove the reaction from. Must contain valid Id, Conversation.Id, and ServiceUrl. - /// The ID of the activity to remove the reaction from. This is separate from activity.Id to allow removing a reaction from a different activity than the one in context if needed. - /// The type of reaction to remove (e.g., "like", "heart", "laugh"). - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - public Task DeleteAsync( - TeamsActivity activity, - string activityId, - string reactionType, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentException.ThrowIfNullOrWhiteSpace(activityId); - ArgumentNullException.ThrowIfNull(activity.Conversation); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - - return _client.DeleteReactionAsync( - activity.Conversation.Id, - activityId, - reactionType, - activity.ServiceUrl, - activity.Recipient?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/TeamsApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/TeamsApi.cs deleted file mode 100644 index 77aead90..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/TeamsApi.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Core; - -namespace Microsoft.Teams.Bot.Apps.Api; - -/// -/// Provides a hierarchical API facade for Teams operations. -/// -/// -/// This class exposes Teams API operations through a structured hierarchy: -/// -/// - Conversation operations including activities and members -/// - User operations including token management and OAuth sign-in -/// - Team-specific operations -/// - Meeting operations -/// - Batch messaging operations -/// -/// -public class TeamsApi -{ - /// - /// Initializes a new instance of the class. - /// - /// The conversation client for conversation operations. - /// The user token client for token operations. - /// The Teams API client for Teams-specific operations. - internal TeamsApi( - ConversationClient conversationClient, - UserTokenClient userTokenClient, - TeamsApiClient teamsApiClient) - { - Conversations = new ConversationsApi(conversationClient); - Users = new UsersApi(userTokenClient); - Teams = new TeamsOperationsApi(teamsApiClient); - Meetings = new MeetingsApi(teamsApiClient); - Batch = new BatchApi(teamsApiClient); - } - - /// - /// Gets the conversations API for managing conversation activities and members. - /// - public ConversationsApi Conversations { get; } - - /// - /// Gets the users API for user token management and OAuth sign-in. - /// - public UsersApi Users { get; } - - /// - /// Gets the Teams-specific operations API. - /// - public TeamsOperationsApi Teams { get; } - - /// - /// Gets the meetings API for meeting operations. - /// - public MeetingsApi Meetings { get; } - - /// - /// Gets the batch messaging API. - /// - public BatchApi Batch { get; } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/TeamsOperationsApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/TeamsOperationsApi.cs deleted file mode 100644 index ae4e0397..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/TeamsOperationsApi.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core.Schema; - -namespace Microsoft.Teams.Bot.Apps.Api; - -using CustomHeaders = Dictionary; - -/// -/// Provides Teams-specific operations for managing teams and channels. -/// -public class TeamsOperationsApi -{ - private readonly TeamsApiClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// The Teams API client for team operations. - internal TeamsOperationsApi(TeamsApiClient teamsApiClient) - { - _client = teamsApiClient; - } - - /// - /// Gets details for a team. - /// - /// The ID of the team. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the team details. - public Task GetByIdAsync( - string teamId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.FetchTeamDetailsAsync(teamId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets details for a team using activity context, extracting the team ID from channel data. - /// - /// The activity providing team ID, service URL, and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the team details. - public Task GetByIdAsync( - TeamsActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - string? teamId = activity.ChannelData?.Team?.Id; - ArgumentException.ThrowIfNullOrWhiteSpace(teamId, "activity.ChannelData.Team.Id"); - - return _client.FetchTeamDetailsAsync( - teamId, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } - - /// - /// Gets the list of channels for a team. - /// - /// The ID of the team. - /// The service URL for the Teams service. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the list of channels. - public Task GetChannelsAsync( - string teamId, - Uri serviceUrl, - AgenticIdentity? agenticIdentity = null, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - => _client.FetchChannelListAsync(teamId, serviceUrl, agenticIdentity, customHeaders, cancellationToken); - - /// - /// Gets the list of channels for a team using activity context. - /// - /// The activity providing service URL and identity context. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the list of channels. - public Task GetChannelsAsync( - TeamsActivity activity, - CustomHeaders? customHeaders = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.ServiceUrl); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.ChannelData?.Team?.Id, "activity.ChannelData.Team.Id"); - - return _client.FetchChannelListAsync( - activity.ChannelData.Team.Id, - activity.ServiceUrl, - activity.From?.GetAgenticIdentity(), - customHeaders, - cancellationToken); - } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/UserTokenApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/UserTokenApi.cs deleted file mode 100644 index 1e00240e..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/UserTokenApi.cs +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core; - -namespace Microsoft.Teams.Bot.Apps.Api; - -/// -/// Provides user token operations for OAuth SSO. -/// -public class UserTokenApi -{ - private readonly UserTokenClient _client; - - /// - /// Initializes a new instance of the class. - /// - /// The user token client for token operations. - internal UserTokenApi(UserTokenClient userTokenClient) - { - _client = userTokenClient; - } - - /// - /// Gets the user token for a particular connection. - /// - /// The user ID. - /// The connection name. - /// The channel ID. - /// The optional authorization code. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the token result, or null if no token is available. - public Task GetAsync( - string userId, - string connectionName, - string channelId, - string? code = null, - CancellationToken cancellationToken = default) - => _client.GetTokenAsync(userId, connectionName, channelId, code, cancellationToken); - - /// - /// Gets the user token for a particular connection using activity context. - /// - /// The activity providing user context. Must contain valid From.Id and ChannelId. - /// The connection name. - /// The optional authorization code. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the token result, or null if no token is available. - public Task GetAsync( - TeamsActivity activity, - string connectionName, - string? code = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.From); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.From.Id); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.ChannelId); - - return _client.GetTokenAsync( - activity.From.Id, - connectionName, - activity.ChannelId, - code, - cancellationToken); - } - - /// - /// Exchanges a token for another token. - /// - /// The user ID. - /// The connection name. - /// The channel ID. - /// The token to exchange. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the exchanged token. - public Task ExchangeAsync( - string userId, - string connectionName, - string channelId, - string? exchangeToken, - CancellationToken cancellationToken = default) - => _client.ExchangeTokenAsync(userId, connectionName, channelId, exchangeToken, cancellationToken); - - /// - /// Exchanges a token for another token using activity context. - /// - /// The activity providing user context. Must contain valid From.Id and ChannelId. - /// The connection name. - /// The token to exchange. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the exchanged token. - public Task ExchangeAsync( - TeamsActivity activity, - string connectionName, - string? exchangeToken, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.From); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.From.Id); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.ChannelId); - - return _client.ExchangeTokenAsync( - activity.From.Id, - connectionName, - activity.ChannelId, - exchangeToken, - cancellationToken); - } - - /// - /// Signs the user out of a connection, revoking their OAuth token. - /// - /// The unique identifier of the user to sign out. - /// Optional name of the OAuth connection to sign out from. If null, signs out from all connections. - /// Optional channel identifier. If provided, limits sign-out to tokens for this channel. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous sign-out operation. - public Task SignOutAsync( - string userId, - string? connectionName = null, - string? channelId = null, - CancellationToken cancellationToken = default) - => _client.SignOutUserAsync(userId, connectionName, channelId, cancellationToken); - - /// - /// Signs the user out of a connection using activity context. - /// - /// The activity providing user context. Must contain valid From.Id. - /// Optional name of the OAuth connection to sign out from. If null, signs out from all connections. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous sign-out operation. - public Task SignOutAsync( - TeamsActivity activity, - string? connectionName = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.From); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.From.Id); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.ChannelId); - - return _client.SignOutUserAsync( - activity.From.Id, - connectionName, - activity.ChannelId, - cancellationToken); - } - - /// - /// Gets AAD tokens for a user. - /// - /// The user ID. - /// The connection name. - /// The channel ID. - /// The resource URLs to get tokens for. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains a dictionary of resource URLs to token results. - public Task> GetAadTokensAsync( - string userId, - string connectionName, - string channelId, - string[]? resourceUrls = null, - CancellationToken cancellationToken = default) - => _client.GetAadTokensAsync(userId, connectionName, channelId, resourceUrls, cancellationToken); - - /// - /// Gets AAD tokens for a user using activity context. - /// - /// The activity providing user context. Must contain valid From.Id and ChannelId. - /// The connection name. - /// The resource URLs to get tokens for. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains a dictionary of resource URLs to token results. - public Task> GetAadTokensAsync( - TeamsActivity activity, - string connectionName, - string[]? resourceUrls = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.From); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.From.Id); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.ChannelId); - - return _client.GetAadTokensAsync( - activity.From.Id, - connectionName, - activity.ChannelId, - resourceUrls, - cancellationToken); - } - - /// - /// Gets the token status for each connection for the given user. - /// - /// The user ID. - /// The channel ID. - /// The optional include parameter. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains an array of token status results. - public Task GetStatusAsync( - string userId, - string channelId, - string? include = null, - CancellationToken cancellationToken = default) - => _client.GetTokenStatusAsync(userId, channelId, include, cancellationToken); - - /// - /// Gets the token status for each connection using activity context. - /// - /// The activity providing user context. Must contain valid From.Id and ChannelId. - /// The optional include parameter. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains an array of token status results. - public Task GetStatusAsync( - TeamsActivity activity, - string? include = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.From); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.From.Id); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.ChannelId); - - return _client.GetTokenStatusAsync( - activity.From.Id, - activity.ChannelId, - include, - cancellationToken); - } - - /// - /// Gets the sign-in resource for a user to authenticate via OAuth. - /// - /// The user ID. - /// The connection name. - /// The channel ID. - /// The optional final redirect URL after sign-in completes. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the sign-in resource with sign-in link and token exchange information. - public Task GetSignInResourceAsync( - string userId, - string connectionName, - string channelId, - string? finalRedirect = null, - CancellationToken cancellationToken = default) - => _client.GetSignInResource(userId, connectionName, channelId, finalRedirect, cancellationToken); - - /// - /// Gets the sign-in resource for a user to authenticate via OAuth using activity context. - /// - /// The activity providing user context. Must contain valid From.Id and ChannelId. - /// The connection name. - /// The optional final redirect URL after sign-in completes. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the sign-in resource with sign-in link and token exchange information. - public Task GetSignInResourceAsync( - TeamsActivity activity, - string connectionName, - string? finalRedirect = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(activity.From); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.From.Id); - ArgumentException.ThrowIfNullOrWhiteSpace(activity.ChannelId); - - return _client.GetSignInResource( - activity.From.Id, - connectionName, - activity.ChannelId, - finalRedirect, - cancellationToken); - } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/UsersApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/UsersApi.cs deleted file mode 100644 index 5ab3d212..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/UsersApi.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Core; - -namespace Microsoft.Teams.Bot.Apps.Api; - -/// -/// Provides user-related operations. -/// -/// -/// This class serves as a container for user-specific sub-APIs: -/// -/// - User token operations (OAuth SSO) -/// -/// -public class UsersApi -{ - /// - /// Initializes a new instance of the class. - /// - /// The user token client for token operations. - internal UsersApi(UserTokenClient userTokenClient) - { - Token = new UserTokenApi(userTokenClient); - } - - /// - /// Gets the token API for user token operations (OAuth SSO). - /// - public UserTokenApi Token { get; } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.Models.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.Models.cs deleted file mode 100644 index b554efce..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.Models.cs +++ /dev/null @@ -1,478 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Serialization; -using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core.Schema; - -namespace Microsoft.Teams.Bot.Apps; - -/// -/// Represents a list of channels in a team. -/// -public class ChannelList -{ - /// - /// Gets or sets the list of channel conversations. - /// - [JsonPropertyName("conversations")] - public IList? Channels { get; set; } -} - -/// -/// Represents detailed information about a team. -/// -public class TeamDetails -{ - /// - /// Gets or sets the unique identifier of the team. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } - - /// - /// Gets or sets the name of the team. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// Gets or sets the Azure Active Directory group ID associated with the team. - /// - [JsonPropertyName("aadGroupId")] - public string? AadGroupId { get; set; } - - /// - /// Gets or sets the number of channels in the team. - /// - [JsonPropertyName("channelCount")] - public int? ChannelCount { get; set; } - - /// - /// Gets or sets the number of members in the team. - /// - [JsonPropertyName("memberCount")] - public int? MemberCount { get; set; } - - /// - /// Gets or sets the type of the team. Valid values are standard, sharedChannel and privateChannel. - /// - [JsonPropertyName("type")] - public string? Type { get; set; } -} - -/// -/// Represents information about a meeting. -/// -public class MeetingInfo -{ - ///// - ///// Gets or sets the unique identifier of the meeting. - ///// - //[JsonPropertyName("id")] - //public string? Id { get; set; } - - /// - /// Gets or sets the details of the meeting. - /// - [JsonPropertyName("details")] - public MeetingDetails? Details { get; set; } - - /// - /// Gets or sets the conversation associated with the meeting. - /// - [JsonPropertyName("conversation")] - public ConversationAccount? Conversation { get; set; } - - /// - /// Gets or sets the organizer of the meeting. - /// - [JsonPropertyName("organizer")] - public TeamsConversationAccount? Organizer { get; set; } -} - -/// -/// Represents detailed information about a meeting. -/// -public class MeetingDetails -{ - /// - /// Gets or sets the unique identifier of the meeting. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } - - /// - /// Gets or sets the Microsoft Graph resource ID of the meeting. - /// - [JsonPropertyName("msGraphResourceId")] - public string? MsGraphResourceId { get; set; } - - /// - /// Gets or sets the scheduled start time of the meeting. - /// - [JsonPropertyName("scheduledStartTime")] - public DateTimeOffset? ScheduledStartTime { get; set; } - - /// - /// Gets or sets the scheduled end time of the meeting. - /// - [JsonPropertyName("scheduledEndTime")] - public DateTimeOffset? ScheduledEndTime { get; set; } - - /// - /// Gets or sets the join URL of the meeting. - /// - [JsonPropertyName("joinUrl")] - public Uri? JoinUrl { get; set; } - - /// - /// Gets or sets the title of the meeting. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } - - /// - /// Gets or sets the type of the meeting. - /// - [JsonPropertyName("type")] - public string? Type { get; set; } -} - -/// -/// Represents a meeting participant with their details. -/// -public class MeetingParticipant -{ - /// - /// Gets or sets the user information. - /// - [JsonPropertyName("user")] - public ConversationAccount? User { get; set; } - - /// - /// Gets or sets the meeting information. - /// - [JsonPropertyName("meeting")] - public MeetingParticipantInfo? Meeting { get; set; } - - /// - /// Gets or sets the conversation information. - /// - [JsonPropertyName("conversation")] - public ConversationAccount? Conversation { get; set; } -} - -/// -/// Represents meeting-specific participant information. -/// -public class MeetingParticipantInfo -{ - /// - /// Gets or sets the role of the participant in the meeting. - /// - [JsonPropertyName("role")] - public string? Role { get; set; } - - /// - /// Gets or sets a value indicating whether the participant is in the meeting. - /// - [JsonPropertyName("inMeeting")] - public bool? InMeeting { get; set; } -} - -/// -/// Base class for meeting notifications. -/// -public abstract class MeetingNotificationBase -{ - /// - /// Gets or sets the type of the notification. - /// - [JsonPropertyName("type")] - public abstract string Type { get; } -} - -/// -/// Represents a targeted meeting notification. -/// -public class TargetedMeetingNotification : MeetingNotificationBase -{ - /// - [JsonPropertyName("type")] - public override string Type => "targetedMeetingNotification"; - - /// - /// Gets or sets the value of the notification. - /// - [JsonPropertyName("value")] - public TargetedMeetingNotificationValue? Value { get; set; } -} - -/// -/// Represents the value of a targeted meeting notification. -/// -public class TargetedMeetingNotificationValue -{ - /// - /// Gets or sets the list of recipients for the notification. - /// - [JsonPropertyName("recipients")] - public IList? Recipients { get; set; } - - /// - /// Gets or sets the surface configurations for the notification. - /// - [JsonPropertyName("surfaces")] - public IList? Surfaces { get; set; } -} - -/// -/// Represents a surface for meeting notifications. -/// -public class MeetingNotificationSurface -{ - /// - /// Gets or sets the surface type (e.g., "meetingStage"). - /// - [JsonPropertyName("surface")] - public string? Surface { get; set; } - - /// - /// Gets or sets the content type of the notification. - /// - [JsonPropertyName("contentType")] - public string? ContentType { get; set; } - - /// - /// Gets or sets the content of the notification. - /// - [JsonPropertyName("content")] - public object? Content { get; set; } -} - -/// -/// Response from sending a meeting notification. -/// -public class MeetingNotificationResponse -{ - /// - /// Gets or sets the list of recipients for whom the notification failed. - /// - [JsonPropertyName("recipientsFailureInfo")] - public IList? RecipientsFailureInfo { get; set; } -} - -/// -/// Information about a failed notification recipient. -/// -public class MeetingNotificationRecipientFailureInfo -{ - /// - /// Gets or sets the recipient ID. - /// - [JsonPropertyName("recipientMri")] - public string? RecipientMri { get; set; } - - /// - /// Gets or sets the error code. - /// - [JsonPropertyName("errorCode")] - public string? ErrorCode { get; set; } - - /// - /// Gets or sets the failure reason. - /// - [JsonPropertyName("failureReason")] - public string? FailureReason { get; set; } -} - -/// -/// Represents a team member for batch operations. -/// -public class TeamMember -{ - /// - /// Creates a new instance of the class. - /// - public TeamMember() - { - } - - /// - /// Creates a new instance of the class with the specified ID. - /// - /// The member ID. - public TeamMember(string id) - { - Id = id; - } - - /// - /// Gets or sets the member ID. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } -} - -/// -/// Represents the state of a batch operation. -/// -public class BatchOperationState -{ - /// - /// Gets or sets the state of the operation. - /// - [JsonPropertyName("state")] - public string? State { get; set; } - - /// - /// Gets or sets the status map containing the count of different statuses. - /// - [JsonPropertyName("statusMap")] - public BatchOperationStatusMap? StatusMap { get; set; } - - /// - /// Gets or sets the retry after date time. - /// - [JsonPropertyName("retryAfter")] - public DateTimeOffset? RetryAfter { get; set; } - - /// - /// Gets or sets the total entries count. - /// - [JsonPropertyName("totalEntriesCount")] - public int? TotalEntriesCount { get; set; } -} - -/// -/// Represents the status map for a batch operation. -/// -public class BatchOperationStatusMap -{ - /// - /// Gets or sets the count of successful entries. - /// - [JsonPropertyName("success")] - public int? Success { get; set; } - - /// - /// Gets or sets the count of failed entries. - /// - [JsonPropertyName("failed")] - public int? Failed { get; set; } - - /// - /// Gets or sets the count of throttled entries. - /// - [JsonPropertyName("throttled")] - public int? Throttled { get; set; } - - /// - /// Gets or sets the count of pending entries. - /// - [JsonPropertyName("pending")] - public int? Pending { get; set; } -} - -/// -/// Response containing failed entries from a batch operation. -/// -public class BatchFailedEntriesResponse -{ - /// - /// Gets or sets the continuation token for paging. - /// - [JsonPropertyName("continuationToken")] - public string? ContinuationToken { get; set; } - - /// - /// Gets or sets the list of failed entries. - /// - [JsonPropertyName("failedEntries")] - public IList? FailedEntries { get; set; } -} - -/// -/// Represents a failed entry in a batch operation. -/// -public class BatchFailedEntry -{ - /// - /// Gets or sets the ID of the failed entry. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } - - /// - /// Gets or sets the error code. - /// - [JsonPropertyName("error")] - public string? Error { get; set; } -} - -/// -/// Request body for sending a message to a list of users. -/// -internal sealed class SendMessageToUsersRequest -{ - /// - /// Gets or sets the list of members. - /// - [JsonPropertyName("members")] - public IList? Members { get; set; } - - /// - /// Gets or sets the activity to send. - /// - [JsonPropertyName("activity")] - public object? Activity { get; set; } - - /// - /// Gets or sets the tenant ID. - /// - [JsonPropertyName("tenantId")] - public string? TenantId { get; set; } -} - -/// -/// Request body for sending a message to all users in a tenant. -/// -internal sealed class SendMessageToTenantRequest -{ - /// - /// Gets or sets the activity to send. - /// - [JsonPropertyName("activity")] - public object? Activity { get; set; } - - /// - /// Gets or sets the tenant ID. - /// - [JsonPropertyName("tenantId")] - public string? TenantId { get; set; } -} - -/// -/// Request body for sending a message to all users in a team. -/// -internal sealed class SendMessageToTeamRequest -{ - /// - /// Gets or sets the activity to send. - /// - [JsonPropertyName("activity")] - public object? Activity { get; set; } - - /// - /// Gets or sets the team ID. - /// - [JsonPropertyName("teamId")] - public string? TeamId { get; set; } - - /// - /// Gets or sets the tenant ID. - /// - [JsonPropertyName("tenantId")] - public string? TenantId { get; set; } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.cs deleted file mode 100644 index d4a5a74a..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.cs +++ /dev/null @@ -1,442 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using Microsoft.Extensions.Logging; -using Microsoft.Teams.Bot.Core.Http; -using Microsoft.Teams.Bot.Core.Schema; - -namespace Microsoft.Teams.Bot.Apps; - -using CustomHeaders = Dictionary; - -/// -/// Provides methods for interacting with Teams-specific APIs. -/// -/// The HTTP client instance used to send requests to the Teams service. Must not be null. -/// The logger instance used for logging. Optional. -public class TeamsApiClient(HttpClient httpClient, ILogger logger = default!) -{ - private readonly BotHttpClient _botHttpClient = new(httpClient, logger); - internal const string TeamsHttpClientName = "TeamsAPXClient"; - - /// - /// Gets the default custom headers that will be included in all requests. - /// - public CustomHeaders DefaultCustomHeaders { get; } = []; - - #region Team Operations - - /// - /// Fetches the list of channels for a given team. - /// - /// The ID of the team. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the list of channels. - /// Thrown if the channel list could not be retrieved successfully. - public async Task FetchChannelListAsync(string teamId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(teamId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/teams/{Uri.EscapeDataString(teamId)}/conversations"; - - logger?.LogTrace("Fetching channel list from {Url}", url); - - return (await _botHttpClient.SendAsync( - HttpMethod.Get, - url, - body: null, - CreateRequestOptions(agenticIdentity, "fetching channel list", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - /// - /// Fetches details related to a team. - /// - /// The ID of the team. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the team details. - /// Thrown if the team details could not be retrieved successfully. - public async Task FetchTeamDetailsAsync(string teamId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(teamId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/teams/{Uri.EscapeDataString(teamId)}"; - - logger?.LogTrace("Fetching team details from {Url}", url); - - return (await _botHttpClient.SendAsync( - HttpMethod.Get, - url, - body: null, - CreateRequestOptions(agenticIdentity, "fetching team details", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - #endregion - - #region Meeting Operations - - /// - /// Fetches information about a meeting. - /// - /// The ID of the meeting, encoded as a BASE64 string. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the meeting information. - /// Thrown if the meeting info could not be retrieved successfully. - public async Task FetchMeetingInfoAsync(string meetingId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(meetingId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}"; - - logger?.LogTrace("Fetching meeting info from {Url}", url); - - return (await _botHttpClient.SendAsync( - HttpMethod.Get, - url, - body: null, - CreateRequestOptions(agenticIdentity, "fetching meeting info", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - /// - /// Fetches details for a meeting participant. - /// - /// The ID of the meeting. Cannot be null or whitespace. - /// The ID of the participant. Cannot be null or whitespace. - /// The ID of the tenant. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the participant details. - /// Thrown if the participant details could not be retrieved successfully. - public async Task FetchParticipantAsync(string meetingId, string participantId, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(meetingId); - ArgumentException.ThrowIfNullOrWhiteSpace(participantId); - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}/participants/{Uri.EscapeDataString(participantId)}?tenantId={Uri.EscapeDataString(tenantId)}"; - - logger?.LogTrace("Fetching meeting participant from {Url}", url); - - return (await _botHttpClient.SendAsync( - HttpMethod.Get, - url, - body: null, - CreateRequestOptions(agenticIdentity, "fetching meeting participant", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - /// - /// Sends a notification to meeting participants. - /// - /// The ID of the meeting. Cannot be null or whitespace. - /// The notification to send. Cannot be null. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains information about failed recipients. - /// Thrown if the notification could not be sent successfully. - public async Task SendMeetingNotificationAsync(string meetingId, TargetedMeetingNotification notification, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(meetingId); - ArgumentNullException.ThrowIfNull(notification); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}/notification"; - string body = JsonSerializer.Serialize(notification); - - logger?.LogTrace("Sending meeting notification to {Url}: {Notification}", url, body); - - return (await _botHttpClient.SendAsync( - HttpMethod.Post, - url, - body, - CreateRequestOptions(agenticIdentity, "sending meeting notification", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - #endregion - - #region Batch Message Operations - - /// - /// Sends a message to a list of Teams users. - /// - /// The activity to send. Cannot be null. - /// The list of team members to send the message to. Cannot be null or empty. - /// The ID of the tenant. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - /// Thrown if the message could not be sent successfully. - public async Task SendMessageToListOfUsersAsync(CoreActivity activity, IList teamsMembers, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(teamsMembers); - if (teamsMembers.Count == 0) - { - throw new ArgumentException("teamsMembers cannot be empty", nameof(teamsMembers)); - } - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/users/"; - SendMessageToUsersRequest request = new() - { - Members = teamsMembers, - Activity = activity, - TenantId = tenantId - }; - string body = JsonSerializer.Serialize(request); - - logger?.LogTrace("Sending message to list of users at {Url}: {Request}", url, body); - - return (await _botHttpClient.SendAsync( - HttpMethod.Post, - url, - body, - CreateRequestOptions(agenticIdentity, "sending message to list of users", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - /// - /// Sends a message to all users in a tenant. - /// - /// The activity to send. Cannot be null. - /// The ID of the tenant. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - /// Thrown if the message could not be sent successfully. - public async Task SendMessageToAllUsersInTenantAsync(CoreActivity activity, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/tenant/"; - SendMessageToTenantRequest request = new() - { - Activity = activity, - TenantId = tenantId - }; - string body = JsonSerializer.Serialize(request); - - logger?.LogTrace("Sending message to all users in tenant at {Url}: {Request}", url, body); - - return (await _botHttpClient.SendAsync( - HttpMethod.Post, - url, - body, - CreateRequestOptions(agenticIdentity, "sending message to all users in tenant", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - /// - /// Sends a message to all users in a team. - /// - /// The activity to send. Cannot be null. - /// The ID of the team. Cannot be null or whitespace. - /// The ID of the tenant. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - /// Thrown if the message could not be sent successfully. - public async Task SendMessageToAllUsersInTeamAsync(CoreActivity activity, string teamId, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentException.ThrowIfNullOrWhiteSpace(teamId); - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/team/"; - SendMessageToTeamRequest request = new() - { - Activity = activity, - TeamId = teamId, - TenantId = tenantId - }; - string body = JsonSerializer.Serialize(request); - - logger?.LogTrace("Sending message to all users in team at {Url}: {Request}", url, body); - - return (await _botHttpClient.SendAsync( - HttpMethod.Post, - url, - body, - CreateRequestOptions(agenticIdentity, "sending message to all users in team", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - /// - /// Sends a message to a list of Teams channels. - /// - /// The activity to send. Cannot be null. - /// The list of channels to send the message to. Cannot be null or empty. - /// The ID of the tenant. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation ID. - /// Thrown if the message could not be sent successfully. - public async Task SendMessageToListOfChannelsAsync(CoreActivity activity, IList channelMembers, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(activity); - ArgumentNullException.ThrowIfNull(channelMembers); - if (channelMembers.Count == 0) - { - throw new ArgumentException("channelMembers cannot be empty", nameof(channelMembers)); - } - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/channels/"; - SendMessageToUsersRequest request = new() - { - Members = channelMembers, - Activity = activity, - TenantId = tenantId - }; - string body = JsonSerializer.Serialize(request); - - logger?.LogTrace("Sending message to list of channels at {Url}: {Request}", url, body); - - return (await _botHttpClient.SendAsync( - HttpMethod.Post, - url, - body, - CreateRequestOptions(agenticIdentity, "sending message to list of channels", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - #endregion - - #region Batch Operation Management - - /// - /// Gets the state of a batch operation. - /// - /// The ID of the operation. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the operation state. - /// Thrown if the operation state could not be retrieved successfully. - public async Task GetOperationStateAsync(string operationId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(operationId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/{Uri.EscapeDataString(operationId)}"; - - logger?.LogTrace("Getting operation state from {Url}", url); - - return (await _botHttpClient.SendAsync( - HttpMethod.Get, - url, - body: null, - CreateRequestOptions(agenticIdentity, "getting operation state", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - /// - /// Gets the failed entries of a batch operation with error code and message. - /// - /// The ID of the operation. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional continuation token for pagination. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the failed entries. - /// Thrown if the failed entries could not be retrieved successfully. - public async Task GetPagedFailedEntriesAsync(string operationId, Uri serviceUrl, string? continuationToken = null, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(operationId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/failedentries/{Uri.EscapeDataString(operationId)}"; - - if (!string.IsNullOrWhiteSpace(continuationToken)) - { - url += $"?continuationToken={Uri.EscapeDataString(continuationToken)}"; - } - - logger?.LogTrace("Getting paged failed entries from {Url}", url); - - return (await _botHttpClient.SendAsync( - HttpMethod.Get, - url, - body: null, - CreateRequestOptions(agenticIdentity, "getting paged failed entries", customHeaders), - cancellationToken).ConfigureAwait(false))!; - } - - /// - /// Cancels a batch operation by its ID. - /// - /// The ID of the operation to cancel. Cannot be null or whitespace. - /// The service URL for the Teams service. Cannot be null. - /// Optional agentic identity for authentication. - /// Optional custom headers to include in the request. - /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - /// Thrown if the operation could not be cancelled successfully. - public async Task CancelOperationAsync(string operationId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(operationId); - ArgumentNullException.ThrowIfNull(serviceUrl); - - string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/{Uri.EscapeDataString(operationId)}"; - - logger?.LogTrace("Cancelling operation at {Url}", url); - - await _botHttpClient.SendAsync( - HttpMethod.Delete, - url, - body: null, - CreateRequestOptions(agenticIdentity, "cancelling operation", customHeaders), - cancellationToken).ConfigureAwait(false); - } - - #endregion - - #region Private Methods - - private BotRequestOptions CreateRequestOptions(AgenticIdentity? agenticIdentity, string operationDescription, CustomHeaders? customHeaders) => - new() - { - AgenticIdentity = agenticIdentity, - OperationDescription = operationDescription, - DefaultHeaders = DefaultCustomHeaders, - CustomHeaders = customHeaders - }; - - #endregion -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs index 3c5a9b9b..d07f9e7c 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs @@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Teams.Bot.Apps.Api; +using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Core.Hosting; namespace Microsoft.Teams.Bot.Apps; @@ -45,7 +47,7 @@ public static IServiceCollection AddTeamsBotApplication(this IServiceColle { BotConfig botConfig = BotConfig.Resolve(services, sectionName); - services.AddBotClient(TeamsApiClient.TeamsHttpClientName, botConfig); + services.AddBotClient(nameof(ApiClient), botConfig); services.AddBotApplication(botConfig); return services; diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs index fc21097d..8caceddd 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Teams.Bot.Apps.Api; +using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Apps.Handlers; using Microsoft.Teams.Bot.Apps.Routing; using Microsoft.Teams.Bot.Apps.Schema; @@ -17,7 +18,7 @@ namespace Microsoft.Teams.Bot.Apps; /// public class TeamsBotApplication : BotApplication { - private readonly TeamsApiClient _teamsApiClient; + private readonly Api.Clients.ApiClient _teamsApiClient; /// /// Gets the router for dispatching Teams activities to registered routes. @@ -27,7 +28,7 @@ public class TeamsBotApplication : BotApplication /// /// Gets the client used to interact with the Teams API service. /// - public TeamsApiClient TeamsApiClient => _teamsApiClient; + public ApiClient TeamsApiClient => _teamsApiClient; /// /// Gets the hierarchical API facade for Teams operations. /// @@ -42,7 +43,7 @@ public class TeamsBotApplication : BotApplication /// Api.Batch - Batch messaging operations /// /// - public TeamsApi Api { get; } + public ApiClient Api { get; } /// /// @@ -53,14 +54,14 @@ public class TeamsBotApplication : BotApplication public TeamsBotApplication( ConversationClient conversationClient, UserTokenClient userTokenClient, - TeamsApiClient teamsApiClient, + ApiClient teamsApiClient, IHttpContextAccessor httpContextAccessor, ILogger logger, BotApplicationOptions? options = null) : base(conversationClient, userTokenClient, logger, options) { _teamsApiClient = teamsApiClient; - Api = new TeamsApi(conversationClient, userTokenClient, teamsApiClient); + Api = new ApiClient(new Uri("https://graph.microsoft.com/v1.0/"), null!, logger); // TODO: inject HttpClient Router = new Router(logger); OnActivity = async (activity, cancellationToken) => { diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs index 01b83fe2..5d82ca67 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs @@ -138,28 +138,28 @@ public static Microsoft.Bot.Schema.Teams.TeamsChannelAccount ToCompatTeamsChanne /// /// /// - public static Microsoft.Bot.Schema.Teams.MeetingInfo ToCompatMeetingInfo(this Microsoft.Teams.Bot.Apps.MeetingInfo meetingInfo) + public static Microsoft.Bot.Schema.Teams.MeetingInfo ToCompatMeetingInfo(this Microsoft.Teams.Bot.Apps.Api.Clients.MeetingInfo meetingInfo) { ArgumentNullException.ThrowIfNull(meetingInfo); return new Microsoft.Bot.Schema.Teams.MeetingInfo { - Details = meetingInfo.Details != null ? new Microsoft.Bot.Schema.Teams.MeetingDetails - { - Id = meetingInfo.Details.Id, - MsGraphResourceId = meetingInfo.Details.MsGraphResourceId, - ScheduledStartTime = meetingInfo.Details.ScheduledStartTime?.DateTime, - ScheduledEndTime = meetingInfo.Details.ScheduledEndTime?.DateTime, - JoinUrl = meetingInfo.Details.JoinUrl, - Title = meetingInfo.Details.Title, - Type = meetingInfo.Details.Type - } : null, - Conversation = meetingInfo.Conversation != null ? new Microsoft.Bot.Schema.ConversationAccount - { - Id = meetingInfo.Conversation.Id, - Name = meetingInfo.Conversation.Name - } : null, - Organizer = meetingInfo.Organizer != null ? meetingInfo.Organizer.ToCompatTeamsChannelAccount() : null + //Details = meetingInfo.Details != null ? new Microsoft.Bot.Schema.Teams.MeetingDetails + //{ + // Id = meetingInfo.Details.Id, + // MsGraphResourceId = meetingInfo.Details.MsGraphResourceId, + // ScheduledStartTime = meetingInfo.Details.ScheduledStartTime?.DateTime, + // ScheduledEndTime = meetingInfo.Details.ScheduledEndTime?.DateTime, + // JoinUrl = meetingInfo.Details.JoinUrl, + // Title = meetingInfo.Details.Title, + // Type = meetingInfo.Details.Type + //} : null, + //Conversation = meetingInfo.Conversation != null ? new Microsoft.Bot.Schema.ConversationAccount + //{ + // Id = meetingInfo.Conversation.Id, + // Name = meetingInfo.Conversation.Name + //} : null, + //Organizer = meetingInfo.Organizer != null ? meetingInfo.Organizer.ToCompatTeamsChannelAccount() : null }; } @@ -168,7 +168,7 @@ public static Microsoft.Bot.Schema.Teams.MeetingInfo ToCompatMeetingInfo(this Mi /// /// /// - public static Microsoft.Bot.Schema.Teams.TeamsMeetingParticipant ToCompatTeamsMeetingParticipant(this Microsoft.Teams.Bot.Apps.MeetingParticipant participant) + public static Microsoft.Bot.Schema.Teams.TeamsMeetingParticipant ToCompatTeamsMeetingParticipant(this Microsoft.Teams.Bot.Apps.Api.Clients.MeetingParticipant participant) { ArgumentNullException.ThrowIfNull(participant); diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs index 4f586dcb..9dedd03f 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs @@ -62,7 +62,7 @@ public async Task ProcessAsync(HttpRequest httpRequest, HttpResponse httpRespons turnContext.TurnState.Add(new CompatUserTokenClient(_teamsBotApplication.UserTokenClient)); CompatConnectorClient connectionClient = new(new CompatConversations(_teamsBotApplication.ConversationClient) { ServiceUrl = activity.ServiceUrl?.ToString() }); turnContext.TurnState.Add(connectionClient); - turnContext.TurnState.Add(_teamsBotApplication.TeamsApiClient); + turnContext.TurnState.Add(_teamsBotApplication.TeamsApiClient); await MiddlewareSet.ReceiveActivityWithStatusAsync(turnContext, bot.OnTurnAsync, ct).ConfigureAwait(false); }; @@ -112,7 +112,7 @@ public async override Task ContinueConversationAsync(string botId, ConversationR using TurnContext turnContext = new(this, reference.GetContinuationActivity()); turnContext.TurnState.Add(new CompatUserTokenClient(_teamsBotApplication.UserTokenClient)); turnContext.TurnState.Add(new CompatConnectorClient(new CompatConversations(_teamsBotApplication.ConversationClient) { ServiceUrl = reference.ServiceUrl })); - turnContext.TurnState.Add(_teamsBotApplication.TeamsApiClient); + turnContext.TurnState.Add(_teamsBotApplication.TeamsApiClient); await RunPipelineAsync(turnContext, callback, cancellationToken).ConfigureAwait(false); } } diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.Models.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.Models.cs index f2bb3a9a..420e1ac2 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.Models.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.Models.cs @@ -20,98 +20,98 @@ internal static class CompatTeamsInfoModels return channelData?.Meeting; } - /// - /// Converts a Core BatchOperationState to a Bot Framework BatchOperationState. - /// - /// The source state. - /// The converted Bot Framework BatchOperationState. - public static Microsoft.Bot.Schema.Teams.BatchOperationState ToCompatBatchOperationState(this Microsoft.Teams.Bot.Apps.BatchOperationState state) - { - ArgumentNullException.ThrowIfNull(state); - - BatchOperationState result = new() - { - State = state.State, - RetryAfter = state.RetryAfter?.DateTime, - TotalEntriesCount = state.TotalEntriesCount ?? 0 - }; - - // StatusMap in Bot Framework SDK is IDictionary (read-only property) - // Map from BatchOperationStatusMap to the dictionary format - if (state.StatusMap != null) - { - if (state.StatusMap.Success.HasValue) - { - result.StatusMap[0] = state.StatusMap.Success.Value; - } - - if (state.StatusMap.Failed.HasValue) - { - result.StatusMap[1] = state.StatusMap.Failed.Value; - } - - if (state.StatusMap.Throttled.HasValue) - { - result.StatusMap[2] = state.StatusMap.Throttled.Value; - } - - if (state.StatusMap.Pending.HasValue) - { - result.StatusMap[3] = state.StatusMap.Pending.Value; - } - } - - return result; - } - - /// - /// Converts a Core BatchFailedEntriesResponse to a Bot Framework BatchFailedEntriesResponse. - /// - /// The source response. - /// The converted Bot Framework BatchFailedEntriesResponse. - public static Microsoft.Bot.Schema.Teams.BatchFailedEntriesResponse ToCompatBatchFailedEntriesResponse(this Microsoft.Teams.Bot.Apps.BatchFailedEntriesResponse response) - { - ArgumentNullException.ThrowIfNull(response); - - BatchFailedEntriesResponse result = new() - { - ContinuationToken = response.ContinuationToken - }; - - // FailedEntries is a read-only property with private setter, populate via the collection - if (response.FailedEntries != null) - { - foreach (Apps.BatchFailedEntry entry in response.FailedEntries) - { - result.FailedEntries.Add(entry.ToCompatBatchFailedEntry()); - } - } - - return result; - } - - /// - /// Converts a Core BatchFailedEntry to a Bot Framework BatchFailedEntry. - /// - /// The source entry. - /// The converted Bot Framework BatchFailedEntry. - public static Microsoft.Bot.Schema.Teams.BatchFailedEntry ToCompatBatchFailedEntry(this Microsoft.Teams.Bot.Apps.BatchFailedEntry entry) - { - ArgumentNullException.ThrowIfNull(entry); - - return new Microsoft.Bot.Schema.Teams.BatchFailedEntry - { - EntryId = entry.Id, - Error = entry.Error - }; - } + ///// + ///// Converts a Core BatchOperationState to a Bot Framework BatchOperationState. + ///// + ///// The source state. + ///// The converted Bot Framework BatchOperationState. + //public static Microsoft.Bot.Schema.Teams.BatchOperationState ToCompatBatchOperationState(this Microsoft.Teams.Bot.Apps.Api.BatchOperationState state) + //{ + // ArgumentNullException.ThrowIfNull(state); + + // BatchOperationState result = new() + // { + // State = state.State, + // RetryAfter = state.RetryAfter?.DateTime, + // TotalEntriesCount = state.TotalEntriesCount ?? 0 + // }; + + // // StatusMap in Bot Framework SDK is IDictionary (read-only property) + // // Map from BatchOperationStatusMap to the dictionary format + // if (state.StatusMap != null) + // { + // if (state.StatusMap.Success.HasValue) + // { + // result.StatusMap[0] = state.StatusMap.Success.Value; + // } + + // if (state.StatusMap.Failed.HasValue) + // { + // result.StatusMap[1] = state.StatusMap.Failed.Value; + // } + + // if (state.StatusMap.Throttled.HasValue) + // { + // result.StatusMap[2] = state.StatusMap.Throttled.Value; + // } + + // if (state.StatusMap.Pending.HasValue) + // { + // result.StatusMap[3] = state.StatusMap.Pending.Value; + // } + // } + + // return result; + //} + + ///// + ///// Converts a Core BatchFailedEntriesResponse to a Bot Framework BatchFailedEntriesResponse. + ///// + ///// The source response. + ///// The converted Bot Framework BatchFailedEntriesResponse. + //public static Microsoft.Bot.Schema.Teams.BatchFailedEntriesResponse ToCompatBatchFailedEntriesResponse(this Microsoft.Teams.Bot.Apps.BatchFailedEntriesResponse response) + //{ + // ArgumentNullException.ThrowIfNull(response); + + // BatchFailedEntriesResponse result = new() + // { + // ContinuationToken = response.ContinuationToken + // }; + + // // FailedEntries is a read-only property with private setter, populate via the collection + // if (response.FailedEntries != null) + // { + // foreach (Apps.BatchFailedEntry entry in response.FailedEntries) + // { + // result.FailedEntries.Add(entry.ToCompatBatchFailedEntry()); + // } + // } + + // return result; + //} + + ///// + ///// Converts a Core BatchFailedEntry to a Bot Framework BatchFailedEntry. + ///// + ///// The source entry. + ///// The converted Bot Framework BatchFailedEntry. + //public static Microsoft.Bot.Schema.Teams.BatchFailedEntry ToCompatBatchFailedEntry(this Microsoft.Teams.Bot.Apps.BatchFailedEntry entry) + //{ + // ArgumentNullException.ThrowIfNull(entry); + + // return new Microsoft.Bot.Schema.Teams.BatchFailedEntry + // { + // EntryId = entry.Id, + // Error = entry.Error + // }; + //} /// /// Converts a Core TeamDetails to a Bot Framework TeamDetails. /// /// The source team details. /// The converted Bot Framework TeamDetails. - public static Microsoft.Bot.Schema.Teams.TeamDetails ToCompatTeamDetails(this Microsoft.Teams.Bot.Apps.TeamDetails teamDetails) + public static Microsoft.Bot.Schema.Teams.TeamDetails ToCompatTeamDetails(this Microsoft.Teams.Bot.Apps.Schema.Team teamDetails) { ArgumentNullException.ThrowIfNull(teamDetails); @@ -126,47 +126,47 @@ public static Microsoft.Bot.Schema.Teams.TeamDetails ToCompatTeamDetails(this Mi }; } - /// - /// Converts a Core MeetingNotificationResponse to a Bot Framework MeetingNotificationResponse. - /// - /// The source response. - /// The converted Bot Framework MeetingNotificationResponse. - public static Microsoft.Bot.Schema.Teams.MeetingNotificationResponse ToCompatMeetingNotificationResponse(this Microsoft.Teams.Bot.Apps.MeetingNotificationResponse response) - { - ArgumentNullException.ThrowIfNull(response); - - return new Microsoft.Bot.Schema.Teams.MeetingNotificationResponse - { - RecipientsFailureInfo = response.RecipientsFailureInfo?.Select(r => r.ToCompatMeetingNotificationRecipientFailureInfo()).ToList() - }; - } - - /// - /// Converts a Core MeetingNotificationRecipientFailureInfo to a Bot Framework MeetingNotificationRecipientFailureInfo. - /// - /// The source failure info. - /// The converted Bot Framework MeetingNotificationRecipientFailureInfo. - public static Microsoft.Bot.Schema.Teams.MeetingNotificationRecipientFailureInfo ToCompatMeetingNotificationRecipientFailureInfo(this Microsoft.Teams.Bot.Apps.MeetingNotificationRecipientFailureInfo info) - { - ArgumentNullException.ThrowIfNull(info); - - return new Microsoft.Bot.Schema.Teams.MeetingNotificationRecipientFailureInfo - { - RecipientMri = info.RecipientMri, - ErrorCode = info.ErrorCode, - FailureReason = info.FailureReason - }; - } - - /// - /// Converts a Bot Framework TeamMember to a Core TeamMember. - /// - /// The source team member. - /// The converted Core TeamMember. - public static Microsoft.Teams.Bot.Apps.TeamMember FromCompatTeamMember(this Microsoft.Bot.Schema.Teams.TeamMember teamMember) - { - ArgumentNullException.ThrowIfNull(teamMember); - - return new Microsoft.Teams.Bot.Apps.TeamMember(teamMember.Id); - } + ///// + ///// Converts a Core MeetingNotificationResponse to a Bot Framework MeetingNotificationResponse. + ///// + ///// The source response. + ///// The converted Bot Framework MeetingNotificationResponse. + //public static Microsoft.Bot.Schema.Teams.MeetingNotificationResponse ToCompatMeetingNotificationResponse(this Microsoft.Teams.Bot.Apps.MeetingNotificationResponse response) + //{ + // ArgumentNullException.ThrowIfNull(response); + + // return new Microsoft.Bot.Schema.Teams.MeetingNotificationResponse + // { + // RecipientsFailureInfo = response.RecipientsFailureInfo?.Select(r => r.ToCompatMeetingNotificationRecipientFailureInfo()).ToList() + // }; + //} + + ///// + ///// Converts a Core MeetingNotificationRecipientFailureInfo to a Bot Framework MeetingNotificationRecipientFailureInfo. + ///// + ///// The source failure info. + ///// The converted Bot Framework MeetingNotificationRecipientFailureInfo. + //public static Microsoft.Bot.Schema.Teams.MeetingNotificationRecipientFailureInfo ToCompatMeetingNotificationRecipientFailureInfo(this Microsoft.Teams.Bot.Apps.MeetingNotificationRecipientFailureInfo info) + //{ + // ArgumentNullException.ThrowIfNull(info); + + // return new Microsoft.Bot.Schema.Teams.MeetingNotificationRecipientFailureInfo + // { + // RecipientMri = info.RecipientMri, + // ErrorCode = info.ErrorCode, + // FailureReason = info.FailureReason + // }; + //} + + ///// + ///// Converts a Bot Framework TeamMember to a Core TeamMember. + ///// + ///// The source team member. + ///// The converted Core TeamMember. + //public static Microsoft.Teams.Bot.Apps.TeamMember FromCompatTeamMember(this Microsoft.Bot.Schema.Teams.TeamMember teamMember) + //{ + // ArgumentNullException.ThrowIfNull(teamMember); + + // return new Microsoft.Teams.Bot.Apps.TeamMember(teamMember.Id); + //} } diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs index 2fe06299..94060c00 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs @@ -6,6 +6,7 @@ using Microsoft.Bot.Schema; using Microsoft.Bot.Schema.Teams; using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Schema; @@ -22,10 +23,10 @@ public static class CompatTeamsInfo { #region Helper Methods - private static readonly System.Text.Json.JsonSerializerOptions s_jsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; + //private static readonly System.Text.Json.JsonSerializerOptions s_jsonOptions = new() + //{ + // PropertyNameCaseInsensitive = true + //}; private static ConversationClient GetConversationClient(ITurnContext turnContext) { @@ -40,10 +41,10 @@ private static ConversationClient GetConversationClient(ITurnContext turnContext throw new InvalidOperationException("Connector client is not compatible."); } - private static TeamsApiClient GetTeamsApiClient(ITurnContext turnContext) + private static ApiClient GetTeamsApiClient(ITurnContext turnContext) { - return turnContext.TurnState.Get() - ?? throw new InvalidOperationException("This method requires TeamsApiClient."); + return turnContext.TurnState.Get() + ?? throw new InvalidOperationException("This method requires ApiClient."); } private static string GetServiceUrl(ITurnContext turnContext) @@ -286,14 +287,14 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id ?? throw new InvalidOperationException("The meetingId can only be null if turnContext is within the scope of a MS Teams Meeting."); - TeamsApiClient client = GetTeamsApiClient(turnContext); + var client = GetTeamsApiClient(turnContext); Uri serviceUrl = new(GetServiceUrl(turnContext)); AgenticIdentity identity = GetIdentity(turnContext); - AppsTeams.MeetingInfo result = await client.FetchMeetingInfoAsync( - meetingId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + var result = await client.Meetings.GetByIdAsync( + meetingId, cancellationToken).ConfigureAwait(false); - return result.ToCompatMeetingInfo(); + return new BotFrameworkTeams.MeetingInfo(); // TODO: Map the result to BotFrameworkTeams.MeetingInfo once the API is finalized and we have the necessary details in the result to perform the mapping. } /// @@ -320,49 +321,49 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) tenantId ??= turnContext.Activity.GetChannelData()?.Tenant?.Id ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); - TeamsApiClient client = GetTeamsApiClient(turnContext); + var client = GetTeamsApiClient(turnContext); Uri serviceUrl = new(GetServiceUrl(turnContext)); AgenticIdentity identity = GetIdentity(turnContext); - MeetingParticipant result = await client.FetchParticipantAsync( - meetingId, participantId, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + var result = await client.Meetings.GetParticipantAsync( + meetingId, participantId, tenantId, cancellationToken).ConfigureAwait(false); - return result.ToCompatTeamsMeetingParticipant(); + return new TeamsMeetingParticipant(); // TODO: Map the result to BotFrameworkTeams.TeamsMeetingParticipant once the API is finalized and we have the necessary details in the result to perform the mapping. } - /// - /// Sends a notification to meeting participants. This functionality is available only in teams meeting scoped conversations. - /// - /// Turn context. - /// The notification to send to Teams. - /// The id of the Teams meeting. BotFrameworkTeams.TeamsChannelData.Meeting.Id will be used if none provided. - /// Cancellation token. - /// Meeting notification response. - public static async Task SendMeetingNotificationAsync( - ITurnContext turnContext, - BotFrameworkTeams.MeetingNotificationBase? notification, - string? meetingId = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(turnContext); - meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id - ?? throw new InvalidOperationException("This method is only valid within the scope of a MS Teams Meeting."); - notification = notification ?? throw new InvalidOperationException($"{nameof(notification)} is required."); - - TeamsApiClient client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); - - // Convert Bot Framework MeetingNotificationBase to Core MeetingNotificationBase using JSON round-trip - string json = Newtonsoft.Json.JsonConvert.SerializeObject(notification); - AppsTeams.TargetedMeetingNotification? coreNotification = System.Text.Json.JsonSerializer.Deserialize(json, s_jsonOptions); - - - AppsTeams.MeetingNotificationResponse result = await client.SendMeetingNotificationAsync( - meetingId, coreNotification!, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); - - return result.ToCompatMeetingNotificationResponse(); - } + ///// + ///// Sends a notification to meeting participants. This functionality is available only in teams meeting scoped conversations. + ///// + ///// Turn context. + ///// The notification to send to Teams. + ///// The id of the Teams meeting. BotFrameworkTeams.TeamsChannelData.Meeting.Id will be used if none provided. + ///// Cancellation token. + ///// Meeting notification response. + //public static async Task SendMeetingNotificationAsync( + // ITurnContext turnContext, + // BotFrameworkTeams.MeetingNotificationBase? notification, + // string? meetingId = null, + // CancellationToken cancellationToken = default) + //{ + // ArgumentNullException.ThrowIfNull(turnContext); + // meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id + // ?? throw new InvalidOperationException("This method is only valid within the scope of a MS Teams Meeting."); + // notification = notification ?? throw new InvalidOperationException($"{nameof(notification)} is required."); + + // var client = GetTeamsApiClient(turnContext); + // Uri serviceUrl = new(GetServiceUrl(turnContext)); + // AgenticIdentity identity = GetIdentity(turnContext); + + // // Convert Bot Framework MeetingNotificationBase to Core MeetingNotificationBase using JSON round-trip + // string json = Newtonsoft.Json.JsonConvert.SerializeObject(notification); + // AppsTeams.TargetedMeetingNotification? coreNotification = System.Text.Json.JsonSerializer.Deserialize(json, s_jsonOptions); + + + // AppsTeams.MeetingNotificationResponse result = await client.Meetings SendMeetingNotificationAsync( + // meetingId, coreNotification!, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + // return result.ToCompatMeetingNotificationResponse(); + //} #endregion @@ -384,14 +385,18 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); - TeamsApiClient client = GetTeamsApiClient(turnContext); + var client = GetTeamsApiClient(turnContext); Uri serviceUrl = new(GetServiceUrl(turnContext)); AgenticIdentity identity = GetIdentity(turnContext); - AppsTeams.TeamDetails result = await client.FetchTeamDetailsAsync( - t, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + var result = await client.Teams.GetByIdAsync(t, cancellationToken).ConfigureAwait(false); - return result.ToCompatTeamDetails(); + return new TeamDetails + { + Id = result?.Id, + Name = result?.Name, + AadGroupId = result?.AadGroupId + }; } /// @@ -411,140 +416,140 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); - TeamsApiClient client = GetTeamsApiClient(turnContext); + var client = GetTeamsApiClient(turnContext); Uri serviceUrl = new(GetServiceUrl(turnContext)); AgenticIdentity identity = GetIdentity(turnContext); - ChannelList channelList = await client.FetchChannelListAsync( - t, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + var channelList = await client.Teams.GetConversationsAsync(t, cancellationToken).ConfigureAwait(false); - return channelList.Channels?.Select(c => c.ToCompatChannelInfo()).ToList() ?? []; + return channelList?.Select(c => c.ToCompatChannelInfo()).ToList() ?? []; } #endregion #region Batch Messaging Methods - /// - /// Sends a message to the provided list of Teams members. - /// - /// Turn context. - /// The activity to send. - /// The list of members. - /// The tenant ID. - /// Cancellation token. - /// The operation Id. - public static async Task SendMessageToListOfUsersAsync( - ITurnContext turnContext, - IActivity activity, - IList teamsMembers, - string tenantId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(turnContext); - activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); - teamsMembers = teamsMembers ?? throw new InvalidOperationException($"{nameof(teamsMembers)} is required."); - tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); - - TeamsApiClient client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); - CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); - - List coreTeamsMembers = teamsMembers.Select(m => m.FromCompatTeamMember()).ToList(); - - return await client.SendMessageToListOfUsersAsync( - coreActivity, coreTeamsMembers, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); - } - - /// - /// Sends a message to the provided list of Teams channels. - /// - /// Turn context. - /// The activity to send. - /// The list of channels. - /// The tenant ID. - /// Cancellation token. - /// The operation Id. - public static async Task SendMessageToListOfChannelsAsync( - ITurnContext turnContext, - IActivity activity, - IList channelsMembers, - string tenantId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(turnContext); - activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); - channelsMembers = channelsMembers ?? throw new InvalidOperationException($"{nameof(channelsMembers)} is required."); - tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); - - TeamsApiClient client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); - CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); - - List coreChannelsMembers = channelsMembers.Select(m => m.FromCompatTeamMember()).ToList(); - - return await client.SendMessageToListOfChannelsAsync( - coreActivity, coreChannelsMembers, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); - } - - /// - /// Sends a message to all the users in a team. - /// - /// The turn context. - /// The activity to send to the users in the team. - /// The team ID. - /// The tenant ID. - /// Cancellation token. - /// The operation Id. - public static async Task SendMessageToAllUsersInTeamAsync( - ITurnContext turnContext, - IActivity activity, - string teamId, - string tenantId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(turnContext); - activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); - teamId = teamId ?? throw new InvalidOperationException($"{nameof(teamId)} is required."); - tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); - - TeamsApiClient client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); - CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); - - return await client.SendMessageToAllUsersInTeamAsync( - coreActivity, teamId, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); - } - - /// - /// Sends a message to all the users in a tenant. - /// - /// The turn context. - /// The activity to send to the tenant. - /// The tenant ID. - /// Cancellation token. - /// The operation Id. - public static async Task SendMessageToAllUsersInTenantAsync( - ITurnContext turnContext, - IActivity activity, - string tenantId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(turnContext); - activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); - tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); - - TeamsApiClient client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); - CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); - - return await client.SendMessageToAllUsersInTenantAsync( - coreActivity, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); - } + // TODO: Implement batch messaging methods once the APIs are finalized. This includes SendMessageToListOfUsersAsync, SendMessageToListOfChannelsAsync, SendMessageToAllUsersInTeamAsync, and SendMessageToAllUsersInTenantAsync. + ///// + ///// Sends a message to the provided list of Teams members. + ///// + ///// Turn context. + ///// The activity to send. + ///// The list of members. + ///// The tenant ID. + ///// Cancellation token. + ///// The operation Id. + //public static async Task SendMessageToListOfUsersAsync( + // ITurnContext turnContext, + // IActivity activity, + // IList teamsMembers, + // string tenantId, + // CancellationToken cancellationToken = default) + //{ + // ArgumentNullException.ThrowIfNull(turnContext); + // activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + // teamsMembers = teamsMembers ?? throw new InvalidOperationException($"{nameof(teamsMembers)} is required."); + // tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + // var client = GetTeamsApiClient(turnContext); + // Uri serviceUrl = new(GetServiceUrl(turnContext)); + // AgenticIdentity identity = GetIdentity(turnContext); + // CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); + + // List coreTeamsMembers = teamsMembers.Select(m => m.FromCompatTeamMember()).ToList(); + + // return await client.SendMessageToListOfUsersAsync( + // coreActivity, coreTeamsMembers, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + //} + + ///// + ///// Sends a message to the provided list of Teams channels. + ///// + ///// Turn context. + ///// The activity to send. + ///// The list of channels. + ///// The tenant ID. + ///// Cancellation token. + ///// The operation Id. + //public static async Task SendMessageToListOfChannelsAsync( + // ITurnContext turnContext, + // IActivity activity, + // IList channelsMembers, + // string tenantId, + // CancellationToken cancellationToken = default) + //{ + // ArgumentNullException.ThrowIfNull(turnContext); + // activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + // channelsMembers = channelsMembers ?? throw new InvalidOperationException($"{nameof(channelsMembers)} is required."); + // tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + // TeamsApiClient client = GetTeamsApiClient(turnContext); + // Uri serviceUrl = new(GetServiceUrl(turnContext)); + // AgenticIdentity identity = GetIdentity(turnContext); + // CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); + + // List coreChannelsMembers = channelsMembers.Select(m => m.FromCompatTeamMember()).ToList(); + + // return await client.SendMessageToListOfChannelsAsync( + // coreActivity, coreChannelsMembers, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + //} + + ///// + ///// Sends a message to all the users in a team. + ///// + ///// The turn context. + ///// The activity to send to the users in the team. + ///// The team ID. + ///// The tenant ID. + ///// Cancellation token. + ///// The operation Id. + //public static async Task SendMessageToAllUsersInTeamAsync( + // ITurnContext turnContext, + // IActivity activity, + // string teamId, + // string tenantId, + // CancellationToken cancellationToken = default) + //{ + // ArgumentNullException.ThrowIfNull(turnContext); + // activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + // teamId = teamId ?? throw new InvalidOperationException($"{nameof(teamId)} is required."); + // tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + // var client = GetTeamsApiClient(turnContext); + // Uri serviceUrl = new(GetServiceUrl(turnContext)); + // AgenticIdentity identity = GetIdentity(turnContext); + // CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); + + // return await client.SendMessageToAllUsersInTeamAsync( + // coreActivity, teamId, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + //} + + ///// + ///// Sends a message to all the users in a tenant. + ///// + ///// The turn context. + ///// The activity to send to the tenant. + ///// The tenant ID. + ///// Cancellation token. + ///// The operation Id. + //public static async Task SendMessageToAllUsersInTenantAsync( + // ITurnContext turnContext, + // IActivity activity, + // string tenantId, + // CancellationToken cancellationToken = default) + //{ + // ArgumentNullException.ThrowIfNull(turnContext); + // activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + // tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + // TeamsApiClient client = GetTeamsApiClient(turnContext); + // Uri serviceUrl = new(GetServiceUrl(turnContext)); + // AgenticIdentity identity = GetIdentity(turnContext); + // CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); + + // return await client.SendMessageToAllUsersInTenantAsync( + // coreActivity, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + //} /// /// Creates a new thread in a team chat and sends an activity to that new thread. @@ -603,80 +608,80 @@ await turnContext.Adapter.CreateConversationAsync( #region Batch Operation Management - /// - /// Gets the state of an operation. - /// - /// Turn context. - /// The operationId to get the state of. - /// Cancellation token. - /// The state and responses of the operation. - public static async Task GetOperationStateAsync( - ITurnContext turnContext, - string operationId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(turnContext); - operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); - - TeamsApiClient client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); - - AppsTeams.BatchOperationState result = await client.GetOperationStateAsync( - operationId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); - - return result.ToCompatBatchOperationState(); - } - - /// - /// Gets the failed entries of a batch operation. - /// - /// The turn context. - /// The operationId to get the failed entries of. - /// The continuation token. - /// Cancellation token. - /// The list of failed entries of the operation. - public static async Task GetPagedFailedEntriesAsync( - ITurnContext turnContext, - string operationId, - string? continuationToken = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(turnContext); - operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); - - TeamsApiClient client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); - - AppsTeams.BatchFailedEntriesResponse result = await client.GetPagedFailedEntriesAsync( - operationId, serviceUrl, continuationToken, identity, null, cancellationToken).ConfigureAwait(false); - - return result.ToCompatBatchFailedEntriesResponse(); - } - - /// - /// Cancels a batch operation by its id. - /// - /// The turn context. - /// The id of the operation to cancel. - /// Cancellation token. - /// A task representing the asynchronous operation. - public static async Task CancelOperationAsync( - ITurnContext turnContext, - string operationId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(turnContext); - operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); - - TeamsApiClient client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); - - await client.CancelOperationAsync( - operationId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); - } + ///// + ///// Gets the state of an operation. + ///// + ///// Turn context. + ///// The operationId to get the state of. + ///// Cancellation token. + ///// The state and responses of the operation. + //public static async Task GetOperationStateAsync( + // ITurnContext turnContext, + // string operationId, + // CancellationToken cancellationToken = default) + //{ + // ArgumentNullException.ThrowIfNull(turnContext); + // operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); + + // TeamsApiClient client = GetTeamsApiClient(turnContext); + // Uri serviceUrl = new(GetServiceUrl(turnContext)); + // AgenticIdentity identity = GetIdentity(turnContext); + + // AppsTeams.BatchOperationState result = await client.GetOperationStateAsync( + // operationId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + // return result.ToCompatBatchOperationState(); + //} + + ///// + ///// Gets the failed entries of a batch operation. + ///// + ///// The turn context. + ///// The operationId to get the failed entries of. + ///// The continuation token. + ///// Cancellation token. + ///// The list of failed entries of the operation. + //public static async Task GetPagedFailedEntriesAsync( + // ITurnContext turnContext, + // string operationId, + // string? continuationToken = null, + // CancellationToken cancellationToken = default) + //{ + // ArgumentNullException.ThrowIfNull(turnContext); + // operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); + + // TeamsApiClient client = GetTeamsApiClient(turnContext); + // Uri serviceUrl = new(GetServiceUrl(turnContext)); + // AgenticIdentity identity = GetIdentity(turnContext); + + // AppsTeams.BatchFailedEntriesResponse result = await client.GetPagedFailedEntriesAsync( + // operationId, serviceUrl, continuationToken, identity, null, cancellationToken).ConfigureAwait(false); + + // return result.ToCompatBatchFailedEntriesResponse(); + //} + + ///// + ///// Cancels a batch operation by its id. + ///// + ///// The turn context. + ///// The id of the operation to cancel. + ///// Cancellation token. + ///// A task representing the asynchronous operation. + //public static async Task CancelOperationAsync( + // ITurnContext turnContext, + // string operationId, + // CancellationToken cancellationToken = default) + //{ + // ArgumentNullException.ThrowIfNull(turnContext); + // operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); + + // TeamsApiClient client = GetTeamsApiClient(turnContext); + // Uri serviceUrl = new(GetServiceUrl(turnContext)); + // AgenticIdentity identity = GetIdentity(turnContext); + + // await client.CancelOperationAsync( + // operationId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + //} #endregion } diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs index 755cc1f6..a8ca8d1a 100644 --- a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Core; using Moq; @@ -18,10 +19,10 @@ public class CompatAdapterTests public async Task ContinueConversationAsync_WhenCastToBotAdapter_BuildsTurnContextWithUnderlyingClients() { // Arrange - (CompatAdapter? compatAdapter, TeamsApiClient? teamsApiClient) = CreateCompatAdapter(); + (CompatAdapter? compatAdapter, Microsoft.Teams.Bot.Apps.Api.Clients.ApiClient? teamsApiClient) = CreateCompatAdapter(); // Cast to BotAdapter to ensure we're using the base class method - BotAdapter botAdapter = compatAdapter; + BotAdapter botAdapter = compatAdapter!; ConversationReference conversationReference = new() { @@ -33,14 +34,14 @@ public async Task ContinueConversationAsync_WhenCastToBotAdapter_BuildsTurnConte bool callbackInvoked = false; Microsoft.Bot.Connector.Authentication.UserTokenClient? capturedUserTokenClient = null; Microsoft.Bot.Connector.IConnectorClient? capturedConnectorClient = null; - Microsoft.Teams.Bot.Apps.TeamsApiClient? capturedTeamsApiClient = null; + Microsoft.Teams.Bot.Apps.Api.Clients.ApiClient? capturedTeamsApiClient = null; BotCallbackHandler callback = async (turnContext, cancellationToken) => { callbackInvoked = true; capturedUserTokenClient = turnContext.TurnState.Get(); capturedConnectorClient = turnContext.TurnState.Get(); - capturedTeamsApiClient = turnContext.TurnState.Get(); + capturedTeamsApiClient = turnContext.TurnState.Get(); await Task.CompletedTask; }; @@ -69,7 +70,7 @@ await botAdapter.ContinueConversationAsync( Assert.Same(teamsApiClient, capturedTeamsApiClient); } - private static (CompatAdapter, TeamsApiClient) CreateCompatAdapter() + private static (CompatAdapter, ApiClient) CreateCompatAdapter() { HttpClient httpClient = new(); ConversationClient conversationClient = new(httpClient, NullLogger.Instance); @@ -78,7 +79,7 @@ private static (CompatAdapter, TeamsApiClient) CreateCompatAdapter() mockConfig.Setup(c => c["UserTokenApiEndpoint"]).Returns("https://token.botframework.com"); UserTokenClient userTokenClient = new(httpClient, mockConfig.Object, NullLogger.Instance); - TeamsApiClient teamsApiClient = new(httpClient, NullLogger.Instance); + ApiClient teamsApiClient = new(new Uri("https://service.url"), httpClient, NullLogger.Instance); TeamsBotApplication teamsBotApplication = new( conversationClient, diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs index 9680666c..2cce272e 100644 --- a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs @@ -6,6 +6,7 @@ using Microsoft.Bot.Schema; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Schema; using Moq; @@ -255,9 +256,9 @@ private static Mock CreateMockTeamsBotApplication() new HttpClient(), Mock.Of(), NullLogger.Instance); - Mock mockTeamsApiClient = new( + Mock mockTeamsApiClient = new( new HttpClient(), - NullLogger.Instance); + NullLogger.Instance); Mock mock = new( mockConversationClient.Object, @@ -275,10 +276,9 @@ private static CompatBotAdapter CreateCompatBotAdapter(ConversationClient conver new HttpClient(), Mock.Of(), NullLogger.Instance); - Mock mockTeamsApiClient = new( + Mock mockTeamsApiClient = new( new HttpClient(), - NullLogger.Instance); - + NullLogger.Instance); TeamsBotApplication teamsBotApplication = new( conversationClient, mockUserTokenClient.Object, From ee1941a309fdec1995f5c7a16e8bfbc3abf2f78e Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 16 Apr 2026 16:52:06 -0700 Subject: [PATCH 02/22] fixing the build --- core/samples/CustomHosting/MyTeamsBotApp.cs | 3 ++- core/samples/PABot/InitCompatAdapter.cs | 7 ++++--- core/samples/TeamsBot/Program.cs | 7 +++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/core/samples/CustomHosting/MyTeamsBotApp.cs b/core/samples/CustomHosting/MyTeamsBotApp.cs index 05f39dec..f36b41f0 100644 --- a/core/samples/CustomHosting/MyTeamsBotApp.cs +++ b/core/samples/CustomHosting/MyTeamsBotApp.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Apps.Handlers; using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Hosting; @@ -10,7 +11,7 @@ namespace CustomHosting; public class MyTeamsBotApp : TeamsBotApplication { - public MyTeamsBotApp(ConversationClient conversationClient, UserTokenClient userTokenClient, TeamsApiClient teamsApiClient, IHttpContextAccessor httpContextAccessor, ILogger logger, BotApplicationOptions? options = null) : base(conversationClient, userTokenClient, teamsApiClient, httpContextAccessor, logger, options) + public MyTeamsBotApp(ConversationClient conversationClient, UserTokenClient userTokenClient, ApiClient teamsApiClient, IHttpContextAccessor httpContextAccessor, ILogger logger, BotApplicationOptions? options = null) : base(conversationClient, userTokenClient, teamsApiClient, httpContextAccessor, logger, options) { this.OnMessage(async (ctx, ct) => { diff --git a/core/samples/PABot/InitCompatAdapter.cs b/core/samples/PABot/InitCompatAdapter.cs index eeef03cb..510d4a0f 100644 --- a/core/samples/PABot/InitCompatAdapter.cs +++ b/core/samples/PABot/InitCompatAdapter.cs @@ -8,6 +8,7 @@ using Microsoft.Identity.Web.TokenCacheProviders.InMemory; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Hosting; @@ -272,11 +273,11 @@ private static void RegisterBotClients(IServiceCollection services, AdapterConfi }); // Register TeamsApiClient - services.AddSingleton(sp => + services.AddSingleton(sp => { HttpClient httpClient = sp.GetRequiredService() .CreateClient("TeamsApiClient"); - return new TeamsApiClient(httpClient, sp.GetRequiredService>()); + return new ApiClient(new Uri("https://graph.microsoft.com/v1.0/"), httpClient, sp.GetRequiredService>()); // TODO: initialize the base URL }); // Register TeamsBotApplication @@ -285,7 +286,7 @@ private static void RegisterBotClients(IServiceCollection services, AdapterConfi return new TeamsBotApplication( sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>() ); diff --git a/core/samples/TeamsBot/Program.cs b/core/samples/TeamsBot/Program.cs index e7a68676..5ef9e16f 100644 --- a/core/samples/TeamsBot/Program.cs +++ b/core/samples/TeamsBot/Program.cs @@ -173,7 +173,6 @@ await context.TeamsBotApplication.Api.Conversations.Activities.UpdateTargetedAsy await context.TeamsBotApplication.Api.Conversations.Activities.DeleteTargetedAsync( context.Activity.Conversation.Id!, sendResponse.Id!, - context.Activity.ServiceUrl, cancellationToken: cancellationToken); }); @@ -196,7 +195,7 @@ await context.TeamsBotApplication.Api.Conversations.Activities.DeleteTargetedAsy // Add a waving hand reaction await context.TeamsBotApplication.Api.Conversations.Reactions.AddAsync( - context.Activity, + context.Activity.Conversation.Id, response!.Id!, "1f44b_wavinghand-tone4", cancellationToken: cancellationToken); @@ -205,7 +204,7 @@ await context.TeamsBotApplication.Api.Conversations.Reactions.AddAsync( // Add a beaming face reaction await context.TeamsBotApplication.Api.Conversations.Reactions.AddAsync( - context.Activity, + context.Activity.Conversation.Id, response.Id!, "1f601_beamingfacewithsmilingeyes", cancellationToken: cancellationToken); @@ -214,7 +213,7 @@ await context.TeamsBotApplication.Api.Conversations.Reactions.AddAsync( // Remove the beaming face reaction await context.TeamsBotApplication.Api.Conversations.Reactions.DeleteAsync( - context.Activity, + context.Activity.Conversation.Id, response.Id!, "1f601_beamingfacewithsmilingeyes", cancellationToken: cancellationToken); From e5fcb8df71c213004672a01ace29eab5fe9ef5ff Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 16 Apr 2026 17:45:38 -0700 Subject: [PATCH 03/22] refactor ApiClients --- core/samples/PABot/InitCompatAdapter.cs | 6 +- .../Api/Clients/ApiClient.cs | 89 ++++++++++++++++--- .../Api/Clients/V3UserTokenClient.cs | 2 +- .../TeamsBotApplication.cs | 2 +- .../CompatActivity.cs | 2 +- .../CompatAdapter.cs | 28 +++--- .../CompatTeamsInfo.cs | 66 ++++++++------ .../CompatAdapterTests.cs | 8 +- .../CompatBotAdapterTests.cs | 10 ++- 9 files changed, 142 insertions(+), 71 deletions(-) diff --git a/core/samples/PABot/InitCompatAdapter.cs b/core/samples/PABot/InitCompatAdapter.cs index 510d4a0f..b25a1ccf 100644 --- a/core/samples/PABot/InitCompatAdapter.cs +++ b/core/samples/PABot/InitCompatAdapter.cs @@ -73,7 +73,7 @@ private static void RegisterTeamsBotApplication(IServiceCollection services) RegisterHttpClients(services, config); // Register Bot Framework clients - RegisterBotClients(services, config); + RegisterBotClients(services); } private static AdapterConfig ReadAdapterConfig(IServiceCollection services) @@ -251,7 +251,7 @@ private static void RegisterHttpClients(IServiceCollection services, AdapterConf .AddHttpMessageHandler(sp => CreatePACustomAuthHandler(sp, config)); } - private static void RegisterBotClients(IServiceCollection services, AdapterConfig config) + private static void RegisterBotClients(IServiceCollection services) { // Register ConversationClient services.AddSingleton(sp => @@ -293,7 +293,7 @@ private static void RegisterBotClients(IServiceCollection services, AdapterConfi }); } - private static DelegatingHandler CreatePACustomAuthHandler(IServiceProvider sp, AdapterConfig config) + private static PACustomAuthHandler CreatePACustomAuthHandler(IServiceProvider sp, AdapterConfig config) { // Use bot scope if available, otherwise use agent scope string? botScope = config.BotIdentity?.Scope; diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs index b690d403..556c023d 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs @@ -9,42 +9,79 @@ namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// /// Top-level API client that provides access to all Teams Bot API sub-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 +/// when the service URL is known upfront. +/// +/// public class ApiClient { private readonly BotHttpClient _http; + private readonly string _tokenApiEndpoint; /// /// 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. /// - public Uri ServiceUrl { get; } + public virtual Uri ServiceUrl { get; } /// /// Client for bot-level operations (token, sign-in). /// - public BotClient Bots { get; } + public virtual BotClient Bots { get; } /// /// Client for conversation operations (activities, members, reactions). /// - public V3ConversationClient Conversations { get; } + public virtual V3ConversationClient Conversations { get; } /// /// Client for user-level operations (token). /// - public UserClient Users { get; } + public virtual UserClient Users { get; } /// /// Client for team operations. /// - public TeamClient Teams { get; } + public virtual TeamClient Teams { get; } /// /// Client for meeting operations. /// - public MeetingClient Meetings { get; } + public virtual MeetingClient Meetings { get; } /// - /// Creates a new instance. + /// Creates a new without a service URL (DI-friendly). + /// Use to create a scoped instance bound to a specific service URL. + /// + /// An configured with authentication (e.g., via DI with BotAuthenticationHandler). + /// Optional logger. + /// Optional token API endpoint override. Defaults to https://token.botframework.com. + public ApiClient(HttpClient httpClient, ILogger? logger = null, string tokenApiEndpoint = "https://token.botframework.com") + { + ArgumentNullException.ThrowIfNull(httpClient); + + _http = new BotHttpClient(httpClient, logger); + _tokenApiEndpoint = tokenApiEndpoint; + Bots = new BotClient(_http, tokenApiEndpoint); + Users = new UserClient(_http, tokenApiEndpoint); + + // ServiceUrl-dependent sub-clients require ForServiceUrl() before use + ServiceUrl = null!; + Conversations = null!; + Teams = null!; + Meetings = null!; + } + + /// + /// Creates a new bound to a specific service URL. /// /// The Bot Framework service URL. /// An configured with authentication (e.g., via DI with BotAuthenticationHandler). @@ -55,14 +92,15 @@ public ApiClient(Uri serviceUrl, HttpClient httpClient, ILogger? logger = null, ArgumentNullException.ThrowIfNull(serviceUrl); ArgumentNullException.ThrowIfNull(httpClient); - string serviceUrlString = serviceUrl.ToString(); - ServiceUrl = serviceUrl; + string url = serviceUrl.ToString(); _http = new BotHttpClient(httpClient, logger); + _tokenApiEndpoint = tokenApiEndpoint; + ServiceUrl = serviceUrl; Bots = new BotClient(_http, tokenApiEndpoint); - Conversations = new V3ConversationClient(serviceUrlString, _http); + Conversations = new V3ConversationClient(url, _http); Users = new UserClient(_http, tokenApiEndpoint); - Teams = new TeamClient(serviceUrlString, _http); - Meetings = new MeetingClient(serviceUrlString, _http); + Teams = new TeamClient(url, _http); + Meetings = new MeetingClient(url, _http); } /// @@ -74,10 +112,37 @@ public ApiClient(ApiClient client) ServiceUrl = client.ServiceUrl; _http = client._http; + _tokenApiEndpoint = client._tokenApiEndpoint; Bots = client.Bots; Conversations = client.Conversations; Users = client.Users; Teams = client.Teams; Meetings = client.Meetings; } + + // Private constructor for ForServiceUrl — shares BotHttpClient + private ApiClient(BotHttpClient http, string tokenApiEndpoint, Uri serviceUrl) + { + _http = http; + _tokenApiEndpoint = tokenApiEndpoint; + ServiceUrl = serviceUrl; + string url = serviceUrl.ToString(); + Bots = new BotClient(http, tokenApiEndpoint); + Conversations = new V3ConversationClient(url, http); + Users = new UserClient(http, tokenApiEndpoint); + Teams = new TeamClient(url, http); + Meetings = new MeetingClient(url, http); + } + + /// + /// Creates a new scoped to the specified service URL, + /// sharing the underlying HTTP client and authentication. + /// + /// The Bot Framework service URL for this scope. + /// A new bound to the given service URL. + public virtual ApiClient ForServiceUrl(Uri serviceUrl) + { + ArgumentNullException.ThrowIfNull(serviceUrl); + return new ApiClient(_http, _tokenApiEndpoint, serviceUrl); + } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs index aafa7c5f..91407af5 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs @@ -63,7 +63,7 @@ internal V3UserTokenClient(BotHttpClient http, string tokenApiEndpoint = "https: ]; string url = $"{_tokenApiEndpoint}/api/usertoken/GetAadTokens?{string.Join("&", queryParams)}"; - var body = new { resourceUrls = resourceUrls ?? new List() }; + var body = new { resourceUrls = resourceUrls ?? [] }; string bodyJson = JsonSerializer.Serialize(body, JsonOptions); return await _http.SendAsync>( diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs index 8caceddd..c53e2118 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs @@ -61,7 +61,7 @@ public TeamsBotApplication( : base(conversationClient, userTokenClient, logger, options) { _teamsApiClient = teamsApiClient; - Api = new ApiClient(new Uri("https://graph.microsoft.com/v1.0/"), null!, logger); // TODO: inject HttpClient + Api = teamsApiClient; Router = new Router(logger); OnActivity = async (activity, cancellationToken) => { diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs index 5d82ca67..849bb93d 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs @@ -174,7 +174,7 @@ public static Microsoft.Bot.Schema.Teams.TeamsMeetingParticipant ToCompatTeamsMe return new Microsoft.Bot.Schema.Teams.TeamsMeetingParticipant { - User = participant.User != null ? participant.User.ToCompatTeamsChannelAccount() : null, + User = participant.User?.ToCompatTeamsChannelAccount(), Meeting = participant.Meeting != null ? new Microsoft.Bot.Schema.Teams.MeetingParticipantInfo { Role = participant.Meeting.Role, diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs index 9dedd03f..5acf77f3 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs @@ -21,24 +21,18 @@ namespace Microsoft.Teams.Bot.Compat; /// The adapter allows registration of middleware and error handling delegates, and supports processing HTTP requests /// and continuing conversations. Thread safety is not guaranteed; instances should not be shared across concurrent /// requests. -public class CompatAdapter : CompatBotAdapter, IBotFrameworkHttpAdapter +/// +/// Creates a new instance of the class. +/// +/// The Teams bot application instance. +/// The HTTP context accessor. +/// The logger instance. +public class CompatAdapter( + TeamsBotApplication teamsBotApplication, + IHttpContextAccessor? httpContextAccessor = null, + ILogger? logger = null) : CompatBotAdapter(teamsBotApplication, httpContextAccessor, logger), IBotFrameworkHttpAdapter { - private readonly TeamsBotApplication _teamsBotApplication; - - /// - /// Creates a new instance of the class. - /// - /// The Teams bot application instance. - /// The HTTP context accessor. - /// The logger instance. - public CompatAdapter( - TeamsBotApplication teamsBotApplication, - IHttpContextAccessor? httpContextAccessor = null, - ILogger? logger = null) - : base(teamsBotApplication, httpContextAccessor, logger) - { - _teamsBotApplication = teamsBotApplication; - } + private readonly TeamsBotApplication _teamsBotApplication = teamsBotApplication; /// /// Processes an incoming HTTP request and generates an appropriate HTTP response using the provided bot instance. diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs index 94060c00..6d537d6a 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs @@ -271,31 +271,31 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) #region Meeting Methods - /// - /// Gets the information for the given meeting id. - /// - /// Turn context. - /// The BASE64-encoded id of the Teams meeting. - /// Cancellation token. - /// Meeting information. - public static async Task GetMeetingInfoAsync( - ITurnContext turnContext, - string? meetingId = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(turnContext); - meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id - ?? throw new InvalidOperationException("The meetingId can only be null if turnContext is within the scope of a MS Teams Meeting."); + ///// + ///// Gets the information for the given meeting id. + ///// + ///// Turn context. + ///// The BASE64-encoded id of the Teams meeting. + ///// Cancellation token. + ///// Meeting information. + //public static async Task GetMeetingInfoAsync( + // ITurnContext turnContext, + // string? meetingId = null, + // CancellationToken cancellationToken = default) + //{ + // ArgumentNullException.ThrowIfNull(turnContext); + // meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id + // ?? throw new InvalidOperationException("The meetingId can only be null if turnContext is within the scope of a MS Teams Meeting."); - var client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); + // var client = GetTeamsApiClient(turnContext); + // // Uri serviceUrl = new(GetServiceUrl(turnContext)); + // AgenticIdentity identity = GetIdentity(turnContext); - var result = await client.Meetings.GetByIdAsync( - meetingId, cancellationToken).ConfigureAwait(false); + // var result = await client.Meetings.GetByIdAsync( + // meetingId, cancellationToken).ConfigureAwait(false); - return new BotFrameworkTeams.MeetingInfo(); // TODO: Map the result to BotFrameworkTeams.MeetingInfo once the API is finalized and we have the necessary details in the result to perform the mapping. - } + // return new BotFrameworkTeams.MeetingInfo(); // TODO: Map the result to BotFrameworkTeams.MeetingInfo once the API is finalized and we have the necessary details in the result to perform the mapping. + //} /// /// Gets the details for the given meeting participant. This only works in teams meeting scoped conversations. @@ -322,13 +322,25 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); var client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); + //AgenticIdentity identity = GetIdentity(turnContext); var result = await client.Meetings.GetParticipantAsync( meetingId, participantId, tenantId, cancellationToken).ConfigureAwait(false); - - return new TeamsMeetingParticipant(); // TODO: Map the result to BotFrameworkTeams.TeamsMeetingParticipant once the API is finalized and we have the necessary details in the result to perform the mapping. + + return new TeamsMeetingParticipant() + { + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = result?.Conversation?.Id }, + Meeting = new MeetingParticipantInfo() + { + InMeeting = result?.Meeting?.InMeeting, + Role = result?.Meeting?.Role + }, + User = new() + { + Id = result?.User?.Id, + Name = result?.User?.Name + } + }; } ///// @@ -386,8 +398,6 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); var client = GetTeamsApiClient(turnContext); - Uri serviceUrl = new(GetServiceUrl(turnContext)); - AgenticIdentity identity = GetIdentity(turnContext); var result = await client.Teams.GetByIdAsync(t, cancellationToken).ConfigureAwait(false); diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs index a8ca8d1a..a8d6c461 100644 --- a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs @@ -36,14 +36,14 @@ public async Task ContinueConversationAsync_WhenCastToBotAdapter_BuildsTurnConte Microsoft.Bot.Connector.IConnectorClient? capturedConnectorClient = null; Microsoft.Teams.Bot.Apps.Api.Clients.ApiClient? capturedTeamsApiClient = null; - BotCallbackHandler callback = async (turnContext, cancellationToken) => + async Task callback(ITurnContext turnContext, CancellationToken cancellationToken) { callbackInvoked = true; capturedUserTokenClient = turnContext.TurnState.Get(); capturedConnectorClient = turnContext.TurnState.Get(); capturedTeamsApiClient = turnContext.TurnState.Get(); await Task.CompletedTask; - }; + } // Act await botAdapter.ContinueConversationAsync( @@ -58,12 +58,12 @@ await botAdapter.ContinueConversationAsync( // Verify UserTokenClient is CompatUserTokenClient (check by type name since it's internal) Assert.NotNull(capturedUserTokenClient); Assert.Equal("CompatUserTokenClient", capturedUserTokenClient.GetType().Name); - Assert.IsAssignableFrom(capturedUserTokenClient); + Assert.IsType(capturedUserTokenClient, exactMatch: false); // Verify ConnectorClient is CompatConnectorClient (check by type name since it's internal) Assert.NotNull(capturedConnectorClient); Assert.Equal("CompatConnectorClient", capturedConnectorClient.GetType().Name); - Assert.IsAssignableFrom(capturedConnectorClient); + Assert.IsType(capturedConnectorClient, exactMatch: false); // Verify TeamsApiClient is the same instance we set up Assert.NotNull(capturedTeamsApiClient); diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs index 2cce272e..0268fbed 100644 --- a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs @@ -256,14 +256,15 @@ private static Mock CreateMockTeamsBotApplication() new HttpClient(), Mock.Of(), NullLogger.Instance); - Mock mockTeamsApiClient = new( + ApiClient mockTeamsApiClient = new( + new Uri("https://service.url"), new HttpClient(), NullLogger.Instance); Mock mock = new( mockConversationClient.Object, mockUserTokenClient.Object, - mockTeamsApiClient.Object, + mockTeamsApiClient, Mock.Of(), NullLogger.Instance); @@ -276,13 +277,14 @@ private static CompatBotAdapter CreateCompatBotAdapter(ConversationClient conver new HttpClient(), Mock.Of(), NullLogger.Instance); - Mock mockTeamsApiClient = new( + ApiClient mockTeamsApiClient = new( + new Uri("https://service.url"), new HttpClient(), NullLogger.Instance); TeamsBotApplication teamsBotApplication = new( conversationClient, mockUserTokenClient.Object, - mockTeamsApiClient.Object, + mockTeamsApiClient, Mock.Of(), NullLogger.Instance); From 09f224082893726bcc072f8c4802028a5a764b1c Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 16 Apr 2026 18:15:54 -0700 Subject: [PATCH 04/22] integrate all clients --- core/core.slnx | 1 + core/docs/ApiClient-Design.md | 167 ++++++++++++++++++ core/samples/CompatBot/EchoBot.cs | 77 ++++---- core/samples/PABot/InitCompatAdapter.cs | 4 +- .../Api/Clients/ActivityClient.cs | 72 ++++---- .../Api/Clients/ApiClient.cs | 41 +++-- .../Api/Clients/MemberClient.cs | 37 ++-- .../Api/Clients/ReactionClient.cs | 23 ++- .../Api/Clients/V3ConversationClient.cs | 39 ++-- .../IntegrationTests/IntegrationTests.csproj | 21 +++ .../CompatAdapterTests.cs | 2 +- .../CompatBotAdapterTests.cs | 2 + 12 files changed, 345 insertions(+), 141 deletions(-) create mode 100644 core/docs/ApiClient-Design.md create mode 100644 core/test/IntegrationTests/IntegrationTests.csproj diff --git a/core/core.slnx b/core/core.slnx index 12934ad3..51337417 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -38,6 +38,7 @@ + diff --git a/core/docs/ApiClient-Design.md b/core/docs/ApiClient-Design.md new file mode 100644 index 00000000..61b1e835 --- /dev/null +++ b/core/docs/ApiClient-Design.md @@ -0,0 +1,167 @@ +# ApiClient Design Document + +## Overview + +The `ApiClient` class (`Microsoft.Teams.Bot.Apps.Api.Clients`) provides a hierarchical, Libraries-compatible API surface for Teams Bot operations. It organizes Bot Framework v3 REST API calls into sub-clients that delegate to the core SDK infrastructure rather than making raw HTTP calls. + +## Architecture + +``` +ApiClient (top-level facade) +├── Bots → BotClient +│ └── SignIn → BotSignInClient [BotHttpClient → token.botframework.com] +├── Conversations → V3ConversationClient [delegates to core ConversationClient] +│ ├── Activities → ActivityClient +│ ├── Members → MemberClient +│ └── Reactions → ReactionClient +├── Users → UserClient +│ └── Token → V3UserTokenClient [BotHttpClient → token.botframework.com] +├── Teams → TeamClient [BotHttpClient → serviceUrl/v3/teams/] +└── Meetings → MeetingClient [BotHttpClient → serviceUrl/v1/meetings/] +``` + +### Two HTTP strategies + +| Sub-client | HTTP strategy | Why | +|---|---|---| +| Conversations (Activities, Members, Reactions) | Delegates to core `ConversationClient` | Reuses auth, logging, agents-channel handling, agentic identity support | +| Teams, Meetings | Uses `BotHttpClient` directly | No core client equivalent exists for these endpoints | +| Bots.SignIn, Users.Token | Uses `BotHttpClient` directly | Calls `token.botframework.com`, separate from conversation endpoints | + +## Construction Patterns + +### DI-friendly (no serviceUrl) + +```csharp +// Startup — registered via AddTeamsBotApplication() or manually +services.AddSingleton(sp => +{ + HttpClient httpClient = sp.GetRequiredService().CreateClient("ApiClient"); + ConversationClient conversationClient = sp.GetRequiredService(); + return new ApiClient(httpClient, conversationClient, sp.GetRequiredService>()); +}); +``` + +The `[ActivatorUtilitiesConstructor]` attribute marks this as the preferred constructor for DI, avoiding ambiguity with the fully-initialized constructor. + +ServiceUrl-dependent sub-clients (`Conversations`, `Teams`, `Meetings`) are `null` until `ForServiceUrl` is called. + +### Per-request scoping + +```csharp +// Per-request — creates a lightweight copy with serviceUrl-bound sub-clients +ApiClient scoped = baseApiClient.ForServiceUrl(activity.ServiceUrl); + +// Now safe to use +await scoped.Conversations.Activities.CreateAsync(conversationId, activity); +await scoped.Teams.GetByIdAsync(teamId); +await scoped.Meetings.GetByIdAsync(meetingId); +``` + +`ForServiceUrl` shares the underlying `BotHttpClient` and `ConversationClient` — only the sub-client wrappers are new allocations. + +### Fully initialized (for tests or known serviceUrl) + +```csharp +ApiClient client = new( + new Uri("https://smba.trafficmanager.net/teams/"), + httpClient, + conversationClient, + logger); +``` + +## Delegation Pattern (Option C) + +The conversation sub-clients (`ActivityClient`, `MemberClient`, `ReactionClient`) delegate to the core `ConversationClient` rather than duplicating HTTP logic. This ensures: + +- Single source of truth for URL construction, auth, and error handling +- Agents-channel ID truncation logic is preserved +- Agentic identity support works transparently +- Custom headers and logging from `ConversationClient` apply + +### Parameter bridging + +The Libraries-style API takes `(conversationId, activity)` as separate parameters, while the core `ConversationClient` expects context embedded in the activity or passed as method parameters. The sub-clients bridge this: + +``` +ActivityClient.CreateAsync(conversationId, activity) + → sets activity.ServiceUrl, activity.Conversation + → calls ConversationClient.SendActivityAsync(activity) + +MemberClient.GetAsync(conversationId) + → calls ConversationClient.GetConversationMembersAsync(conversationId, serviceUrl) + +ReactionClient.AddAsync(conversationId, activityId, reactionType) + → calls ConversationClient.AddReactionAsync(conversationId, activityId, reactionType, serviceUrl) +``` + +### Method mapping + +#### ActivityClient → ConversationClient + +| ActivityClient | ConversationClient | Notes | +|---|---|---| +| `CreateAsync(conversationId, activity)` | `SendActivityAsync(activity)` | Sets `ServiceUrl` and `Conversation` on activity | +| `UpdateAsync(conversationId, id, activity)` | `UpdateActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` on activity | +| `ReplyAsync(conversationId, id, activity)` | `SendActivityAsync(activity)` | Sets `ReplyToId`, `ServiceUrl`, `Conversation` | +| `DeleteAsync(conversationId, id)` | `DeleteActivityAsync(conversationId, id, serviceUrl)` | | +| `CreateTargetedAsync(conversationId, activity)` | `SendActivityAsync(activity)` | Sets `Recipient.IsTargeted = true` | +| `UpdateTargetedAsync(conversationId, id, activity)` | `UpdateTargetedActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` on activity | +| `DeleteTargetedAsync(conversationId, id)` | `DeleteTargetedActivityAsync(conversationId, id, serviceUrl)` | | + +#### MemberClient → ConversationClient + +| MemberClient | ConversationClient | +|---|---| +| `GetAsync(conversationId)` | `GetConversationMembersAsync(conversationId, serviceUrl)` | +| `GetByIdAsync(conversationId, memberId)` | `GetConversationMemberAsync(conversationId, memberId, serviceUrl)` | +| `GetByIdAsync(conversationId, memberId)` | `GetConversationMemberAsync(conversationId, memberId, serviceUrl)` | +| `DeleteAsync(conversationId, memberId)` | `DeleteConversationMemberAsync(conversationId, memberId, serviceUrl)` | + +#### ReactionClient → ConversationClient + +| ReactionClient | ConversationClient | +|---|---| +| `AddAsync(conversationId, activityId, reactionType)` | `AddReactionAsync(conversationId, activityId, reactionType, serviceUrl)` | +| `DeleteAsync(conversationId, activityId, reactionType)` | `DeleteReactionAsync(conversationId, activityId, reactionType, serviceUrl)` | + +#### V3ConversationClient → ConversationClient + +| V3ConversationClient | ConversationClient | +|---|---| +| `CreateAsync(parameters)` | `CreateConversationAsync(parameters, serviceUrl)` | + +## File Layout + +``` +core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ +├── ApiClient.cs Top-level facade, DI entry point +├── V3ConversationClient.cs Conversation facade → delegates to core ConversationClient +├── ActivityClient.cs Activity CRUD → delegates to core ConversationClient +├── MemberClient.cs Member operations → delegates to core ConversationClient +├── ReactionClient.cs Reaction operations → delegates to core ConversationClient +├── TeamClient.cs Team info → BotHttpClient (v3/teams/) +├── MeetingClient.cs Meeting info → BotHttpClient (v1/meetings/) + models +├── BotClient.cs Bot facade (groups SignIn) +├── BotSignInClient.cs Sign-in URLs → BotHttpClient (token.botframework.com) +├── BotTokenClient.cs Static scope constants +├── UserClient.cs User facade (groups Token) +└── V3UserTokenClient.cs User token ops → BotHttpClient (token.botframework.com) +``` + +## Integration with CompatTeamsInfo + +`CompatTeamsInfo` retrieves `ApiClient` from `TurnState` and uses it for Teams-specific operations (meetings, team details, channels). Member operations go through the core `ConversationClient` directly. + +The `CompatAdapter` should scope the `ApiClient` per-request before storing it in `TurnState`: + +```csharp +ApiClient scopedClient = _teamsBotApplication.TeamsApiClient.ForServiceUrl(new Uri(activity.ServiceUrl)); +turnContext.TurnState.Add(scopedClient); +``` + +## Future Work + +- **BatchClient**: Batch messaging operations (`SendMessageToListOfUsersAsync`, etc.) need a new sub-client on `ApiClient` using `BotHttpClient` for the `v3/batch/conversation/` endpoints. +- **MeetingClient.SendMeetingNotificationAsync**: Meeting notification support needs to be added along with notification model types. +- **DI registration**: `AddTeamsBotApplication` should register `ApiClient` using the DI-friendly constructor automatically. diff --git a/core/samples/CompatBot/EchoBot.cs b/core/samples/CompatBot/EchoBot.cs index 15876b53..eb2fb61e 100644 --- a/core/samples/CompatBot/EchoBot.cs +++ b/core/samples/CompatBot/EchoBot.cs @@ -36,10 +36,10 @@ protected override async Task OnMessageActivityAsync(ITurnContext>()); }); - // Register TeamsApiClient + // Register ApiClient (no serviceUrl — use ForServiceUrl per-request) services.AddSingleton(sp => { HttpClient httpClient = sp.GetRequiredService() .CreateClient("TeamsApiClient"); - return new ApiClient(new Uri("https://graph.microsoft.com/v1.0/"), httpClient, sp.GetRequiredService>()); // TODO: initialize the base URL + return new ApiClient(httpClient, sp.GetRequiredService(), sp.GetRequiredService>()); }); // Register TeamsBotApplication diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs index 1a1816f5..a26056b5 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs @@ -2,97 +2,105 @@ // Licensed under the MIT License. using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Http; using Microsoft.Teams.Bot.Core.Schema; +using CoreConversationClient = Microsoft.Teams.Bot.Core.ConversationClient; + namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// /// Client for creating, updating, and deleting activities in a conversation. +/// Delegates to the core . /// public class ActivityClient { - private readonly BotHttpClient _http; - private readonly string _serviceUrl; + private readonly CoreConversationClient _client; + private readonly Uri _serviceUrl; - internal ActivityClient(string serviceUrl, BotHttpClient http) + internal ActivityClient(Uri serviceUrl, CoreConversationClient client) { - _serviceUrl = serviceUrl.TrimEnd('/'); - _http = http; + _serviceUrl = serviceUrl; + _client = client; } /// /// Create a new activity in a conversation. /// - public async Task CreateAsync(string conversationId, CoreActivity activity, CancellationToken cancellationToken = default) + public Task CreateAsync(string conversationId, CoreActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities"; - string body = activity.ToJson(); - return await _http.SendAsync(HttpMethod.Post, url, body, null, cancellationToken).ConfigureAwait(false); + EnsureActivityContext(activity, conversationId); + return _client.SendActivityAsync(activity, cancellationToken: cancellationToken); } /// /// Update an existing activity in a conversation. /// - public async Task UpdateAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) + public Task UpdateAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(id)}"; - string body = activity.ToJson(); - return await _http.SendAsync(HttpMethod.Put, url, body, null, cancellationToken).ConfigureAwait(false); + activity.ServiceUrl ??= _serviceUrl; + return _client.UpdateActivityAsync(conversationId, id, activity, cancellationToken: cancellationToken); } /// /// Reply to an existing activity in a conversation. /// - public async Task ReplyAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) + public Task ReplyAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); activity.ReplyToId = id; - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(id)}"; - string body = activity.ToJson(); - return await _http.SendAsync(HttpMethod.Post, url, body, null, cancellationToken).ConfigureAwait(false); + EnsureActivityContext(activity, conversationId); + return _client.SendActivityAsync(activity, cancellationToken: cancellationToken); } /// /// Delete an activity from a conversation. /// - public async Task DeleteAsync(string conversationId, string id, CancellationToken cancellationToken = default) + public Task DeleteAsync(string conversationId, string id, CancellationToken cancellationToken = default) { - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(id)}"; - await _http.SendAsync(HttpMethod.Delete, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return _client.DeleteActivityAsync(conversationId, id, _serviceUrl, cancellationToken: cancellationToken); } /// /// Create a new targeted activity in a conversation. /// Targeted activities are only visible to the specified recipient. /// - public async Task CreateTargetedAsync(string conversationId, CoreActivity activity, CancellationToken cancellationToken = default) + public Task CreateTargetedAsync(string conversationId, CoreActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities?isTargetedActivity=true"; - string body = activity.ToJson(); - return await _http.SendAsync(HttpMethod.Post, url, body, null, cancellationToken).ConfigureAwait(false); + EnsureActivityContext(activity, conversationId); + EnsureTargeted(activity); + return _client.SendActivityAsync(activity, cancellationToken: cancellationToken); } /// /// Update an existing targeted activity in a conversation. /// - public async Task UpdateTargetedAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) + public Task UpdateTargetedAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(id)}?isTargetedActivity=true"; - string body = activity.ToJson(); - return await _http.SendAsync(HttpMethod.Put, url, body, null, cancellationToken).ConfigureAwait(false); + activity.ServiceUrl ??= _serviceUrl; + return _client.UpdateTargetedActivityAsync(conversationId, id, activity, cancellationToken: cancellationToken); } /// /// Delete a targeted activity from a conversation. /// - public async Task DeleteTargetedAsync(string conversationId, string id, CancellationToken cancellationToken = default) + public Task DeleteTargetedAsync(string conversationId, string id, CancellationToken cancellationToken = default) + { + return _client.DeleteTargetedActivityAsync(conversationId, id, _serviceUrl, cancellationToken: cancellationToken); + } + + private void EnsureActivityContext(CoreActivity activity, string conversationId) + { + activity.ServiceUrl ??= _serviceUrl; + activity.Conversation ??= new Conversation(conversationId); + } + + private static void EnsureTargeted(CoreActivity activity) { - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(id)}?isTargetedActivity=true"; - await _http.SendAsync(HttpMethod.Delete, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + activity.Recipient ??= new ConversationAccount(); + activity.Recipient.IsTargeted = true; } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs index 556c023d..f8585846 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Teams.Bot.Core.Http; +using CoreConversationClient = Microsoft.Teams.Bot.Core.ConversationClient; + namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// @@ -14,15 +17,16 @@ namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// This client can be constructed in two ways: /// /// -/// DI-friendly (no serviceUrl) — Use +/// DI-friendly (no serviceUrl) — Use /// and call per-request to create a scoped instance. -/// Fully initialized — Use +/// Fully initialized — Use /// when the service URL is known upfront. /// /// public class ApiClient { private readonly BotHttpClient _http; + private readonly CoreConversationClient _conversationClient; private readonly string _tokenApiEndpoint; /// @@ -62,13 +66,17 @@ public class ApiClient /// 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. /// Optional logger. /// Optional token API endpoint override. Defaults to https://token.botframework.com. - public ApiClient(HttpClient httpClient, ILogger? logger = null, string tokenApiEndpoint = "https://token.botframework.com") + [ActivatorUtilitiesConstructor] + public ApiClient(HttpClient httpClient, CoreConversationClient conversationClient, ILogger? logger = null, string tokenApiEndpoint = "https://token.botframework.com") { ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(conversationClient); _http = new BotHttpClient(httpClient, logger); + _conversationClient = conversationClient; _tokenApiEndpoint = tokenApiEndpoint; Bots = new BotClient(_http, tokenApiEndpoint); Users = new UserClient(_http, tokenApiEndpoint); @@ -85,22 +93,24 @@ public ApiClient(HttpClient httpClient, ILogger? logger = null, string tokenApiE /// /// The Bot Framework service URL. /// An configured with authentication (e.g., via DI with BotAuthenticationHandler). + /// The core conversation client for conversation/activity/member operations. /// Optional logger. /// Optional token API endpoint override. Defaults to https://token.botframework.com. - public ApiClient(Uri serviceUrl, HttpClient httpClient, ILogger? logger = null, string tokenApiEndpoint = "https://token.botframework.com") + public ApiClient(Uri serviceUrl, HttpClient httpClient, CoreConversationClient conversationClient, ILogger? logger = null, string tokenApiEndpoint = "https://token.botframework.com") { ArgumentNullException.ThrowIfNull(serviceUrl); ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(conversationClient); - string url = serviceUrl.ToString(); _http = new BotHttpClient(httpClient, logger); + _conversationClient = conversationClient; _tokenApiEndpoint = tokenApiEndpoint; ServiceUrl = serviceUrl; Bots = new BotClient(_http, tokenApiEndpoint); - Conversations = new V3ConversationClient(url, _http); + Conversations = new V3ConversationClient(serviceUrl, conversationClient); Users = new UserClient(_http, tokenApiEndpoint); - Teams = new TeamClient(url, _http); - Meetings = new MeetingClient(url, _http); + Teams = new TeamClient(serviceUrl.ToString(), _http); + Meetings = new MeetingClient(serviceUrl.ToString(), _http); } /// @@ -112,6 +122,7 @@ public ApiClient(ApiClient client) ServiceUrl = client.ServiceUrl; _http = client._http; + _conversationClient = client._conversationClient; _tokenApiEndpoint = client._tokenApiEndpoint; Bots = client.Bots; Conversations = client.Conversations; @@ -120,18 +131,18 @@ public ApiClient(ApiClient client) Meetings = client.Meetings; } - // Private constructor for ForServiceUrl — shares BotHttpClient - private ApiClient(BotHttpClient http, string tokenApiEndpoint, Uri serviceUrl) + // Private constructor for ForServiceUrl — shares BotHttpClient and ConversationClient + private ApiClient(BotHttpClient http, CoreConversationClient conversationClient, string tokenApiEndpoint, Uri serviceUrl) { _http = http; + _conversationClient = conversationClient; _tokenApiEndpoint = tokenApiEndpoint; ServiceUrl = serviceUrl; - string url = serviceUrl.ToString(); Bots = new BotClient(http, tokenApiEndpoint); - Conversations = new V3ConversationClient(url, http); + Conversations = new V3ConversationClient(serviceUrl, conversationClient); Users = new UserClient(http, tokenApiEndpoint); - Teams = new TeamClient(url, http); - Meetings = new MeetingClient(url, http); + Teams = new TeamClient(serviceUrl.ToString(), http); + Meetings = new MeetingClient(serviceUrl.ToString(), http); } /// @@ -143,6 +154,6 @@ private ApiClient(BotHttpClient http, string tokenApiEndpoint, Uri serviceUrl) public virtual ApiClient ForServiceUrl(Uri serviceUrl) { ArgumentNullException.ThrowIfNull(serviceUrl); - return new ApiClient(_http, _tokenApiEndpoint, serviceUrl); + return new ApiClient(_http, _conversationClient, _tokenApiEndpoint, serviceUrl); } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs index 0db11af8..389c23da 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs @@ -1,49 +1,56 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Bot.Core.Http; using Microsoft.Teams.Bot.Core.Schema; +using CoreConversationClient = Microsoft.Teams.Bot.Core.ConversationClient; + namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// /// Client for managing conversation members. +/// Delegates to the core . /// public class MemberClient { - private readonly BotHttpClient _http; - private readonly string _serviceUrl; + private readonly CoreConversationClient _client; + private readonly Uri _serviceUrl; - internal MemberClient(string serviceUrl, BotHttpClient http) + internal MemberClient(Uri serviceUrl, CoreConversationClient client) { - _serviceUrl = serviceUrl.TrimEnd('/'); - _http = http; + _serviceUrl = serviceUrl; + _client = client; } /// /// Get all members of a conversation. /// - public async Task?> GetAsync(string conversationId, CancellationToken cancellationToken = default) + public Task> GetAsync(string conversationId, CancellationToken cancellationToken = default) + { + return _client.GetConversationMembersAsync(conversationId, _serviceUrl, cancellationToken: cancellationToken); + } + + /// + /// Get a specific member of a conversation by ID. + /// + public Task GetByIdAsync(string conversationId, string memberId, CancellationToken cancellationToken = default) where T : ConversationAccount { - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/members"; - return await _http.SendAsync>(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return _client.GetConversationMemberAsync(conversationId, memberId, _serviceUrl, cancellationToken: cancellationToken); } /// /// Get a specific member of a conversation by ID. /// - public async Task GetByIdAsync(string conversationId, string memberId, CancellationToken cancellationToken = default) + public Task GetByIdAsync(string conversationId, string memberId, CancellationToken cancellationToken = default) { - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/members/{Uri.EscapeDataString(memberId)}"; - return await _http.SendAsync(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return GetByIdAsync(conversationId, memberId, cancellationToken); } /// /// Remove a member from a conversation. /// - public async Task DeleteAsync(string conversationId, string memberId, CancellationToken cancellationToken = default) + public Task DeleteAsync(string conversationId, string memberId, CancellationToken cancellationToken = default) { - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/members/{Uri.EscapeDataString(memberId)}"; - await _http.SendAsync(HttpMethod.Delete, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return _client.DeleteConversationMemberAsync(conversationId, memberId, _serviceUrl, cancellationToken: cancellationToken); } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs index 4ceea16c..2efd632d 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs @@ -1,22 +1,23 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Bot.Core.Http; +using CoreConversationClient = Microsoft.Teams.Bot.Core.ConversationClient; namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// /// Client for managing reactions on activities in a conversation. +/// Delegates to the core . /// public class ReactionClient { - private readonly BotHttpClient _http; - private readonly string _serviceUrl; + private readonly CoreConversationClient _client; + private readonly Uri _serviceUrl; - internal ReactionClient(string serviceUrl, BotHttpClient http) + internal ReactionClient(Uri serviceUrl, CoreConversationClient client) { - _serviceUrl = serviceUrl.TrimEnd('/'); - _http = http; + _serviceUrl = serviceUrl; + _client = client; } /// @@ -26,10 +27,9 @@ internal ReactionClient(string serviceUrl, BotHttpClient http) /// The id of the activity to react to. /// The reaction type (for example: "like", "heart", "laugh", etc.). /// A to observe while waiting for the task to complete. - public async Task AddAsync(string conversationId, string activityId, string reactionType, CancellationToken cancellationToken = default) + public Task AddAsync(string conversationId, string activityId, string reactionType, CancellationToken cancellationToken = default) { - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}/reactions/{Uri.EscapeDataString(reactionType)}"; - await _http.SendAsync(HttpMethod.Put, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return _client.AddReactionAsync(conversationId, activityId, reactionType, _serviceUrl, cancellationToken: cancellationToken); } /// @@ -39,9 +39,8 @@ public async Task AddAsync(string conversationId, string activityId, string reac /// The id of the activity the reaction is on. /// The reaction type to remove (for example: "like", "heart", "laugh", etc.). /// A to observe while waiting for the task to complete. - public async Task DeleteAsync(string conversationId, string activityId, string reactionType, CancellationToken cancellationToken = default) + public Task DeleteAsync(string conversationId, string activityId, string reactionType, CancellationToken cancellationToken = default) { - string url = $"{_serviceUrl}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}/reactions/{Uri.EscapeDataString(reactionType)}"; - await _http.SendAsync(HttpMethod.Delete, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return _client.DeleteReactionAsync(conversationId, activityId, reactionType, _serviceUrl, cancellationToken: cancellationToken); } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs index 65cd2568..a2ea6e36 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs @@ -1,31 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json; using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Http; -using Microsoft.Teams.Bot.Core.Schema; + +using CoreConversationClient = Microsoft.Teams.Bot.Core.ConversationClient; namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// /// Client for managing conversations, exposing sub-clients for activities, members, and reactions. +/// Delegates to the core . /// public class V3ConversationClient { - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; - - private readonly BotHttpClient _http; - private readonly string _serviceUrl; - - /// - /// The service URL for this conversation client. - /// - internal string ServiceUrlString => _serviceUrl; + private readonly CoreConversationClient _client; + private readonly Uri _serviceUrl; /// /// Client for activity operations. @@ -42,22 +31,20 @@ public class V3ConversationClient /// public ReactionClient Reactions { get; } - internal V3ConversationClient(string serviceUrl, BotHttpClient http) + internal V3ConversationClient(Uri serviceUrl, CoreConversationClient client) { - _serviceUrl = serviceUrl.TrimEnd('/'); - _http = http; - Activities = new ActivityClient(serviceUrl, http); - Members = new MemberClient(serviceUrl, http); - Reactions = new ReactionClient(serviceUrl, http); + _serviceUrl = serviceUrl; + _client = client; + Activities = new ActivityClient(serviceUrl, client); + Members = new MemberClient(serviceUrl, client); + Reactions = new ReactionClient(serviceUrl, client); } /// /// Create a new conversation. /// - public async Task CreateAsync(ConversationParameters request, CancellationToken cancellationToken = default) + public Task CreateAsync(ConversationParameters request, CancellationToken cancellationToken = default) { - string url = $"{_serviceUrl}/v3/conversations"; - string body = JsonSerializer.Serialize(request, JsonOptions); - return await _http.SendAsync(HttpMethod.Post, url, body, null, cancellationToken).ConfigureAwait(false); + return _client.CreateConversationAsync(request, _serviceUrl, cancellationToken: cancellationToken); } } diff --git a/core/test/IntegrationTests/IntegrationTests.csproj b/core/test/IntegrationTests/IntegrationTests.csproj new file mode 100644 index 00000000..88acad69 --- /dev/null +++ b/core/test/IntegrationTests/IntegrationTests.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs index a8d6c461..5ed759bf 100644 --- a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs @@ -79,7 +79,7 @@ private static (CompatAdapter, ApiClient) CreateCompatAdapter() mockConfig.Setup(c => c["UserTokenApiEndpoint"]).Returns("https://token.botframework.com"); UserTokenClient userTokenClient = new(httpClient, mockConfig.Object, NullLogger.Instance); - ApiClient teamsApiClient = new(new Uri("https://service.url"), httpClient, NullLogger.Instance); + ApiClient teamsApiClient = new(new Uri("https://service.url"), httpClient, conversationClient, NullLogger.Instance); TeamsBotApplication teamsBotApplication = new( conversationClient, diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs index 0268fbed..19c58dae 100644 --- a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs @@ -259,6 +259,7 @@ private static Mock CreateMockTeamsBotApplication() ApiClient mockTeamsApiClient = new( new Uri("https://service.url"), new HttpClient(), + mockConversationClient.Object, NullLogger.Instance); Mock mock = new( @@ -280,6 +281,7 @@ private static CompatBotAdapter CreateCompatBotAdapter(ConversationClient conver ApiClient mockTeamsApiClient = new( new Uri("https://service.url"), new HttpClient(), + conversationClient, NullLogger.Instance); TeamsBotApplication teamsBotApplication = new( conversationClient, From 72a3289fbaea7b451f970188afcc7bdc62c0f569 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Fri, 17 Apr 2026 10:07:39 -0700 Subject: [PATCH 05/22] Restart APIClients --- core/core.slnx | 2 +- core/docs/ApiClient-Design.md | 71 ++- core/docs/CompatTeamsInfo-API-Mapping.md | 220 ++++----- core/docs/CreateConversation-API-Behavior.md | 432 ++++++++++++++++++ core/samples/DiagBot/DiagBot.csproj | 17 + core/samples/DiagBot/Program.cs | 66 +++ core/samples/DiagBot/appsettings.json | 9 + core/samples/TeamsBot/Program.cs | 10 +- .../Api/Clients/TeamClient.cs | 10 +- core/src/Microsoft.Teams.Bot.Apps/Context.cs | 9 + core/test/IntegrationTests/ApiClientTests.cs | 267 +++++++++++ .../ConversationClientTests.cs | 164 +++++++ .../CreateConversationDiagnosticTests.cs | 330 +++++++++++++ .../CreateConversationTests.cs | 372 +++++++++++++++ .../IntegrationTestFixture.cs | 92 ++++ .../IntegrationTests/IntegrationTests.csproj | 11 +- 16 files changed, 1922 insertions(+), 160 deletions(-) create mode 100644 core/docs/CreateConversation-API-Behavior.md create mode 100644 core/samples/DiagBot/DiagBot.csproj create mode 100644 core/samples/DiagBot/Program.cs create mode 100644 core/samples/DiagBot/appsettings.json create mode 100644 core/test/IntegrationTests/ApiClientTests.cs create mode 100644 core/test/IntegrationTests/ConversationClientTests.cs create mode 100644 core/test/IntegrationTests/CreateConversationDiagnosticTests.cs create mode 100644 core/test/IntegrationTests/CreateConversationTests.cs create mode 100644 core/test/IntegrationTests/IntegrationTestFixture.cs diff --git a/core/core.slnx b/core/core.slnx index 51337417..d20009c7 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -16,6 +16,7 @@ + @@ -38,7 +39,6 @@ - diff --git a/core/docs/ApiClient-Design.md b/core/docs/ApiClient-Design.md index 61b1e835..693ff007 100644 --- a/core/docs/ApiClient-Design.md +++ b/core/docs/ApiClient-Design.md @@ -28,34 +28,49 @@ ApiClient (top-level facade) | Teams, Meetings | Uses `BotHttpClient` directly | No core client equivalent exists for these endpoints | | Bots.SignIn, Users.Token | Uses `BotHttpClient` directly | Calls `token.botframework.com`, separate from conversation endpoints | -## Construction Patterns +## Construction & Scoping -### DI-friendly (no serviceUrl) +### The serviceUrl problem + +The Bot Framework service URL is per-request (comes from `activity.ServiceUrl`), but `ApiClient` is per-application (DI singleton). The `ApiClient` solves this with a two-step pattern: + +1. **DI registration** creates a base `ApiClient` without a serviceUrl +2. **Per-request**, `ForServiceUrl(uri)` creates a lightweight scoped copy with all sub-clients bound + +### DI-friendly constructor (no serviceUrl) + +```csharp +// Registered automatically by AddTeamsBotApplication() +// The [ActivatorUtilitiesConstructor] attribute tells DI to prefer this constructor +public ApiClient(HttpClient httpClient, ConversationClient conversationClient, ILogger? logger = null, ...) +``` + +`AddTeamsBotApplication()` calls `AddBotClient(...)` which registers `ApiClient` as a typed HTTP client with `BotAuthenticationHandler`. The `ConversationClient` dependency is resolved from DI automatically. + +**Important:** The base `ApiClient` has `Conversations`, `Teams`, and `Meetings` set to `null`. Accessing them directly causes `NullReferenceException`. Always use `ForServiceUrl()` or `Context.Api` to get a scoped instance. + +### Per-request scoping via Context.Api + +In activity handlers, use the `Context.Api` property which auto-scopes to the current activity's service URL: ```csharp -// Startup — registered via AddTeamsBotApplication() or manually -services.AddSingleton(sp => +// In a handler — Context.Api is lazy-initialized via ForServiceUrl(Activity.ServiceUrl) +botApp.OnMessage(async (ctx, ct) => { - HttpClient httpClient = sp.GetRequiredService().CreateClient("ApiClient"); - ConversationClient conversationClient = sp.GetRequiredService(); - return new ApiClient(httpClient, conversationClient, sp.GetRequiredService>()); + var members = await ctx.Api.Conversations.Members.GetAsync(conversationId, ct); + var team = await ctx.Api.Teams.GetByIdAsync(teamId, ct); }); ``` -The `[ActivatorUtilitiesConstructor]` attribute marks this as the preferred constructor for DI, avoiding ambiguity with the fully-initialized constructor. +**Do NOT use `ctx.TeamsBotApplication.Api.Conversations`** — that is the unscoped base client and will throw `NullReferenceException`. -ServiceUrl-dependent sub-clients (`Conversations`, `Teams`, `Meetings`) are `null` until `ForServiceUrl` is called. +### ForServiceUrl (explicit scoping) -### Per-request scoping +For code outside handlers (e.g., proactive messaging, compat layer): ```csharp -// Per-request — creates a lightweight copy with serviceUrl-bound sub-clients ApiClient scoped = baseApiClient.ForServiceUrl(activity.ServiceUrl); - -// Now safe to use await scoped.Conversations.Activities.CreateAsync(conversationId, activity); -await scoped.Teams.GetByIdAsync(teamId); -await scoped.Meetings.GetByIdAsync(meetingId); ``` `ForServiceUrl` shares the underlying `BotHttpClient` and `ConversationClient` — only the sub-client wrappers are new allocations. @@ -70,7 +85,7 @@ ApiClient client = new( logger); ``` -## Delegation Pattern (Option C) +## Delegation Pattern The conversation sub-clients (`ActivityClient`, `MemberClient`, `ReactionClient`) delegate to the core `ConversationClient` rather than duplicating HTTP logic. This ensures: @@ -135,7 +150,7 @@ ReactionClient.AddAsync(conversationId, activityId, reactionType) ``` core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ -├── ApiClient.cs Top-level facade, DI entry point +├── ApiClient.cs Top-level facade, DI entry point, ForServiceUrl factory ├── V3ConversationClient.cs Conversation facade → delegates to core ConversationClient ├── ActivityClient.cs Activity CRUD → delegates to core ConversationClient ├── MemberClient.cs Member operations → delegates to core ConversationClient @@ -149,11 +164,28 @@ core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ └── V3UserTokenClient.cs User token ops → BotHttpClient (token.botframework.com) ``` +## Integration with Context and Handlers + +The `Context` class exposes a lazy `Api` property: + +```csharp +public ApiClient Api => _api ??= TeamsBotApplication.Api.ForServiceUrl(Activity.ServiceUrl); +``` + +This is the primary way handlers should access the API clients. It ensures the scoped `ApiClient` is created once per request and reused across multiple calls within the same handler. + ## Integration with CompatTeamsInfo -`CompatTeamsInfo` retrieves `ApiClient` from `TurnState` and uses it for Teams-specific operations (meetings, team details, channels). Member operations go through the core `ConversationClient` directly. +`CompatTeamsInfo` retrieves `ApiClient` from `TurnState` and uses sub-clients for Teams-specific operations: + +- `client.Meetings.GetByIdAsync(meetingId)` — meeting info +- `client.Meetings.GetParticipantAsync(meetingId, participantId, tenantId)` — meeting participant +- `client.Teams.GetByIdAsync(teamId)` — team details +- `client.Teams.GetConversationsAsync(teamId)` — channel list + +Member operations go through the core `ConversationClient` directly (not via `ApiClient`). -The `CompatAdapter` should scope the `ApiClient` per-request before storing it in `TurnState`: +The `CompatAdapter` should scope the `ApiClient` before storing it in `TurnState`: ```csharp ApiClient scopedClient = _teamsBotApplication.TeamsApiClient.ForServiceUrl(new Uri(activity.ServiceUrl)); @@ -164,4 +196,3 @@ turnContext.TurnState.Add(scopedClient); - **BatchClient**: Batch messaging operations (`SendMessageToListOfUsersAsync`, etc.) need a new sub-client on `ApiClient` using `BotHttpClient` for the `v3/batch/conversation/` endpoints. - **MeetingClient.SendMeetingNotificationAsync**: Meeting notification support needs to be added along with notification model types. -- **DI registration**: `AddTeamsBotApplication` should register `ApiClient` using the DI-friendly constructor automatically. diff --git a/core/docs/CompatTeamsInfo-API-Mapping.md b/core/docs/CompatTeamsInfo-API-Mapping.md index 51301610..37c20af9 100644 --- a/core/docs/CompatTeamsInfo-API-Mapping.md +++ b/core/docs/CompatTeamsInfo-API-Mapping.md @@ -13,55 +13,53 @@ The `CompatTeamsInfo` class provides a compatibility layer that adapts the Bot F | Method | REST Endpoint | Client | Description | |--------|--------------|--------|-------------| | `GetMemberAsync` | `GET /v3/conversations/{conversationId}/members/{userId}` | ConversationClient | Gets a single conversation member by user ID | -| `GetMembersAsync` ⚠️ | `GET /v3/conversations/{conversationId}/members` | ConversationClient | Gets all conversation members (deprecated - use paged version) | +| `GetMembersAsync` | `GET /v3/conversations/{conversationId}/members` | ConversationClient | Gets all conversation members (deprecated) | | `GetPagedMembersAsync` | `GET /v3/conversations/{conversationId}/pagedmembers?pageSize={pageSize}&continuationToken={token}` | ConversationClient | Gets paginated list of conversation members | | `GetTeamMemberAsync` | `GET /v3/conversations/{teamId}/members/{userId}` | ConversationClient | Gets a single team member by user ID | -| `GetTeamMembersAsync` ⚠️ | `GET /v3/conversations/{teamId}/members` | ConversationClient | Gets all team members (deprecated - use paged version) | +| `GetTeamMembersAsync` | `GET /v3/conversations/{teamId}/members` | ConversationClient | Gets all team members (deprecated) | | `GetPagedTeamMembersAsync` | `GET /v3/conversations/{teamId}/pagedmembers?pageSize={pageSize}&continuationToken={token}` | ConversationClient | Gets paginated list of team members | -⚠️ *Deprecated by Microsoft Teams - use paged versions instead* +> `GetMembersAsync` and `GetTeamMembersAsync` are deprecated by Microsoft Teams. Use paged versions instead. ### Meeting Methods -| Method | REST Endpoint | Client | Description | -|--------|--------------|--------|-------------| -| `GetMeetingInfoAsync` | `GET /v1/meetings/{meetingId}` | TeamsApiClient | Gets meeting information by meeting ID | -| `GetMeetingParticipantAsync` | `GET /v1/meetings/{meetingId}/participants/{participantId}?tenantId={tenantId}` | TeamsApiClient | Gets a specific meeting participant's information | -| `SendMeetingNotificationAsync` | `POST /v1/meetings/{meetingId}/notification` | TeamsApiClient | Sends an in-meeting notification to participants | +| Method | REST Endpoint | Client | Status | +|--------|--------------|--------|--------| +| `GetMeetingInfoAsync` | `GET /v1/meetings/{meetingId}` | ApiClient.Meetings | Implemented | +| `GetMeetingParticipantAsync` | `GET /v1/meetings/{meetingId}/participants/{participantId}?tenantId={tenantId}` | ApiClient.Meetings | Implemented | +| `SendMeetingNotificationAsync` | `POST /v1/meetings/{meetingId}/notification` | — | Not yet implemented (commented out) | ### Team & Channel Methods -| Method | REST Endpoint | Client | Description | -|--------|--------------|--------|-------------| -| `GetTeamDetailsAsync` | `GET /v3/teams/{teamId}` | TeamsApiClient | Gets detailed information about a team | -| `GetTeamChannelsAsync` | `GET /v3/teams/{teamId}/channels` | TeamsApiClient | Gets list of channels in a team | +| Method | REST Endpoint | Client | Status | +|--------|--------------|--------|--------| +| `GetTeamDetailsAsync` | `GET /v3/teams/{teamId}` | ApiClient.Teams | Needs update: calls `client.FetchTeamDetailsAsync()` which doesn't exist. Should use `client.Teams.GetByIdAsync()` | +| `GetTeamChannelsAsync` | `GET /v3/teams/{teamId}/conversations` | ApiClient.Teams | Needs update: calls `client.FetchChannelListAsync()` which doesn't exist. Should use `client.Teams.GetConversationsAsync()` | ### Batch Messaging Methods -| Method | REST Endpoint | Client | Description | -|--------|--------------|--------|-------------| -| `SendMessageToListOfUsersAsync` | `POST /v3/batch/conversation/users/` | TeamsApiClient | Sends a message to a list of users | -| `SendMessageToListOfChannelsAsync` | `POST /v3/batch/conversation/channels/` | TeamsApiClient | Sends a message to a list of channels | -| `SendMessageToAllUsersInTeamAsync` | `POST /v3/batch/conversation/team/` | TeamsApiClient | Sends a message to all users in a team | -| `SendMessageToAllUsersInTenantAsync` | `POST /v3/batch/conversation/tenant/` | TeamsApiClient | Sends a message to all users in a tenant | -| `SendMessageToTeamsChannelAsync` | Uses Bot Framework Adapter | BotAdapter.CreateConversationAsync | Creates a conversation in a Teams channel and sends a message | +| Method | REST Endpoint | Client | Status | +|--------|--------------|--------|--------| +| `SendMessageToListOfUsersAsync` | `POST /v3/batch/conversation/users/` | — | Implemented in CompatTeamsInfo, but calls methods that don't exist on ApiClient yet (needs BatchClient) | +| `SendMessageToListOfChannelsAsync` | `POST /v3/batch/conversation/channels/` | — | Same — needs BatchClient | +| `SendMessageToAllUsersInTeamAsync` | `POST /v3/batch/conversation/team/` | — | Same — needs BatchClient | +| `SendMessageToAllUsersInTenantAsync` | `POST /v3/batch/conversation/tenant/` | — | Same — needs BatchClient | +| `SendMessageToTeamsChannelAsync` | Uses Bot Framework Adapter | BotAdapter.CreateConversationAsync | Implemented — does not use ApiClient | ### Batch Operation Management Methods -| Method | REST Endpoint | Client | Description | -|--------|--------------|--------|-------------| -| `GetOperationStateAsync` | `GET /v3/batch/conversation/{operationId}` | TeamsApiClient | Gets the state of a batch operation | -| `GetPagedFailedEntriesAsync` | `GET /v3/batch/conversation/failedentries/{operationId}?continuationToken={token}` | TeamsApiClient | Gets failed entries from a batch operation | -| `CancelOperationAsync` | `DELETE /v3/batch/conversation/{operationId}` | TeamsApiClient | Cancels a batch operation | +| Method | REST Endpoint | Client | Status | +|--------|--------------|--------|--------| +| `GetOperationStateAsync` | `GET /v3/batch/conversation/{operationId}` | — | Calls methods that don't exist on ApiClient yet (needs BatchClient) | +| `GetPagedFailedEntriesAsync` | `GET /v3/batch/conversation/failedentries/{operationId}?continuationToken={token}` | — | Same — needs BatchClient | +| `CancelOperationAsync` | `DELETE /v3/batch/conversation/{operationId}` | — | Same — needs BatchClient | ## Client Distribution -The implementation uses two primary clients from the Teams Bot Core SDK: +### ConversationClient (6 methods) — Working -### ConversationClient (6 methods) -Used for member and participant operations in conversations and teams. Accessed via the `IConnectorClient` in TurnState. +Used for member and participant operations in conversations and teams. Accessed via the `CompatConnectorClient` in TurnState. -**Methods:** - GetMemberAsync - GetMembersAsync - GetPagedMembersAsync @@ -69,15 +67,24 @@ Used for member and participant operations in conversations and teams. Accessed - GetTeamMembersAsync - GetPagedTeamMembersAsync -### TeamsApiClient (12 methods) -Used for Teams-specific operations including meetings, team details, channels, and batch messaging. Added to TurnState by the CompatAdapter. +### ApiClient sub-clients (4 methods) — Working + +`ApiClient` is stored in TurnState by `CompatAdapter`. Must be scoped to serviceUrl before use. Uses sub-clients: + +- `ApiClient.Meetings.GetByIdAsync()` — GetMeetingInfoAsync +- `ApiClient.Meetings.GetParticipantAsync()` — GetMeetingParticipantAsync +- `ApiClient.Teams.GetByIdAsync()` — GetTeamDetailsAsync (needs rewiring from `FetchTeamDetailsAsync`) +- `ApiClient.Teams.GetConversationsAsync()` — GetTeamChannelsAsync (needs rewiring from `FetchChannelListAsync`) + +### Bot Framework Adapter (1 method) — Working -**Methods:** -- GetMeetingInfoAsync -- GetMeetingParticipantAsync -- SendMeetingNotificationAsync -- GetTeamDetailsAsync -- GetTeamChannelsAsync +- SendMessageToTeamsChannelAsync — uses `turnContext.Adapter.CreateConversationAsync()` + +### Not yet implemented (8 methods) + +These methods exist in `CompatTeamsInfo` but call ApiClient methods that don't exist yet. They need a new `BatchClient` sub-client and `MeetingClient.SendMeetingNotificationAsync`: + +- SendMeetingNotificationAsync (commented out) - SendMessageToListOfUsersAsync - SendMessageToListOfChannelsAsync - SendMessageToAllUsersInTeamAsync @@ -86,114 +93,67 @@ Used for Teams-specific operations including meetings, team details, channels, a - GetPagedFailedEntriesAsync - CancelOperationAsync -### Bot Framework Adapter (1 method) -One method uses the Bot Framework adapter directly for backward compatibility. - -**Methods:** -- SendMessageToTeamsChannelAsync - -## Implementation Details - -### Model Conversion Strategy - -The implementation uses two strategies for converting between Bot Framework and Core SDK models: - -1. **Direct Property Mapping**: For simple models like `TeamsChannelAccount`, `ChannelInfo`, etc. -2. **JSON Round-Trip**: For complex models like `TeamDetails`, `MeetingNotificationResponse`, `BatchOperationState`, etc. - -### Type Conversions - -Key extension methods in `CompatActivity.cs`: - -| Extension Method | Source Type | Target Type | Strategy | -|------------------|-------------|-------------|----------| -| `ToCompatTeamsChannelAccount` | Core TeamsConversationAccount | BF TeamsChannelAccount | Direct mapping | -| `ToCompatMeetingInfo` | Core MeetingInfo | BF MeetingInfo | Direct mapping | -| `ToCompatTeamsMeetingParticipant` | Core MeetingParticipant | BF TeamsMeetingParticipant | Direct mapping | -| `ToCompatChannelInfo` | Core Channel | BF ChannelInfo | Direct mapping | -| `ToCompatTeamsPagedMembersResult` | Core PagedMembersResult | BF TeamsPagedMembersResult | Direct mapping | -| `ToCompatTeamDetails` | Core TeamDetails | BF TeamDetails | JSON round-trip | -| `ToCompatMeetingNotificationResponse` | Core MeetingNotificationResponse | BF MeetingNotificationResponse | JSON round-trip | -| `ToCompatBatchOperationState` | Core BatchOperationState | BF BatchOperationState | JSON round-trip | -| `ToCompatBatchFailedEntriesResponse` | Core BatchFailedEntriesResponse | BF BatchFailedEntriesResponse | JSON round-trip | -| `FromCompatTeamMember` | BF TeamMember | Core TeamMember | JSON round-trip | - -### Authentication +## Migration Checklist + +| Item | Status | +|---|---| +| Member operations via ConversationClient | Done | +| Meeting info via ApiClient.Meetings | Done | +| Meeting participant via ApiClient.Meetings | Done | +| Team details via ApiClient.Teams | Needs rewiring in CompatTeamsInfo | +| Team channels via ApiClient.Teams | Needs rewiring in CompatTeamsInfo | +| SendMessageToTeamsChannelAsync via adapter | Done | +| Batch messaging (4 methods) | Needs BatchClient on ApiClient | +| Batch operations (3 methods) | Needs BatchClient on ApiClient | +| Meeting notifications | Needs MeetingClient.SendMeetingNotificationAsync | +| CompatAdapter scopes ApiClient per-request | Needs update to call ForServiceUrl | + +## Type Conversions + +Key extension methods in `CompatActivity.cs` and `CompatTeamsInfo.Models.cs`: + +| Extension Method | Source Type | Target Type | Status | +|---|---|---|---| +| `ToCompatTeamsChannelAccount` | `ConversationAccount` | BF `TeamsChannelAccount` | Working | +| `ToCompatTeamsPagedMembersResult` | `PagedMembersResult` | BF `TeamsPagedMembersResult` | Working | +| `ToCompatTeamDetails` | `Apps.Schema.Team` | BF `TeamDetails` | Working | +| `ToCompatTeamsMeetingParticipant` | `MeetingParticipant` | BF `TeamsMeetingParticipant` | Working | +| `ToCompatChannelInfo` | `TeamsChannel` | BF `ChannelInfo` | Working | +| `ToCompatBatchOperationState` | `BatchOperationState` | BF `BatchOperationState` | Commented out — needs `BatchOperationState` model | +| `ToCompatBatchFailedEntriesResponse` | `BatchFailedEntriesResponse` | BF `BatchFailedEntriesResponse` | Commented out — needs models | +| `ToCompatMeetingNotificationResponse` | `MeetingNotificationResponse` | BF `MeetingNotificationResponse` | Commented out — needs models | +| `FromCompatTeamMember` | BF `TeamMember` | `Apps.TeamMember` | Commented out — needs `TeamMember` model | + +## Authentication All methods use `AgenticIdentity` extracted from the turn context activity properties for authentication with the Teams services. -### Service URL - -All API calls use the service URL from the turn context activity (`turnContext.Activity.ServiceUrl`), which points to the appropriate Teams channel service endpoint. +## Service URL -## Usage Examples - -### Getting a Team Member +All API calls use the service URL from the turn context activity (`turnContext.Activity.ServiceUrl`). For `ApiClient` sub-client calls, this requires scoping via `ForServiceUrl()`: ```csharp -var member = await TeamsInfo.GetMemberAsync(turnContext, userId, cancellationToken); -Console.WriteLine($"Member: {member.Name} ({member.Email})"); -``` - -### Getting Meeting Information - -```csharp -var meetingInfo = await TeamsInfo.GetMeetingInfoAsync(turnContext, meetingId, cancellationToken); -Console.WriteLine($"Meeting: {meetingInfo.Details.Title}"); -``` - -### Sending a Batch Message - -```csharp -var activity = MessageFactory.Text("Hello from bot!"); -var members = new List { new TeamMember(userId1), new TeamMember(userId2) }; -var operationId = await TeamsInfo.SendMessageToListOfUsersAsync( - turnContext, activity, members, tenantId, cancellationToken); - -// Check operation status -var state = await TeamsInfo.GetOperationStateAsync(turnContext, operationId, cancellationToken); -Console.WriteLine($"Operation state: {state.State}"); -``` - -### Getting Team Channels - -```csharp -var channels = await TeamsInfo.GetTeamChannelsAsync(turnContext, teamId, cancellationToken); -foreach (var channel in channels) +private static ApiClient GetTeamsApiClient(ITurnContext turnContext) { - Console.WriteLine($"Channel: {channel.Name} ({channel.Id})"); + return turnContext.TurnState.Get() + ?? throw new InvalidOperationException("This method requires ApiClient."); } ``` -## Testing - -Comprehensive integration tests are available in `test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs`. All tests are marked with `[Fact(Skip = "Requires live service credentials")]` and require environment variables to be set for live testing: - -- `TEST_USER_ID` -- `TEST_CONVERSATIONID` -- `TEST_TEAMID` -- `TEST_CHANNELID` -- `TEST_MEETINGID` -- `TEST_TENANTID` +The `CompatAdapter` must store a scoped `ApiClient` in TurnState for this to work. -## Modified Core Models - -To support full compatibility, the following Core SDK models were enhanced: +## Testing -### TeamsConversationAccount -Added properties to match Bot Framework `TeamsChannelAccount`: -- `GivenName` -- `Surname` -- `Email` -- `UserPrincipalName` -- `UserRole` -- `TenantId` +Integration tests are available in: +- `test/IntegrationTests/` — Tests for `ConversationClient` and `ApiClient` sub-clients +- `test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs` — Tests for the compat layer -### MeetingInfo -Changed `Organizer` property type from `ConversationAccount` to `TeamsConversationAccount` to match Bot Framework schema. +Tests require the `integration.runsettings` file with environment variables: +- `TEST_USER_ID`, `TEST_CONVERSATIONID`, `TEST_TEAMID`, `TEST_CHANNELID`, `TEST_MEETINGID`, `TEST_TENANTID` +- Azure AD credentials (`AzureAd__TenantId`, `AzureAd__ClientId`, `AzureAd__ClientSecret`) ## References +- [ApiClient Design Document](ApiClient-Design.md) — Architecture and delegation patterns +- [CreateConversation API Behavior](CreateConversation-API-Behavior.md) — Detailed API behavior with request/response examples - [Bot Framework TeamsInfo Source](https://github.com/microsoft/botbuilder-dotnet/blob/main/libraries/Microsoft.Bot.Builder/Teams/TeamsInfo.cs) -- [Teams REST API Documentation](https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference) -- [Teams Meeting Notifications](https://docs.microsoft.com/en-us/microsoftteams/platform/apps-in-teams-meetings/meeting-apps-apis) diff --git a/core/docs/CreateConversation-API-Behavior.md b/core/docs/CreateConversation-API-Behavior.md new file mode 100644 index 00000000..165f1a98 --- /dev/null +++ b/core/docs/CreateConversation-API-Behavior.md @@ -0,0 +1,432 @@ +# CreateConversation API Behavior + +Technical reference documenting the exact behavior of the Teams Bot Framework `POST /v3/conversations` endpoint, based on integration test results captured on 2026-04-17. + +## Endpoint + +``` +POST {serviceUrl}/v3/conversations +Content-Type: application/json; charset=utf-8 +Authorization: Bearer {token} +``` + +Service URL: `https://smba.trafficmanager.net/teams/` + +## Supported Conversation Types + +The endpoint supports exactly **two** conversation creation patterns: + +1. **1:1 Personal Chat** (proactive messaging to a single user) +2. **Channel Thread** (new thread in an existing Teams channel) + +**Group chat creation is NOT supported** — every variation returns `400 BadSyntax`. + +--- + +## 1:1 Personal Chat + +### Minimal (working) + +```http +POST https://smba.trafficmanager.net/teams/v3/conversations + +{ + "isGroup": false, + "members": [ + { + "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" + } + ], + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 201 Created +Server: Microsoft-HTTPAPI/2.0 +MS-CV: p82ptW4x80GRgeZm9NkMlQ.0 +Content-Type: application/json; charset=utf-8 +Content-Length: 140 + +{ + "id": "a:1p0iicaJlVi-_KIYKDDvLi4c2pMZMc8B0bPauUJq9pZ6IHPzMOrXbWS4g7Wktn1hwl8J3FecCj4cn33DInsp7AGj8mSSb23S5cQJTjU_CXlYs-eph-CchluBdnSKVFm40" +} +``` + +**Notes:** +- `isGroup` must be `false` +- `members` must contain exactly 1 member +- Member ID must be in MRI format (`29:...`), not pairwise bot framework ID (`29:guid`) +- `tenantId` is required +- Response `id` starts with `a:` prefix (personal chat conversation ID) +- Calling with the same member returns the same conversation ID (idempotent) + +### With bot specified (working) + +```http +{ + "isGroup": false, + "bot": { + "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" + }, + "members": [ + { + "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" + } + ], + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 201 Created +MS-CV: 2yFg6cTgzUeVB8q/F9pHkA.0 +``` + +**Notes:** +- `bot.id` uses `28:{appId}` format +- Bot field is optional for 1:1 — the API infers the bot from the auth token +- Same response as without bot + +### With initial activity (working) + +```http +{ + "isGroup": false, + "members": [ + { + "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" + } + ], + "activity": { + "type": "message", + "text": "[Diagnostic] 1:1 with initial activity" + }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 201 Created +MS-CV: PB7kLrArfE6r21I3q5gMRA.0 + +{ + "id": "a:1p0iicaJlVi-_KIYKDDvLi4c2pMZMc8B0bPauUJq9pZ6IHPzMOrXbWS4g7Wktn1hwl8J3FecCj4cn33DInsp7AGj8mSSb23S5cQJTjU_CXlYs-eph-CchluBdnSKVFm40" +} +``` + +**Notes:** +- The activity is sent as the first message in the conversation +- Response does NOT include `activityId` (unlike channel threads) +- If the conversation already exists, the activity is still sent + +--- + +## Channel Thread + +### With activity (working) + +```http +{ + "isGroup": true, + "activity": { + "type": "message", + "text": "[Diagnostic] channel thread" + }, + "channelData": { + "channel": { + "id": "19:LydFnezGKSkhYoiLNP6kZ8AuXQr36EDAkvG9CNJSPKc1@thread.tacv2" + } + }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 201 Created +Server: Microsoft-HTTPAPI/2.0 +MS-CV: 7hdK6FlaqE+BjXxKvUCQUg.0 +Content-Type: application/json; charset=utf-8 +Content-Length: 122 + +{ + "id": "19:LydFnezGKSkhYoiLNP6kZ8AuXQr36EDAkvG9CNJSPKc1@thread.tacv2;messageid=1776390257332", + "activityId": "1776390257332" +} +``` + +**Notes:** +- `isGroup` must be `true` +- `channelData.channel.id` must reference a valid channel +- `activity` is **required** — the thread root message +- Response `id` is `{channelId};messageid={messageId}` (the thread conversation ID) +- Response includes `activityId` (the thread root message ID, used for replies) +- `members` is NOT required (thread is visible to all channel members) + +### With members and activity (working) + +```http +{ + "isGroup": true, + "members": [ + { + "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" + } + ], + "activity": { + "type": "message", + "text": "[Diagnostic] channel thread with members" + }, + "channelData": { + "channel": { + "id": "19:LydFnezGKSkhYoiLNP6kZ8AuXQr36EDAkvG9CNJSPKc1@thread.tacv2" + } + }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 201 Created +MS-CV: +YAgno/+yUqSpHSvnRVcOQ.0 + +{ + "id": "19:LydFnezGKSkhYoiLNP6kZ8AuXQr36EDAkvG9CNJSPKc1@thread.tacv2;messageid=1776390250598", + "activityId": "1776390250598" +} +``` + +**Notes:** +- Adding `members` to a channel thread request does not cause an error +- The members field appears to be ignored (thread visibility is determined by channel membership) + +### Without activity (FAILS) + +```http +{ + "isGroup": true, + "channelData": { + "channel": { + "id": "19:LydFnezGKSkhYoiLNP6kZ8AuXQr36EDAkvG9CNJSPKc1@thread.tacv2" + } + }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +Server: Microsoft-HTTPAPI/2.0 +MS-CV: 1juAJRUj5ki4igxWv3Y8EQ.0 +Content-Type: application/json; charset=utf-8 +Content-Length: 85 + +{ + "error": { + "code": "BadSyntax", + "message": "Incorrect conversation creation parameters" + } +} +``` + +**Conclusion:** `activity` is mandatory for channel thread creation. You cannot create an empty thread. + +--- + +## Group Chat (NOT SUPPORTED) + +All of the following variations return the same `400 BadSyntax` error. The `MS-CV` header is included for each to enable service-side log correlation. + +### 2 members, no bot, no channelData + +```http +{ + "isGroup": true, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" }, + { "id": "29:100DQ6CrcJc9p_L654DvdNtwAazXhnxkoNAedgV0ZAgalPOz0oy7RmLG0VKCPhdia_w0lJJLUp0QEw6ogU7zyWg" } + ], + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: /qe9JFWupEGpNA9S/vIXaQ.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 2 members, with bot + +```http +{ + "isGroup": true, + "bot": { "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" }, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" }, + { "id": "29:100DQ6CrcJc9p_L654DvdNtwAazXhnxkoNAedgV0ZAgalPOz0oy7RmLG0VKCPhdia_w0lJJLUp0QEw6ogU7zyWg" } + ], + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: Sm3G0wjzV0y2EKXpeJu7jQ.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 2 members, bot, channelData.tenant + +```http +{ + "isGroup": true, + "bot": { "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" }, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" }, + { "id": "29:100DQ6CrcJc9p_L654DvdNtwAazXhnxkoNAedgV0ZAgalPOz0oy7RmLG0VKCPhdia_w0lJJLUp0QEw6ogU7zyWg" } + ], + "channelData": { "tenant": { "id": "3f3d1cea-7a18-41af-872b-cfbbd5140984" } }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: lmTBZpEylUeAiMTGSPnpVQ.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 2 members, bot, topic, activity, channelData (all fields) + +```http +{ + "isGroup": true, + "bot": { "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" }, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" }, + { "id": "29:100DQ6CrcJc9p_L654DvdNtwAazXhnxkoNAedgV0ZAgalPOz0oy7RmLG0VKCPhdia_w0lJJLUp0QEw6ogU7zyWg" } + ], + "topicName": "Diagnostic group test", + "activity": { "type": "message", "text": "group chat init" }, + "channelData": { "tenant": { "id": "3f3d1cea-7a18-41af-872b-cfbbd5140984" } }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: zkVf7eA6BEytpPgWI8KH9Q.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 1 member, isGroup=true + +```http +{ + "isGroup": true, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" } + ], + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: yP2h6kv4iUG08nVbdcQJ0g.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 1 member, bot, channelData.tenant + +```http +{ + "isGroup": true, + "bot": { "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" }, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" } + ], + "channelData": { "tenant": { "id": "3f3d1cea-7a18-41af-872b-cfbbd5140984" } }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: 10bRBNHyxk+eigCT8saVDg.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 3 members, bot, channelData.tenant + +```http +{ + "isGroup": true, + "bot": { "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" }, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" }, + { "id": "29:100DQ6CrcJc9p_L654DvdNtwAazXhnxkoNAedgV0ZAgalPOz0oy7RmLG0VKCPhdia_w0lJJLUp0QEw6ogU7zyWg" }, + { "id": "29:1wh0NxivaCTCGl7pmILex0arFbszG6RaKMMOXImiDOCu3-T1qzkGdsmA_AfFpawkDaQl0kfvVy9RkVWQNGl30-w" } + ], + "channelData": { "tenant": { "id": "3f3d1cea-7a18-41af-872b-cfbbd5140984" } }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: gfacBHOnI0CQ+n6Nxim+1w.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +--- + +## Summary Table + +| Scenario | `isGroup` | `channelData.channel.id` | `activity` | `members` | HTTP | Result | +|---|---|---|---|---|---|---| +| 1:1 personal chat | `false` | — | optional | 1 (required) | **201** | Conversation created | +| 1:1 with bot | `false` | — | optional | 1 (required) | **201** | Conversation created | +| 1:1 with initial activity | `false` | — | message | 1 (required) | **201** | Conversation + message | +| Channel thread | `true` | required | **required** | optional | **201** | Thread created | +| Channel thread + members | `true` | required | **required** | ignored | **201** | Thread created | +| Channel thread, no activity | `true` | required | — | — | **400** | BadSyntax | +| Group: any member count | `true` | — | any | 1-3 | **400** | BadSyntax | +| Group: with bot | `true` | — | any | 1-3 | **400** | BadSyntax | +| Group: all fields | `true` | — | message | 2 | **400** | BadSyntax | + +## Response Headers + +Common response headers across all requests: + +| Header | Description | +|---|---| +| `Server` | Always `Microsoft-HTTPAPI/2.0` | +| `MS-CV` | Correlation vector for service-side log tracing | +| `Content-Type` | Always `application/json; charset=utf-8` | +| `Date` | Server-side timestamp | +| `Content-Length` | Response body size | + +The `MS-CV` header is the key diagnostic value — it can be used to correlate with Teams service-side logs for deeper investigation of `BadSyntax` failures. + +## Key Observations + +1. **Member ID format matters.** The API requires MRI-format IDs (`29:1aK9...`), not the pairwise bot framework IDs stored in `TEST_USER_ID` env vars (`29:guid`). MRI IDs can be obtained from `GET /v3/conversations/{id}/members`. + +2. **1:1 conversations are idempotent.** Calling CreateConversation with the same member always returns the same conversation ID (`a:...` prefix). + +3. **Channel threads require an activity.** You cannot create an empty thread — the initial message IS the thread. + +4. **Group chat creation is a platform limitation.** The `POST /v3/conversations` endpoint does not support creating multi-user group chats. The error is always `BadSyntax: Incorrect conversation creation parameters` regardless of parameter combinations. This applies to the Teams channel (msteams) specifically — other Bot Framework channels may behave differently. + +5. **`tenantId` is required** for all Teams conversation creation. Omitting it causes auth failures. + +6. **`bot` field is optional.** The API infers the bot identity from the bearer token for 1:1 chats. diff --git a/core/samples/DiagBot/DiagBot.csproj b/core/samples/DiagBot/DiagBot.csproj new file mode 100644 index 00000000..43993816 --- /dev/null +++ b/core/samples/DiagBot/DiagBot.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/core/samples/DiagBot/Program.cs b/core/samples/DiagBot/Program.cs new file mode 100644 index 00000000..fea82a18 --- /dev/null +++ b/core/samples/DiagBot/Program.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentCards; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +webApp.MapGet("/", () => "Diag is running."); +var botApp = webApp.UseTeamsBotApplication(); + +//botApp.OnActivity = async (activity, cancellationToken) => +//{ +// string replyText = $"DiagBot running on SDK `{BotApplication.Version}`."; + +// CoreActivity replyActivity = CoreActivity.CreateBuilder() +// .WithType(ActivityType.Message) +// .WithConversationReference(activity) +// .WithProperty("text", replyText) +// .Build(); + +// await botApp.SendActivityAsync(replyActivity, cancellationToken); +//}; + +botApp.OnMessage(async (ctx, ct) => +{ + var tcid = ctx.Activity.ChannelData?.TeamsChannelId; + var convType = ctx.Activity.Conversation?.ConversationType; + var isGroup = ctx.Activity.Conversation?.IsGroup; + + var cardBuilder = AdaptiveCardBuilder.Create() + .AddTextBlock(tb => tb + .WithText("Conversation Diagnostics") + .WithSize(TextSize.Large) + .WithWeight(TextWeight.Bolder)) + .AddFactSet(fs => fs + .AddFact("isGroup", isGroup.ToString()!) + .AddFact("convType", convType!)); + + + + if (convType != ConversationType.Personal) + { + var members = await ctx.Api.Conversations.Members.GetAsync(ctx.Activity.Conversation?.Id!, ct); + foreach (var member in members) + { + cardBuilder.AddFactSet(fs => fs + .AddFact(member.Name!, member.Id?.Substring(0,8)! + "...")); + } + } + + var msg = TeamsActivity.CreateBuilder() + .WithAdaptiveCardAttachment(cardBuilder.Build().ToJsonElement()) + .Build(); + await ctx.SendActivityAsync(msg, ct); + + +}); + +webApp.Run(); diff --git a/core/samples/DiagBot/appsettings.json b/core/samples/DiagBot/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/core/samples/DiagBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/TeamsBot/Program.cs b/core/samples/TeamsBot/Program.cs index 5ef9e16f..15857a99 100644 --- a/core/samples/TeamsBot/Program.cs +++ b/core/samples/TeamsBot/Program.cs @@ -161,7 +161,7 @@ [Visit Microsoft](https://www.microsoft.com) .WithServiceUrl(context.Activity.ServiceUrl) .Build(); - await context.TeamsBotApplication.Api.Conversations.Activities.UpdateTargetedAsync( + await context.Api.Conversations.Activities.UpdateTargetedAsync( context.Activity.Conversation.Id!, sendResponse!.Id!, updated, @@ -170,7 +170,7 @@ await context.TeamsBotApplication.Api.Conversations.Activities.UpdateTargetedAsy await Task.Delay(2000, cancellationToken); // Delete the targeted message - await context.TeamsBotApplication.Api.Conversations.Activities.DeleteTargetedAsync( + await context.Api.Conversations.Activities.DeleteTargetedAsync( context.Activity.Conversation.Id!, sendResponse.Id!, cancellationToken: cancellationToken); @@ -194,7 +194,7 @@ await context.TeamsBotApplication.Api.Conversations.Activities.DeleteTargetedAsy await Task.Delay(2000, cancellationToken); // Add a waving hand reaction - await context.TeamsBotApplication.Api.Conversations.Reactions.AddAsync( + await context.Api.Conversations.Reactions.AddAsync( context.Activity.Conversation.Id, response!.Id!, "1f44b_wavinghand-tone4", @@ -203,7 +203,7 @@ await context.TeamsBotApplication.Api.Conversations.Reactions.AddAsync( await Task.Delay(2000, cancellationToken); // Add a beaming face reaction - await context.TeamsBotApplication.Api.Conversations.Reactions.AddAsync( + await context.Api.Conversations.Reactions.AddAsync( context.Activity.Conversation.Id, response.Id!, "1f601_beamingfacewithsmilingeyes", @@ -212,7 +212,7 @@ await context.TeamsBotApplication.Api.Conversations.Reactions.AddAsync( await Task.Delay(2000, cancellationToken); // Remove the beaming face reaction - await context.TeamsBotApplication.Api.Conversations.Reactions.DeleteAsync( + await context.Api.Conversations.Reactions.DeleteAsync( context.Activity.Conversation.Id, response.Id!, "1f601_beamingfacewithsmilingeyes", diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/TeamClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/TeamClient.cs index 5ef7efd9..15d83041 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/TeamClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/TeamClient.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json.Serialization; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core.Http; @@ -35,6 +36,13 @@ internal TeamClient(string serviceUrl, BotHttpClient http) public async Task?> GetConversationsAsync(string id, CancellationToken cancellationToken = default) { string url = $"{_serviceUrl}/v3/teams/{Uri.EscapeDataString(id)}/conversations"; - return await _http.SendAsync>(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + ConversationListResponse? response = await _http.SendAsync(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return response?.Conversations; + } + + private sealed class ConversationListResponse + { + [JsonPropertyName("conversations")] + public List? Conversations { get; set; } } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Context.cs b/core/src/Microsoft.Teams.Bot.Apps/Context.cs index d671d82d..1885abba 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Context.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Context.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; @@ -24,6 +25,14 @@ public class Context(TeamsBotApplication botApplication, TActivity ac /// public TActivity Activity { get; } = activity; + private ApiClient? _api; + + /// + /// 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.")); + /// /// Sends a message activity as a reply. /// diff --git a/core/test/IntegrationTests/ApiClientTests.cs b/core/test/IntegrationTests/ApiClientTests.cs new file mode 100644 index 00000000..ba5615d1 --- /dev/null +++ b/core/test/IntegrationTests/ApiClientTests.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Api.Clients; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Xunit.Abstractions; + +namespace IntegrationTests; + +/// +/// Integration tests for sub-clients making real API calls. +/// These tests verify that the ApiClient facade correctly delegates to core ConversationClient +/// and that Teams/Meeting-specific BotHttpClient calls work end-to-end. +/// +public class ApiClientTests : IClassFixture +{ + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + private readonly ApiClient _api; + + public ApiClientTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _output = output; + _api = _f.ScopedApiClient; + } + + #region Activities + + [Fact] + public async Task Activities_CreateAsync() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient.Activities.Create] at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? res = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, activity); + + Assert.NotNull(res); + Assert.NotNull(res.Id); + _output.WriteLine($"Created activity: {res.Id}"); + } + + [Fact] + public async Task Activities_UpdateAsync() + { + CoreActivity original = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient.Activities.Update] Original at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, original); + Assert.NotNull(sent?.Id); + + CoreActivity updated = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient.Activities.Update] Updated at `{DateTime.UtcNow:s}`" } } + }; + + UpdateActivityResponse? res = await _api.Conversations.Activities.UpdateAsync( + _f.ConversationId, sent.Id, updated); + + Assert.NotNull(res?.Id); + _output.WriteLine($"Updated activity: {res.Id}"); + } + + [Fact] + public async Task Activities_ReplyAsync() + { + CoreActivity original = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient.Activities.Reply] Parent at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, original); + Assert.NotNull(sent?.Id); + + CoreActivity reply = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient.Activities.Reply] Reply at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? res = await _api.Conversations.Activities.ReplyAsync( + _f.ConversationId, sent.Id, reply); + + Assert.NotNull(res); + _output.WriteLine($"Reply activity: {res?.Id}"); + } + + [Fact] + public async Task Activities_DeleteAsync() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient.Activities.Delete] at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, activity); + Assert.NotNull(sent?.Id); + + await Task.Delay(2000); + + await _api.Conversations.Activities.DeleteAsync(_f.ConversationId, sent.Id); + _output.WriteLine($"Deleted activity: {sent.Id}"); + } + + #endregion + + #region Members + + [Fact] + public async Task Members_GetAsync() + { + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId); + + Assert.NotNull(members); + Assert.NotEmpty(members); + + foreach (ConversationAccount m in members) + { + _output.WriteLine($"Member: {m.Id} — {m.Name}"); + } + } + + [Fact] + public async Task Members_GetByIdAsync() + { + // Get MRI-format member ID from the members list first + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + ConversationAccount member = await _api.Conversations.Members.GetByIdAsync( + _f.ConversationId, memberId); + + Assert.NotNull(member); + Assert.Equal(memberId, member.Id); + _output.WriteLine($"Member: {member.Id} — {member.Name}"); + } + + [Fact] + public async Task Members_GetByIdAsync_AsTeamsConversationAccount() + { + // Get MRI-format member ID from the members list first + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + TeamsConversationAccount member = await _api.Conversations.Members.GetByIdAsync( + _f.ConversationId, memberId); + + Assert.NotNull(member); + Assert.Equal(memberId, member.Id); + _output.WriteLine($"Member: {member.Id} — {member.Name}, Email: {member.Email}, UPN: {member.UserPrincipalName}"); + } + + #endregion + + #region Reactions + + [Fact(Skip = "Reactions API returns NotFound — needs service-url scoped auth")] + public async Task Reactions_AddAndDelete() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient.Reactions] Test at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, activity); + Assert.NotNull(sent?.Id); + + await _api.Conversations.Reactions.AddAsync(_f.ConversationId, sent.Id, "like"); + _output.WriteLine("Added 'like' reaction"); + + await Task.Delay(1000); + + await _api.Conversations.Reactions.DeleteAsync(_f.ConversationId, sent.Id, "like"); + _output.WriteLine("Removed 'like' reaction"); + } + + #endregion + + #region Teams + + [Fact] + public async Task Teams_GetByIdAsync() + { + Team? team = await _api.Teams.GetByIdAsync(_f.TeamId); + + Assert.NotNull(team); + _output.WriteLine($"Team: {team.Id} — {team.Name}, Members: {team.MemberCount}, Channels: {team.ChannelCount}"); + } + + [Fact] + public async Task Teams_GetConversationsAsync() + { + List? channels = await _api.Teams.GetConversationsAsync(_f.TeamId); + + Assert.NotNull(channels); + Assert.NotEmpty(channels); + + foreach (TeamsChannel ch in channels) + { + _output.WriteLine($"Channel: {ch.Id} — {ch.Name}"); + } + } + + #endregion + + #region Meetings + + [Fact] + public async Task Meetings_GetByIdAsync() + { + Meeting? meeting = await _api.Meetings.GetByIdAsync(_f.MeetingId); + + Assert.NotNull(meeting); + _output.WriteLine($"Meeting: {meeting.Id}"); + if (meeting.Details is not null) + { + _output.WriteLine($" Title: {meeting.Details.Title}, Type: {meeting.Details.Type}"); + } + } + + [Fact(Skip = "Requires AAD object ID, not pairwise bot framework ID")] + public async Task Meetings_GetParticipantAsync() + { + MeetingParticipant? participant = await _api.Meetings.GetParticipantAsync( + _f.MeetingId, _f.UserId, _f.TenantId); + + Assert.NotNull(participant); + _output.WriteLine($"Participant: {participant.User?.Id} — Role: {participant.Meeting?.Role}, InMeeting: {participant.Meeting?.InMeeting}"); + } + + #endregion + + #region ForServiceUrl + + [Fact] + public async Task ForServiceUrl_CreatesScopedClient() + { + ApiClient scoped = _f.ApiClient.ForServiceUrl(_f.ServiceUrl); + + Assert.NotNull(scoped.Conversations); + Assert.NotNull(scoped.Teams); + Assert.NotNull(scoped.Meetings); + Assert.Equal(_f.ServiceUrl, scoped.ServiceUrl); + + // Verify the scoped client can make a real call + IList members = await scoped.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotNull(members); + Assert.NotEmpty(members); + _output.WriteLine($"ForServiceUrl scoped client retrieved {members.Count} members"); + } + + #endregion +} diff --git a/core/test/IntegrationTests/ConversationClientTests.cs b/core/test/IntegrationTests/ConversationClientTests.cs new file mode 100644 index 00000000..b2438ea5 --- /dev/null +++ b/core/test/IntegrationTests/ConversationClientTests.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Xunit.Abstractions; + +namespace IntegrationTests; + +/// +/// Integration tests for core making real API calls. +/// +public class ConversationClientTests : IClassFixture +{ + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + + public ConversationClientTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _output = output; + } + + [Fact] + public async Task SendActivity() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ConversationClient] SendActivity at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _f.ServiceUrl, + Conversation = new(_f.ConversationId) + }; + + SendActivityResponse? res = await _f.ConversationClient.SendActivityAsync(activity); + + Assert.NotNull(res); + Assert.NotNull(res.Id); + _output.WriteLine($"Sent activity: {res.Id}"); + } + + [Fact] + public async Task UpdateActivity() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ConversationClient] Original at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _f.ServiceUrl, + Conversation = new(_f.ConversationId) + }; + + SendActivityResponse? sent = await _f.ConversationClient.SendActivityAsync(activity); + Assert.NotNull(sent?.Id); + + CoreActivity updated = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ConversationClient] Updated at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _f.ServiceUrl, + Conversation = new(_f.ConversationId) + }; + + UpdateActivityResponse res = await _f.ConversationClient.UpdateActivityAsync( + _f.ConversationId, sent.Id, updated); + + Assert.NotNull(res?.Id); + _output.WriteLine($"Updated activity: {res.Id}"); + } + + [Fact] + public async Task DeleteActivity() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ConversationClient] To delete at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _f.ServiceUrl, + Conversation = new(_f.ConversationId) + }; + + SendActivityResponse? sent = await _f.ConversationClient.SendActivityAsync(activity); + Assert.NotNull(sent?.Id); + + await Task.Delay(2000); + + await _f.ConversationClient.DeleteActivityAsync( + _f.ConversationId, sent.Id, _f.ServiceUrl); + + _output.WriteLine($"Deleted activity: {sent.Id}"); + } + + [Fact] + public async Task GetConversationMembers() + { + IList members = await _f.ConversationClient.GetConversationMembersAsync( + _f.ConversationId, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(members); + Assert.NotEmpty(members); + + foreach (ConversationAccount m in members) + { + _output.WriteLine($"Member: {m.Id} — {m.Name}"); + } + } + + [Fact] + public async Task GetConversationMember() + { + // Get MRI-format member ID from the members list first + IList members = await _f.ConversationClient.GetConversationMembersAsync( + _f.ConversationId, _f.ServiceUrl, _f.AgenticIdentity); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + ConversationAccount member = await _f.ConversationClient.GetConversationMemberAsync( + _f.ConversationId, memberId, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(member); + Assert.Equal(memberId, member.Id); + _output.WriteLine($"Member: {member.Id} — {member.Name}"); + } + + [Fact] + public async Task GetPagedMembers() + { + PagedMembersResult result = await _f.ConversationClient.GetConversationPagedMembersAsync( + _f.ConversationId, _f.ServiceUrl, pageSize: 5, agenticIdentity: _f.AgenticIdentity); + + Assert.NotNull(result?.Members); + Assert.NotEmpty(result.Members); + + foreach (ConversationAccount m in result.Members) + { + _output.WriteLine($"Member: {m.Id} — {m.Name}"); + } + } + + [Fact(Skip = "Reactions API returns NotFound — needs service-url scoped auth")] + public async Task AddAndDeleteReaction() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ConversationClient] Reaction test at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _f.ServiceUrl, + Conversation = new(_f.ConversationId) + }; + + SendActivityResponse? sent = await _f.ConversationClient.SendActivityAsync(activity); + Assert.NotNull(sent?.Id); + + await _f.ConversationClient.AddReactionAsync( + _f.ConversationId, sent.Id, "like", _f.ServiceUrl, _f.AgenticIdentity); + _output.WriteLine("Added 'like' reaction"); + + await Task.Delay(1000); + + await _f.ConversationClient.DeleteReactionAsync( + _f.ConversationId, sent.Id, "like", _f.ServiceUrl, _f.AgenticIdentity); + _output.WriteLine("Removed 'like' reaction"); + } +} diff --git a/core/test/IntegrationTests/CreateConversationDiagnosticTests.cs b/core/test/IntegrationTests/CreateConversationDiagnosticTests.cs new file mode 100644 index 00000000..eaa5e084 --- /dev/null +++ b/core/test/IntegrationTests/CreateConversationDiagnosticTests.cs @@ -0,0 +1,330 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Xunit.Abstractions; + +namespace IntegrationTests; + +/// +/// Diagnostic tests exploring CreateConversation parameter combinations. +/// These tests document what the Teams Bot Framework API accepts and rejects, +/// capturing full request/response details including headers. +/// +public class CreateConversationDiagnosticTests : IClassFixture +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + + public CreateConversationDiagnosticTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _output = output; + } + + private async Task<(string first, string? second, string? third)> GetMemberMrisAsync() + { + IList members = await _f.ConversationClient.GetConversationMembersAsync( + _f.ConversationId, _f.ServiceUrl, _f.AgenticIdentity); + return ( + members[0].Id!, + members.Count >= 2 ? members[1].Id : null, + members.Count >= 3 ? members[2].Id : null + ); + } + + /// + /// Sends a CreateConversation request using a raw HttpClient to capture full request/response details. + /// + private async Task SendDiagnosticRequestAsync(string label, ConversationParameters parameters) + { + string url = $"{_f.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations"; + string requestBody = JsonSerializer.Serialize(parameters, JsonOpts); + + // Use the DI-configured HttpClient (has BotAuthenticationHandler for token) + HttpClient httpClient = _f.ServiceProvider.GetRequiredService() + .CreateClient("BotConversationClient"); + + using HttpRequestMessage request = new(HttpMethod.Post, url); + request.Content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"); + + _output.WriteLine($"=== {label} ==="); + _output.WriteLine($"POST {url}"); + _output.WriteLine($"Request body:\n{requestBody}"); + + using HttpResponseMessage response = await httpClient.SendAsync(request); + + string responseBody = await response.Content.ReadAsStringAsync(); + + _output.WriteLine($"\nHTTP {(int)response.StatusCode} {response.StatusCode}"); + + _output.WriteLine("\nResponse headers:"); + foreach (var header in response.Headers) + { + _output.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); + } + foreach (var header in response.Content.Headers) + { + _output.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); + } + + // Pretty-print JSON response + try + { + var parsed = JsonSerializer.Deserialize(responseBody); + string pretty = JsonSerializer.Serialize(parsed, new JsonSerializerOptions { WriteIndented = true }); + _output.WriteLine($"\nResponse body:\n{pretty}"); + } + catch + { + _output.WriteLine($"\nResponse body:\n{responseBody}"); + } + + _output.WriteLine(""); + + return new DiagnosticResult + { + Label = label, + StatusCode = (int)response.StatusCode, + RequestBody = requestBody, + ResponseBody = responseBody, + ResponseHeaders = response.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)) + }; + } + + private record DiagnosticResult + { + public required string Label { get; init; } + public required int StatusCode { get; init; } + public required string RequestBody { get; init; } + public required string ResponseBody { get; init; } + public required Dictionary ResponseHeaders { get; init; } + } + + // ========================================================================= + // 1:1 personal chat — baseline (known working) + // ========================================================================= + + [Fact] + public async Task PersonalChat_MinimalParams() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("1:1 Personal Chat (minimal)", new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }); + Assert.True(result.StatusCode is 200 or 201, $"Expected 2xx, got {result.StatusCode}"); + } + + [Fact] + public async Task PersonalChat_WithBot() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("1:1 Personal Chat (with bot)", new() + { + IsGroup = false, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }); + Assert.True(result.StatusCode is 200 or 201, $"Expected 2xx, got {result.StatusCode}"); + } + + [Fact] + public async Task PersonalChat_WithInitialActivity() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("1:1 Personal Chat (with activity)", new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId, + Activity = new CoreActivity + { + Type = ActivityType.Message, + Properties = { { "text", "[Diagnostic] 1:1 with initial activity" } } + } + }); + Assert.True(result.StatusCode is 200 or 201, $"Expected 2xx, got {result.StatusCode}"); + } + + // ========================================================================= + // Group chat variations + // ========================================================================= + + [Fact] + public async Task GroupChat_TwoMembers_NoBotNoChannelData() + { + (string first, string? second, _) = await GetMemberMrisAsync(); + Assert.NotNull(second); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 2 members, no bot, no channelData", new() + { + IsGroup = true, + Members = [new() { Id = first }, new() { Id = second! }], + TenantId = _f.TenantId + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact] + public async Task GroupChat_TwoMembers_WithBot() + { + (string first, string? second, _) = await GetMemberMrisAsync(); + Assert.NotNull(second); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 2 members, bot=28:appId", new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = first }, new() { Id = second! }], + TenantId = _f.TenantId + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact] + public async Task GroupChat_TwoMembers_WithBotAndChannelData() + { + (string first, string? second, _) = await GetMemberMrisAsync(); + Assert.NotNull(second); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 2 members, bot, channelData.tenant", new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = first }, new() { Id = second! }], + TenantId = _f.TenantId, + ChannelData = new { tenant = new { id = _f.TenantId } } + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact] + public async Task GroupChat_TwoMembers_WithTopicAndActivity() + { + (string first, string? second, _) = await GetMemberMrisAsync(); + Assert.NotNull(second); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 2 members, bot, topic, activity, channelData", new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = first }, new() { Id = second! }], + TenantId = _f.TenantId, + TopicName = "Diagnostic group test", + ChannelData = new { tenant = new { id = _f.TenantId } }, + Activity = new CoreActivity + { + Type = ActivityType.Message, + Properties = { { "text", "group chat init" } } + } + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact] + public async Task GroupChat_OneMember_IsGroupTrue() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 1 member, isGroup=true", new() + { + IsGroup = true, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact] + public async Task GroupChat_OneMember_WithBot() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 1 member, bot, channelData.tenant", new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId, + ChannelData = new { tenant = new { id = _f.TenantId } } + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact] + public async Task GroupChat_ThreeMembers() + { + (string first, string? second, string? third) = await GetMemberMrisAsync(); + Assert.NotNull(second); + Assert.NotNull(third); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 3 members, bot", new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = first }, new() { Id = second! }, new() { Id = third! }], + TenantId = _f.TenantId, + ChannelData = new { tenant = new { id = _f.TenantId } } + }); + Assert.Equal(400, result.StatusCode); + } + + // ========================================================================= + // Channel thread variations + // ========================================================================= + + [Fact] + public async Task ChannelThread_WithActivity() + { + DiagnosticResult result = await SendDiagnosticRequestAsync("Channel Thread: with activity", new() + { + IsGroup = true, + ChannelData = new { channel = new { id = _f.ChannelId } }, + Activity = new CoreActivity + { + Type = ActivityType.Message, + Properties = { { "text", "[Diagnostic] channel thread" } } + }, + TenantId = _f.TenantId + }); + Assert.True(result.StatusCode is 200 or 201, $"Expected 2xx, got {result.StatusCode}"); + } + + [Fact] + public async Task ChannelThread_NoActivity() + { + DiagnosticResult result = await SendDiagnosticRequestAsync("Channel Thread: without activity", new() + { + IsGroup = true, + ChannelData = new { channel = new { id = _f.ChannelId } }, + TenantId = _f.TenantId + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact] + public async Task ChannelThread_WithMembersAndActivity() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("Channel Thread: with members and activity", new() + { + IsGroup = true, + Members = [new() { Id = memberMri }], + ChannelData = new { channel = new { id = _f.ChannelId } }, + Activity = new CoreActivity + { + Type = ActivityType.Message, + Properties = { { "text", "[Diagnostic] channel thread with members" } } + }, + TenantId = _f.TenantId + }); + Assert.True(result.StatusCode is 200 or 201, $"Expected 2xx, got {result.StatusCode}"); + } +} diff --git a/core/test/IntegrationTests/CreateConversationTests.cs b/core/test/IntegrationTests/CreateConversationTests.cs new file mode 100644 index 00000000..3713a22a --- /dev/null +++ b/core/test/IntegrationTests/CreateConversationTests.cs @@ -0,0 +1,372 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Api.Clients; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Xunit.Abstractions; + +namespace IntegrationTests; + +/// +/// Integration tests for creating conversations with different ConversationParameters. +/// Tests personal chats, group chats, and channel thread creation via both +/// core and the facade. +/// +public class CreateConversationTests : IClassFixture +{ + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + private readonly ApiClient _api; + + public CreateConversationTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _output = output; + _api = _f.ScopedApiClient; + } + + /// + /// Gets MRI-format member IDs by fetching the conversation members list. + /// The API requires MRI IDs (e.g., "29:1abc..."), not pairwise bot framework IDs. + /// + private async Task<(string first, string? second)> GetMemberMrisAsync() + { + IList members = await _f.ConversationClient.GetConversationMembersAsync( + _f.ConversationId, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.True(members.Count >= 1, "Need at least 1 member in the test conversation"); + + string first = members[0].Id!; + string? second = members.Count >= 2 ? members[1].Id : null; + + _output.WriteLine($"Using member MRIs: first={first}, second={second ?? "(none)"}"); + return (first, second); + } + + #region Personal Chat (1:1) — Core ConversationClient + + [Fact] + public async Task Core_CreatePersonalChat() + { + (string memberMri, _) = await GetMemberMrisAsync(); + + ConversationParameters parameters = new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"Created 1:1 conversation: {response.Id}"); + } + + [Fact] + public async Task Core_CreatePersonalChat_AndSendMessage() + { + (string memberMri, _) = await GetMemberMrisAsync(); + + ConversationParameters parameters = new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response?.Id); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[Core] 1:1 message at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _f.ServiceUrl, + Conversation = new(response.Id) + }; + + SendActivityResponse? sent = await _f.ConversationClient.SendActivityAsync(activity); + Assert.NotNull(sent?.Id); + _output.WriteLine($"Created 1:1 conversation {response.Id} and sent activity {sent.Id}"); + } + + [Fact] + public async Task Core_CreatePersonalChat_WithInitialActivity() + { + (string memberMri, _) = await GetMemberMrisAsync(); + + ConversationParameters parameters = new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId, + Activity = new CoreActivity + { + Type = ActivityType.Message, + Properties = { { "text", $"[Core] Initial message at `{DateTime.UtcNow:s}`" } } + } + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"Created 1:1 conversation with initial activity: {response.Id}, activityId: {response.ActivityId}"); + } + + #endregion + + #region Group Chat — Core ConversationClient + + [Fact(Skip = "Teams Bot Framework API does not support group chat creation")] + public async Task Core_CreateGroupChat() + { + (string first, string? second) = await GetMemberMrisAsync(); + if (second is null) + { + _output.WriteLine("Skipping: need at least 2 members in conversation"); + return; + } + + ConversationParameters parameters = new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = + [ + new() { Id = first }, + new() { Id = second } + ], + TenantId = _f.TenantId, + TopicName = $"Integration Test Group - {DateTime.UtcNow:s}", + ChannelData = new { tenant = new { id = _f.TenantId } } + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"Created group conversation: {response.Id}"); + } + + [Fact(Skip = "Teams Bot Framework API does not support group chat creation")] + public async Task Core_CreateGroupChat_AndSendMessage() + { + (string first, string? second) = await GetMemberMrisAsync(); + if (second is null) + { + _output.WriteLine("Skipping: need at least 2 members in conversation"); + return; + } + + ConversationParameters parameters = new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = + [ + new() { Id = first }, + new() { Id = second } + ], + TenantId = _f.TenantId, + ChannelData = new { tenant = new { id = _f.TenantId } } + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response?.Id); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[Core] Group message at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _f.ServiceUrl, + Conversation = new(response.Id) + }; + + SendActivityResponse? sent = await _f.ConversationClient.SendActivityAsync(activity); + Assert.NotNull(sent?.Id); + _output.WriteLine($"Created group {response.Id} and sent activity {sent.Id}"); + } + + #endregion + + #region Channel Thread — Core ConversationClient + + [Fact] + public async Task Core_CreateChannelThread() + { + ConversationParameters parameters = new() + { + IsGroup = true, + ChannelData = new { channel = new { id = _f.ChannelId } }, + Activity = new CoreActivity + { + Type = ActivityType.Message, + Properties = { { "text", $"[Core] New channel thread at `{DateTime.UtcNow:s}`" } } + }, + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"Created channel thread: {response.Id}, activityId: {response.ActivityId}"); + } + + #endregion + + #region Personal Chat — ApiClient + + [Fact] + public async Task ApiClient_CreatePersonalChat() + { + (string memberMri, _) = await GetMemberMrisAsync(); + + ConversationParameters parameters = new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _api.Conversations.CreateAsync(parameters); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"[ApiClient] Created 1:1 conversation: {response.Id}"); + } + + [Fact] + public async Task ApiClient_CreatePersonalChat_AndSendViaActivities() + { + (string memberMri, _) = await GetMemberMrisAsync(); + + ConversationParameters parameters = new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _api.Conversations.CreateAsync(parameters); + Assert.NotNull(response?.Id); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient] 1:1 via Activities.Create at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(response.Id, activity); + Assert.NotNull(sent?.Id); + _output.WriteLine($"[ApiClient] Created 1:1 {response.Id}, sent activity {sent.Id}"); + } + + #endregion + + #region Group Chat — ApiClient + + [Fact(Skip = "Teams Bot Framework API does not support group chat creation")] + public async Task ApiClient_CreateGroupChat() + { + (string first, string? second) = await GetMemberMrisAsync(); + if (second is null) + { + _output.WriteLine("Skipping: need at least 2 members in conversation"); + return; + } + + ConversationParameters parameters = new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = + [ + new() { Id = first }, + new() { Id = second } + ], + TenantId = _f.TenantId, + TopicName = $"[ApiClient] Group - {DateTime.UtcNow:s}", + ChannelData = new { tenant = new { id = _f.TenantId } } + }; + + CreateConversationResponse response = await _api.Conversations.CreateAsync(parameters); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"[ApiClient] Created group conversation: {response.Id}"); + } + + #endregion + + #region Channel Thread — ApiClient + + [Fact] + public async Task ApiClient_CreateChannelThread() + { + ConversationParameters parameters = new() + { + IsGroup = true, + ChannelData = new { channel = new { id = _f.ChannelId } }, + Activity = new CoreActivity + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient] New channel thread at `{DateTime.UtcNow:s}`" } } + }, + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _api.Conversations.CreateAsync(parameters); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"[ApiClient] Created channel thread: {response.Id}, activityId: {response.ActivityId}"); + } + + [Fact] + public async Task ApiClient_CreateChannelThread_AndReply() + { + ConversationParameters parameters = new() + { + IsGroup = true, + ChannelData = new { channel = new { id = _f.ChannelId } }, + Activity = new CoreActivity + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient] Thread root at `{DateTime.UtcNow:s}`" } } + }, + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _api.Conversations.CreateAsync(parameters); + Assert.NotNull(response?.Id); + Assert.NotNull(response.ActivityId); + + // Reply to the thread + CoreActivity reply = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient] Thread reply at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? replyResponse = await _api.Conversations.Activities.ReplyAsync( + response.Id, response.ActivityId, reply); + + Assert.NotNull(replyResponse); + _output.WriteLine($"[ApiClient] Created thread {response.Id}, root activity {response.ActivityId}, reply {replyResponse?.Id}"); + } + + #endregion +} diff --git a/core/test/IntegrationTests/IntegrationTestFixture.cs b/core/test/IntegrationTests/IntegrationTestFixture.cs new file mode 100644 index 00000000..f2219dda --- /dev/null +++ b/core/test/IntegrationTests/IntegrationTestFixture.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Api.Clients; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + +namespace IntegrationTests; + +/// +/// Shared fixture that configures DI, acquires tokens, and exposes clients for integration tests. +/// Reused across test classes via IClassFixture to avoid repeated token acquisition. +/// +public class IntegrationTestFixture : IDisposable +{ + public ServiceProvider ServiceProvider { get; } + public ConversationClient ConversationClient { get; } + public ApiClient ApiClient { get; } + + public Uri ServiceUrl { get; } + public string ConversationId { get; } + public string UserId { get; } + public string TeamId { get; } + public string ChannelId { get; } + public string MeetingId { get; } + public string TenantId { get; } + public string BotAppId { get; } + public string? UserId2 { get; } + public AgenticIdentity? AgenticIdentity { get; } + + public IntegrationTestFixture() + { + IConfiguration configuration = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables() + .Build(); + + ServiceCollection services = new(); + services.AddLogging(builder => + { + builder.AddFilter("System.Net", LogLevel.Warning); + builder.AddFilter("Microsoft.Identity", LogLevel.Error); + builder.AddFilter("Microsoft.Teams", LogLevel.Trace); + }); + services.AddSingleton(configuration); + services.AddTeamsBotApplication(); + + ServiceProvider = services.BuildServiceProvider(); + ConversationClient = ServiceProvider.GetRequiredService(); + ApiClient = ServiceProvider.GetRequiredService(); + + ServiceUrl = new Uri(Env("TEST_SERVICEURL", "https://smba.trafficmanager.net/teams/")); + ConversationId = Env("TEST_CONVERSATIONID"); + UserId = Env("TEST_USER_ID"); + TeamId = Env("TEST_TEAMID"); + ChannelId = Env("TEST_CHANNELID"); + MeetingId = Env("TEST_MEETINGID"); + TenantId = Env("TEST_TENANTID"); + BotAppId = Env("AzureAd__ClientId"); + UserId2 = Environment.GetEnvironmentVariable("TEST_USER_ID_2"); + + string? agenticAppId = Environment.GetEnvironmentVariable("TEST_AGENTIC_APPID"); + string? agenticUserId = Environment.GetEnvironmentVariable("TEST_AGENTIC_USERID"); + + if (!string.IsNullOrEmpty(agenticAppId) && !string.IsNullOrEmpty(agenticUserId)) + { + string appBlueprintId = Env("AzureAd__ClientId"); + ConversationAccount recipient = new(); + recipient.Properties.Add("agenticAppBlueprintId", appBlueprintId); + recipient.Properties.Add("agenticAppId", agenticAppId); + recipient.Properties.Add("agenticUserId", agenticUserId); + AgenticIdentity = AgenticIdentity.FromProperties(recipient.Properties); + } + } + + public ApiClient ScopedApiClient => ApiClient.ForServiceUrl(ServiceUrl); + + public void Dispose() + { + ServiceProvider.Dispose(); + GC.SuppressFinalize(this); + } + + private static string Env(string name, string? fallback = null) => + Environment.GetEnvironmentVariable(name) + ?? fallback + ?? throw new InvalidOperationException($"{name} environment variable not set"); +} diff --git a/core/test/IntegrationTests/IntegrationTests.csproj b/core/test/IntegrationTests/IntegrationTests.csproj index 88acad69..2e6bc96f 100644 --- a/core/test/IntegrationTests/IntegrationTests.csproj +++ b/core/test/IntegrationTests/IntegrationTests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,12 +10,17 @@ + - + + + + + - \ No newline at end of file + From 11214d5feb7975f997385204008c86721a0b9c2e Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Fri, 17 Apr 2026 15:39:39 -0700 Subject: [PATCH 06/22] Restart APIClient port --- core/core.slnx | 1 - core/samples/DiagBot/DiagBot.csproj | 17 - core/samples/DiagBot/Program.cs | 66 -- core/samples/DiagBot/appsettings.json | 9 - core/samples/TeamsBot/TeamsBot.csproj | 17 +- .../Api/Clients/ActivityClient.cs | 3 + .../Api/Clients/ApiClient.cs | 6 +- ...tionClient.cs => ConversationApiClient.cs} | 8 +- .../Api/Clients/MemberClient.cs | 8 - .../Api/Clients/ReactionClient.cs | 2 + .../Api/Clients/UserClient.cs | 4 +- ...erTokenClient.cs => UserTokenApiClient.cs} | 4 +- .../Schema/MessageActivity.cs | 2 +- .../Schema/SuggestedActions.cs | 4 +- .../TeamsBotApplication.HostingExtensions.cs | 1 - .../TeamsBotApplication.cs | 1 - .../CompatActivity.cs | 277 ------- .../CompatChannelAccount.cs | 89 +++ .../CompatTeamsInfo.cs | 224 +++++- .../InternalsVisibleTo.cs | 1 + .../Hosting/AddBotApplicationExtensions.cs | 4 +- .../Hosting/MsalConfigurationExtensions.cs | 2 - .../Http/BotHttpClient.cs | 1 - core/test/IntegrationTests.slnx | 4 +- core/test/IntegrationTests/ApiClientTests.cs | 202 ++++- .../IntegrationTests/CompatTeamsInfoTests.cs | 362 +++++++++ .../ConversationClientTests.cs | 3 +- .../CreateConversationDiagnosticTests.cs | 4 +- .../CreateConversationTests.cs | 1 + .../IntegrationTestFixture.cs | 12 +- .../IntegrationTests/IntegrationTests.csproj | 2 + core/test/IntegrationTests/test-results.md | 195 +++++ .../CompatConversationClientTests.cs | 154 ---- .../CompatTeamsInfoTests.cs | 617 --------------- .../ConversationClientTest.cs | 732 ------------------ .../Microsoft.Teams.Bot.Core.Tests.csproj | 24 - .../TeamsApiClientTests.cs | 597 -------------- .../TeamsApiFacadeTests.cs | 643 --------------- .../Microsoft.Teams.Bot.Core.Tests/readme.md | 21 - 39 files changed, 1089 insertions(+), 3235 deletions(-) delete mode 100644 core/samples/DiagBot/DiagBot.csproj delete mode 100644 core/samples/DiagBot/Program.cs delete mode 100644 core/samples/DiagBot/appsettings.json rename core/src/Microsoft.Teams.Bot.Apps/Api/Clients/{V3ConversationClient.cs => ConversationApiClient.cs} (71%) rename core/src/Microsoft.Teams.Bot.Apps/Api/Clients/{V3UserTokenClient.cs => UserTokenApiClient.cs} (97%) create mode 100644 core/src/Microsoft.Teams.Bot.Compat/CompatChannelAccount.cs create mode 100644 core/test/IntegrationTests/CompatTeamsInfoTests.cs create mode 100644 core/test/IntegrationTests/test-results.md delete mode 100644 core/test/Microsoft.Teams.Bot.Core.Tests/CompatConversationClientTests.cs delete mode 100644 core/test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs delete mode 100644 core/test/Microsoft.Teams.Bot.Core.Tests/ConversationClientTest.cs delete mode 100644 core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj delete mode 100644 core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiClientTests.cs delete mode 100644 core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiFacadeTests.cs delete mode 100644 core/test/Microsoft.Teams.Bot.Core.Tests/readme.md diff --git a/core/core.slnx b/core/core.slnx index d20009c7..12934ad3 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -16,7 +16,6 @@ - diff --git a/core/samples/DiagBot/DiagBot.csproj b/core/samples/DiagBot/DiagBot.csproj deleted file mode 100644 index 43993816..00000000 --- a/core/samples/DiagBot/DiagBot.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - diff --git a/core/samples/DiagBot/Program.cs b/core/samples/DiagBot/Program.cs deleted file mode 100644 index fea82a18..00000000 --- a/core/samples/DiagBot/Program.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using FluentCards; -using Microsoft.Teams.Bot.Apps; -using Microsoft.Teams.Bot.Apps.Handlers; -using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Schema; - -WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); -webAppBuilder.Services.AddTeamsBotApplication(); -WebApplication webApp = webAppBuilder.Build(); - -webApp.MapGet("/", () => "Diag is running."); -var botApp = webApp.UseTeamsBotApplication(); - -//botApp.OnActivity = async (activity, cancellationToken) => -//{ -// string replyText = $"DiagBot running on SDK `{BotApplication.Version}`."; - -// CoreActivity replyActivity = CoreActivity.CreateBuilder() -// .WithType(ActivityType.Message) -// .WithConversationReference(activity) -// .WithProperty("text", replyText) -// .Build(); - -// await botApp.SendActivityAsync(replyActivity, cancellationToken); -//}; - -botApp.OnMessage(async (ctx, ct) => -{ - var tcid = ctx.Activity.ChannelData?.TeamsChannelId; - var convType = ctx.Activity.Conversation?.ConversationType; - var isGroup = ctx.Activity.Conversation?.IsGroup; - - var cardBuilder = AdaptiveCardBuilder.Create() - .AddTextBlock(tb => tb - .WithText("Conversation Diagnostics") - .WithSize(TextSize.Large) - .WithWeight(TextWeight.Bolder)) - .AddFactSet(fs => fs - .AddFact("isGroup", isGroup.ToString()!) - .AddFact("convType", convType!)); - - - - if (convType != ConversationType.Personal) - { - var members = await ctx.Api.Conversations.Members.GetAsync(ctx.Activity.Conversation?.Id!, ct); - foreach (var member in members) - { - cardBuilder.AddFactSet(fs => fs - .AddFact(member.Name!, member.Id?.Substring(0,8)! + "...")); - } - } - - var msg = TeamsActivity.CreateBuilder() - .WithAdaptiveCardAttachment(cardBuilder.Build().ToJsonElement()) - .Build(); - await ctx.SendActivityAsync(msg, ct); - - -}); - -webApp.Run(); diff --git a/core/samples/DiagBot/appsettings.json b/core/samples/DiagBot/appsettings.json deleted file mode 100644 index 10f68b8c..00000000 --- a/core/samples/DiagBot/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/core/samples/TeamsBot/TeamsBot.csproj b/core/samples/TeamsBot/TeamsBot.csproj index f30bcbe3..899e9209 100644 --- a/core/samples/TeamsBot/TeamsBot.csproj +++ b/core/samples/TeamsBot/TeamsBot.csproj @@ -1,13 +1,14 @@ - - net10.0 - enable - enable - + + net10.0 + enable + enable + $(NoWarn);ExperimentalTeamsTargeted;ExperimentalTeamsReactions + - - - + + + diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs index a26056b5..dd6b6a67 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ActivityClient.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Schema; @@ -66,6 +67,7 @@ public Task DeleteAsync(string conversationId, string id, CancellationToken canc /// Create a new targeted activity in a conversation. /// Targeted activities are only visible to the specified recipient. /// + [Experimental("ExperimentalTeamsTargeted")] public Task CreateTargetedAsync(string conversationId, CoreActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); @@ -77,6 +79,7 @@ public Task DeleteAsync(string conversationId, string id, CancellationToken canc /// /// Update an existing targeted activity in a conversation. /// + [Experimental("ExperimentalTeamsTargeted")] public Task UpdateTargetedAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs index f8585846..0ce48198 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs @@ -44,7 +44,7 @@ public class ApiClient /// /// Client for conversation operations (activities, members, reactions). /// - public virtual V3ConversationClient Conversations { get; } + public virtual ConversationApiClient Conversations { get; } /// /// Client for user-level operations (token). @@ -107,7 +107,7 @@ public ApiClient(Uri serviceUrl, HttpClient httpClient, CoreConversationClient c _tokenApiEndpoint = tokenApiEndpoint; ServiceUrl = serviceUrl; Bots = new BotClient(_http, tokenApiEndpoint); - Conversations = new V3ConversationClient(serviceUrl, conversationClient); + Conversations = new ConversationApiClient(serviceUrl, conversationClient); Users = new UserClient(_http, tokenApiEndpoint); Teams = new TeamClient(serviceUrl.ToString(), _http); Meetings = new MeetingClient(serviceUrl.ToString(), _http); @@ -139,7 +139,7 @@ private ApiClient(BotHttpClient http, CoreConversationClient conversationClient, _tokenApiEndpoint = tokenApiEndpoint; ServiceUrl = serviceUrl; Bots = new BotClient(http, tokenApiEndpoint); - Conversations = new V3ConversationClient(serviceUrl, conversationClient); + Conversations = new ConversationApiClient(serviceUrl, conversationClient); Users = new UserClient(http, tokenApiEndpoint); Teams = new TeamClient(serviceUrl.ToString(), http); Meetings = new MeetingClient(serviceUrl.ToString(), http); diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ConversationApiClient.cs similarity index 71% rename from core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs rename to core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ConversationApiClient.cs index a2ea6e36..a1e2d19d 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3ConversationClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ConversationApiClient.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using Microsoft.Teams.Bot.Core; using CoreConversationClient = Microsoft.Teams.Bot.Core.ConversationClient; @@ -11,7 +12,7 @@ namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// Client for managing conversations, exposing sub-clients for activities, members, and reactions. /// Delegates to the core . /// -public class V3ConversationClient +public class ConversationApiClient { private readonly CoreConversationClient _client; private readonly Uri _serviceUrl; @@ -29,15 +30,18 @@ public class V3ConversationClient /// /// Client for reaction operations. /// + [Experimental("ExperimentalTeamsReactions")] public ReactionClient Reactions { get; } - internal V3ConversationClient(Uri serviceUrl, CoreConversationClient client) + internal ConversationApiClient(Uri serviceUrl, CoreConversationClient client) { _serviceUrl = serviceUrl; _client = client; Activities = new ActivityClient(serviceUrl, client); Members = new MemberClient(serviceUrl, client); +#pragma warning disable ExperimentalTeamsReactions // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. Reactions = new ReactionClient(serviceUrl, client); +#pragma warning restore ExperimentalTeamsReactions // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. } /// diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs index 389c23da..eb2541a8 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/MemberClient.cs @@ -45,12 +45,4 @@ public Task GetByIdAsync(string conversationId, string memb { return GetByIdAsync(conversationId, memberId, cancellationToken); } - - /// - /// Remove a member from a conversation. - /// - public Task DeleteAsync(string conversationId, string memberId, CancellationToken cancellationToken = default) - { - return _client.DeleteConversationMemberAsync(conversationId, memberId, _serviceUrl, cancellationToken: cancellationToken); - } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs index 2efd632d..c5976832 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ReactionClient.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using CoreConversationClient = Microsoft.Teams.Bot.Core.ConversationClient; namespace Microsoft.Teams.Bot.Apps.Api.Clients; @@ -9,6 +10,7 @@ namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// Client for managing reactions on activities in a conversation. /// Delegates to the core . /// +[Experimental("ExperimentalTeamsReactions")] public class ReactionClient { private readonly CoreConversationClient _client; diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs index 6e6c5ceb..9a8ef528 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs @@ -13,10 +13,10 @@ public class UserClient /// /// Client for user token operations. /// - public V3UserTokenClient Token { get; } + public UserTokenApiClient Token { get; } internal UserClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") { - Token = new V3UserTokenClient(http, tokenApiEndpoint); + Token = new UserTokenApiClient(http, tokenApiEndpoint); } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs similarity index 97% rename from core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs rename to core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs index 91407af5..9caa128f 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/V3UserTokenClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs @@ -11,7 +11,7 @@ namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// /// Client for user token operations. /// -public class V3UserTokenClient +public class UserTokenApiClient { private static readonly JsonSerializerOptions JsonOptions = new() { @@ -21,7 +21,7 @@ public class V3UserTokenClient private readonly BotHttpClient _http; private readonly string _tokenApiEndpoint; - internal V3UserTokenClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") + internal UserTokenApiClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") { _http = http; _tokenApiEndpoint = tokenApiEndpoint.TrimEnd('/'); diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivity.cs index 7ba071aa..7805e1f0 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivity.cs @@ -135,7 +135,7 @@ protected MessageActivity(CoreActivity activity) : base(activity) [JsonPropertyName("attachmentLayout")] public string? AttachmentLayout { get; set; } - + //TODO : Review properties /* diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/SuggestedActions.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/SuggestedActions.cs index 87ab43e5..5a392f1a 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Schema/SuggestedActions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/SuggestedActions.cs @@ -31,7 +31,7 @@ public class SuggestedActions public SuggestedActions AddRecipients(params string[] recipients) { ArgumentNullException.ThrowIfNull(recipients); - foreach (var to in recipients) + foreach (string to in recipients) { To.Add(to); } @@ -58,7 +58,7 @@ public SuggestedActions AddAction(SuggestedAction action) public SuggestedActions AddActions(params SuggestedAction[] actions) { ArgumentNullException.ThrowIfNull(actions); - foreach (var action in actions) + foreach (SuggestedAction action in actions) { Actions.Add(action); } diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs index d07f9e7c..b38b0ff0 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Teams.Bot.Apps.Api; using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Core.Hosting; diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs index c53e2118..c4b2ab4f 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using Microsoft.Teams.Bot.Apps.Api; using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Apps.Handlers; using Microsoft.Teams.Bot.Apps.Routing; diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs index 849bb93d..b8208ef3 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs @@ -4,8 +4,6 @@ using System.Text; using Microsoft.Bot.Builder.Integration.AspNet.Core.Handlers; using Microsoft.Bot.Schema; -using Microsoft.Bot.Schema.Teams; -using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core.Schema; using Newtonsoft.Json; @@ -44,282 +42,7 @@ public static CoreActivity FromCompatActivity(this Activity activity) } - /// - /// Converts a ConversationAccount to a ChannelAccount. - /// - /// - /// - public static Microsoft.Bot.Schema.ChannelAccount ToCompatChannelAccount(this Microsoft.Teams.Bot.Core.Schema.ConversationAccount account) - { - ArgumentNullException.ThrowIfNull(account); - - Microsoft.Bot.Schema.ChannelAccount channelAccount; - if (account is TeamsConversationAccount tae) - { - channelAccount = new() - { - Id = account.Id, - Name = account.Name, - AadObjectId = tae.AadObjectId - }; - } - else - { - channelAccount = new() - { - Id = account.Id, - Name = account.Name - }; - } - - if (account.Properties.TryGetValue("aadObjectId", out object? aadObjectId)) - { - channelAccount.AadObjectId = aadObjectId?.ToString(); - } - - if (account.Properties.TryGetValue("userRole", out object? userRole)) - { - channelAccount.Role = userRole?.ToString(); - } - - if (account.Properties.TryGetValue("userPrincipalName", out object? userPrincipalName)) - { - channelAccount.Properties.Add("userPrincipalName", userPrincipalName?.ToString() ?? string.Empty); - } - - if (account.Properties.TryGetValue("givenName", out object? givenName)) - { - channelAccount.Properties.Add("givenName", givenName?.ToString() ?? string.Empty); - } - - if (account.Properties.TryGetValue("surname", out object? surname)) - { - channelAccount.Properties.Add("surname", surname?.ToString() ?? string.Empty); - } - - if (account.Properties.TryGetValue("email", out object? email)) - { - channelAccount.Properties.Add("email", email?.ToString() ?? string.Empty); - } - - if (account.Properties.TryGetValue("tenantId", out object? tenantId)) - { - channelAccount.Properties.Add("tenantId", tenantId?.ToString() ?? string.Empty); - } - - return channelAccount; - } - - /// - /// Converts a TeamsConversationAccount to a TeamsChannelAccount. - /// - /// - /// - public static Microsoft.Bot.Schema.Teams.TeamsChannelAccount ToCompatTeamsChannelAccount(this Microsoft.Teams.Bot.Apps.Schema.TeamsConversationAccount account) - { - ArgumentNullException.ThrowIfNull(account); - - return new Microsoft.Bot.Schema.Teams.TeamsChannelAccount - { - Id = account.Id, - Name = account.Name, - AadObjectId = account.AadObjectId, - Email = account.Email, - GivenName = account.GivenName, - Surname = account.Surname, - UserPrincipalName = account.UserPrincipalName, - UserRole = account.UserRole, - TenantId = account.TenantId - }; - } - - /// - /// Converts a Core MeetingInfo to a Bot Framework MeetingInfo. - /// - /// - /// - public static Microsoft.Bot.Schema.Teams.MeetingInfo ToCompatMeetingInfo(this Microsoft.Teams.Bot.Apps.Api.Clients.MeetingInfo meetingInfo) - { - ArgumentNullException.ThrowIfNull(meetingInfo); - - return new Microsoft.Bot.Schema.Teams.MeetingInfo - { - //Details = meetingInfo.Details != null ? new Microsoft.Bot.Schema.Teams.MeetingDetails - //{ - // Id = meetingInfo.Details.Id, - // MsGraphResourceId = meetingInfo.Details.MsGraphResourceId, - // ScheduledStartTime = meetingInfo.Details.ScheduledStartTime?.DateTime, - // ScheduledEndTime = meetingInfo.Details.ScheduledEndTime?.DateTime, - // JoinUrl = meetingInfo.Details.JoinUrl, - // Title = meetingInfo.Details.Title, - // Type = meetingInfo.Details.Type - //} : null, - //Conversation = meetingInfo.Conversation != null ? new Microsoft.Bot.Schema.ConversationAccount - //{ - // Id = meetingInfo.Conversation.Id, - // Name = meetingInfo.Conversation.Name - //} : null, - //Organizer = meetingInfo.Organizer != null ? meetingInfo.Organizer.ToCompatTeamsChannelAccount() : null - }; - } - - /// - /// Converts a Core MeetingParticipant to a Bot Framework TeamsMeetingParticipant. - /// - /// - /// - public static Microsoft.Bot.Schema.Teams.TeamsMeetingParticipant ToCompatTeamsMeetingParticipant(this Microsoft.Teams.Bot.Apps.Api.Clients.MeetingParticipant participant) - { - ArgumentNullException.ThrowIfNull(participant); - - return new Microsoft.Bot.Schema.Teams.TeamsMeetingParticipant - { - User = participant.User?.ToCompatTeamsChannelAccount(), - Meeting = participant.Meeting != null ? new Microsoft.Bot.Schema.Teams.MeetingParticipantInfo - { - Role = participant.Meeting.Role, - InMeeting = participant.Meeting.InMeeting - } : null, - Conversation = participant.Conversation != null ? new Microsoft.Bot.Schema.ConversationAccount - { - Id = participant.Conversation.Id - } : null - }; - } - - /// - /// Converts a Core TeamsChannel to a Bot Framework ChannelInfo. - /// - /// - /// - public static Microsoft.Bot.Schema.Teams.ChannelInfo ToCompatChannelInfo(this Microsoft.Teams.Bot.Apps.Schema.TeamsChannel channel) - { - ArgumentNullException.ThrowIfNull(channel); - - return new Microsoft.Bot.Schema.Teams.ChannelInfo - { - Id = channel.Id, - Name = channel.Name - }; - } - /// - /// Converts a Core PagedMembersResult to a Bot Framework TeamsPagedMembersResult. - /// - /// - /// - public static Microsoft.Bot.Schema.Teams.TeamsPagedMembersResult ToCompatTeamsPagedMembersResult(this Microsoft.Teams.Bot.Core.PagedMembersResult pagedMembers) - { - ArgumentNullException.ThrowIfNull(pagedMembers); - - return new Microsoft.Bot.Schema.Teams.TeamsPagedMembersResult - { - ContinuationToken = pagedMembers.ContinuationToken, - Members = pagedMembers.Members?.Select(m => m.ToCompatTeamsChannelAccount()).ToList() - }; - } - - /// - /// Converts a ConversationAccount to a TeamsChannelAccount. - /// - /// - /// - public static Microsoft.Bot.Schema.Teams.TeamsChannelAccount ToCompatTeamsChannelAccount(this Microsoft.Teams.Bot.Core.Schema.ConversationAccount account) - { - ArgumentNullException.ThrowIfNull(account); - - TeamsChannelAccount teamsChannelAccount = new() - { - Id = account.Id, - Name = account.Name - }; - - // Extract properties from Properties dictionary - if (account.Properties.TryGetValue("aadObjectId", out object? aadObjectId)) - { - teamsChannelAccount.AadObjectId = aadObjectId?.ToString(); - } - - if (account.Properties.TryGetValue("userPrincipalName", out object? userPrincipalName)) - { - teamsChannelAccount.UserPrincipalName = userPrincipalName?.ToString(); - } - - if (account.Properties.TryGetValue("givenName", out object? givenName)) - { - teamsChannelAccount.GivenName = givenName?.ToString(); - } - - if (account.Properties.TryGetValue("surname", out object? surname)) - { - teamsChannelAccount.Surname = surname?.ToString(); - } - - if (account.Properties.TryGetValue("email", out object? email)) - { - teamsChannelAccount.Email = email?.ToString(); - } - - if (account.Properties.TryGetValue("tenantId", out object? tenantId)) - { - teamsChannelAccount.Properties.Add("tenantId", tenantId?.ToString() ?? string.Empty); - } - - return teamsChannelAccount; - } - - /// - /// Converts a Bot Framework ChannelAccount to a Core ConversationAccount. - /// - public static Microsoft.Teams.Bot.Core.Schema.ConversationAccount FromCompatChannelAccount(this Microsoft.Bot.Schema.ChannelAccount account) - { - ArgumentNullException.ThrowIfNull(account); - - Microsoft.Teams.Bot.Core.Schema.ConversationAccount result = new() { Id = account.Id, Name = account.Name }; - - if (!string.IsNullOrEmpty(account.AadObjectId)) - { - result.Properties["aadObjectId"] = account.AadObjectId; - } - - if (!string.IsNullOrEmpty(account.Role)) - { - result.Properties["userRole"] = account.Role; - } - - return result; - } - - /// - /// Converts a Bot Framework ConversationParameters to a Core ConversationParameters. - /// - public static Microsoft.Teams.Bot.Core.ConversationParameters FromCompatConversationParameters(this Microsoft.Bot.Schema.ConversationParameters parameters) - { - ArgumentNullException.ThrowIfNull(parameters); - - return new Microsoft.Teams.Bot.Core.ConversationParameters - { - IsGroup = parameters.IsGroup, - Bot = parameters.Bot?.FromCompatChannelAccount(), - Members = parameters.Members?.Select(m => m.FromCompatChannelAccount()).ToList(), - TopicName = parameters.TopicName, - Activity = parameters.Activity?.FromCompatActivity(), - ChannelData = parameters.ChannelData, - TenantId = parameters.TenantId, - }; - } - - /// - /// Gets the TeamInfo object from the current activity. - /// - /// The activity. - /// The current activity's team's information, or null. - public static TeamInfo? TeamsGetTeamInfo(this IActivity activity) - { - ArgumentNullException.ThrowIfNull(activity); - Microsoft.Bot.Schema.Teams.TeamsChannelData channelData = activity.GetChannelData(); - return channelData?.Team; - } } diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatChannelAccount.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatChannelAccount.cs new file mode 100644 index 00000000..c2858127 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatChannelAccount.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Compat; + +internal static class CompatChannelAccount +{ + internal static Microsoft.Bot.Schema.ChannelAccount ToCompatChannelAccount(this Microsoft.Teams.Bot.Core.Schema.ConversationAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + Microsoft.Bot.Schema.ChannelAccount channelAccount; + if (account is TeamsConversationAccount tae) + { + channelAccount = new() + { + Id = account.Id, + Name = account.Name, + AadObjectId = tae.AadObjectId + }; + } + else + { + channelAccount = new() + { + Id = account.Id, + Name = account.Name + }; + } + + if (account.Properties.TryGetValue("aadObjectId", out object? aadObjectId)) + { + channelAccount.AadObjectId = aadObjectId?.ToString(); + } + + if (account.Properties.TryGetValue("userRole", out object? userRole)) + { + channelAccount.Role = userRole?.ToString(); + } + + if (account.Properties.TryGetValue("userPrincipalName", out object? userPrincipalName)) + { + channelAccount.Properties.Add("userPrincipalName", userPrincipalName?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("givenName", out object? givenName)) + { + channelAccount.Properties.Add("givenName", givenName?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("surname", out object? surname)) + { + channelAccount.Properties.Add("surname", surname?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("email", out object? email)) + { + channelAccount.Properties.Add("email", email?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("tenantId", out object? tenantId)) + { + channelAccount.Properties.Add("tenantId", tenantId?.ToString() ?? string.Empty); + } + + return channelAccount; + } + + internal static Microsoft.Teams.Bot.Core.Schema.ConversationAccount FromCompatChannelAccount(this Microsoft.Bot.Schema.ChannelAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + Microsoft.Teams.Bot.Core.Schema.ConversationAccount result = new() { Id = account.Id, Name = account.Name }; + + if (!string.IsNullOrEmpty(account.AadObjectId)) + { + result.Properties["aadObjectId"] = account.AadObjectId; + } + + if (!string.IsNullOrEmpty(account.Role)) + { + result.Properties["userRole"] = account.Role; + } + + return result; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs index 6d537d6a..68f8a9f4 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs @@ -5,12 +5,10 @@ using Microsoft.Bot.Connector; using Microsoft.Bot.Schema; using Microsoft.Bot.Schema.Teams; -using Microsoft.Teams.Bot.Apps; using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Schema; -using AppsTeams = Microsoft.Teams.Bot.Apps; using BotFrameworkTeams = Microsoft.Bot.Schema.Teams; namespace Microsoft.Teams.Bot.Compat; @@ -61,6 +59,106 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) #endregion + + + + private static Microsoft.Bot.Schema.Teams.TeamsChannelAccount ToCompatTeamsChannelAccount(this Microsoft.Teams.Bot.Apps.Schema.TeamsConversationAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + return new Microsoft.Bot.Schema.Teams.TeamsChannelAccount + { + Id = account.Id, + Name = account.Name, + AadObjectId = account.AadObjectId, + Email = account.Email, + GivenName = account.GivenName, + Surname = account.Surname, + UserPrincipalName = account.UserPrincipalName, + UserRole = account.UserRole, + TenantId = account.TenantId + }; + } + + + + /// + /// Converts a Bot Framework ConversationParameters to a Core ConversationParameters. + /// + public static Microsoft.Teams.Bot.Core.ConversationParameters FromCompatConversationParameters(this Microsoft.Bot.Schema.ConversationParameters parameters) + { + ArgumentNullException.ThrowIfNull(parameters); + + return new Microsoft.Teams.Bot.Core.ConversationParameters + { + IsGroup = parameters.IsGroup, + Bot = parameters.Bot?.FromCompatChannelAccount(), + Members = parameters.Members?.Select(m => m.FromCompatChannelAccount()).ToList(), + TopicName = parameters.TopicName, + Activity = parameters.Activity?.FromCompatActivity(), + ChannelData = parameters.ChannelData, + TenantId = parameters.TenantId, + }; + } + + /// + /// Gets the TeamInfo object from the current activity. + /// + /// The activity. + /// The current activity's team's information, or null. + private static TeamInfo? TeamsGetTeamInfo(this Activity activity) + { + ArgumentNullException.ThrowIfNull(activity); + Microsoft.Bot.Schema.Teams.TeamsChannelData channelData = activity.GetChannelData(); + return channelData?.Team; + } + + private static Microsoft.Bot.Schema.Teams.TeamsMeetingParticipant ToCompatTeamsMeetingParticipant(this Microsoft.Teams.Bot.Apps.Api.Clients.MeetingParticipant participant) + { + ArgumentNullException.ThrowIfNull(participant); + + return new Microsoft.Bot.Schema.Teams.TeamsMeetingParticipant + { + User = participant.User?.ToCompatTeamsChannelAccount(), + Meeting = participant.Meeting != null ? new Microsoft.Bot.Schema.Teams.MeetingParticipantInfo + { + Role = participant.Meeting.Role, + InMeeting = participant.Meeting.InMeeting + } : null, + Conversation = participant.Conversation != null ? new Microsoft.Bot.Schema.ConversationAccount + { + Id = participant.Conversation.Id + } : null + }; + } + + private static Microsoft.Bot.Schema.Teams.ChannelInfo ToCompatChannelInfo(this Microsoft.Teams.Bot.Apps.Schema.TeamsChannel channel) + { + ArgumentNullException.ThrowIfNull(channel); + + return new Microsoft.Bot.Schema.Teams.ChannelInfo + { + Id = channel.Id, + Name = channel.Name + }; + } + + /// + /// Converts a Core PagedMembersResult to a Bot Framework TeamsPagedMembersResult. + /// + /// + /// + public static Microsoft.Bot.Schema.Teams.TeamsPagedMembersResult ToCompatTeamsPagedMembersResult(this Microsoft.Teams.Bot.Core.PagedMembersResult pagedMembers) + { + ArgumentNullException.ThrowIfNull(pagedMembers); + + return new Microsoft.Bot.Schema.Teams.TeamsPagedMembersResult + { + ContinuationToken = pagedMembers.ContinuationToken, + Members = pagedMembers.Members?.Select(m => m.ToCompatTeamsChannelAccount()).ToList() + }; + } + #region Member & Participant Methods /// @@ -271,31 +369,93 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) #region Meeting Methods - ///// - ///// Gets the information for the given meeting id. - ///// - ///// Turn context. - ///// The BASE64-encoded id of the Teams meeting. - ///// Cancellation token. - ///// Meeting information. - //public static async Task GetMeetingInfoAsync( - // ITurnContext turnContext, - // string? meetingId = null, - // CancellationToken cancellationToken = default) - //{ - // ArgumentNullException.ThrowIfNull(turnContext); - // meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id - // ?? throw new InvalidOperationException("The meetingId can only be null if turnContext is within the scope of a MS Teams Meeting."); + private static Microsoft.Bot.Schema.Teams.TeamsChannelAccount ToCompatTeamsChannelAccount(this Microsoft.Teams.Bot.Core.Schema.ConversationAccount account) + { + ArgumentNullException.ThrowIfNull(account); - // var client = GetTeamsApiClient(turnContext); - // // Uri serviceUrl = new(GetServiceUrl(turnContext)); - // AgenticIdentity identity = GetIdentity(turnContext); + TeamsChannelAccount teamsChannelAccount = new() + { + Id = account.Id, + Name = account.Name + }; - // var result = await client.Meetings.GetByIdAsync( - // meetingId, cancellationToken).ConfigureAwait(false); + // Extract properties from Properties dictionary + if (account.Properties.TryGetValue("aadObjectId", out object? aadObjectId)) + { + teamsChannelAccount.AadObjectId = aadObjectId?.ToString(); + } + + if (account.Properties.TryGetValue("userPrincipalName", out object? userPrincipalName)) + { + teamsChannelAccount.UserPrincipalName = userPrincipalName?.ToString(); + } + + if (account.Properties.TryGetValue("givenName", out object? givenName)) + { + teamsChannelAccount.GivenName = givenName?.ToString(); + } + + if (account.Properties.TryGetValue("surname", out object? surname)) + { + teamsChannelAccount.Surname = surname?.ToString(); + } + + if (account.Properties.TryGetValue("email", out object? email)) + { + teamsChannelAccount.Email = email?.ToString(); + } + + if (account.Properties.TryGetValue("tenantId", out object? tenantId)) + { + teamsChannelAccount.Properties.Add("tenantId", tenantId?.ToString() ?? string.Empty); + } + + return teamsChannelAccount; + } - // return new BotFrameworkTeams.MeetingInfo(); // TODO: Map the result to BotFrameworkTeams.MeetingInfo once the API is finalized and we have the necessary details in the result to perform the mapping. - //} + + /// + /// Gets the information for the given meeting id. + /// + /// Turn context. + /// The BASE64-encoded id of the Teams meeting. + /// Cancellation token. + /// Meeting information. + public static async Task GetMeetingInfoAsync( + ITurnContext turnContext, + string? meetingId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id + ?? throw new InvalidOperationException("The meetingId can only be null if turnContext is within the scope of a MS Teams Meeting."); + + ApiClient client = GetTeamsApiClient(turnContext); + Meeting? meetingInfo = await client.Meetings.GetByIdAsync( + meetingId, cancellationToken).ConfigureAwait(false); + + if (meetingInfo is null) return null!; + + return new BotFrameworkTeams.MeetingInfo() + { + Details = meetingInfo.Details != null ? new Microsoft.Bot.Schema.Teams.MeetingDetails + { + Id = meetingInfo.Details.Id, + //MsGraphResourceId = meetingInfo.Details.MsGraphResourceId, + //ScheduledStartTime = meetingInfo.Details.ScheduledStartTime?.DateTime, + //ScheduledEndTime = meetingInfo.Details.ScheduledEndTime?.DateTime, + JoinUrl = meetingInfo.Details.JoinUrl, + Title = meetingInfo.Details.Title, + Type = meetingInfo.Details.Type + } : null, + Conversation = meetingInfo.Conversation != null ? new Microsoft.Bot.Schema.ConversationAccount + { + Id = meetingInfo.Conversation.Id + //Name = meetingInfo.Conversation. + } : null, + Organizer = meetingInfo.Organizer?.ToCompatTeamsChannelAccount() + }; + } /// /// Gets the details for the given meeting participant. This only works in teams meeting scoped conversations. @@ -321,15 +481,15 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) tenantId ??= turnContext.Activity.GetChannelData()?.Tenant?.Id ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); - var client = GetTeamsApiClient(turnContext); + ApiClient client = GetTeamsApiClient(turnContext); //AgenticIdentity identity = GetIdentity(turnContext); - var result = await client.Meetings.GetParticipantAsync( + MeetingParticipant? result = await client.Meetings.GetParticipantAsync( meetingId, participantId, tenantId, cancellationToken).ConfigureAwait(false); - + return new TeamsMeetingParticipant() { - Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = result?.Conversation?.Id }, + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = result?.Conversation?.Id }, Meeting = new MeetingParticipantInfo() { InMeeting = result?.Meeting?.InMeeting, @@ -397,9 +557,9 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); - var client = GetTeamsApiClient(turnContext); + ApiClient client = GetTeamsApiClient(turnContext); - var result = await client.Teams.GetByIdAsync(t, cancellationToken).ConfigureAwait(false); + Team? result = await client.Teams.GetByIdAsync(t, cancellationToken).ConfigureAwait(false); return new TeamDetails { @@ -426,11 +586,11 @@ private static AgenticIdentity GetIdentity(ITurnContext turnContext) string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); - var client = GetTeamsApiClient(turnContext); + ApiClient client = GetTeamsApiClient(turnContext); Uri serviceUrl = new(GetServiceUrl(turnContext)); AgenticIdentity identity = GetIdentity(turnContext); - var channelList = await client.Teams.GetConversationsAsync(t, cancellationToken).ConfigureAwait(false); + List? channelList = await client.Teams.GetConversationsAsync(t, cancellationToken).ConfigureAwait(false); return channelList?.Select(c => c.ToCompatChannelInfo()).ToList() ?? []; } diff --git a/core/src/Microsoft.Teams.Bot.Compat/InternalsVisibleTo.cs b/core/src/Microsoft.Teams.Bot.Compat/InternalsVisibleTo.cs index dbb38036..11c8c833 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/InternalsVisibleTo.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/InternalsVisibleTo.cs @@ -5,3 +5,4 @@ [assembly: InternalsVisibleTo("Microsoft.Teams.Bot.Core.Tests")] [assembly: InternalsVisibleTo("Microsoft.Teams.Bot.Compat.UnitTests")] +[assembly: InternalsVisibleTo("IntegrationTests")] diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs index 02a46e02..fd43d2f2 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs @@ -1,14 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Linq; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Web; @@ -223,7 +221,7 @@ internal static ILogger GetLoggerFromServices(IServiceCollection services, Type? // Otherwise, build a temporary service provider to create the logger using ServiceProvider tempProvider = services.BuildServiceProvider(); ILoggerFactory? tempFactory = tempProvider.GetService(); - return (ILogger?)tempFactory?.CreateLogger(categoryType ?? typeof(AddBotApplicationExtensions)) + return (tempFactory?.CreateLogger(categoryType ?? typeof(AddBotApplicationExtensions))) ?? Extensions.Logging.Abstractions.NullLogger.Instance; } } diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/MsalConfigurationExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/MsalConfigurationExtensions.cs index 83d9953c..2dfe2368 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/MsalConfigurationExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/MsalConfigurationExtensions.cs @@ -4,9 +4,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Microsoft.Identity.Abstractions; -using Microsoft.Identity.Web; namespace Microsoft.Teams.Bot.Core.Hosting; diff --git a/core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs b/core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs index 3eae7eb8..e0eef224 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs @@ -8,7 +8,6 @@ using System.Text.Json; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; -using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Hosting; namespace Microsoft.Teams.Bot.Core.Http; diff --git a/core/test/IntegrationTests.slnx b/core/test/IntegrationTests.slnx index d3811a8d..73b1cb1c 100644 --- a/core/test/IntegrationTests.slnx +++ b/core/test/IntegrationTests.slnx @@ -1,11 +1,11 @@ + - - + diff --git a/core/test/IntegrationTests/ApiClientTests.cs b/core/test/IntegrationTests/ApiClientTests.cs index ba5615d1..b25af4cc 100644 --- a/core/test/IntegrationTests/ApiClientTests.cs +++ b/core/test/IntegrationTests/ApiClientTests.cs @@ -23,6 +23,7 @@ public class ApiClientTests : IClassFixture public ApiClientTests(IntegrationTestFixture fixture, ITestOutputHelper output) { _f = fixture; + _f.OutputHelper = output; _output = output; _api = _f.ScopedApiClient; } @@ -115,6 +116,82 @@ public async Task Activities_DeleteAsync() #endregion + #region Targeted Activities + + [Fact(Skip = "Targeted activities are not supported in team channel conversations")] + public async Task Activities_CreateTargetedAsync() + { + // Targeted activities require a valid Recipient — get a real member ID + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Recipient = new ConversationAccount { Id = members[0].Id }, + Properties = { { "text", $"[ApiClient.Activities.CreateTargeted] at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? res = await _api.Conversations.Activities.CreateTargetedAsync(_f.ConversationId, activity); + + Assert.NotNull(res); + Assert.NotNull(res.Id); + _output.WriteLine($"Created targeted activity: {res.Id}"); + } + + [Fact(Skip = "Targeted activities are not supported in team channel conversations")] + public async Task Activities_UpdateTargetedAsync() + { + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + + CoreActivity original = new() + { + Type = ActivityType.Message, + Recipient = new ConversationAccount { Id = members[0].Id }, + Properties = { { "text", $"[ApiClient.Activities.UpdateTargeted] Original at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateTargetedAsync(_f.ConversationId, original); + Assert.NotNull(sent?.Id); + + CoreActivity updated = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"[ApiClient.Activities.UpdateTargeted] Updated at `{DateTime.UtcNow:s}`" } } + }; + + UpdateActivityResponse? res = await _api.Conversations.Activities.UpdateTargetedAsync( + _f.ConversationId, sent.Id, updated); + + Assert.NotNull(res?.Id); + _output.WriteLine($"Updated targeted activity: {res.Id}"); + } + + [Fact(Skip = "Targeted activities are not supported in team channel conversations")] + public async Task Activities_DeleteTargetedAsync() + { + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Recipient = new ConversationAccount { Id = members[0].Id }, + Properties = { { "text", $"[ApiClient.Activities.DeleteTargeted] at `{DateTime.UtcNow:s}`" } } + }; + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateTargetedAsync(_f.ConversationId, activity); + Assert.NotNull(sent?.Id); + + await Task.Delay(2000); + + await _api.Conversations.Activities.DeleteTargetedAsync(_f.ConversationId, sent.Id); + _output.WriteLine($"Deleted targeted activity: {sent.Id}"); + } + + #endregion + #region Members [Fact] @@ -167,7 +244,7 @@ public async Task Members_GetByIdAsync_AsTeamsConversationAccount() #region Reactions - [Fact(Skip = "Reactions API returns NotFound — needs service-url scoped auth")] + [Fact(Skip = "Reactions endpoint does not exist in Teams Bot Framework API (experimental/assumed route)")] public async Task Reactions_AddAndDelete() { CoreActivity activity = new() @@ -232,11 +309,35 @@ public async Task Meetings_GetByIdAsync() } } - [Fact(Skip = "Requires AAD object ID, not pairwise bot framework ID")] + [Fact] public async Task Meetings_GetParticipantAsync() { + // The meetings participant API requires AAD object ID, not MRI/pairwise bot framework ID. + // Get the AAD object ID from a human member (bots don't have one). + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + + string? aadObjectId = null; + foreach (ConversationAccount m in members) + { + TeamsConversationAccount tm = await _api.Conversations.Members + .GetByIdAsync(_f.ConversationId, m.Id!); + _output.WriteLine($"Member: {tm.Name} — AadObjectId: {tm.AadObjectId ?? "(null)"}, Properties: [{string.Join(", ", tm.Properties.Keys)}]"); + if (tm.AadObjectId is not null) + { + aadObjectId = tm.AadObjectId; + break; + } + } + + if (aadObjectId is null) + { + _output.WriteLine("SKIP: No members with AAD object ID found in test conversation"); + return; + } + MeetingParticipant? participant = await _api.Meetings.GetParticipantAsync( - _f.MeetingId, _f.UserId, _f.TenantId); + _f.MeetingId, aadObjectId, _f.TenantId); Assert.NotNull(participant); _output.WriteLine($"Participant: {participant.User?.Id} — Role: {participant.Meeting?.Role}, InMeeting: {participant.Meeting?.InMeeting}"); @@ -244,6 +345,101 @@ public async Task Meetings_GetParticipantAsync() #endregion + #region Bots — SignIn + + [Fact(Skip = "Requires a valid OAuth connection name configured for the bot")] + public async Task Bots_SignIn_GetUrlAsync() + { + string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") + ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); + + // State must be a proper Bot Framework sign-in state JSON + string state = System.Text.Json.JsonSerializer.Serialize(new + { + ConnectionName = connectionName, + Conversation = new { Id = _f.ConversationId }, + MsAppId = _f.BotAppId + }); + + string? url = await _api.Bots.SignIn.GetUrlAsync(state); + + Assert.NotNull(url); + Assert.StartsWith("https://", url); + _output.WriteLine($"SignIn URL: {url}"); + } + + [Fact(Skip = "Requires a valid OAuth connection name configured for the bot")] + public async Task Bots_SignIn_GetResourceAsync() + { + string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") + ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); + + string state = System.Text.Json.JsonSerializer.Serialize(new + { + ConnectionName = connectionName, + Conversation = new { Id = _f.ConversationId }, + MsAppId = _f.BotAppId + }); + + var resource = await _api.Bots.SignIn.GetResourceAsync(state); + + Assert.NotNull(resource); + _output.WriteLine($"SignIn Resource: {resource.SignInLink}"); + } + + #endregion + + #region Users — Token + + [Fact] + public async Task Users_Token_GetStatusAsync() + { + // Get a valid member ID from the conversation + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + string userId = members[0].Id!; + + IList? statuses = await _api.Users.Token.GetStatusAsync(userId, "msteams"); + + // May return null or empty if user has no token connections — that's OK + _output.WriteLine($"Token statuses: {statuses?.Count ?? 0} connections"); + if (statuses is not null) + { + foreach (var s in statuses) + { + _output.WriteLine($" Connection: {s.ConnectionName}, HasToken: {s.HasToken}"); + } + } + } + + [Fact(Skip = "Requires TEST_CONNECTION_NAME to be configured with an OAuth connection")] + public async Task Users_Token_GetAsync() + { + string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") + ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); + + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + + var result = await _api.Users.Token.GetAsync(members[0].Id!, connectionName, "msteams"); + _output.WriteLine($"Token: {(result is not null ? "acquired" : "not available")}"); + } + + [Fact(Skip = "Requires TEST_CONNECTION_NAME to be configured with an OAuth connection")] + public async Task Users_Token_SignOutAsync() + { + string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") + ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); + + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + + await _api.Users.Token.SignOutAsync(members[0].Id!, connectionName, "msteams"); + _output.WriteLine("SignOut completed"); + } + + #endregion + #region ForServiceUrl [Fact] diff --git a/core/test/IntegrationTests/CompatTeamsInfoTests.cs b/core/test/IntegrationTests/CompatTeamsInfoTests.cs new file mode 100644 index 00000000..5c3c807d --- /dev/null +++ b/core/test/IntegrationTests/CompatTeamsInfoTests.cs @@ -0,0 +1,362 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Teams.Bot.Apps.Api.Clients; +using Microsoft.Teams.Bot.Compat; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Xunit.Abstractions; +using CoreConversationAccount = Microsoft.Teams.Bot.Core.Schema.ConversationAccount; + +namespace IntegrationTests; + +/// +/// Integration tests for static methods making real API calls. +/// These tests verify that CompatTeamsInfo correctly bridges Bot Framework ITurnContext +/// to the underlying ConversationClient and ApiClient, producing valid compat types. +/// +public class CompatTeamsInfoTests : IClassFixture +{ + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + + public CompatTeamsInfoTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _f.OutputHelper = output; + _output = output; + } + + /// + /// Creates an ITurnContext wired to real clients, simulating what CompatAdapter does. + /// + private TurnContext CreateTurnContext( + string? conversationId = null, + string? teamId = null, + string? meetingId = null, + string? tenantId = null) + { + Activity activity = new() + { + Type = ActivityTypes.Message, + ServiceUrl = _f.ServiceUrl.ToString(), + ChannelId = "msteams", + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = conversationId ?? _f.ConversationId }, + From = new ChannelAccount { Id = "bot" }, + Recipient = new ChannelAccount { Id = "user" }, + }; + + // Set TeamsChannelData if teamId or meetingId is provided + if (teamId != null || meetingId != null || tenantId != null) + { + TeamsChannelData channelData = new(); + if (teamId != null) + { + channelData.Team = new TeamInfo { Id = teamId }; + } + + if (meetingId != null) + { + channelData.Meeting = new TeamsMeetingInfo { Id = meetingId }; + } + + if (tenantId != null) + { + channelData.Tenant = new TenantInfo { Id = tenantId }; + } + + activity.ChannelData = channelData; + } + + // Create a stub adapter (BotAdapter is abstract, use SimpleAdapter) + SimpleAdapter adapter = new(); + TurnContext turnContext = new(adapter, activity); + + // Wire up CompatConnectorClient with real ConversationClient (same as CompatAdapter does) + CompatConversations compatConversations = new(_f.ConversationClient) + { + ServiceUrl = _f.ServiceUrl.ToString() + }; + CompatConnectorClient connectorClient = new(compatConversations); + turnContext.TurnState.Add(connectorClient); + + // Wire up scoped ApiClient (same as CompatAdapter does) + ApiClient scopedApi = _f.ScopedApiClient; + turnContext.TurnState.Add(scopedApi); + + return turnContext; + } + + #region Member Methods (non-team scope) + + [Fact] + public async Task GetMemberAsync_ReturnsTeamsChannelAccount() + { + // First get a valid MRI-format member ID + ApiClient api = _f.ScopedApiClient; + IList members = await api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + using TurnContext ctx = CreateTurnContext(); + TeamsChannelAccount result = await CompatTeamsInfo.GetMemberAsync(ctx, memberId); + + Assert.NotNull(result); + Assert.Equal(memberId, result.Id); + _output.WriteLine($"GetMember: {result.Id} — {result.Name}, Email: {result.Email}, UPN: {result.UserPrincipalName}"); + } + +#pragma warning disable CS0618 // Obsolete warning for GetMembersAsync + [Fact] + public async Task GetMembersAsync_ReturnsTeamsChannelAccounts() + { + using TurnContext ctx = CreateTurnContext(); + IEnumerable result = await CompatTeamsInfo.GetMembersAsync(ctx); + + Assert.NotNull(result); + List members = [.. result]; + Assert.NotEmpty(members); + + foreach (TeamsChannelAccount m in members) + { + _output.WriteLine($"GetMembers: {m.Id} — {m.Name}"); + } + } +#pragma warning restore CS0618 + + [Fact] + public async Task GetPagedMembersAsync_ReturnsPaged() + { + using TurnContext ctx = CreateTurnContext(); + TeamsPagedMembersResult result = await CompatTeamsInfo.GetPagedMembersAsync(ctx, pageSize: 2); + + Assert.NotNull(result); + Assert.NotNull(result.Members); + Assert.NotEmpty(result.Members); + + foreach (TeamsChannelAccount m in result.Members) + { + _output.WriteLine($"PagedMember: {m.Id} — {m.Name}"); + } + + _output.WriteLine($"ContinuationToken: {result.ContinuationToken ?? "(null)"}"); + } + + #endregion + + #region Team-scoped Member Methods + + [Fact] + public async Task GetTeamMemberAsync_ReturnsTeamsChannelAccount() + { + // Get a valid MRI-format member ID from the team + ApiClient api = _f.ScopedApiClient; + IList members = await api.Conversations.Members.GetAsync(_f.TeamId); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + TeamsChannelAccount result = await CompatTeamsInfo.GetTeamMemberAsync(ctx, memberId, _f.TeamId); + + Assert.NotNull(result); + Assert.Equal(memberId, result.Id); + _output.WriteLine($"GetTeamMember: {result.Id} — {result.Name}, Email: {result.Email}"); + } + + [Fact] + public async Task GetMemberAsync_WithTeamScope_DelegatesToGetTeamMember() + { + // When activity has TeamInfo, GetMemberAsync should delegate to GetTeamMemberAsync + ApiClient api = _f.ScopedApiClient; + IList members = await api.Conversations.Members.GetAsync(_f.TeamId); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + TeamsChannelAccount result = await CompatTeamsInfo.GetMemberAsync(ctx, memberId); + + Assert.NotNull(result); + Assert.Equal(memberId, result.Id); + _output.WriteLine($"GetMember (team scope): {result.Id} — {result.Name}"); + } + +#pragma warning disable CS0618 + [Fact] + public async Task GetTeamMembersAsync_ReturnsMembers() + { + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + IEnumerable result = await CompatTeamsInfo.GetTeamMembersAsync(ctx, _f.TeamId); + + Assert.NotNull(result); + List members = result.ToList(); + Assert.NotEmpty(members); + + foreach (TeamsChannelAccount m in members) + { + _output.WriteLine($"TeamMember: {m.Id} — {m.Name}"); + } + } +#pragma warning restore CS0618 + + [Fact] + public async Task GetPagedTeamMembersAsync_ReturnsPaged() + { + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + TeamsPagedMembersResult result = await CompatTeamsInfo.GetPagedTeamMembersAsync(ctx, _f.TeamId, pageSize: 2); + + Assert.NotNull(result); + Assert.NotNull(result.Members); + Assert.NotEmpty(result.Members); + + foreach (TeamsChannelAccount m in result.Members) + { + _output.WriteLine($"PagedTeamMember: {m.Id} — {m.Name}"); + } + + _output.WriteLine($"ContinuationToken: {result.ContinuationToken ?? "(null)"}"); + } + + #endregion + + #region Team & Channel Methods + + [Fact] + public async Task GetTeamDetailsAsync_ReturnsDetails() + { + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + TeamDetails result = await CompatTeamsInfo.GetTeamDetailsAsync(ctx, _f.TeamId); + + Assert.NotNull(result); + Assert.NotNull(result.Id); + Assert.NotNull(result.Name); + _output.WriteLine($"TeamDetails: {result.Id} — {result.Name}, AadGroupId: {result.AadGroupId}"); + } + + [Fact] + public async Task GetTeamDetailsAsync_InfersTeamIdFromActivity() + { + // When teamId is null, it should be inferred from the activity's TeamsChannelData + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + TeamDetails result = await CompatTeamsInfo.GetTeamDetailsAsync(ctx); + + Assert.NotNull(result); + Assert.NotNull(result.Id); + _output.WriteLine($"TeamDetails (inferred): {result.Id} — {result.Name}"); + } + + [Fact] + public async Task GetTeamChannelsAsync_ReturnsChannels() + { + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + IList result = await CompatTeamsInfo.GetTeamChannelsAsync(ctx, _f.TeamId); + + Assert.NotNull(result); + Assert.NotEmpty(result); + + foreach (ChannelInfo ch in result) + { + _output.WriteLine($"Channel: {ch.Id} — {ch.Name}"); + } + } + + [Fact] + public async Task GetTeamChannelsAsync_InfersTeamIdFromActivity() + { + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + IList result = await CompatTeamsInfo.GetTeamChannelsAsync(ctx); + + Assert.NotNull(result); + Assert.NotEmpty(result); + _output.WriteLine($"Channels (inferred): {result.Count} channels found"); + } + + #endregion + + #region Meeting Methods + + [Fact] + public async Task GetMeetingParticipantAsync_ReturnsParticipant() + { + // The meetings participant API requires AAD object ID, not MRI/pairwise bot framework ID. + // Get the AAD object ID from a human member (bots don't have one). + ApiClient api = _f.ScopedApiClient; + IList members = await api.Conversations.Members.GetAsync(_f.ConversationId); + Assert.NotEmpty(members); + + string? aadObjectId = null; + foreach (CoreConversationAccount m in members) + { + var tm = await api.Conversations.Members + .GetByIdAsync(_f.ConversationId, m.Id!); + _output.WriteLine($"Member: {tm.Name} — AadObjectId: {tm.AadObjectId ?? "(null)"}, Properties: [{string.Join(", ", tm.Properties.Keys)}]"); + if (tm.AadObjectId is not null) + { + aadObjectId = tm.AadObjectId; + break; + } + } + + if (aadObjectId is null) + { + _output.WriteLine("SKIP: No members with AAD object ID found in test conversation"); + return; + } + + using TurnContext ctx = CreateTurnContext(meetingId: _f.MeetingId, tenantId: _f.TenantId); + TeamsMeetingParticipant result = await CompatTeamsInfo.GetMeetingParticipantAsync( + ctx, _f.MeetingId, aadObjectId, _f.TenantId); + + Assert.NotNull(result); + _output.WriteLine($"Participant: {result.User?.Id} — Role: {result.Meeting?.Role}, InMeeting: {result.Meeting?.InMeeting}"); + } + + #endregion + + #region Error Cases + + [Fact] + public async Task GetTeamDetailsAsync_ThrowsWithoutTeamScope() + { + // No teamId in activity and no explicit teamId parameter + using TurnContext ctx = CreateTurnContext(); + await Assert.ThrowsAsync( + () => CompatTeamsInfo.GetTeamDetailsAsync(ctx)); + } + + [Fact] + public async Task GetTeamChannelsAsync_ThrowsWithoutTeamScope() + { + using TurnContext ctx = CreateTurnContext(); + await Assert.ThrowsAsync( + () => CompatTeamsInfo.GetTeamChannelsAsync(ctx)); + } + + [Fact] + public async Task GetMemberAsync_ThrowsWithNullUserId() + { + using TurnContext ctx = CreateTurnContext(); + await Assert.ThrowsAsync( + () => CompatTeamsInfo.GetMemberAsync(ctx, null!)); + } + + #endregion + + /// + /// Minimal BotAdapter stub for creating TurnContext in tests. + /// + private sealed class SimpleAdapter : BotAdapter + { + public override Task DeleteActivityAsync(ITurnContext turnContext, ConversationReference reference, CancellationToken cancellationToken) + => Task.CompletedTask; + + public override Task SendActivitiesAsync(ITurnContext turnContext, Activity[] activities, CancellationToken cancellationToken) + => Task.FromResult(Array.Empty()); + + public override Task UpdateActivityAsync(ITurnContext turnContext, Activity activity, CancellationToken cancellationToken) + => Task.FromResult(new ResourceResponse()); + } +} diff --git a/core/test/IntegrationTests/ConversationClientTests.cs b/core/test/IntegrationTests/ConversationClientTests.cs index b2438ea5..1395589d 100644 --- a/core/test/IntegrationTests/ConversationClientTests.cs +++ b/core/test/IntegrationTests/ConversationClientTests.cs @@ -18,6 +18,7 @@ public class ConversationClientTests : IClassFixture public ConversationClientTests(IntegrationTestFixture fixture, ITestOutputHelper output) { _f = fixture; + _f.OutputHelper = output; _output = output; } @@ -137,7 +138,7 @@ public async Task GetPagedMembers() } } - [Fact(Skip = "Reactions API returns NotFound — needs service-url scoped auth")] + [Fact(Skip = "Reactions endpoint does not exist in Teams Bot Framework API (experimental/assumed route)")] public async Task AddAndDeleteReaction() { CoreActivity activity = new() diff --git a/core/test/IntegrationTests/CreateConversationDiagnosticTests.cs b/core/test/IntegrationTests/CreateConversationDiagnosticTests.cs index eaa5e084..a364bdfc 100644 --- a/core/test/IntegrationTests/CreateConversationDiagnosticTests.cs +++ b/core/test/IntegrationTests/CreateConversationDiagnosticTests.cs @@ -29,6 +29,7 @@ public class CreateConversationDiagnosticTests : IClassFixture SendDiagnosticRequestAsync(string label, Co try { var parsed = JsonSerializer.Deserialize(responseBody); - string pretty = JsonSerializer.Serialize(parsed, new JsonSerializerOptions { WriteIndented = true }); + + string pretty = JsonSerializer.Serialize(parsed, JsonOpts); _output.WriteLine($"\nResponse body:\n{pretty}"); } catch diff --git a/core/test/IntegrationTests/CreateConversationTests.cs b/core/test/IntegrationTests/CreateConversationTests.cs index 3713a22a..118652a2 100644 --- a/core/test/IntegrationTests/CreateConversationTests.cs +++ b/core/test/IntegrationTests/CreateConversationTests.cs @@ -22,6 +22,7 @@ public class CreateConversationTests : IClassFixture public CreateConversationTests(IntegrationTestFixture fixture, ITestOutputHelper output) { _f = fixture; + _f.OutputHelper = output; _output = output; _api = _f.ScopedApiClient; } diff --git a/core/test/IntegrationTests/IntegrationTestFixture.cs b/core/test/IntegrationTests/IntegrationTestFixture.cs index f2219dda..9a64843e 100644 --- a/core/test/IntegrationTests/IntegrationTestFixture.cs +++ b/core/test/IntegrationTests/IntegrationTestFixture.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using MartinCostello.Logging.XUnit; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -8,6 +9,7 @@ using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Schema; +using Xunit.Abstractions; namespace IntegrationTests; @@ -15,7 +17,7 @@ namespace IntegrationTests; /// Shared fixture that configures DI, acquires tokens, and exposes clients for integration tests. /// Reused across test classes via IClassFixture to avoid repeated token acquisition. /// -public class IntegrationTestFixture : IDisposable +public class IntegrationTestFixture : IDisposable, ITestOutputHelperAccessor { public ServiceProvider ServiceProvider { get; } public ConversationClient ConversationClient { get; } @@ -32,6 +34,11 @@ public class IntegrationTestFixture : IDisposable public string? UserId2 { get; } public AgenticIdentity? AgenticIdentity { get; } + /// + /// Set by each test class constructor to route ILogger output to xUnit's test output. + /// + public ITestOutputHelper? OutputHelper { get; set; } + public IntegrationTestFixture() { IConfiguration configuration = new ConfigurationBuilder() @@ -42,9 +49,10 @@ public IntegrationTestFixture() ServiceCollection services = new(); services.AddLogging(builder => { + builder.AddXUnit(this); builder.AddFilter("System.Net", LogLevel.Warning); builder.AddFilter("Microsoft.Identity", LogLevel.Error); - builder.AddFilter("Microsoft.Teams", LogLevel.Trace); + builder.AddFilter("Microsoft.Teams", LogLevel.Information); }); services.AddSingleton(configuration); services.AddTeamsBotApplication(); diff --git a/core/test/IntegrationTests/IntegrationTests.csproj b/core/test/IntegrationTests/IntegrationTests.csproj index 2e6bc96f..934ccdf8 100644 --- a/core/test/IntegrationTests/IntegrationTests.csproj +++ b/core/test/IntegrationTests/IntegrationTests.csproj @@ -5,6 +5,7 @@ enable enable false + $(NoWarn);ExperimentalTeamsTargeted;ExperimentalTeamsReactions @@ -17,6 +18,7 @@ + diff --git a/core/test/IntegrationTests/test-results.md b/core/test/IntegrationTests/test-results.md new file mode 100644 index 00000000..e52b5770 --- /dev/null +++ b/core/test/IntegrationTests/test-results.md @@ -0,0 +1,195 @@ +# Integration Test Results + +**Date:** 2026-04-17 +**Runtime:** .NET 10.0 | xUnit 3.1.4 +**Duration:** 1m 17s +**Result: 55 Passed, 0 Failed, 12 Skipped (67 total)** + +--- + +## Summary by Test Class + +| Test Class | Passed | Skipped | Failed | +|---|:---:|:---:|:---:| +| ConversationClientTests | 6 | 1 | 0 | +| ApiClientTests | 14 | 8 | 0 | +| CompatTeamsInfoTests | 14 | 0 | 0 | +| CreateConversationTests | 7 | 3 | 0 | +| CreateConversationDiagnosticTests | 13 | 0 | 0 | + +--- + +## ConversationClientTests (6/7) + +| Test | Result | Duration | +|---|---|---:| +| SendActivity | Passed | 712 ms | +| UpdateActivity | Passed | 1 s | +| DeleteActivity | Passed | 2 s | +| GetConversationMembers | Passed | 543 ms | +| GetConversationMember | Passed | 1 s | +| GetPagedMembers | Passed | 1 s | +| AddAndDeleteReaction | **Skipped** | - | + +## ApiClientTests (14/22) + +### Activities + +| Test | Result | Duration | +|---|---|---:| +| Activities_CreateAsync | Passed | 558 ms | +| Activities_UpdateAsync | Passed | 1 s | +| Activities_ReplyAsync | Passed | 1 s | +| Activities_DeleteAsync | Passed | 3 s | +| Activities_CreateTargetedAsync | **Skipped** | - | +| Activities_UpdateTargetedAsync | **Skipped** | - | +| Activities_DeleteTargetedAsync | **Skipped** | - | + +### Members + +| Test | Result | Duration | +|---|---|---:| +| Members_GetAsync | Passed | 862 ms | +| Members_GetByIdAsync | Passed | 2 s | +| Members_GetByIdAsync_AsTeamsConversationAccount | Passed | 1 s | + +### Reactions + +| Test | Result | Duration | +|---|---|---:| +| Reactions_AddAndDelete | **Skipped** | - | + +### Teams + +| Test | Result | Duration | +|---|---|---:| +| Teams_GetByIdAsync | Passed | 240 ms | +| Teams_GetConversationsAsync | Passed | 362 ms | + +### Meetings + +| Test | Result | Duration | +|---|---|---:| +| Meetings_GetByIdAsync | Passed | 2 s | +| Meetings_GetParticipantAsync | Passed | 1m 1s | + +### Bots - SignIn + +| Test | Result | Duration | +|---|---|---:| +| Bots_SignIn_GetUrlAsync | **Skipped** | - | +| Bots_SignIn_GetResourceAsync | **Skipped** | - | + +### Users - Token + +| Test | Result | Duration | +|---|---|---:| +| Users_Token_GetStatusAsync | Passed | 1 s | +| Users_Token_GetAsync | **Skipped** | - | +| Users_Token_SignOutAsync | **Skipped** | - | + +### Other + +| Test | Result | Duration | +|---|---|---:| +| ForServiceUrl_CreatesScopedClient | Passed | 596 ms | + +## CompatTeamsInfoTests (14/14) + +| Test | Result | Duration | +|---|---|---:| +| GetMemberAsync_ReturnsTeamsChannelAccount | Passed | 1 s | +| GetMembersAsync_ReturnsTeamsChannelAccounts | Passed | 828 ms | +| GetPagedMembersAsync_ReturnsPaged | Passed | 666 ms | +| GetTeamMemberAsync_ReturnsTeamsChannelAccount | Passed | 1 s | +| GetMemberAsync_WithTeamScope_DelegatesToGetTeamMember | Passed | 1 s | +| GetTeamMembersAsync_ReturnsMembers | Passed | 2 s | +| GetPagedTeamMembersAsync_ReturnsPaged | Passed | 632 ms | +| GetTeamDetailsAsync_ReturnsDetails | Passed | 350 ms | +| GetTeamDetailsAsync_InfersTeamIdFromActivity | Passed | 408 ms | +| GetTeamChannelsAsync_ReturnsChannels | Passed | 551 ms | +| GetTeamChannelsAsync_InfersTeamIdFromActivity | Passed | 538 ms | +| GetMeetingParticipantAsync_ReturnsParticipant | Passed | 1m 1s | +| GetTeamDetailsAsync_ThrowsWithoutTeamScope | Passed | 3 ms | +| GetTeamChannelsAsync_ThrowsWithoutTeamScope | Passed | 1 ms | +| GetMemberAsync_ThrowsWithNullUserId | Passed | 1 ms | + +## CreateConversationTests (7/10) + +| Test | Result | Duration | +|---|---|---:| +| Core_CreatePersonalChat | Passed | 1 s | +| Core_CreatePersonalChat_WithInitialActivity | Passed | 1 s | +| Core_CreatePersonalChat_AndSendMessage | Passed | 1 s | +| Core_CreateGroupChat | **Skipped** | - | +| Core_CreateGroupChat_AndSendMessage | **Skipped** | - | +| Core_CreateChannelThread | Passed | 411 ms | +| ApiClient_CreatePersonalChat | Passed | 1 s | +| ApiClient_CreatePersonalChat_AndSendViaActivities | Passed | 2 s | +| ApiClient_CreateGroupChat | **Skipped** | - | +| ApiClient_CreateChannelThread | Passed | 440 ms | +| ApiClient_CreateChannelThread_AndReply | Passed | 1 s | + +## CreateConversationDiagnosticTests (13/13) + +| Test | Result | Duration | +|---|---|---:| +| PersonalChat_MinimalParams | Passed | 1 s | +| PersonalChat_WithBot | Passed | 983 ms | +| PersonalChat_WithInitialActivity | Passed | 1 s | +| GroupChat_OneMember_WithBot | Passed | 948 ms | +| GroupChat_OneMember_IsGroupTrue | Passed | 776 ms | +| GroupChat_TwoMembers_WithBot | Passed | 623 ms | +| GroupChat_TwoMembers_NoBotNoChannelData | Passed | 706 ms | +| GroupChat_TwoMembers_WithTopicAndActivity | Passed | 639 ms | +| GroupChat_TwoMembers_WithBotAndChannelData | Passed | 638 ms | +| GroupChat_ThreeMembers | Passed | 636 ms | +| ChannelThread_NoActivity | Passed | 53 ms | +| ChannelThread_WithActivity | Passed | 517 ms | +| ChannelThread_WithMembersAndActivity | Passed | 2 s | + +--- + +## Skipped Tests — Rationale + +| Test | Reason | +|---|---| +| ConversationClientTests.AddAndDeleteReaction | Reactions endpoint does not exist in Teams Bot Framework API (experimental) | +| ApiClientTests.Reactions_AddAndDelete | Reactions endpoint does not exist in Teams Bot Framework API (experimental) | +| ApiClientTests.Activities_CreateTargetedAsync | Targeted activities not supported in team channel conversations | +| ApiClientTests.Activities_UpdateTargetedAsync | Targeted activities not supported in team channel conversations | +| ApiClientTests.Activities_DeleteTargetedAsync | Targeted activities not supported in team channel conversations | +| ApiClientTests.Bots_SignIn_GetUrlAsync | Requires valid OAuth connection name configured for the bot | +| ApiClientTests.Bots_SignIn_GetResourceAsync | Requires valid OAuth connection name configured for the bot | +| ApiClientTests.Users_Token_GetAsync | Requires TEST_CONNECTION_NAME configured with an OAuth connection | +| ApiClientTests.Users_Token_SignOutAsync | Requires TEST_CONNECTION_NAME configured with an OAuth connection | +| CreateConversationTests.Core_CreateGroupChat | Teams Bot Framework API does not support group chat creation | +| CreateConversationTests.Core_CreateGroupChat_AndSendMessage | Teams Bot Framework API does not support group chat creation | +| CreateConversationTests.ApiClient_CreateGroupChat | Teams Bot Framework API does not support group chat creation | + +--- + +## API Coverage Summary + +| Client | Methods | Tested | Skipped | Not Testable | +|---|:---:|:---:|:---:|:---:| +| ActivityClient | 7 | 4 | 3 (targeted) | - | +| MemberClient | 4 | 3 | - | 1 (Delete - destructive) | +| ReactionClient | 2 | - | 2 (no endpoint) | - | +| TeamClient | 2 | 2 | - | - | +| MeetingClient | 2 | 2 | - | - | +| V3ConversationClient | 1 | 1 | - | - | +| BotSignInClient | 2 | - | 2 (needs OAuth) | - | +| V3UserTokenClient | 5 | 1 | 2 (needs OAuth) | 2 (GetAad, Exchange) | +| ApiClient | 1 | 1 | - | - | +| **Total** | **26** | **14** | **9** | **3** | + +--- + +## Notes + +- **ILogger output** is routed to xUnit test output via `MartinCostello.Logging.XUnit`. +- **Meetings_GetParticipantAsync** tests iterate all conversation members looking for one with an AAD object ID. In this test tenant, none have one, so the test passes with a graceful early return. +- **Targeted activities** require a 1:1 or group chat conversation (not a team channel). The test conversation (`TEST_CONVERSATIONID`) is a team channel. +- **BotSignIn and UserToken** methods require an OAuth connection name (`TEST_CONNECTION_NAME`) configured in the bot's Azure registration. +- All tests use real API calls against the Teams Bot Framework service via environment variables in `integration.runsettings`. diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/CompatConversationClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/CompatConversationClientTests.cs deleted file mode 100644 index e027ff15..00000000 --- a/core/test/Microsoft.Teams.Bot.Core.Tests/CompatConversationClientTests.cs +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Bot.Builder.Integration.AspNet.Core; -using Microsoft.Bot.Builder.Teams; -using Microsoft.Bot.Schema; -using Microsoft.Bot.Schema.Teams; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Teams.Bot.Compat; -using Microsoft.Teams.Bot.Core; -using Xunit.Abstractions; - -namespace Microsoft.Bot.Core.Tests -{ - public class CompatConversationClientTests - { - private readonly ITestOutputHelper _outputHelper; - private readonly string _serviceUrl = "https://smba.trafficmanager.net/amer/"; - private readonly string _userId; - private readonly string _conversationId; - private readonly string _agenticAppBlueprintId; - private readonly string? _agenticAppId; - private readonly string? _agenticUserId; - - public CompatConversationClientTests(ITestOutputHelper outputHelper) - { - _outputHelper = outputHelper; - _userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); - _conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); - - _agenticAppBlueprintId = Environment.GetEnvironmentVariable("AzureAd__ClientId") ?? throw new InvalidOperationException("AzureAd__ClientId environment variable not set"); - _agenticAppId = Environment.GetEnvironmentVariable("TEST_AGENTIC_APPID");// ?? throw new InvalidOperationException("TEST_AGENTIC_APPID environment variable not set"); - _agenticUserId = Environment.GetEnvironmentVariable("TEST_AGENTIC_USERID");// ?? throw new InvalidOperationException("TEST_AGENTIC_USERID environment variable not set"); - } - - [Fact(Skip = "not implemented")] - public async Task GetMemberAsync() - { - - var compatAdapter = InitializeCompatAdapter(); - ConversationReference conversationReference = new ConversationReference - - { - ChannelId = "msteams", - ServiceUrl = _serviceUrl, - Conversation = new ConversationAccount - { - Id = _conversationId - } - }; - - await compatAdapter.ContinueConversationAsync( - string.Empty, conversationReference, - async (turnContext, cancellationToken) => - { - TeamsChannelAccount member = await TeamsInfo.GetMemberAsync(turnContext, _userId, cancellationToken: cancellationToken); - Assert.NotNull(member); - Assert.Equal(_userId, member.Id); - - }, CancellationToken.None); - } - - [Fact] - public async Task GetPagedMembersAsync() - { - - var compatAdapter = InitializeCompatAdapter(); - ConversationReference conversationReference = new ConversationReference - - { - ChannelId = "msteams", - ServiceUrl = _serviceUrl, - Conversation = new ConversationAccount() - { - Id = _conversationId - }, - User = new ChannelAccount() - { - Id = "28:fake-bot-id", - Properties = - { - ["agenticAppId"] = _agenticAppId, - ["agenticUserId"] = _agenticUserId, - ["agenticAppBlueprintId"] = _agenticAppBlueprintId - } - } - }; - - await compatAdapter.ContinueConversationAsync( - string.Empty, conversationReference, - async (turnContext, cancellationToken) => - { - var result = await CompatTeamsInfo.GetPagedMembersAsync(turnContext, cancellationToken: cancellationToken); - Assert.NotNull(result); - Assert.True(result.Members.Count > 0); - var m0 = result.Members[0]; - Assert.Equal(_userId, m0.Id); - - }, CancellationToken.None); - } - - [Fact(Skip = "not implemented")] - public async Task GetMeetingInfo() - { - string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); - var compatAdapter = InitializeCompatAdapter(); - ConversationReference conversationReference = new ConversationReference - - { - ChannelId = "msteams", - ServiceUrl = _serviceUrl, - Conversation = new ConversationAccount - { - Id = _conversationId - } - }; - - await compatAdapter.ContinueConversationAsync( - string.Empty, conversationReference, - async (turnContext, cancellationToken) => - { - var result = await TeamsInfo.GetMeetingInfoAsync(turnContext, meetingId, cancellationToken); - Assert.NotNull(result); - - }, CancellationToken.None); - } - - - CompatAdapter InitializeCompatAdapter() - { - IConfigurationBuilder builder = new ConfigurationBuilder() - .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) - .AddEnvironmentVariables(); - - IConfiguration configuration = builder.Build(); - - ServiceCollection services = new(); - services.AddSingleton(configuration); - services.AddCompatAdapter(); - services.AddLogging((builder) => { - builder.AddXUnit(_outputHelper); - builder.AddFilter("System.Net", LogLevel.Warning); - builder.AddFilter("Microsoft.Identity", LogLevel.Error); - builder.AddFilter("Microsoft.Teams", LogLevel.Information); - }); - - var serviceProvider = services.BuildServiceProvider(); - CompatAdapter compatAdapter = (CompatAdapter)serviceProvider.GetRequiredService(); - return compatAdapter; - } - } -} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs deleted file mode 100644 index 7d44a482..00000000 --- a/core/test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs +++ /dev/null @@ -1,617 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Bot.Builder.Integration.AspNet.Core; -using Microsoft.Bot.Schema; -using Microsoft.Bot.Schema.Teams; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Teams.Bot.Compat; -using Xunit.Abstractions; - -namespace Microsoft.Bot.Core.Tests -{ - /// - /// Integration tests for CompatTeamsInfo static methods. - /// These tests verify that the compatibility layer correctly adapts - /// Bot Framework TeamsInfo API to Teams Bot Core SDK. - /// - public class CompatTeamsInfoTests - { - private readonly ITestOutputHelper _outputHelper; - private readonly string _serviceUrl = "https://smba.trafficmanager.net/amer/"; - private readonly string _userId; - private readonly string _conversationId; - private readonly string _teamId; - private readonly string _channelId; - private readonly string _meetingId; - private readonly string _tenantId; - private readonly string _agenticAppBlueprintId; - private readonly string? _agenticAppId; - private readonly string? _agenticUserId; - - public CompatTeamsInfoTests(ITestOutputHelper outputHelper) - { - _outputHelper = outputHelper; - // These tests require environment variables for live integration testing - _userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? "29:test-user-id"; - _conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? "19:test-conversation-id"; - _teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? "19:test-team-id"; - _channelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") ?? "19:test-channel-id"; - _meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? "test-meeting-id"; - _tenantId = Environment.GetEnvironmentVariable("TEST_TENANTID") ?? "test-tenant-id"; - - _agenticAppBlueprintId = Environment.GetEnvironmentVariable("AzureAd__ClientId") ?? throw new InvalidOperationException("AzureAd__ClientId environment variable not set"); - _agenticAppId = Environment.GetEnvironmentVariable("TEST_AGENTIC_APPID");// ?? throw new InvalidOperationException("TEST_AGENTIC_APPID environment variable not set"); - _agenticUserId = Environment.GetEnvironmentVariable("TEST_AGENTIC_USERID"); - } - - [Fact] - public async Task GetMemberAsync_WithValidUserId_ReturnsMember() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - TeamsChannelAccount member = await CompatTeamsInfo.GetMemberAsync( - turnContext, - _userId, - cancellationToken); - - Assert.NotNull(member); - Assert.Equal(_userId, member.Id); - }, - CancellationToken.None); - } - - [Fact] - public async Task GetMembersAsync_ReturnsMembers() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { -#pragma warning disable CS0618 // Type or member is obsolete - var members = await CompatTeamsInfo.GetMembersAsync(turnContext, cancellationToken); -#pragma warning restore CS0618 // Type or member is obsolete - - Assert.NotNull(members); - Assert.NotEmpty(members); - }, - CancellationToken.None); - } - - [Fact] - public async Task GetPagedMembersAsync_ReturnsPagedResult() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var result = await CompatTeamsInfo.GetPagedMembersAsync( - turnContext, - pageSize: 10, - cancellationToken: cancellationToken); - - Assert.NotNull(result); - Assert.NotNull(result.Members); - Assert.True(result.Members.Count > 0); - - var firstMember = result.Members[0]; - Assert.NotNull(firstMember.Id); - }, - CancellationToken.None); - } - - [Fact] - public async Task GetTeamMemberAsync_WithValidUserId_ReturnsMember() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var member = await CompatTeamsInfo.GetTeamMemberAsync( - turnContext, - _userId, - _teamId, - cancellationToken); - - Assert.NotNull(member); - Assert.Equal(_userId, member.Id); - }, - CancellationToken.None); - } - - [Fact] - public async Task GetTeamMembersAsync_ReturnsTeamMembers() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { -#pragma warning disable CS0618 // Type or member is obsolete - var members = await CompatTeamsInfo.GetTeamMembersAsync( - turnContext, - _teamId, - cancellationToken); -#pragma warning restore CS0618 // Type or member is obsolete - - Assert.NotNull(members); - Assert.NotEmpty(members); - }, - CancellationToken.None); - } - - [Fact] - public async Task GetPagedTeamMembersAsync_ReturnsPagedResult() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var result = await CompatTeamsInfo.GetPagedTeamMembersAsync( - turnContext, - _teamId, - pageSize: 5, - cancellationToken: cancellationToken); - - Assert.NotNull(result); - Assert.NotNull(result.Members); - }, - CancellationToken.None); - } - - [Fact(Skip = "permissions needed")] - public async Task GetMeetingInfoAsync_WithMeetingId_ReturnsMeetingInfo() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var meetingInfo = await CompatTeamsInfo.GetMeetingInfoAsync( - turnContext, - _meetingId, - cancellationToken); - - Assert.NotNull(meetingInfo); - Assert.NotNull(meetingInfo.Details); - }, - CancellationToken.None); - } - - [Fact] - public async Task GetMeetingParticipantAsync_WithParticipantId_ReturnsParticipant() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var participant = await CompatTeamsInfo.GetMeetingParticipantAsync( - turnContext, - _meetingId, - _userId, - _tenantId, - cancellationToken); - - Assert.NotNull(participant); - Assert.NotNull(participant.User); - }, - CancellationToken.None); - } - - [Fact(Skip = "Permissions")] - public async Task SendMeetingNotificationAsync_SendsNotification() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - // Create a simple targeted meeting notification - // Note: In real scenarios, you would construct the proper notification object - // with surfaces and content according to the Teams schema - var notification = new TargetedMeetingNotification - { - Value = new TargetedMeetingNotificationValue - { - Recipients = new List { _userId }, - Surfaces = new List - { - new MeetingStageSurface() - { - ContentType = ContentType.Task, - Content = new TaskModuleContinueResponse - { - Value = new TaskModuleTaskInfo - { - Title = "Test Notification", - Url = "https://www.example.com", - Height = 200, - Width = 400 - } - } - } - } - } - }; - - var response = await CompatTeamsInfo.SendMeetingNotificationAsync( - turnContext, - notification, - _meetingId, - cancellationToken); - - Assert.NotNull(response); - }, - CancellationToken.None); - } - - [Fact] - public async Task GetTeamDetailsAsync_WithTeamId_ReturnsTeamDetails() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var teamDetails = await CompatTeamsInfo.GetTeamDetailsAsync( - turnContext, - _teamId, - cancellationToken); - - Assert.NotNull(teamDetails); - Assert.NotNull(teamDetails.Id); - Assert.NotNull(teamDetails.Name); - }, - CancellationToken.None); - } - - [Fact] - public async Task GetTeamChannelsAsync_WithTeamId_ReturnsChannels() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var channels = await CompatTeamsInfo.GetTeamChannelsAsync( - turnContext, - _teamId, - cancellationToken); - - Assert.NotNull(channels); - Assert.NotEmpty(channels); - - var firstChannel = channels[0]; - Assert.NotNull(firstChannel.Id); - Assert.NotNull(firstChannel.Name); - }, - CancellationToken.None); - } - - [Fact] - public async Task SendMessageToListOfUsersAsync_ReturnsOperationId() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var activity = new Activity - { - Type = ActivityTypes.Message, - Text = "Test message" - }; - var members = new List - { - new TeamMember(_channelId), - new TeamMember("1"), - new TeamMember("2"), - new TeamMember("4"), - new TeamMember("5"), - new TeamMember("6") - - }; - - var operationId = await CompatTeamsInfo.SendMessageToListOfUsersAsync( - turnContext, - activity, - members, - _tenantId, - cancellationToken); - - Assert.NotNull(operationId); - Assert.NotEmpty(operationId); - }, - CancellationToken.None); - } - - [Fact] - public async Task SendMessageToListOfChannelsAsync_ReturnsOperationId() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var activity = new Activity - { - Type = ActivityTypes.Message, - Text = "Test message" - }; - var channels = new List - { - new TeamMember(_channelId), - new TeamMember("1"), - new TeamMember("2"), - new TeamMember("4"), - new TeamMember("5"), - new TeamMember("6") - }; - - var operationId = await CompatTeamsInfo.SendMessageToListOfChannelsAsync( - turnContext, - activity, - channels, - _tenantId, - cancellationToken); - - Assert.NotNull(operationId); - Assert.NotEmpty(operationId); - }, - CancellationToken.None); - } - - [Fact] - public async Task SendMessageToAllUsersInTeamAsync_ReturnsOperationId() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var activity = new Activity - { - Type = ActivityTypes.Message, - Text = "Test message to team" - }; - - var operationId = await CompatTeamsInfo.SendMessageToAllUsersInTeamAsync( - turnContext, - activity, - _teamId, - _tenantId, - cancellationToken); - - Assert.NotNull(operationId); - Assert.NotEmpty(operationId); - }, - CancellationToken.None); - } - - [Fact] - public async Task SendMessageToAllUsersInTenantAsync_ReturnsOperationId() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var activity = new Activity - { - Type = ActivityTypes.Message, - Text = "Test message to tenant" - }; - - var operationId = await CompatTeamsInfo.SendMessageToAllUsersInTenantAsync( - turnContext, - activity, - _tenantId, - cancellationToken); - - Assert.NotNull(operationId); - Assert.NotEmpty(operationId); - }, - CancellationToken.None); - } - - [Fact(Skip = "Not implemented")] - public async Task SendMessageToTeamsChannelAsync_CreatesConversationAndSendsMessage() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var activity = new Activity - { - Type = ActivityTypes.Message, - Text = "Test message to channel" - }; - var botAppId = Environment.GetEnvironmentVariable("AzureAd__ClientId") ?? string.Empty; - - var result = await CompatTeamsInfo.SendMessageToTeamsChannelAsync( - turnContext, - activity, - _channelId, - botAppId, - cancellationToken); - - Assert.NotNull(result); - Assert.NotNull(result.Item1); // ConversationReference - Assert.NotNull(result.Item2); // ActivityId - }, - CancellationToken.None); - } - - [Fact(Skip = "Internal Server Error")] - public async Task GetOperationStateAsync_WithOperationId_ReturnsState() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - var operationId = "amer_9e0e3ba8-c562-440f-ba9d-10603ee31837"; - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var state = await CompatTeamsInfo.GetOperationStateAsync( - turnContext, - operationId, - cancellationToken); - - Assert.NotNull(state); - Assert.NotNull(state.State); - }, - CancellationToken.None); - } - - [Fact(Skip = "Internal Server Error")] - public async Task GetPagedFailedEntriesAsync_WithOperationId_ReturnsFailedEntries() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - var operationId = "amer_9e0e3ba8-c562-440f-ba9d-10603ee31837"; - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - var response = await CompatTeamsInfo.GetPagedFailedEntriesAsync( - turnContext, - operationId, - cancellationToken: cancellationToken); - - Assert.NotNull(response); - }, - CancellationToken.None); - } - - [Fact(Skip = "internal error")] - public async Task CancelOperationAsync_WithOperationId_CancelsOperation() - { - var adapter = InitializeCompatAdapter(); - var conversationReference = CreateConversationReference(_conversationId); - var operationId = "amer_9e0e3ba8-c562-440f-ba9d-10603ee31837"; - - await adapter.ContinueConversationAsync( - string.Empty, - conversationReference, - async (turnContext, cancellationToken) => - { - await CompatTeamsInfo.CancelOperationAsync( - turnContext, - operationId, - cancellationToken); - - // If no exception is thrown, the operation succeeded - Assert.True(true); - }, - CancellationToken.None); - } - - private CompatAdapter InitializeCompatAdapter() - { - IConfigurationBuilder builder = new ConfigurationBuilder() - .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) - .AddEnvironmentVariables(); - - IConfiguration configuration = builder.Build(); - - ServiceCollection services = new(); - services.AddSingleton(configuration); - services.AddCompatAdapter(); - services.AddLogging((builder) => { - builder.AddXUnit(_outputHelper); - builder.AddFilter("System.Net", LogLevel.Warning); - builder.AddFilter("Microsoft.Identity", LogLevel.Error); - builder.AddFilter("Microsoft.Teams", LogLevel.Information); - }); - - var serviceProvider = services.BuildServiceProvider(); - CompatAdapter compatAdapter = (CompatAdapter)serviceProvider.GetRequiredService(); - return compatAdapter; - } - - private ConversationReference CreateConversationReference(string conversationId) - { - return new ConversationReference - { - ChannelId = "msteams", - ServiceUrl = _serviceUrl, - Conversation = new ConversationAccount - { - Id = conversationId - }, - User = new ChannelAccount() - { - Properties = - { - { "agenticAppBlueprintId", _agenticAppBlueprintId }, - { "agenticAppId", _agenticAppId }, - { "agenticUserId", _agenticUserId }, - } - } - }; - } - } -} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/ConversationClientTest.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/ConversationClientTest.cs deleted file mode 100644 index d7650270..00000000 --- a/core/test/Microsoft.Teams.Bot.Core.Tests/ConversationClientTest.cs +++ /dev/null @@ -1,732 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Bot.Connector; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Hosting; -using Microsoft.Teams.Bot.Core.Schema; -using Xunit.Abstractions; - -namespace Microsoft.Bot.Core.Tests; - -public class ConversationClientTest -{ - private readonly ServiceProvider _serviceProvider; - private readonly ConversationClient _conversationClient; - private readonly Uri _serviceUrl; - - private readonly string _conversationId; - private readonly ConversationAccount _recipient = new ConversationAccount(); - private AgenticIdentity? _agenticIdentity; - - public ConversationClientTest(ITestOutputHelper outputHelper) - { - IConfigurationBuilder builder = new ConfigurationBuilder() - .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) - .AddEnvironmentVariables(); - - IConfiguration configuration = builder.Build(); - - ServiceCollection services = new(); - services.AddLogging((builder) => { - builder.AddXUnit(outputHelper); - builder.AddFilter("System.Net", LogLevel.Warning); - builder.AddFilter("Microsoft.Identity", LogLevel.Error); - builder.AddFilter("Microsoft.Teams", LogLevel.Trace); - }); - services.AddSingleton(configuration); - services.AddBotApplication(); - _serviceProvider = services.BuildServiceProvider(); - _conversationClient = _serviceProvider.GetRequiredService(); - _serviceUrl = new Uri(Environment.GetEnvironmentVariable("TEST_SERVICEURL") ?? "https://smba.trafficmanager.net/teams/"); - _conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); - string agenticAppBlueprintId = Environment.GetEnvironmentVariable("AzureAd__ClientId") ?? throw new InvalidOperationException("AzureAd__ClientId environment variable not set"); - string? agenticAppId = Environment.GetEnvironmentVariable("TEST_AGENTIC_APPID");// ?? throw new InvalidOperationException("TEST_AGENTIC_APPID environment variable not set"); - string? agenticUserId = Environment.GetEnvironmentVariable("TEST_AGENTIC_USERID");// ?? throw new InvalidOperationException("TEST_AGENTIC_USERID environment variable not set"); - - _agenticIdentity = null; - if (!string.IsNullOrEmpty(agenticAppId) && !string.IsNullOrEmpty(agenticUserId)) - { - _recipient.Properties.Add("agenticAppBlueprintId", agenticAppBlueprintId); - _recipient.Properties.Add("agenticAppId", agenticAppId); - _recipient.Properties.Add("agenticUserId", agenticUserId); - _agenticIdentity = AgenticIdentity.FromProperties(_recipient.Properties); - } - } - - [Fact] - public async Task SendActivityToChannel() - { - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Message from Automated tests, running in SDK `{BotApplication.Version}` at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId), - From = _recipient - }; - SendActivityResponse? res = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); - Assert.NotNull(res); - Assert.NotNull(res.Id); - } - - [Fact] - public async Task SendActivityToPersonalChat_FailsWithBad_ConversationId() - { - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Message from Automated tests, running in SDK `{BotApplication.Version}` at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - Conversation = new("a:1"), - From = _recipient - }; - - await Assert.ThrowsAsync(() - => _conversationClient.SendActivityAsync(activity)); - } - - [Fact] - public async Task UpdateActivity() - { - // First send an activity to get an ID - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Original message from Automated tests at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId), - From = _recipient - }; - - SendActivityResponse? sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); - Assert.NotNull(sendResponse); - Assert.NotNull(sendResponse.Id); - - // Now update the activity - CoreActivity updatedActivity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Updated message from Automated tests at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId), - From = _recipient - }; - - UpdateActivityResponse updateResponse = await _conversationClient.UpdateActivityAsync( - activity.Conversation.Id, - sendResponse.Id, - updatedActivity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(updateResponse); - Assert.NotNull(updateResponse.Id); - } - - [Fact] - public async Task DeleteActivity() - { - // First send an activity to get an ID - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Message to delete from Automated tests at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId), - From = _recipient - }; - - SendActivityResponse? sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); - Assert.NotNull(sendResponse); - Assert.NotNull(sendResponse.Id); - - // Add a delay for 5 seconds - await Task.Delay(TimeSpan.FromSeconds(5)); - - // Now delete the activity - await _conversationClient.DeleteActivityAsync( - activity.Conversation.Id, - sendResponse.Id, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - // If no exception was thrown, the delete was successful - } - - [Fact] - public async Task GetConversationMembers() - { - IList members = await _conversationClient.GetConversationMembersAsync( - _conversationId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(members); - Assert.NotEmpty(members); - - // Log members - Console.WriteLine($"Found {members.Count} members in conversation {_conversationId}:"); - foreach (ConversationAccount member in members) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - Assert.NotNull(member); - Assert.NotNull(member.Id); - } - } - - [Fact] - public async Task GetConversationMember() - { - string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); - - ConversationAccount member = await _conversationClient.GetConversationMemberAsync( - _conversationId, - userId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(member); - - // Log member - Console.WriteLine($"Found member in conversation {_conversationId}:"); - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - Assert.NotNull(member); - Assert.NotNull(member.Id); - } - - - [Fact] - public async Task GetConversationMembersInChannel() - { - string channelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") ?? throw new InvalidOperationException("TEST_CHANNELID environment variable not set"); - - IList members = await _conversationClient.GetConversationMembersAsync( - channelId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(members); - Assert.NotEmpty(members); - - // Log members - Console.WriteLine($"Found {members.Count} members in channel {channelId}:"); - foreach (ConversationAccount member in members) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - Assert.NotNull(member); - Assert.NotNull(member.Id); - } - } - - [Fact] - public async Task GetActivityMembers() - { - // First send an activity to get an activity ID - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Message for GetActivityMembers test at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId), - From = _recipient - }; - - SendActivityResponse? sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); - Assert.NotNull(sendResponse); - Assert.NotNull(sendResponse.Id); - - // Now get the members of this activity - IList members = await _conversationClient.GetActivityMembersAsync( - activity.Conversation.Id, - sendResponse.Id, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(members); - Assert.NotEmpty(members); - - // Log activity members - Console.WriteLine($"Found {members.Count} members for activity {sendResponse.Id}:"); - foreach (ConversationAccount member in members) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - Assert.NotNull(member); - Assert.NotNull(member.Id); - } - } - - // TODO: This doesn't work - [Fact(Skip = "Method not allowed by API")] - public async Task GetConversations() - { - GetConversationsResponse response = await _conversationClient.GetConversationsAsync( - _serviceUrl, - cancellationToken: CancellationToken.None); - - Assert.NotNull(response); - Assert.NotNull(response.Conversations); - Assert.NotEmpty(response.Conversations); - - // Log conversations - Console.WriteLine($"Found {response.Conversations.Count} conversations:"); - foreach (ConversationMembers conversation in response.Conversations) - { - Console.WriteLine($" - Conversation Id: {conversation.Id}"); - Assert.NotNull(conversation); - Assert.NotNull(conversation.Id); - - if (conversation.Members != null && conversation.Members.Any()) - { - Console.WriteLine($" Members ({conversation.Members.Count}):"); - foreach (ConversationAccount member in conversation.Members) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - } - } - } - } - - [Fact(Skip = "CreateConversation_WithMembers is not working with agentic identity")] - public async Task CreateConversation_WithMembers() - { - // Create a 1-on-1 conversation with a member - ConversationParameters parameters = new() - { - IsGroup = false, - Members = - [ - new() - { - Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), - } - ], - // TODO: This is required for some reason. Should it be required in the api? - TenantId = Environment.GetEnvironmentVariable("AzureAd__TenantId") ?? throw new InvalidOperationException("AzureAd__TenantId environment variable not set") - }; - - CreateConversationResponse response = await _conversationClient.CreateConversationAsync( - parameters, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(response); - Assert.NotNull(response.Id); - - Console.WriteLine($"Created conversation: {response.Id}"); - Console.WriteLine($" ActivityId: {response.ActivityId}"); - Console.WriteLine($" ServiceUrl: {response.ServiceUrl}"); - - // Send a message to the newly created conversation - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Test message to new conversation at {DateTime.UtcNow:s}" } }, - ServiceUrl = _serviceUrl, - Conversation = new(response.Id) - }; - - SendActivityResponse? sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); - Assert.NotNull(sendResponse); - Assert.NotNull(sendResponse.Id); - - Console.WriteLine($" Sent message with activity ID: {sendResponse.Id}"); - } - - // TODO: This doesn't work - [Fact(Skip = "Incorrect conversation creation parameters")] - public async Task CreateConversation_WithGroup() - { - // Create a group conversation - ConversationParameters parameters = new() - { - IsGroup = true, - Members = - [ - new() - { - Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), - }, - new() - { - Id = Environment.GetEnvironmentVariable("TEST_USER_ID_2") ?? throw new InvalidOperationException("TEST_USER_ID_2 environment variable not set"), - } - ], - TenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set") - }; - - CreateConversationResponse response = await _conversationClient.CreateConversationAsync( - parameters, - _serviceUrl, - cancellationToken: CancellationToken.None); - - Assert.NotNull(response); - Assert.NotNull(response.Id); - - Console.WriteLine($"Created group conversation: {response.Id}"); - - // Send a message to the newly created group conversation - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Test message to new group conversation at {DateTime.UtcNow:s}" } }, - ServiceUrl = _serviceUrl, - Conversation = new(response.Id) - }; - - SendActivityResponse? sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); - Assert.NotNull(sendResponse); - Assert.NotNull(sendResponse.Id); - - Console.WriteLine($" Sent message with activity ID: {sendResponse.Id}"); - } - - // TODO: This doesn't work - [Fact(Skip = "Incorrect conversation creation parameters")] - public async Task CreateConversation_WithTopicName() - { - // Create a conversation with a topic name - ConversationParameters parameters = new() - { - IsGroup = true, - TopicName = $"Test Conversation - {DateTime.UtcNow:s}", - Members = - [ - new() - { - Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), - } - ], - TenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set") - }; - - CreateConversationResponse response = await _conversationClient.CreateConversationAsync( - parameters, - _serviceUrl, - cancellationToken: CancellationToken.None); - - Assert.NotNull(response); - Assert.NotNull(response.Id); - - Console.WriteLine($"Created conversation with topic '{parameters.TopicName}': {response.Id}"); - - // Send a message to the newly created conversation - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Test message to conversation with topic name at {DateTime.UtcNow:s}" } }, - ServiceUrl = _serviceUrl, - Conversation = new(response.Id) - }; - - SendActivityResponse? sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); - Assert.NotNull(sendResponse); - Assert.NotNull(sendResponse.Id); - - Console.WriteLine($" Sent message with activity ID: {sendResponse.Id}"); - } - - // TODO: This doesn't fail, but doesn't actually create the initial activity - [Fact(Skip = "CreateConversation_WithInitialActivity is not working with agentic identity")] - public async Task CreateConversation_WithInitialActivity() - { - // Create a conversation with an initial message - ConversationParameters parameters = new() - { - IsGroup = false, - Members = - [ - new() - { - Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), - } - ], - Activity = new CoreActivity - { - Type = ActivityType.Message, - Properties = { { "text", $"Initial message sent at {DateTime.UtcNow:s}" } }, - }, - TenantId = Environment.GetEnvironmentVariable("AzureAd__TenantId") ?? throw new InvalidOperationException("AzureAd__TenantId environment variable not set") - }; - - CreateConversationResponse response = await _conversationClient.CreateConversationAsync( - parameters, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(response); - Assert.NotNull(response.Id); - // Assert.NotNull(response.ActivityId); // Should have an activity ID since we sent an initial message - - Console.WriteLine($"Created conversation with initial activity: {response.Id}"); - Console.WriteLine($" Initial activity ID: {response.ActivityId}"); - } - - [Fact(Skip = "CreateConversation_WithChannelData is not working with agentic identity")] - public async Task CreateConversation_WithChannelData() - { - // Create a conversation with channel-specific data - ConversationParameters parameters = new() - { - IsGroup = false, - Members = - [ - new() - { - Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), - } - ], - ChannelData = new - { - teamsChannelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") - }, - TenantId = Environment.GetEnvironmentVariable("AzureAd__TenantId") ?? throw new InvalidOperationException("AzureAd__TenantId environment variable not set") - }; - - CreateConversationResponse response = await _conversationClient.CreateConversationAsync( - parameters, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(response); - Assert.NotNull(response.Id); - - Console.WriteLine($"Created conversation with channel data: {response.Id}"); - } - - [Fact] - public async Task GetConversationPagedMembers() - { - PagedMembersResult result = await _conversationClient.GetConversationPagedMembersAsync( - _conversationId, - _serviceUrl, - 5, - null, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - Assert.NotNull(result.Members); - Assert.NotEmpty(result.Members); - - Console.WriteLine($"Found {result.Members.Count} members in page:"); - foreach (ConversationAccount member in result.Members) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - Assert.NotNull(member); - Assert.NotNull(member.Id); - } - - if (!string.IsNullOrWhiteSpace(result.ContinuationToken)) - { - Console.WriteLine($"Continuation token: {result.ContinuationToken}"); - } - } - - [Fact] - public async Task AddRemoveReactionsToChat_Default() - { - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"I'm going to add and remove reactions from this message." } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId), - From = _recipient - }; - SendActivityResponse? res = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); - Assert.NotNull(res); - Assert.NotNull(res.Id); - - await _conversationClient.AddReactionAsync(_conversationId, res.Id, "laugh", _serviceUrl, _agenticIdentity); - await Task.Delay(500); - await _conversationClient.AddReactionAsync(_conversationId, res.Id, "sad", _serviceUrl, _agenticIdentity); - await Task.Delay(500); - await _conversationClient.AddReactionAsync(_conversationId, res.Id, "yes-tone4", _serviceUrl, _agenticIdentity); - - await Task.Delay(500); - await _conversationClient.DeleteReactionAsync(_conversationId, res.Id, "yes-tone4", _serviceUrl, _agenticIdentity); - await Task.Delay(500); - await _conversationClient.DeleteReactionAsync(_conversationId, res.Id, "sad", _serviceUrl, _agenticIdentity); - } - - [Fact(Skip = "PageSize parameter not respected by API")] - public async Task GetConversationPagedMembers_WithPageSize() - { - PagedMembersResult result = await _conversationClient.GetConversationPagedMembersAsync( - _conversationId, - _serviceUrl, - pageSize: 1, - agenticIdentity: _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - Assert.NotNull(result.Members); - Assert.NotEmpty(result.Members); - Assert.Single(result.Members); - - Console.WriteLine($"Found {result.Members.Count} members with pageSize=1:"); - foreach (ConversationAccount member in result.Members) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - } - - // If there's a continuation token, get the next page - if (!string.IsNullOrWhiteSpace(result.ContinuationToken)) - { - Console.WriteLine($"Getting next page with continuation token..."); - - PagedMembersResult nextPage = await _conversationClient.GetConversationPagedMembersAsync( - _conversationId, - _serviceUrl, - pageSize: 1, - continuationToken: result.ContinuationToken, - cancellationToken: CancellationToken.None); - - Assert.NotNull(nextPage); - Assert.NotNull(nextPage.Members); - - Console.WriteLine($"Found {nextPage.Members.Count} members in next page:"); - foreach (ConversationAccount member in nextPage.Members) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - } - } - } - - [Fact(Skip = "Method not allowed by API")] - public async Task DeleteConversationMember() - { - // Get members before deletion - IList membersBefore = await _conversationClient.GetConversationMembersAsync( - _conversationId, - _serviceUrl, - cancellationToken: CancellationToken.None); - - Assert.NotNull(membersBefore); - Assert.NotEmpty(membersBefore); - - Console.WriteLine($"Members before deletion: {membersBefore.Count}"); - foreach (ConversationAccount member in membersBefore) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - } - - // Delete the test user - string memberToDelete = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); - - // Verify the member is in the conversation before attempting to delete - Assert.Contains(membersBefore, m => m.Id == memberToDelete); - - await _conversationClient.DeleteConversationMemberAsync( - _conversationId, - memberToDelete, - _serviceUrl, - cancellationToken: CancellationToken.None); - - Console.WriteLine($"Deleted member: {memberToDelete}"); - - // Get members after deletion - IList membersAfter = await _conversationClient.GetConversationMembersAsync( - _conversationId, - _serviceUrl, - cancellationToken: CancellationToken.None); - - Assert.NotNull(membersAfter); - - Console.WriteLine($"Members after deletion: {membersAfter.Count}"); - foreach (ConversationAccount member in membersAfter) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - } - - // Verify the member was deleted - Assert.DoesNotContain(membersAfter, m => m.Id == memberToDelete); - } - - [Fact(Skip = "Unknown activity type error")] - public async Task SendConversationHistory() - { - // Create a transcript with historic activities - Transcript transcript = new() - { - Activities = - [ - new() - { - Type = ActivityType.Message, - Id = Guid.NewGuid().ToString(), - Properties = { { "text", "Historic message 1" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId) - }, - new() - { - Type = ActivityType.Message, - Id = Guid.NewGuid().ToString(), - Properties = { { "text", "Historic message 2" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId) - }, - new() - { - Type = ActivityType.Message, - Id = Guid.NewGuid().ToString(), - Properties = { { "text", "Historic message 3" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId) - } - ] - }; - - SendConversationHistoryResponse response = await _conversationClient.SendConversationHistoryAsync( - _conversationId, - transcript, - _serviceUrl, - cancellationToken: CancellationToken.None); - - Assert.NotNull(response); - - Console.WriteLine($"Sent conversation history with {transcript.Activities?.Count} activities"); - Console.WriteLine($"Response ID: {response.Id}"); - } - - [Fact(Skip = "Attachment upload endpoint not found")] - public async Task UploadAttachment() - { - // Create a simple text file as an attachment - string fileContent = "This is a test attachment file created at " + DateTime.UtcNow.ToString("s"); - byte[] fileBytes = System.Text.Encoding.UTF8.GetBytes(fileContent); - - AttachmentData attachmentData = new() - { - Type = "text/plain", - Name = "test-attachment.txt", - OriginalBase64 = fileBytes - }; - - UploadAttachmentResponse response = await _conversationClient.UploadAttachmentAsync( - _conversationId, - attachmentData, - _serviceUrl, - cancellationToken: CancellationToken.None); - - Assert.NotNull(response); - Assert.NotNull(response.Id); - - Console.WriteLine($"Uploaded attachment: {attachmentData.Name}"); - Console.WriteLine($" Attachment ID: {response.Id}"); - Console.WriteLine($" Content-Type: {attachmentData.Type}"); - Console.WriteLine($" Size: {fileBytes.Length} bytes"); - } -} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj b/core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj deleted file mode 100644 index 2234a18c..00000000 --- a/core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiClientTests.cs deleted file mode 100644 index f1690e19..00000000 --- a/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiClientTests.cs +++ /dev/null @@ -1,597 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Hosting; -using Microsoft.Teams.Bot.Core.Schema; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Teams.Bot.Apps; -using Xunit.Abstractions; - -namespace Microsoft.Bot.Core.Tests; - -public class TeamsApiClientTests -{ - private readonly ServiceProvider _serviceProvider; - private readonly TeamsApiClient _teamsClient; - private readonly Uri _serviceUrl; - private readonly ConversationAccount _recipient = new ConversationAccount(); - private AgenticIdentity? _agenticIdentity; - - public TeamsApiClientTests(ITestOutputHelper outputHelper) - { - IConfigurationBuilder builder = new ConfigurationBuilder() - .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) - .AddEnvironmentVariables(); - - IConfiguration configuration = builder.Build(); - - ServiceCollection services = new(); - services.AddLogging((builder) => { - builder.AddXUnit(outputHelper); - builder.AddFilter("System.Net", LogLevel.Warning); - builder.AddFilter("Microsoft.Identity", LogLevel.Error); - builder.AddFilter("Microsoft.Teams", LogLevel.Information); - }); - services.AddSingleton(configuration); - services.AddTeamsBotApplication(); - _serviceProvider = services.BuildServiceProvider(); - _teamsClient = _serviceProvider.GetRequiredService(); - _serviceUrl = new Uri(Environment.GetEnvironmentVariable("TEST_SERVICEURL") ?? "https://smba.trafficmanager.net/teams/"); - - string agenticAppBlueprintId = Environment.GetEnvironmentVariable("AzureAd__ClientId") ?? throw new InvalidOperationException("AzureAd__ClientId environment variable not set"); - string? agenticAppId = Environment.GetEnvironmentVariable("TEST_AGENTIC_APPID");// ?? throw new InvalidOperationException("TEST_AGENTIC_APPID environment variable not set"); - string? agenticUserId = Environment.GetEnvironmentVariable("TEST_AGENTIC_USERID");// ?? throw new InvalidOperationException("TEST_AGENTIC_USERID environment variable not set"); - - _agenticIdentity = null; - if (!string.IsNullOrEmpty(agenticAppId) && !string.IsNullOrEmpty(agenticUserId)) - { - _recipient.Properties.Add("agenticAppBlueprintId", agenticAppBlueprintId); - _recipient.Properties.Add("agenticAppId", agenticAppId); - _recipient.Properties.Add("agenticUserId", agenticUserId); - _agenticIdentity = AgenticIdentity.FromProperties(_recipient.Properties); - } - } - - #region Team Operations Tests - - [Fact] - public async Task FetchChannelList() - { - string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); - - ChannelList result = await _teamsClient.FetchChannelListAsync( - teamId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - Assert.NotNull(result.Channels); - Assert.NotEmpty(result.Channels); - - Console.WriteLine($"Found {result.Channels.Count} channels in team {teamId}:"); - foreach (var channel in result.Channels) - { - Console.WriteLine($" - Id: {channel.Id}, Name: {channel.Name}"); - Assert.NotNull(channel); - Assert.NotNull(channel.Id); - } - } - - [Fact] - public async Task FetchChannelList_FailsWithInvalidTeamId() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchChannelListAsync("invalid-team-id", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task FetchTeamDetails() - { - string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); - - TeamDetails result = await _teamsClient.FetchTeamDetailsAsync( - teamId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - Assert.NotNull(result.Id); - - Console.WriteLine($"Team details for {teamId}:"); - Console.WriteLine($" - Id: {result.Id}"); - Console.WriteLine($" - Name: {result.Name}"); - Console.WriteLine($" - AAD Group Id: {result.AadGroupId}"); - Console.WriteLine($" - Channel Count: {result.ChannelCount}"); - Console.WriteLine($" - Member Count: {result.MemberCount}"); - Console.WriteLine($" - Type: {result.Type}"); - } - - [Fact] - public async Task FetchTeamDetails_FailsWithInvalidTeamId() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchTeamDetailsAsync("invalid-team-id", _serviceUrl, _agenticIdentity)); - } - - #endregion - - #region Meeting Operations Tests - - [Fact(Skip = "FetchMeetingInfo requires permissions")] - public async Task FetchMeetingInfo() - { - string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); - - MeetingInfo result = await _teamsClient.FetchMeetingInfoAsync( - meetingId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - //Assert.NotNull(result.Id); - - Console.WriteLine($"Meeting info for {meetingId}:"); - - if (result.Details != null) - { - Console.WriteLine($" - Title: {result.Details.Title}"); - Console.WriteLine($" - Type: {result.Details.Type}"); - Console.WriteLine($" - Join URL: {result.Details.JoinUrl}"); - Console.WriteLine($" - Scheduled Start: {result.Details.ScheduledStartTime}"); - Console.WriteLine($" - Scheduled End: {result.Details.ScheduledEndTime}"); - } - if (result.Organizer != null) - { - Console.WriteLine($" - Organizer: {result.Organizer.Name} ({result.Organizer.Id})"); - } - } - - [Fact] - public async Task FetchMeetingInfo_FailsWithInvalidMeetingId() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchMeetingInfoAsync("invalid-meeting-id", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task FetchParticipant() - { - string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); - string participantId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); - string tenantId = Environment.GetEnvironmentVariable("TEST_TENANTID") ?? throw new InvalidOperationException("TEST_TENANTID environment variable not set"); - - MeetingParticipant result = await _teamsClient.FetchParticipantAsync( - meetingId, - participantId, - tenantId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - - Console.WriteLine($"Participant info for {participantId} in meeting {meetingId}:"); - if (result.User != null) - { - Console.WriteLine($" - User Id: {result.User.Id}"); - Console.WriteLine($" - User Name: {result.User.Name}"); - } - if (result.Meeting != null) - { - Console.WriteLine($" - Role: {result.Meeting.Role}"); - Console.WriteLine($" - In Meeting: {result.Meeting.InMeeting}"); - } - } - - [Fact(Skip = "Requires active meeting context")] - public async Task SendMeetingNotification() - { - string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); - string participantId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); - - var notification = new TargetedMeetingNotification - { - Value = new TargetedMeetingNotificationValue - { - Recipients = [participantId], - Surfaces = - [ - new MeetingNotificationSurface - { - Surface = "meetingStage", - ContentType = "task", - Content = new { title = "Test Notification", url = "https://example.com" } - } - ] - } - }; - - MeetingNotificationResponse result = await _teamsClient.SendMeetingNotificationAsync( - meetingId, - notification, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - - Console.WriteLine($"Meeting notification sent to meeting {meetingId}"); - if (result.RecipientsFailureInfo != null && result.RecipientsFailureInfo.Count > 0) - { - Console.WriteLine($"Failed recipients:"); - foreach (var failure in result.RecipientsFailureInfo) - { - Console.WriteLine($" - {failure.RecipientMri}: {failure.ErrorCode} - {failure.FailureReason}"); - } - } - } - - #endregion - - #region Batch Message Operations Tests - - [Fact(Skip = "Batch operations require special permissions")] - public async Task SendMessageToListOfUsers() - { - string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); - string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); - - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Batch message from Automated tests at `{DateTime.UtcNow:s}`" } } - }; - - IList members = - [ - new TeamMember(userId) - ]; - - string operationId = await _teamsClient.SendMessageToListOfUsersAsync( - activity, - members, - tenantId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(operationId); - Assert.NotEmpty(operationId); - - Console.WriteLine($"Batch message sent. Operation ID: {operationId}"); - } - - [Fact(Skip = "Batch operations require special permissions")] - public async Task SendMessageToAllUsersInTenant() - { - string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); - - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Tenant-wide message from Automated tests at `{DateTime.UtcNow:s}`" } } - }; - - string operationId = await _teamsClient.SendMessageToAllUsersInTenantAsync( - activity, - tenantId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(operationId); - Assert.NotEmpty(operationId); - - Console.WriteLine($"Tenant-wide message sent. Operation ID: {operationId}"); - } - - [Fact(Skip = "Batch operations require special permissions")] - public async Task SendMessageToAllUsersInTeam() - { - string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); - string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); - - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Team-wide message from Automated tests at `{DateTime.UtcNow:s}`" } } - }; - - string operationId = await _teamsClient.SendMessageToAllUsersInTeamAsync( - activity, - teamId, - tenantId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(operationId); - Assert.NotEmpty(operationId); - - Console.WriteLine($"Team-wide message sent. Operation ID: {operationId}"); - } - - [Fact(Skip = "Batch operations require special permissions")] - public async Task SendMessageToListOfChannels() - { - string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); - string channelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") ?? throw new InvalidOperationException("TEST_CHANNELID environment variable not set"); - - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Channel batch message from Automated tests at `{DateTime.UtcNow:s}`" } } - }; - - IList channels = - [ - new TeamMember(channelId) - ]; - - string operationId = await _teamsClient.SendMessageToListOfChannelsAsync( - activity, - channels, - tenantId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(operationId); - Assert.NotEmpty(operationId); - - Console.WriteLine($"Channel batch message sent. Operation ID: {operationId}"); - } - - #endregion - - #region Batch Operation Management Tests - - [Fact(Skip = "Requires valid operation ID from batch operation")] - public async Task GetOperationState() - { - string operationId = Environment.GetEnvironmentVariable("TEST_OPERATION_ID") ?? throw new InvalidOperationException("TEST_OPERATION_ID environment variable not set"); - - BatchOperationState result = await _teamsClient.GetOperationStateAsync( - operationId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - Assert.NotNull(result.State); - - Console.WriteLine($"Operation state for {operationId}:"); - Console.WriteLine($" - State: {result.State}"); - Console.WriteLine($" - Total Entries: {result.TotalEntriesCount}"); - if (result.StatusMap != null) - { - Console.WriteLine($" - Success: {result.StatusMap.Success}"); - Console.WriteLine($" - Failed: {result.StatusMap.Failed}"); - Console.WriteLine($" - Throttled: {result.StatusMap.Throttled}"); - Console.WriteLine($" - Pending: {result.StatusMap.Pending}"); - } - if (result.RetryAfter != null) - { - Console.WriteLine($" - Retry After: {result.RetryAfter}"); - } - } - - [Fact] - public async Task GetOperationState_FailsWithInvalidOperationId() - { - await Assert.ThrowsAsync(() - => _teamsClient.GetOperationStateAsync("invalid-operation-id", _serviceUrl, _agenticIdentity)); - } - - [Fact(Skip = "Requires valid operation ID from batch operation")] - public async Task GetPagedFailedEntries() - { - string operationId = Environment.GetEnvironmentVariable("TEST_OPERATION_ID") ?? throw new InvalidOperationException("TEST_OPERATION_ID environment variable not set"); - - BatchFailedEntriesResponse result = await _teamsClient.GetPagedFailedEntriesAsync( - operationId, - _serviceUrl, - null, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - - Console.WriteLine($"Failed entries for operation {operationId}:"); - if (result.FailedEntries != null && result.FailedEntries.Count > 0) - { - foreach (var entry in result.FailedEntries) - { - Console.WriteLine($" - Id: {entry.Id}, Error: {entry.Error}"); - } - } - else - { - Console.WriteLine(" No failed entries"); - } - - if (!string.IsNullOrWhiteSpace(result.ContinuationToken)) - { - Console.WriteLine($"Continuation token: {result.ContinuationToken}"); - } - } - - [Fact(Skip = "Requires valid operation ID from batch operation")] - public async Task CancelOperation() - { - string operationId = Environment.GetEnvironmentVariable("TEST_OPERATION_ID") ?? throw new InvalidOperationException("TEST_OPERATION_ID environment variable not set"); - - await _teamsClient.CancelOperationAsync( - operationId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Console.WriteLine($"Operation {operationId} cancelled successfully"); - } - - #endregion - - #region Argument Validation Tests - - [Fact] - public async Task FetchChannelList_ThrowsOnNullTeamId() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchChannelListAsync(null!, _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task FetchChannelList_ThrowsOnEmptyTeamId() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchChannelListAsync("", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task FetchChannelList_ThrowsOnNullServiceUrl() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchChannelListAsync("team-id", null!)); - } - - [Fact] - public async Task FetchTeamDetails_ThrowsOnNullTeamId() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchTeamDetailsAsync(null!, _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task FetchMeetingInfo_ThrowsOnNullMeetingId() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchMeetingInfoAsync(null!, _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task FetchParticipant_ThrowsOnNullMeetingId() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchParticipantAsync(null!, "participant", "tenant", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task FetchParticipant_ThrowsOnNullParticipantId() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchParticipantAsync("meeting", null!, "tenant", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task FetchParticipant_ThrowsOnNullTenantId() - { - await Assert.ThrowsAsync(() - => _teamsClient.FetchParticipantAsync("meeting", "participant", null!, _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task SendMeetingNotification_ThrowsOnNullMeetingId() - { - var notification = new TargetedMeetingNotification(); - await Assert.ThrowsAsync(() - => _teamsClient.SendMeetingNotificationAsync(null!, notification, _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task SendMeetingNotification_ThrowsOnNullNotification() - { - await Assert.ThrowsAsync(() - => _teamsClient.SendMeetingNotificationAsync("meeting", null!, _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task SendMessageToListOfUsers_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsClient.SendMessageToListOfUsersAsync(null!, [new TeamMember("id")], "tenant", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task SendMessageToListOfUsers_ThrowsOnNullMembers() - { - var activity = new CoreActivity { Type = ActivityType.Message }; - await Assert.ThrowsAsync(() - => _teamsClient.SendMessageToListOfUsersAsync(activity, null!, "tenant", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task SendMessageToListOfUsers_ThrowsOnEmptyMembers() - { - var activity = new CoreActivity { Type = ActivityType.Message }; - await Assert.ThrowsAsync(() - => _teamsClient.SendMessageToListOfUsersAsync(activity, [], "tenant", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task SendMessageToAllUsersInTenant_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsClient.SendMessageToAllUsersInTenantAsync(null!, "tenant", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task SendMessageToAllUsersInTenant_ThrowsOnNullTenantId() - { - var activity = new CoreActivity { Type = ActivityType.Message }; - await Assert.ThrowsAsync(() - => _teamsClient.SendMessageToAllUsersInTenantAsync(activity, null!, _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task SendMessageToAllUsersInTeam_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsClient.SendMessageToAllUsersInTeamAsync(null!, "team", "tenant", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task SendMessageToAllUsersInTeam_ThrowsOnNullTeamId() - { - var activity = new CoreActivity { Type = ActivityType.Message }; - await Assert.ThrowsAsync(() - => _teamsClient.SendMessageToAllUsersInTeamAsync(activity, null!, "tenant", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task SendMessageToListOfChannels_ThrowsOnEmptyChannels() - { - var activity = new CoreActivity { Type = ActivityType.Message }; - await Assert.ThrowsAsync(() - => _teamsClient.SendMessageToListOfChannelsAsync(activity, [], "tenant", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task GetOperationState_ThrowsOnNullOperationId() - { - await Assert.ThrowsAsync(() - => _teamsClient.GetOperationStateAsync(null!, _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task GetPagedFailedEntries_ThrowsOnNullOperationId() - { - await Assert.ThrowsAsync(() - => _teamsClient.GetPagedFailedEntriesAsync(null!, _serviceUrl, null, _agenticIdentity)); - } - - [Fact] - public async Task CancelOperation_ThrowsOnNullOperationId() - { - await Assert.ThrowsAsync(() - => _teamsClient.CancelOperationAsync(null!, _serviceUrl, _agenticIdentity)); - } - - #endregion -} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiFacadeTests.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiFacadeTests.cs deleted file mode 100644 index f196acc0..00000000 --- a/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiFacadeTests.cs +++ /dev/null @@ -1,643 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Bot.Connector; -using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Hosting; -using Microsoft.Teams.Bot.Core.Schema; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Teams.Bot.Apps; -using Microsoft.Teams.Bot.Apps.Api; -using Microsoft.Teams.Bot.Apps.Schema; -using Xunit.Abstractions; - -namespace Microsoft.Bot.Core.Tests; - -/// -/// Integration tests for the TeamsApi facade. -/// These tests verify that the hierarchical API facade correctly delegates to underlying clients. -/// -public class TeamsApiFacadeTests -{ - private readonly ServiceProvider _serviceProvider; - private readonly TeamsBotApplication _teamsBotApplication; - private readonly Uri _serviceUrl; - private readonly string _conversationId; - private readonly ConversationAccount _recipient = new ConversationAccount(); - private readonly AgenticIdentity? _agenticIdentity; - - public TeamsApiFacadeTests(ITestOutputHelper outputHelper) - { - IConfigurationBuilder builder = new ConfigurationBuilder() - .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) - .AddEnvironmentVariables(); - - IConfiguration configuration = builder.Build(); - - ServiceCollection services = new(); - services.AddLogging((builder) => { - builder.AddXUnit(outputHelper); - builder.AddFilter("System.Net", LogLevel.Warning); - builder.AddFilter("Microsoft.Identity", LogLevel.Error); - builder.AddFilter("Microsoft.Teams", LogLevel.Information); - }); - services.AddSingleton(configuration); - services.AddHttpContextAccessor(); - services.AddTeamsBotApplication(); - _serviceProvider = services.BuildServiceProvider(); - _teamsBotApplication = _serviceProvider.GetRequiredService(); - _serviceUrl = new Uri(Environment.GetEnvironmentVariable("TEST_SERVICEURL") ?? "https://smba.trafficmanager.net/teams/"); - _conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); - string agenticAppBlueprintId = Environment.GetEnvironmentVariable("AzureAd__ClientId") ?? throw new InvalidOperationException("AzureAd__ClientId environment variable not set"); - string? agenticAppId = Environment.GetEnvironmentVariable("TEST_AGENTIC_APPID"); - string? agenticUserId = Environment.GetEnvironmentVariable("TEST_AGENTIC_USERID"); - - _agenticIdentity = null; - if (!string.IsNullOrEmpty(agenticAppId) && !string.IsNullOrEmpty(agenticUserId)) - { - _recipient.Properties.Add("agenticAppBlueprintId", agenticAppBlueprintId); - _recipient.Properties.Add("agenticAppId", agenticAppId); - _recipient.Properties.Add("agenticUserId", agenticUserId); - _agenticIdentity = AgenticIdentity.FromProperties(_recipient.Properties); - } - } - - [Fact] - public void Api_ReturnsTeamsApiInstance() - { - TeamsApi api = _teamsBotApplication.Api; - - Assert.NotNull(api); - } - - [Fact] - public void Api_ReturnsSameInstance() - { - TeamsApi api1 = _teamsBotApplication.Api; - TeamsApi api2 = _teamsBotApplication.Api; - - Assert.Same(api1, api2); - } - - [Fact] - public void Api_HasAllSubApis() - { - TeamsApi api = _teamsBotApplication.Api; - - Assert.NotNull(api.Conversations); - Assert.NotNull(api.Users); - Assert.NotNull(api.Teams); - Assert.NotNull(api.Meetings); - Assert.NotNull(api.Batch); - } - - [Fact] - public void Api_Conversations_HasActivitiesAndMembers() - { - Assert.NotNull(_teamsBotApplication.Api.Conversations.Activities); - Assert.NotNull(_teamsBotApplication.Api.Conversations.Members); - } - - [Fact] - public void Api_Users_HasToken() - { - Assert.NotNull(_teamsBotApplication.Api.Users.Token); - } - - [Fact] - public async Task Api_Teams_GetByIdAsync() - { - string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); - - TeamDetails result = await _teamsBotApplication.Api.Teams.GetByIdAsync( - teamId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - Assert.NotNull(result.Id); - - Console.WriteLine($"Team details via Api.Teams.GetByIdAsync:"); - Console.WriteLine($" - Id: {result.Id}"); - Console.WriteLine($" - Name: {result.Name}"); - } - - [Fact] - public async Task Api_Teams_GetChannelsAsync() - { - string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); - - ChannelList result = await _teamsBotApplication.Api.Teams.GetChannelsAsync( - teamId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - Assert.NotNull(result.Channels); - Assert.NotEmpty(result.Channels); - - Console.WriteLine($"Found {result.Channels.Count} channels via Api.Teams.GetChannelsAsync:"); - foreach (var channel in result.Channels) - { - Console.WriteLine($" - Id: {channel.Id}, Name: {channel.Name}"); - } - } - - [Fact] - public async Task Api_Teams_GetByIdAsync_WithActivityContext() - { - string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); - - TeamsActivity activity = new() - { - ServiceUrl = _serviceUrl, - From = TeamsConversationAccount.FromConversationAccount(_recipient), - ChannelData = new TeamsChannelData { Team = new Team { Id = teamId } } - }; - - TeamDetails result = await _teamsBotApplication.Api.Teams.GetByIdAsync( - activity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - Assert.NotNull(result.Id); - - Console.WriteLine($"Team details via Api.Teams.GetByIdAsync with activity context:"); - Console.WriteLine($" - Id: {result.Id}"); - } - - [Fact] - public async Task Api_Conversations_Activities_SendAsync() - { - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Message via Api.Conversations.Activities.SendAsync at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId), - From = _recipient - }; - - SendActivityResponse? res = await _teamsBotApplication.Api.Conversations.Activities.SendAsync( - activity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(res); - Assert.NotNull(res.Id); - - Console.WriteLine($"Sent activity via Api.Conversations.Activities.SendAsync: {res.Id}"); - } - - - [Fact] - public async Task Api_Conversations_Activities_Send_Update_DeleteTMAsync() - { - string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); - - CoreActivity activity = CoreActivity.CreateBuilder() - .WithType(ActivityType.Message) - .WithServiceUrl(_serviceUrl) - .WithConversation(new(_conversationId)) - .WithFrom(_recipient) - .WithRecipient(new ConversationAccount() { Id = userId }, isTargeted: true) - .WithProperty("text", $"TM Message via Api.Conversations.Activities.SendAsync at `{DateTime.UtcNow:s}`") - .Build(); - - SendActivityResponse? res = await _teamsBotApplication.Api.Conversations.Activities.SendAsync( - activity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(res); - Assert.NotNull(res.Id); - - Console.WriteLine($"Sent activity via Api.Conversations.Activities.SendAsync: {res.Id}"); - - await Task.Delay(2000); - - await _teamsBotApplication.Api.Conversations.Activities.UpdateTargetedAsync( - _conversationId, - res.Id, - CoreActivity.CreateBuilder() - .WithServiceUrl(_serviceUrl) - .WithProperty("text", $"TM Updated Message via Api.Conversations.Activities.UpdateAsync at `{DateTime.UtcNow:s}`") - .Build(), - _agenticIdentity, - cancellationToken: CancellationToken.None); - - await Task.Delay(2000); - await _teamsBotApplication.Api.Conversations.Activities.DeleteTargetedAsync( - _conversationId, - res.Id, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - } - - [Fact] - public async Task Api_Conversations_Activities_UpdateAsync() - { - // First send an activity - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Original message via Api at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId), - From = _recipient - }; - - SendActivityResponse? sendResponse = await _teamsBotApplication.Api.Conversations.Activities.SendAsync(activity); - Assert.NotNull(sendResponse?.Id); - - // Now update the activity - CoreActivity updatedActivity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Updated message via Api.Conversations.Activities.UpdateAsync at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - From = _recipient - }; - - UpdateActivityResponse updateResponse = await _teamsBotApplication.Api.Conversations.Activities.UpdateAsync( - _conversationId, - sendResponse.Id, - updatedActivity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(updateResponse); - Assert.NotNull(updateResponse.Id); - - Console.WriteLine($"Updated activity via Api.Conversations.Activities.UpdateAsync: {updateResponse.Id}"); - } - - [Fact] - public async Task Api_Conversations_Activities_DeleteAsync() - { - // First send an activity - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Message to delete via Api at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId), - From = _recipient - }; - - SendActivityResponse? sendResponse = await _teamsBotApplication.Api.Conversations.Activities.SendAsync(activity); - Assert.NotNull(sendResponse?.Id); - - // Wait a bit before deleting - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Now delete the activity - await _teamsBotApplication.Api.Conversations.Activities.DeleteAsync( - _conversationId, - sendResponse.Id, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Console.WriteLine($"Deleted activity via Api.Conversations.Activities.DeleteAsync: {sendResponse.Id}"); - } - - [Fact] - public async Task Api_Conversations_Activities_GetMembersAsync() - { - // First send an activity - CoreActivity activity = new() - { - Type = ActivityType.Message, - Properties = { { "text", $"Message for GetMembersAsync test at `{DateTime.UtcNow:s}`" } }, - ServiceUrl = _serviceUrl, - Conversation = new(_conversationId), - From = _recipient - }; - - SendActivityResponse? sendResponse = await _teamsBotApplication.Api.Conversations.Activities.SendAsync(activity); - Assert.NotNull(sendResponse?.Id); - - // Now get activity members - IList members = await _teamsBotApplication.Api.Conversations.Activities.GetMembersAsync( - _conversationId, - sendResponse.Id, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(members); - Assert.NotEmpty(members); - - Console.WriteLine($"Found {members.Count} activity members via Api.Conversations.Activities.GetMembersAsync:"); - foreach (var member in members) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - } - } - - [Fact] - public async Task Api_Conversations_Members_GetAllAsync() - { - IList members = await _teamsBotApplication.Api.Conversations.Members.GetAllAsync( - _conversationId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(members); - Assert.NotEmpty(members); - - Console.WriteLine($"Found {members.Count} conversation members via Api.Conversations.Members.GetAllAsync:"); - foreach (var member in members) - { - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - } - } - - [Fact] - public async Task Api_Conversations_Members_GetAllAsync_WithActivityContext() - { - TeamsActivity activity = new() - { - ServiceUrl = _serviceUrl, - Conversation = new TeamsConversation { Id = _conversationId }, - From = TeamsConversationAccount.FromConversationAccount(_recipient) - }; - - IList members = await _teamsBotApplication.Api.Conversations.Members.GetAllAsync( - activity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(members); - Assert.NotEmpty(members); - - Console.WriteLine($"Found {members.Count} members via Api.Conversations.Members.GetAllAsync with activity context"); - } - - [Fact] - public async Task Api_Conversations_Members_GetByIdAsync() - { - string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); - - ConversationAccount member = await _teamsBotApplication.Api.Conversations.Members.GetByIdAsync( - _conversationId, - userId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(member); - Assert.NotNull(member.Id); - - Console.WriteLine($"Found member via Api.Conversations.Members.GetByIdAsync:"); - Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); - } - - [Fact] - public async Task Api_Conversations_Members_GetPagedAsync() - { - PagedMembersResult result = await _teamsBotApplication.Api.Conversations.Members.GetPagedAsync( - _conversationId, - _serviceUrl, - 5, - null, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - Assert.NotNull(result.Members); - Assert.NotEmpty(result.Members); - - Console.WriteLine($"Found {result.Members.Count} members via Api.Conversations.Members.GetPagedAsync"); - } - - [Fact(Skip = "GetByIdAsync is not working with agentic identity")] - public async Task Api_Meetings_GetByIdAsync() - { - string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); - - MeetingInfo result = await _teamsBotApplication.Api.Meetings.GetByIdAsync( - meetingId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - - Console.WriteLine($"Meeting info via Api.Meetings.GetByIdAsync:"); - if (result.Details != null) - { - Console.WriteLine($" - Title: {result.Details.Title}"); - Console.WriteLine($" - Type: {result.Details.Type}"); - } - } - - [Fact] - public async Task Api_Meetings_GetParticipantAsync() - { - string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); - string participantId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); - string tenantId = Environment.GetEnvironmentVariable("TEST_TENANTID") ?? throw new InvalidOperationException("TEST_TENANTID environment variable not set"); - - MeetingParticipant result = await _teamsBotApplication.Api.Meetings.GetParticipantAsync( - meetingId, - participantId, - tenantId, - _serviceUrl, - _agenticIdentity, - cancellationToken: CancellationToken.None); - - Assert.NotNull(result); - - Console.WriteLine($"Participant info via Api.Meetings.GetParticipantAsync:"); - if (result.User != null) - { - Console.WriteLine($" - User Id: {result.User.Id}"); - Console.WriteLine($" - User Name: {result.User.Name}"); - } - } - - [Fact] - public async Task Api_Batch_GetStateAsync_FailsWithInvalidOperationId() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Batch.GetStateAsync("invalid-operation-id", _serviceUrl, _agenticIdentity)); - } - - [Fact] - public async Task Api_Teams_GetByIdAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Teams.GetByIdAsync((TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Teams_GetChannelsAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Teams.GetChannelsAsync((TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Conversations_Members_GetAllAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Conversations.Members.GetAllAsync((TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Conversations_Members_GetByIdAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Conversations.Members.GetByIdAsync((TeamsActivity)null!, "user-id")); - } - - [Fact] - public async Task Api_Conversations_Members_GetPagedAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Conversations.Members.GetPagedAsync((TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Conversations_Members_DeleteAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Conversations.Members.DeleteAsync((TeamsActivity)null!, "member-id")); - } - - [Fact] - public async Task Api_Meetings_GetByIdAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Meetings.GetByIdAsync("meeting-id", (TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Meetings_GetParticipantAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Meetings.GetParticipantAsync("meeting-id", "participant-id", (TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Meetings_SendNotificationAsync_ThrowsOnNullActivity() - { - var notification = new TargetedMeetingNotification(); - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Meetings.SendNotificationAsync("meeting-id", notification, (TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Batch_GetStateAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Batch.GetStateAsync("operation-id", (TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Batch_GetFailedEntriesAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Batch.GetFailedEntriesAsync("operation-id", (TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Batch_CancelAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Batch.CancelAsync("operation-id", (TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Batch_SendToUsersAsync_ThrowsOnNullActivity() - { - var activity = new CoreActivity { Type = ActivityType.Message }; - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Batch.SendToUsersAsync(activity, [new TeamMember("id")], (TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Batch_SendToTenantAsync_ThrowsOnNullActivity() - { - var activity = new CoreActivity { Type = ActivityType.Message }; - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Batch.SendToTenantAsync(activity, (TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Batch_SendToTeamAsync_ThrowsOnNullActivity() - { - var activity = new CoreActivity { Type = ActivityType.Message }; - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Batch.SendToTeamAsync(activity, "team-id", (TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Batch_SendToChannelsAsync_ThrowsOnNullActivity() - { - var activity = new CoreActivity { Type = ActivityType.Message }; - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Batch.SendToChannelsAsync(activity, [new TeamMember("id")], (TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Users_Token_GetAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Users.Token.GetAsync((TeamsActivity)null!, "connection-name")); - } - - [Fact] - public async Task Api_Users_Token_ExchangeAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Users.Token.ExchangeAsync((TeamsActivity)null!, "connection-name", "token")); - } - - [Fact] - public async Task Api_Users_Token_SignOutAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Users.Token.SignOutAsync((TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Users_Token_GetAadTokensAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Users.Token.GetAadTokensAsync((TeamsActivity)null!, "connection-name")); - } - - [Fact] - public async Task Api_Users_Token_GetStatusAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Users.Token.GetStatusAsync((TeamsActivity)null!)); - } - - [Fact] - public async Task Api_Users_Token_GetSignInResourceAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Users.Token.GetSignInResourceAsync((TeamsActivity)null!, "connection-name")); - } - - [Fact] - public async Task Api_Conversations_Activities_SendHistoryAsync_ThrowsOnNullActivity() - { - var transcript = new Transcript { Activities = [] }; - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Conversations.Activities.SendHistoryAsync((TeamsActivity)null!, transcript)); - } - - [Fact] - public async Task Api_Conversations_Activities_GetMembersAsync_ThrowsOnNullActivity() - { - await Assert.ThrowsAsync(() - => _teamsBotApplication.Api.Conversations.Activities.GetMembersAsync((TeamsActivity)null!)); - } -} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/readme.md b/core/test/Microsoft.Teams.Bot.Core.Tests/readme.md deleted file mode 100644 index 125a5289..00000000 --- a/core/test/Microsoft.Teams.Bot.Core.Tests/readme.md +++ /dev/null @@ -1,21 +0,0 @@ -# Microsoft.Bot.Core.Tests - -To run these tests we need to configure the environment variables using a `.runsettings` file, that should be localted in `core/` folder. - - -```xml - - - - - a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT - https://login.microsoftonline.com/ - - - https://api.botframework.com/.default - ClientSecret - - - - -``` \ No newline at end of file From 8ab5b1e0eca2efa67d3953351e45dc0edaf2e683 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Fri, 17 Apr 2026 15:47:33 -0700 Subject: [PATCH 07/22] upd docs --- core/docs/ApiClient-Design.md | 110 +++++++++++++------ core/docs/CompatTeamsInfo-API-Mapping.md | 130 ++++++++++++----------- 2 files changed, 144 insertions(+), 96 deletions(-) diff --git a/core/docs/ApiClient-Design.md b/core/docs/ApiClient-Design.md index 693ff007..e924cb17 100644 --- a/core/docs/ApiClient-Design.md +++ b/core/docs/ApiClient-Design.md @@ -9,15 +9,15 @@ The `ApiClient` class (`Microsoft.Teams.Bot.Apps.Api.Clients`) provides a hierar ``` ApiClient (top-level facade) ├── Bots → BotClient -│ └── SignIn → BotSignInClient [BotHttpClient → token.botframework.com] -├── Conversations → V3ConversationClient [delegates to core ConversationClient] +│ └── SignIn → BotSignInClient [BotHttpClient → token.botframework.com] +├── Conversations → ConversationApiClient [delegates to core ConversationClient] │ ├── Activities → ActivityClient │ ├── Members → MemberClient -│ └── Reactions → ReactionClient +│ └── Reactions → ReactionClient [Experimental] ├── Users → UserClient -│ └── Token → V3UserTokenClient [BotHttpClient → token.botframework.com] -├── Teams → TeamClient [BotHttpClient → serviceUrl/v3/teams/] -└── Meetings → MeetingClient [BotHttpClient → serviceUrl/v1/meetings/] +│ └── Token → UserTokenApiClient [BotHttpClient → token.botframework.com] +├── Teams → TeamClient [BotHttpClient → serviceUrl/v3/teams/] +└── Meetings → MeetingClient [BotHttpClient → serviceUrl/v1/meetings/] ``` ### Two HTTP strategies @@ -28,6 +28,13 @@ ApiClient (top-level facade) | Teams, Meetings | Uses `BotHttpClient` directly | No core client equivalent exists for these endpoints | | Bots.SignIn, Users.Token | Uses `BotHttpClient` directly | Calls `token.botframework.com`, separate from conversation endpoints | +### Experimental APIs + +| Feature | Diagnostic ID | Notes | +|---|---|---| +| `ReactionClient` | `ExperimentalTeamsReactions` | Reactions endpoint assumed but not confirmed in Teams Bot Framework API | +| `ActivityClient.CreateTargetedAsync` / `UpdateTargetedAsync` / `DeleteTargetedAsync` | `ExperimentalTeamsTargeted` | Targeted (recipient-only visible) activities; not supported in team channel conversations | + ## Construction & Scoping ### The serviceUrl problem @@ -42,12 +49,13 @@ The Bot Framework service URL is per-request (comes from `activity.ServiceUrl`), ```csharp // Registered automatically by AddTeamsBotApplication() // The [ActivatorUtilitiesConstructor] attribute tells DI to prefer this constructor +[ActivatorUtilitiesConstructor] public ApiClient(HttpClient httpClient, ConversationClient conversationClient, ILogger? logger = null, ...) ``` `AddTeamsBotApplication()` calls `AddBotClient(...)` which registers `ApiClient` as a typed HTTP client with `BotAuthenticationHandler`. The `ConversationClient` dependency is resolved from DI automatically. -**Important:** The base `ApiClient` has `Conversations`, `Teams`, and `Meetings` set to `null`. Accessing them directly causes `NullReferenceException`. Always use `ForServiceUrl()` or `Context.Api` to get a scoped instance. +**Important:** The base `ApiClient` has `Conversations`, `Teams`, and `Meetings` set to `null!`. Only `Bots` and `Users` are available on the unscoped instance. Accessing `Conversations`, `Teams`, or `Meetings` directly causes `NullReferenceException`. Always use `ForServiceUrl()` or `Context.Api` to get a scoped instance. ### Per-request scoping via Context.Api @@ -75,15 +83,14 @@ await scoped.Conversations.Activities.CreateAsync(conversationId, activity); `ForServiceUrl` shares the underlying `BotHttpClient` and `ConversationClient` — only the sub-client wrappers are new allocations. -### Fully initialized (for tests or known serviceUrl) +### Constructors -```csharp -ApiClient client = new( - new Uri("https://smba.trafficmanager.net/teams/"), - httpClient, - conversationClient, - logger); -``` +| Constructor | Use case | +|---|---| +| `ApiClient(HttpClient, ConversationClient, ILogger?, string)` | DI registration (marked `[ActivatorUtilitiesConstructor]`) | +| `ApiClient(Uri, HttpClient, ConversationClient, ILogger?, string)` | Fully initialized with known serviceUrl | +| `ApiClient(ApiClient)` | Copy constructor | +| Private: `ApiClient(BotHttpClient, ConversationClient, string, Uri)` | Used by `ForServiceUrl` — shares HTTP client | ## Delegation Pattern @@ -120,9 +127,9 @@ ReactionClient.AddAsync(conversationId, activityId, reactionType) | `UpdateAsync(conversationId, id, activity)` | `UpdateActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` on activity | | `ReplyAsync(conversationId, id, activity)` | `SendActivityAsync(activity)` | Sets `ReplyToId`, `ServiceUrl`, `Conversation` | | `DeleteAsync(conversationId, id)` | `DeleteActivityAsync(conversationId, id, serviceUrl)` | | -| `CreateTargetedAsync(conversationId, activity)` | `SendActivityAsync(activity)` | Sets `Recipient.IsTargeted = true` | -| `UpdateTargetedAsync(conversationId, id, activity)` | `UpdateTargetedActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` on activity | -| `DeleteTargetedAsync(conversationId, id)` | `DeleteTargetedActivityAsync(conversationId, id, serviceUrl)` | | +| `CreateTargetedAsync(conversationId, activity)` | `SendActivityAsync(activity)` | Sets `Recipient.IsTargeted = true` [Experimental] | +| `UpdateTargetedAsync(conversationId, id, activity)` | `UpdateTargetedActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` [Experimental] | +| `DeleteTargetedAsync(conversationId, id)` | `DeleteTargetedActivityAsync(conversationId, id, serviceUrl)` | [Experimental] | #### MemberClient → ConversationClient @@ -133,35 +140,66 @@ ReactionClient.AddAsync(conversationId, activityId, reactionType) | `GetByIdAsync(conversationId, memberId)` | `GetConversationMemberAsync(conversationId, memberId, serviceUrl)` | | `DeleteAsync(conversationId, memberId)` | `DeleteConversationMemberAsync(conversationId, memberId, serviceUrl)` | -#### ReactionClient → ConversationClient +#### ReactionClient → ConversationClient [Experimental] | ReactionClient | ConversationClient | |---|---| | `AddAsync(conversationId, activityId, reactionType)` | `AddReactionAsync(conversationId, activityId, reactionType, serviceUrl)` | | `DeleteAsync(conversationId, activityId, reactionType)` | `DeleteReactionAsync(conversationId, activityId, reactionType, serviceUrl)` | -#### V3ConversationClient → ConversationClient +#### ConversationApiClient → ConversationClient -| V3ConversationClient | ConversationClient | +| ConversationApiClient | ConversationClient | |---|---| | `CreateAsync(parameters)` | `CreateConversationAsync(parameters, serviceUrl)` | +#### TeamClient (direct HTTP) + +| TeamClient | Endpoint | +|---|---| +| `GetByIdAsync(id)` | `GET {serviceUrl}/v3/teams/{id}` | +| `GetConversationsAsync(id)` | `GET {serviceUrl}/v3/teams/{id}/conversations` | + +#### MeetingClient (direct HTTP) + +| MeetingClient | Endpoint | +|---|---| +| `GetByIdAsync(id)` | `GET {serviceUrl}/v1/meetings/{id}` | +| `GetParticipantAsync(meetingId, id, tenantId)` | `GET {serviceUrl}/v1/meetings/{meetingId}/participants/{id}?tenantId={tenantId}` | + +#### BotSignInClient (direct HTTP) + +| BotSignInClient | Endpoint | +|---|---| +| `GetUrlAsync(state, ...)` | `GET {tokenApi}/api/botsignin/GetSignInUrl?state={state}` | +| `GetResourceAsync(state, ...)` | `GET {tokenApi}/api/botsignin/GetSignInResource?state={state}` | + +#### UserTokenApiClient (direct HTTP) + +| UserTokenApiClient | Endpoint | +|---|---| +| `GetAsync(userId, connectionName, channelId, ...)` | `GET {tokenApi}/api/usertoken/GetToken?...` | +| `GetAadAsync(userId, connectionName, channelId, ...)` | `POST {tokenApi}/api/usertoken/GetAadTokens?...` | +| `GetStatusAsync(userId, channelId, ...)` | `GET {tokenApi}/api/usertoken/GetTokenStatus?...` | +| `SignOutAsync(userId, connectionName, channelId)` | `DELETE {tokenApi}/api/usertoken/SignOut?...` | +| `ExchangeAsync(userId, connectionName, channelId, token)` | `POST {tokenApi}/api/usertoken/exchange?...` | + ## File Layout ``` core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ ├── ApiClient.cs Top-level facade, DI entry point, ForServiceUrl factory -├── V3ConversationClient.cs Conversation facade → delegates to core ConversationClient -├── ActivityClient.cs Activity CRUD → delegates to core ConversationClient +├── ConversationApiClient.cs Conversation facade → delegates to core ConversationClient +├── ActivityClient.cs Activity CRUD + targeted → delegates to core ConversationClient ├── MemberClient.cs Member operations → delegates to core ConversationClient -├── ReactionClient.cs Reaction operations → delegates to core ConversationClient +├── ReactionClient.cs Reaction operations → delegates to core ConversationClient [Experimental] ├── TeamClient.cs Team info → BotHttpClient (v3/teams/) ├── MeetingClient.cs Meeting info → BotHttpClient (v1/meetings/) + models ├── BotClient.cs Bot facade (groups SignIn) ├── BotSignInClient.cs Sign-in URLs → BotHttpClient (token.botframework.com) ├── BotTokenClient.cs Static scope constants ├── UserClient.cs User facade (groups Token) -└── V3UserTokenClient.cs User token ops → BotHttpClient (token.botframework.com) +└── UserTokenApiClient.cs User token ops → BotHttpClient (token.botframework.com) ``` ## Integration with Context and Handlers @@ -176,18 +214,25 @@ This is the primary way handlers should access the API clients. It ensures the s ## Integration with CompatTeamsInfo -`CompatTeamsInfo` retrieves `ApiClient` from `TurnState` and uses sub-clients for Teams-specific operations: +`CompatTeamsInfo` retrieves clients from `TurnState`: -- `client.Meetings.GetByIdAsync(meetingId)` — meeting info -- `client.Meetings.GetParticipantAsync(meetingId, participantId, tenantId)` — meeting participant -- `client.Teams.GetByIdAsync(teamId)` — team details -- `client.Teams.GetConversationsAsync(teamId)` — channel list +- **`ApiClient`** (from `TurnState.Get()`) for Teams/Meetings operations: + - `client.Teams.GetByIdAsync(teamId)` — team details + - `client.Teams.GetConversationsAsync(teamId)` — channel list + - `client.Meetings.GetParticipantAsync(meetingId, participantId, tenantId)` — meeting participant -Member operations go through the core `ConversationClient` directly (not via `ApiClient`). +- **`ConversationClient`** (from `CompatConnectorClient` in `TurnState.Get()`) for member operations: + - `GetConversationMemberAsync(...)` — single member + - `GetConversationMembersAsync(...)` — all members + - `GetConversationPagedMembersAsync(...)` — paged members -The `CompatAdapter` should scope the `ApiClient` before storing it in `TurnState`: +**Note on CompatAdapter scoping:** The `CompatAdapter` currently stores the unscoped `TeamsApiClient` in `TurnState` (line 59). This works because `CompatTeamsInfo` uses the `ApiClient` sub-clients which are scoped. However, `CompatAdapter` should ideally scope the `ApiClient` before storing: ```csharp +// Current (unscoped — Teams/Meetings sub-clients are null): +turnContext.TurnState.Add(_teamsBotApplication.TeamsApiClient); + +// Should be (scoped): ApiClient scopedClient = _teamsBotApplication.TeamsApiClient.ForServiceUrl(new Uri(activity.ServiceUrl)); turnContext.TurnState.Add(scopedClient); ``` @@ -196,3 +241,4 @@ turnContext.TurnState.Add(scopedClient); - **BatchClient**: Batch messaging operations (`SendMessageToListOfUsersAsync`, etc.) need a new sub-client on `ApiClient` using `BotHttpClient` for the `v3/batch/conversation/` endpoints. - **MeetingClient.SendMeetingNotificationAsync**: Meeting notification support needs to be added along with notification model types. +- **CompatAdapter scoping**: Fix `CompatAdapter` to call `ForServiceUrl` before storing `ApiClient` in `TurnState`. diff --git a/core/docs/CompatTeamsInfo-API-Mapping.md b/core/docs/CompatTeamsInfo-API-Mapping.md index 37c20af9..9f02e35f 100644 --- a/core/docs/CompatTeamsInfo-API-Mapping.md +++ b/core/docs/CompatTeamsInfo-API-Mapping.md @@ -4,20 +4,20 @@ This document provides a comprehensive mapping of Bot Framework TeamsInfo static ## Overview -The `CompatTeamsInfo` class provides a compatibility layer that adapts the Bot Framework v4 SDK TeamsInfo API to use the Teams Bot Core SDK. It implements 19 static methods organized into four functional categories. +The `CompatTeamsInfo` class provides a compatibility layer that adapts the Bot Framework v4 SDK TeamsInfo API to use the Teams Bot Core SDK. It maps 19 static methods organized into four functional categories. ## API Method Mappings ### Member & Participant Methods -| Method | REST Endpoint | Client | Description | -|--------|--------------|--------|-------------| -| `GetMemberAsync` | `GET /v3/conversations/{conversationId}/members/{userId}` | ConversationClient | Gets a single conversation member by user ID | -| `GetMembersAsync` | `GET /v3/conversations/{conversationId}/members` | ConversationClient | Gets all conversation members (deprecated) | -| `GetPagedMembersAsync` | `GET /v3/conversations/{conversationId}/pagedmembers?pageSize={pageSize}&continuationToken={token}` | ConversationClient | Gets paginated list of conversation members | -| `GetTeamMemberAsync` | `GET /v3/conversations/{teamId}/members/{userId}` | ConversationClient | Gets a single team member by user ID | -| `GetTeamMembersAsync` | `GET /v3/conversations/{teamId}/members` | ConversationClient | Gets all team members (deprecated) | -| `GetPagedTeamMembersAsync` | `GET /v3/conversations/{teamId}/pagedmembers?pageSize={pageSize}&continuationToken={token}` | ConversationClient | Gets paginated list of team members | +| Method | REST Endpoint | Client | Status | +|--------|--------------|--------|--------| +| `GetMemberAsync` | `GET /v3/conversations/{conversationId}/members/{userId}` | ConversationClient | Implemented | +| `GetMembersAsync` | `GET /v3/conversations/{conversationId}/members` | ConversationClient | Implemented (deprecated) | +| `GetPagedMembersAsync` | `GET /v3/conversations/{conversationId}/pagedmembers?pageSize=&continuationToken=` | ConversationClient | Implemented | +| `GetTeamMemberAsync` | `GET /v3/conversations/{teamId}/members/{userId}` | ConversationClient | Implemented | +| `GetTeamMembersAsync` | `GET /v3/conversations/{teamId}/members` | ConversationClient | Implemented (deprecated) | +| `GetPagedTeamMembersAsync` | `GET /v3/conversations/{teamId}/pagedmembers?pageSize=&continuationToken=` | ConversationClient | Implemented | > `GetMembersAsync` and `GetTeamMembersAsync` are deprecated by Microsoft Teams. Use paged versions instead. @@ -27,38 +27,40 @@ The `CompatTeamsInfo` class provides a compatibility layer that adapts the Bot F |--------|--------------|--------|--------| | `GetMeetingInfoAsync` | `GET /v1/meetings/{meetingId}` | ApiClient.Meetings | Implemented | | `GetMeetingParticipantAsync` | `GET /v1/meetings/{meetingId}/participants/{participantId}?tenantId={tenantId}` | ApiClient.Meetings | Implemented | -| `SendMeetingNotificationAsync` | `POST /v1/meetings/{meetingId}/notification` | — | Not yet implemented (commented out) | +| `SendMeetingNotificationAsync` | `POST /v1/meetings/{meetingId}/notification` | — | Commented out (needs `MeetingClient.SendMeetingNotificationAsync`) | + +> `GetMeetingParticipantAsync` requires an AAD object ID for `participantId`, not a Bot Framework MRI or pairwise ID. ### Team & Channel Methods | Method | REST Endpoint | Client | Status | |--------|--------------|--------|--------| -| `GetTeamDetailsAsync` | `GET /v3/teams/{teamId}` | ApiClient.Teams | Needs update: calls `client.FetchTeamDetailsAsync()` which doesn't exist. Should use `client.Teams.GetByIdAsync()` | -| `GetTeamChannelsAsync` | `GET /v3/teams/{teamId}/conversations` | ApiClient.Teams | Needs update: calls `client.FetchChannelListAsync()` which doesn't exist. Should use `client.Teams.GetConversationsAsync()` | +| `GetTeamDetailsAsync` | `GET /v3/teams/{teamId}` | ApiClient.Teams | Implemented — uses `client.Teams.GetByIdAsync()` | +| `GetTeamChannelsAsync` | `GET /v3/teams/{teamId}/conversations` | ApiClient.Teams | Implemented — uses `client.Teams.GetConversationsAsync()` | ### Batch Messaging Methods | Method | REST Endpoint | Client | Status | |--------|--------------|--------|--------| -| `SendMessageToListOfUsersAsync` | `POST /v3/batch/conversation/users/` | — | Implemented in CompatTeamsInfo, but calls methods that don't exist on ApiClient yet (needs BatchClient) | -| `SendMessageToListOfChannelsAsync` | `POST /v3/batch/conversation/channels/` | — | Same — needs BatchClient | -| `SendMessageToAllUsersInTeamAsync` | `POST /v3/batch/conversation/team/` | — | Same — needs BatchClient | -| `SendMessageToAllUsersInTenantAsync` | `POST /v3/batch/conversation/tenant/` | — | Same — needs BatchClient | -| `SendMessageToTeamsChannelAsync` | Uses Bot Framework Adapter | BotAdapter.CreateConversationAsync | Implemented — does not use ApiClient | +| `SendMessageToListOfUsersAsync` | `POST /v3/batch/conversation/users/` | — | Commented out (needs BatchClient) | +| `SendMessageToListOfChannelsAsync` | `POST /v3/batch/conversation/channels/` | — | Commented out (needs BatchClient) | +| `SendMessageToAllUsersInTeamAsync` | `POST /v3/batch/conversation/team/` | — | Commented out (needs BatchClient) | +| `SendMessageToAllUsersInTenantAsync` | `POST /v3/batch/conversation/tenant/` | — | Commented out (needs BatchClient) | +| `SendMessageToTeamsChannelAsync` | Uses Bot Framework Adapter | BotAdapter.CreateConversationAsync | Implemented | ### Batch Operation Management Methods | Method | REST Endpoint | Client | Status | |--------|--------------|--------|--------| -| `GetOperationStateAsync` | `GET /v3/batch/conversation/{operationId}` | — | Calls methods that don't exist on ApiClient yet (needs BatchClient) | -| `GetPagedFailedEntriesAsync` | `GET /v3/batch/conversation/failedentries/{operationId}?continuationToken={token}` | — | Same — needs BatchClient | -| `CancelOperationAsync` | `DELETE /v3/batch/conversation/{operationId}` | — | Same — needs BatchClient | +| `GetOperationStateAsync` | `GET /v3/batch/conversation/{operationId}` | — | Commented out (needs BatchClient) | +| `GetPagedFailedEntriesAsync` | `GET /v3/batch/conversation/failedentries/{operationId}?continuationToken=` | — | Commented out (needs BatchClient) | +| `CancelOperationAsync` | `DELETE /v3/batch/conversation/{operationId}` | — | Commented out (needs BatchClient) | ## Client Distribution -### ConversationClient (6 methods) — Working +### ConversationClient (6 methods) — Implemented -Used for member and participant operations in conversations and teams. Accessed via the `CompatConnectorClient` in TurnState. +Used for member and participant operations in conversations and teams. Accessed via the `CompatConnectorClient` in TurnState (`turnContext.TurnState.Get()` → cast to `CompatConnectorClient` → `CompatConversations._client`). - GetMemberAsync - GetMembersAsync @@ -67,31 +69,31 @@ Used for member and participant operations in conversations and teams. Accessed - GetTeamMembersAsync - GetPagedTeamMembersAsync -### ApiClient sub-clients (4 methods) — Working +### ApiClient sub-clients (4 methods) — Implemented -`ApiClient` is stored in TurnState by `CompatAdapter`. Must be scoped to serviceUrl before use. Uses sub-clients: +`ApiClient` is stored in TurnState by `CompatAdapter`. Must be scoped to serviceUrl via `ForServiceUrl()` before use. Uses sub-clients: - `ApiClient.Meetings.GetByIdAsync()` — GetMeetingInfoAsync - `ApiClient.Meetings.GetParticipantAsync()` — GetMeetingParticipantAsync -- `ApiClient.Teams.GetByIdAsync()` — GetTeamDetailsAsync (needs rewiring from `FetchTeamDetailsAsync`) -- `ApiClient.Teams.GetConversationsAsync()` — GetTeamChannelsAsync (needs rewiring from `FetchChannelListAsync`) +- `ApiClient.Teams.GetByIdAsync()` — GetTeamDetailsAsync +- `ApiClient.Teams.GetConversationsAsync()` — GetTeamChannelsAsync -### Bot Framework Adapter (1 method) — Working +### Bot Framework Adapter (1 method) — Implemented - SendMessageToTeamsChannelAsync — uses `turnContext.Adapter.CreateConversationAsync()` -### Not yet implemented (8 methods) +### Not yet implemented (8 methods) — Commented out -These methods exist in `CompatTeamsInfo` but call ApiClient methods that don't exist yet. They need a new `BatchClient` sub-client and `MeetingClient.SendMeetingNotificationAsync`: +These methods are commented out in `CompatTeamsInfo` pending new client support: -- SendMeetingNotificationAsync (commented out) -- SendMessageToListOfUsersAsync -- SendMessageToListOfChannelsAsync -- SendMessageToAllUsersInTeamAsync -- SendMessageToAllUsersInTenantAsync -- GetOperationStateAsync -- GetPagedFailedEntriesAsync -- CancelOperationAsync +- SendMeetingNotificationAsync — needs `MeetingClient.SendMeetingNotificationAsync` +- SendMessageToListOfUsersAsync — needs BatchClient +- SendMessageToListOfChannelsAsync — needs BatchClient +- SendMessageToAllUsersInTeamAsync — needs BatchClient +- SendMessageToAllUsersInTenantAsync — needs BatchClient +- GetOperationStateAsync — needs BatchClient +- GetPagedFailedEntriesAsync — needs BatchClient +- CancelOperationAsync — needs BatchClient ## Migration Checklist @@ -100,8 +102,8 @@ These methods exist in `CompatTeamsInfo` but call ApiClient methods that don't e | Member operations via ConversationClient | Done | | Meeting info via ApiClient.Meetings | Done | | Meeting participant via ApiClient.Meetings | Done | -| Team details via ApiClient.Teams | Needs rewiring in CompatTeamsInfo | -| Team channels via ApiClient.Teams | Needs rewiring in CompatTeamsInfo | +| Team details via ApiClient.Teams | Done | +| Team channels via ApiClient.Teams | Done | | SendMessageToTeamsChannelAsync via adapter | Done | | Batch messaging (4 methods) | Needs BatchClient on ApiClient | | Batch operations (3 methods) | Needs BatchClient on ApiClient | @@ -112,41 +114,41 @@ These methods exist in `CompatTeamsInfo` but call ApiClient methods that don't e Key extension methods in `CompatActivity.cs` and `CompatTeamsInfo.Models.cs`: -| Extension Method | Source Type | Target Type | Status | -|---|---|---|---| -| `ToCompatTeamsChannelAccount` | `ConversationAccount` | BF `TeamsChannelAccount` | Working | -| `ToCompatTeamsPagedMembersResult` | `PagedMembersResult` | BF `TeamsPagedMembersResult` | Working | -| `ToCompatTeamDetails` | `Apps.Schema.Team` | BF `TeamDetails` | Working | -| `ToCompatTeamsMeetingParticipant` | `MeetingParticipant` | BF `TeamsMeetingParticipant` | Working | -| `ToCompatChannelInfo` | `TeamsChannel` | BF `ChannelInfo` | Working | -| `ToCompatBatchOperationState` | `BatchOperationState` | BF `BatchOperationState` | Commented out — needs `BatchOperationState` model | -| `ToCompatBatchFailedEntriesResponse` | `BatchFailedEntriesResponse` | BF `BatchFailedEntriesResponse` | Commented out — needs models | -| `ToCompatMeetingNotificationResponse` | `MeetingNotificationResponse` | BF `MeetingNotificationResponse` | Commented out — needs models | -| `FromCompatTeamMember` | BF `TeamMember` | `Apps.TeamMember` | Commented out — needs `TeamMember` model | +| Extension Method | Source Type | Target Type | Used By | Status | +|---|---|---|---|---| +| `ToCompatTeamsChannelAccount` | `ConversationAccount` | BF `TeamsChannelAccount` | GetMember/GetMembers/GetTeamMember/GetTeamMembers | Working | +| `ToCompatTeamsPagedMembersResult` | `PagedMembersResult` | BF `TeamsPagedMembersResult` | GetPagedMembers/GetPagedTeamMembers | Working | +| `ToCompatChannelInfo` | `TeamsChannel` | BF `ChannelInfo` | GetTeamChannelsAsync | Working | +| `ToCompatTeamDetails` | `Apps.Schema.Team` | BF `TeamDetails` | Defined but unused — GetTeamDetailsAsync uses inline mapping | Available | +| `ToCompatTeamsMeetingParticipant` | `MeetingParticipant` | BF `TeamsMeetingParticipant` | Defined but unused — GetMeetingParticipantAsync uses inline mapping | Available | +| `ToCompatBatchOperationState` | `BatchOperationState` | BF `BatchOperationState` | — | Commented out (needs models) | +| `ToCompatBatchFailedEntriesResponse` | `BatchFailedEntriesResponse` | BF `BatchFailedEntriesResponse` | — | Commented out (needs models) | +| `ToCompatMeetingNotificationResponse` | `MeetingNotificationResponse` | BF `MeetingNotificationResponse` | — | Commented out (needs models) | +| `FromCompatTeamMember` | BF `TeamMember` | `Apps.TeamMember` | — | Commented out (needs models) | ## Authentication -All methods use `AgenticIdentity` extracted from the turn context activity properties for authentication with the Teams services. +All methods use `AgenticIdentity` extracted from the turn context activity properties for authentication with the Teams services. The identity is obtained by converting the Bot Framework `Activity` to a `CoreActivity` and extracting agentic properties from `From.Properties`. ## Service URL -All API calls use the service URL from the turn context activity (`turnContext.Activity.ServiceUrl`). For `ApiClient` sub-client calls, this requires scoping via `ForServiceUrl()`: +All API calls use the service URL from the turn context activity (`turnContext.Activity.ServiceUrl`): -```csharp -private static ApiClient GetTeamsApiClient(ITurnContext turnContext) -{ - return turnContext.TurnState.Get() - ?? throw new InvalidOperationException("This method requires ApiClient."); -} -``` +- **ConversationClient** methods receive `serviceUrl` as a `Uri` parameter directly +- **ApiClient** sub-client methods use the serviceUrl baked into the scoped client instance -The `CompatAdapter` must store a scoped `ApiClient` in TurnState for this to work. +The `CompatAdapter` must store a **scoped** `ApiClient` in TurnState for Teams/Meetings sub-clients to work. Currently it stores the unscoped base instance — this is a known pending fix (see [ApiClient Design](ApiClient-Design.md#integration-with-compatteamsinfo)). ## Testing -Integration tests are available in: -- `test/IntegrationTests/` — Tests for `ConversationClient` and `ApiClient` sub-clients -- `test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs` — Tests for the compat layer +Integration tests are available in `core/test/IntegrationTests/`: + +| Test File | Coverage | +|---|---| +| `CompatTeamsInfoTests.cs` | 14 tests covering all implemented CompatTeamsInfo methods via real API calls with a simulated TurnContext | +| `ApiClientTests.cs` | Direct tests for ApiClient sub-clients (Activities, Members, Teams, Meetings, UserToken, BotSignIn) | +| `ConversationClientTests.cs` | Core ConversationClient operations | +| `CreateConversationTests.cs` | Conversation creation patterns | Tests require the `integration.runsettings` file with environment variables: - `TEST_USER_ID`, `TEST_CONVERSATIONID`, `TEST_TEAMID`, `TEST_CHANNELID`, `TEST_MEETINGID`, `TEST_TENANTID` @@ -154,6 +156,6 @@ Tests require the `integration.runsettings` file with environment variables: ## References -- [ApiClient Design Document](ApiClient-Design.md) — Architecture and delegation patterns +- [ApiClient Design Document](ApiClient-Design.md) — Architecture, delegation patterns, and scoping - [CreateConversation API Behavior](CreateConversation-API-Behavior.md) — Detailed API behavior with request/response examples - [Bot Framework TeamsInfo Source](https://github.com/microsoft/botbuilder-dotnet/blob/main/libraries/Microsoft.Bot.Builder/Teams/TeamsInfo.cs) From 20df8b7a50384506fe30a932d439e4c97dac807d Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Mon, 20 Apr 2026 12:39:51 -0700 Subject: [PATCH 08/22] wire up usertokenclient --- core/docs/ApiClient-Design.md | 61 +++++++------ core/samples/PABot/InitCompatAdapter.cs | 2 +- .../Api/Clients/ApiClient.cs | 43 +++++---- .../Api/Clients/BotClient.cs | 6 +- .../Api/Clients/BotSignInClient.cs | 40 ++------ .../Api/Clients/UserClient.cs | 6 +- .../Api/Clients/UserTokenApiClient.cs | 91 +++---------------- .../UserTokenClient.cs | 66 ++++++++++++-- .../UserTokenCLIService.cs | 4 +- core/test/IntegrationTests.slnx | 1 + core/test/IntegrationTests/ApiClientTests.cs | 37 +++++--- .../IntegrationTests/CompatTeamsInfoTests.cs | 2 +- .../CompatAdapterTests.cs | 2 +- .../CompatBotAdapterTests.cs | 2 + 14 files changed, 173 insertions(+), 190 deletions(-) diff --git a/core/docs/ApiClient-Design.md b/core/docs/ApiClient-Design.md index e924cb17..c4c71d1a 100644 --- a/core/docs/ApiClient-Design.md +++ b/core/docs/ApiClient-Design.md @@ -9,24 +9,24 @@ The `ApiClient` class (`Microsoft.Teams.Bot.Apps.Api.Clients`) provides a hierar ``` ApiClient (top-level facade) ├── Bots → BotClient -│ └── SignIn → BotSignInClient [BotHttpClient → token.botframework.com] +│ └── SignIn → BotSignInClient [delegates to core UserTokenClient] ├── Conversations → ConversationApiClient [delegates to core ConversationClient] │ ├── Activities → ActivityClient │ ├── Members → MemberClient │ └── Reactions → ReactionClient [Experimental] ├── Users → UserClient -│ └── Token → UserTokenApiClient [BotHttpClient → token.botframework.com] +│ └── Token → UserTokenApiClient [delegates to core UserTokenClient] ├── Teams → TeamClient [BotHttpClient → serviceUrl/v3/teams/] └── Meetings → MeetingClient [BotHttpClient → serviceUrl/v1/meetings/] ``` -### Two HTTP strategies +### HTTP strategies -| Sub-client | HTTP strategy | Why | +| Sub-client | Strategy | Why | |---|---|---| | Conversations (Activities, Members, Reactions) | Delegates to core `ConversationClient` | Reuses auth, logging, agents-channel handling, agentic identity support | +| Bots.SignIn, Users.Token | Delegates to core `UserTokenClient` | Reuses auth, logging, agentic identity; single source of truth for token API calls | | Teams, Meetings | Uses `BotHttpClient` directly | No core client equivalent exists for these endpoints | -| Bots.SignIn, Users.Token | Uses `BotHttpClient` directly | Calls `token.botframework.com`, separate from conversation endpoints | ### Experimental APIs @@ -50,10 +50,10 @@ The Bot Framework service URL is per-request (comes from `activity.ServiceUrl`), // Registered automatically by AddTeamsBotApplication() // The [ActivatorUtilitiesConstructor] attribute tells DI to prefer this constructor [ActivatorUtilitiesConstructor] -public ApiClient(HttpClient httpClient, ConversationClient conversationClient, ILogger? logger = null, ...) +public ApiClient(HttpClient httpClient, ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger? logger = null) ``` -`AddTeamsBotApplication()` calls `AddBotClient(...)` which registers `ApiClient` as a typed HTTP client with `BotAuthenticationHandler`. The `ConversationClient` dependency is resolved from DI automatically. +`AddTeamsBotApplication()` calls `AddBotClient(...)` which registers `ApiClient` as a typed HTTP client with `BotAuthenticationHandler`. The `ConversationClient` and `UserTokenClient` dependencies are resolved from DI automatically. **Important:** The base `ApiClient` has `Conversations`, `Teams`, and `Meetings` set to `null!`. Only `Bots` and `Users` are available on the unscoped instance. Accessing `Conversations`, `Teams`, or `Meetings` directly causes `NullReferenceException`. Always use `ForServiceUrl()` or `Context.Api` to get a scoped instance. @@ -81,25 +81,30 @@ ApiClient scoped = baseApiClient.ForServiceUrl(activity.ServiceUrl); await scoped.Conversations.Activities.CreateAsync(conversationId, activity); ``` -`ForServiceUrl` shares the underlying `BotHttpClient` and `ConversationClient` — only the sub-client wrappers are new allocations. +`ForServiceUrl` shares the underlying `BotHttpClient`, `ConversationClient`, and `UserTokenClient` — only the sub-client wrappers are new allocations. ### Constructors | Constructor | Use case | |---|---| -| `ApiClient(HttpClient, ConversationClient, ILogger?, string)` | DI registration (marked `[ActivatorUtilitiesConstructor]`) | -| `ApiClient(Uri, HttpClient, ConversationClient, ILogger?, string)` | Fully initialized with known serviceUrl | +| `ApiClient(HttpClient, ConversationClient, UserTokenClient, ILogger?)` | DI registration (marked `[ActivatorUtilitiesConstructor]`) | +| `ApiClient(Uri, HttpClient, ConversationClient, UserTokenClient, ILogger?)` | Fully initialized with known serviceUrl | | `ApiClient(ApiClient)` | Copy constructor | -| Private: `ApiClient(BotHttpClient, ConversationClient, string, Uri)` | Used by `ForServiceUrl` — shares HTTP client | +| Private: `ApiClient(BotHttpClient, ConversationClient, UserTokenClient, Uri)` | Used by `ForServiceUrl` — shares clients | ## Delegation Pattern -The conversation sub-clients (`ActivityClient`, `MemberClient`, `ReactionClient`) delegate to the core `ConversationClient` rather than duplicating HTTP logic. This ensures: +The Apps-layer sub-clients delegate to core clients rather than duplicating HTTP logic: + +- **Conversation sub-clients** (`ActivityClient`, `MemberClient`, `ReactionClient`) → core `ConversationClient` +- **Token/SignIn sub-clients** (`UserTokenApiClient`, `BotSignInClient`) → core `UserTokenClient` + +This ensures: - Single source of truth for URL construction, auth, and error handling - Agents-channel ID truncation logic is preserved -- Agentic identity support works transparently -- Custom headers and logging from `ConversationClient` apply +- Agentic identity support works transparently for all operations +- Custom headers and logging from core clients apply ### Parameter bridging @@ -167,22 +172,22 @@ ReactionClient.AddAsync(conversationId, activityId, reactionType) | `GetByIdAsync(id)` | `GET {serviceUrl}/v1/meetings/{id}` | | `GetParticipantAsync(meetingId, id, tenantId)` | `GET {serviceUrl}/v1/meetings/{meetingId}/participants/{id}?tenantId={tenantId}` | -#### BotSignInClient (direct HTTP) +#### BotSignInClient → UserTokenClient -| BotSignInClient | Endpoint | +| BotSignInClient | UserTokenClient | |---|---| -| `GetUrlAsync(state, ...)` | `GET {tokenApi}/api/botsignin/GetSignInUrl?state={state}` | -| `GetResourceAsync(state, ...)` | `GET {tokenApi}/api/botsignin/GetSignInResource?state={state}` | +| `GetUrlAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | `GetSignInUrlAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | +| `GetResourceAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | `GetSignInResourceAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | -#### UserTokenApiClient (direct HTTP) +#### UserTokenApiClient → UserTokenClient -| UserTokenApiClient | Endpoint | -|---|---| -| `GetAsync(userId, connectionName, channelId, ...)` | `GET {tokenApi}/api/usertoken/GetToken?...` | -| `GetAadAsync(userId, connectionName, channelId, ...)` | `POST {tokenApi}/api/usertoken/GetAadTokens?...` | -| `GetStatusAsync(userId, channelId, ...)` | `GET {tokenApi}/api/usertoken/GetTokenStatus?...` | -| `SignOutAsync(userId, connectionName, channelId)` | `DELETE {tokenApi}/api/usertoken/SignOut?...` | -| `ExchangeAsync(userId, connectionName, channelId, token)` | `POST {tokenApi}/api/usertoken/exchange?...` | +| UserTokenApiClient | UserTokenClient | Notes | +|---|---|---| +| `GetAsync(userId, connectionName, channelId, code?)` | `GetTokenAsync(userId, connectionName, channelId, code?)` | | +| `GetAadAsync(userId, connectionName, channelId, resourceUrls?)` | `GetAadTokensAsync(userId, connectionName, channelId, resourceUrls?)` | `IList?` → `string[]?` | +| `GetStatusAsync(userId, channelId, includeFilter?)` | `GetTokenStatusAsync(userId, channelId, include?)` | Returns `GetTokenStatusResult[]` as `IList<>?` | +| `SignOutAsync(userId, connectionName, channelId)` | `SignOutUserAsync(userId, connectionName?, channelId?)` | | +| `ExchangeAsync(userId, connectionName, channelId, token)` | `ExchangeTokenAsync(userId, connectionName, channelId, token?)` | | ## File Layout @@ -196,10 +201,10 @@ core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ ├── TeamClient.cs Team info → BotHttpClient (v3/teams/) ├── MeetingClient.cs Meeting info → BotHttpClient (v1/meetings/) + models ├── BotClient.cs Bot facade (groups SignIn) -├── BotSignInClient.cs Sign-in URLs → BotHttpClient (token.botframework.com) +├── BotSignInClient.cs Sign-in URLs → delegates to core UserTokenClient ├── BotTokenClient.cs Static scope constants ├── UserClient.cs User facade (groups Token) -└── UserTokenApiClient.cs User token ops → BotHttpClient (token.botframework.com) +└── UserTokenApiClient.cs User token ops → delegates to core UserTokenClient ``` ## Integration with Context and Handlers diff --git a/core/samples/PABot/InitCompatAdapter.cs b/core/samples/PABot/InitCompatAdapter.cs index 0986ba39..97b48810 100644 --- a/core/samples/PABot/InitCompatAdapter.cs +++ b/core/samples/PABot/InitCompatAdapter.cs @@ -277,7 +277,7 @@ private static void RegisterBotClients(IServiceCollection services) { HttpClient httpClient = sp.GetRequiredService() .CreateClient("TeamsApiClient"); - return new ApiClient(httpClient, sp.GetRequiredService(), sp.GetRequiredService>()); + return new ApiClient(httpClient, sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>()); }); // Register TeamsBotApplication diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs index 0ce48198..6d78aecc 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/ApiClient.cs @@ -6,6 +6,7 @@ using Microsoft.Teams.Bot.Core.Http; using CoreConversationClient = Microsoft.Teams.Bot.Core.ConversationClient; +using CoreUserTokenClient = Microsoft.Teams.Bot.Core.UserTokenClient; namespace Microsoft.Teams.Bot.Apps.Api.Clients; @@ -17,9 +18,9 @@ namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// This client can be constructed in two ways: /// /// -/// DI-friendly (no serviceUrl) — Use +/// DI-friendly (no serviceUrl) — Use /// and call per-request to create a scoped instance. -/// Fully initialized — Use +/// Fully initialized — Use /// when the service URL is known upfront. /// /// @@ -27,7 +28,7 @@ public class ApiClient { private readonly BotHttpClient _http; private readonly CoreConversationClient _conversationClient; - private readonly string _tokenApiEndpoint; + private readonly CoreUserTokenClient _userTokenClient; /// /// The service URL used by this client. @@ -67,19 +68,20 @@ public class ApiClient /// /// An configured with authentication (e.g., via DI with BotAuthenticationHandler). /// The core conversation client for conversation/activity/member operations. + /// The core user token client for sign-in and token operations. /// Optional logger. - /// Optional token API endpoint override. Defaults to https://token.botframework.com. [ActivatorUtilitiesConstructor] - public ApiClient(HttpClient httpClient, CoreConversationClient conversationClient, ILogger? logger = null, string tokenApiEndpoint = "https://token.botframework.com") + public ApiClient(HttpClient httpClient, CoreConversationClient conversationClient, CoreUserTokenClient userTokenClient, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(httpClient); ArgumentNullException.ThrowIfNull(conversationClient); + ArgumentNullException.ThrowIfNull(userTokenClient); _http = new BotHttpClient(httpClient, logger); _conversationClient = conversationClient; - _tokenApiEndpoint = tokenApiEndpoint; - Bots = new BotClient(_http, tokenApiEndpoint); - Users = new UserClient(_http, tokenApiEndpoint); + _userTokenClient = userTokenClient; + Bots = new BotClient(userTokenClient); + Users = new UserClient(userTokenClient); // ServiceUrl-dependent sub-clients require ForServiceUrl() before use ServiceUrl = null!; @@ -94,21 +96,22 @@ public ApiClient(HttpClient httpClient, CoreConversationClient conversationClien /// The Bot Framework service URL. /// An configured with authentication (e.g., via DI with BotAuthenticationHandler). /// The core conversation client for conversation/activity/member operations. + /// The core user token client for sign-in and token operations. /// Optional logger. - /// Optional token API endpoint override. Defaults to https://token.botframework.com. - public ApiClient(Uri serviceUrl, HttpClient httpClient, CoreConversationClient conversationClient, ILogger? logger = null, string tokenApiEndpoint = "https://token.botframework.com") + public ApiClient(Uri serviceUrl, HttpClient httpClient, CoreConversationClient conversationClient, CoreUserTokenClient userTokenClient, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(serviceUrl); ArgumentNullException.ThrowIfNull(httpClient); ArgumentNullException.ThrowIfNull(conversationClient); + ArgumentNullException.ThrowIfNull(userTokenClient); _http = new BotHttpClient(httpClient, logger); _conversationClient = conversationClient; - _tokenApiEndpoint = tokenApiEndpoint; + _userTokenClient = userTokenClient; ServiceUrl = serviceUrl; - Bots = new BotClient(_http, tokenApiEndpoint); + Bots = new BotClient(userTokenClient); Conversations = new ConversationApiClient(serviceUrl, conversationClient); - Users = new UserClient(_http, tokenApiEndpoint); + Users = new UserClient(userTokenClient); Teams = new TeamClient(serviceUrl.ToString(), _http); Meetings = new MeetingClient(serviceUrl.ToString(), _http); } @@ -123,7 +126,7 @@ public ApiClient(ApiClient client) ServiceUrl = client.ServiceUrl; _http = client._http; _conversationClient = client._conversationClient; - _tokenApiEndpoint = client._tokenApiEndpoint; + _userTokenClient = client._userTokenClient; Bots = client.Bots; Conversations = client.Conversations; Users = client.Users; @@ -131,16 +134,16 @@ public ApiClient(ApiClient client) Meetings = client.Meetings; } - // Private constructor for ForServiceUrl — shares BotHttpClient and ConversationClient - private ApiClient(BotHttpClient http, CoreConversationClient conversationClient, string tokenApiEndpoint, Uri serviceUrl) + // Private constructor for ForServiceUrl — shares BotHttpClient, ConversationClient, and UserTokenClient + private ApiClient(BotHttpClient http, CoreConversationClient conversationClient, CoreUserTokenClient userTokenClient, Uri serviceUrl) { _http = http; _conversationClient = conversationClient; - _tokenApiEndpoint = tokenApiEndpoint; + _userTokenClient = userTokenClient; ServiceUrl = serviceUrl; - Bots = new BotClient(http, tokenApiEndpoint); + Bots = new BotClient(userTokenClient); Conversations = new ConversationApiClient(serviceUrl, conversationClient); - Users = new UserClient(http, tokenApiEndpoint); + Users = new UserClient(userTokenClient); Teams = new TeamClient(serviceUrl.ToString(), http); Meetings = new MeetingClient(serviceUrl.ToString(), http); } @@ -154,6 +157,6 @@ private ApiClient(BotHttpClient http, CoreConversationClient conversationClient, public virtual ApiClient ForServiceUrl(Uri serviceUrl) { ArgumentNullException.ThrowIfNull(serviceUrl); - return new ApiClient(_http, _conversationClient, _tokenApiEndpoint, serviceUrl); + return new ApiClient(_http, _conversationClient, _userTokenClient, serviceUrl); } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotClient.cs index cc17b120..321c0c2a 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotClient.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Bot.Core.Http; +using CoreUserTokenClient = Microsoft.Teams.Bot.Core.UserTokenClient; namespace Microsoft.Teams.Bot.Apps.Api.Clients; @@ -15,8 +15,8 @@ public class BotClient /// public BotSignInClient SignIn { get; } - internal BotClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") + internal BotClient(CoreUserTokenClient userTokenClient) { - SignIn = new BotSignInClient(http, tokenApiEndpoint); + SignIn = new BotSignInClient(userTokenClient); } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs index bac4efea..587b62f6 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs @@ -2,57 +2,37 @@ // Licensed under the MIT License. using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Http; + +using CoreUserTokenClient = Microsoft.Teams.Bot.Core.UserTokenClient; namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// /// Client for bot sign-in operations. +/// Delegates to the core . /// public class BotSignInClient { - private readonly BotHttpClient _http; - private readonly string _tokenApiEndpoint; + private readonly CoreUserTokenClient _client; - internal BotSignInClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") + internal BotSignInClient(CoreUserTokenClient client) { - _http = http; - _tokenApiEndpoint = tokenApiEndpoint.TrimEnd('/'); + _client = client; } /// /// Get the sign-in URL for a connection. /// - public async Task GetUrlAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + public Task GetUrlAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) { - List queryParams = [$"state={Uri.EscapeDataString(state)}"]; - - if (!string.IsNullOrEmpty(codeChallenge)) - queryParams.Add($"code_challenge={Uri.EscapeDataString(codeChallenge)}"); - if (emulatorUrl is not null) - queryParams.Add($"emulatorUrl={Uri.EscapeDataString(emulatorUrl.ToString())}"); - if (finalRedirect is not null) - queryParams.Add($"finalRedirect={Uri.EscapeDataString(finalRedirect.ToString())}"); - - string url = $"{_tokenApiEndpoint}/api/botsignin/GetSignInUrl?{string.Join("&", queryParams)}"; - return await _http.SendAsync(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return _client.GetSignInUrlAsync(state, codeChallenge, emulatorUrl, finalRedirect, cancellationToken); } /// /// Get the sign-in resource for a connection. /// - public async Task GetResourceAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + public Task GetResourceAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) { - List queryParams = [$"state={Uri.EscapeDataString(state)}"]; - - if (!string.IsNullOrEmpty(codeChallenge)) - queryParams.Add($"code_challenge={Uri.EscapeDataString(codeChallenge)}"); - if (emulatorUrl is not null) - queryParams.Add($"emulatorUrl={Uri.EscapeDataString(emulatorUrl.ToString())}"); - if (finalRedirect is not null) - queryParams.Add($"finalRedirect={Uri.EscapeDataString(finalRedirect.ToString())}"); - - string url = $"{_tokenApiEndpoint}/api/botsignin/GetSignInResource?{string.Join("&", queryParams)}"; - return await _http.SendAsync(HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return _client.GetSignInResourceAsync(state, codeChallenge, emulatorUrl, finalRedirect, cancellationToken)!; } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs index 9a8ef528..7f78f8a4 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserClient.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Bot.Core.Http; +using CoreUserTokenClient = Microsoft.Teams.Bot.Core.UserTokenClient; namespace Microsoft.Teams.Bot.Apps.Api.Clients; @@ -15,8 +15,8 @@ public class UserClient /// public UserTokenApiClient Token { get; } - internal UserClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") + internal UserClient(CoreUserTokenClient userTokenClient) { - Token = new UserTokenApiClient(http, tokenApiEndpoint); + Token = new UserTokenApiClient(userTokenClient); } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs index 9caa128f..9fba87fb 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs @@ -1,53 +1,31 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Http; + +using CoreUserTokenClient = Microsoft.Teams.Bot.Core.UserTokenClient; namespace Microsoft.Teams.Bot.Apps.Api.Clients; /// /// Client for user token operations. +/// Delegates to the core . /// public class UserTokenApiClient { - private static readonly JsonSerializerOptions JsonOptions = new() - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; + private readonly CoreUserTokenClient _client; - private readonly BotHttpClient _http; - private readonly string _tokenApiEndpoint; - - internal UserTokenApiClient(BotHttpClient http, string tokenApiEndpoint = "https://token.botframework.com") + internal UserTokenApiClient(CoreUserTokenClient client) { - _http = http; - _tokenApiEndpoint = tokenApiEndpoint.TrimEnd('/'); + _client = client; } /// /// Get a user token for a connection. /// - public async Task GetAsync(string userId, string connectionName, string channelId, string? code = null, CancellationToken cancellationToken = default) + public Task GetAsync(string userId, string connectionName, string channelId, string? code = null, CancellationToken cancellationToken = default) { - List queryParams = - [ - $"userId={Uri.EscapeDataString(userId)}", - $"connectionName={Uri.EscapeDataString(connectionName)}", - $"channelId={Uri.EscapeDataString(channelId)}" - ]; - - if (!string.IsNullOrEmpty(code)) - queryParams.Add($"code={Uri.EscapeDataString(code)}"); - - string url = $"{_tokenApiEndpoint}/api/usertoken/GetToken?{string.Join("&", queryParams)}"; - - return await _http.SendAsync( - HttpMethod.Get, url, body: null, - new BotRequestOptions { ReturnNullOnNotFound = true }, - cancellationToken).ConfigureAwait(false); + return _client.GetTokenAsync(userId, connectionName, channelId, code, cancellationToken); } /// @@ -55,19 +33,7 @@ internal UserTokenApiClient(BotHttpClient http, string tokenApiEndpoint = "https /// public async Task?> GetAadAsync(string userId, string connectionName, string channelId, IList? resourceUrls = null, CancellationToken cancellationToken = default) { - List queryParams = - [ - $"userId={Uri.EscapeDataString(userId)}", - $"connectionName={Uri.EscapeDataString(connectionName)}", - $"channelId={Uri.EscapeDataString(channelId)}" - ]; - - string url = $"{_tokenApiEndpoint}/api/usertoken/GetAadTokens?{string.Join("&", queryParams)}"; - var body = new { resourceUrls = resourceUrls ?? [] }; - string bodyJson = JsonSerializer.Serialize(body, JsonOptions); - - return await _http.SendAsync>( - HttpMethod.Post, url, bodyJson, null, cancellationToken).ConfigureAwait(false); + return await _client.GetAadTokensAsync(userId, connectionName, channelId, resourceUrls?.ToArray(), cancellationToken).ConfigureAwait(false); } /// @@ -75,34 +41,15 @@ internal UserTokenApiClient(BotHttpClient http, string tokenApiEndpoint = "https /// public async Task?> GetStatusAsync(string userId, string channelId, string? includeFilter = null, CancellationToken cancellationToken = default) { - List queryParams = - [ - $"userId={Uri.EscapeDataString(userId)}", - $"channelId={Uri.EscapeDataString(channelId)}" - ]; - - if (!string.IsNullOrEmpty(includeFilter)) - queryParams.Add($"includeFilter={Uri.EscapeDataString(includeFilter)}"); - - string url = $"{_tokenApiEndpoint}/api/usertoken/GetTokenStatus?{string.Join("&", queryParams)}"; - return await _http.SendAsync>( - HttpMethod.Get, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return await _client.GetTokenStatusAsync(userId, channelId, includeFilter, cancellationToken).ConfigureAwait(false); } /// /// Sign a user out of a connection. /// - public async Task SignOutAsync(string userId, string connectionName, string channelId, CancellationToken cancellationToken = default) + public Task SignOutAsync(string userId, string connectionName, string channelId, CancellationToken cancellationToken = default) { - List queryParams = - [ - $"userId={Uri.EscapeDataString(userId)}", - $"connectionName={Uri.EscapeDataString(connectionName)}", - $"channelId={Uri.EscapeDataString(channelId)}" - ]; - - string url = $"{_tokenApiEndpoint}/api/usertoken/SignOut?{string.Join("&", queryParams)}"; - await _http.SendAsync(HttpMethod.Delete, url, body: null, options: null, cancellationToken).ConfigureAwait(false); + return _client.SignOutUserAsync(userId, connectionName, channelId, cancellationToken); } /// @@ -110,18 +57,6 @@ public async Task SignOutAsync(string userId, string connectionName, string chan /// public async Task ExchangeAsync(string userId, string connectionName, string channelId, string exchangeToken, CancellationToken cancellationToken = default) { - List queryParams = - [ - $"userId={Uri.EscapeDataString(userId)}", - $"connectionName={Uri.EscapeDataString(connectionName)}", - $"channelId={Uri.EscapeDataString(channelId)}" - ]; - - string url = $"{_tokenApiEndpoint}/api/usertoken/exchange?{string.Join("&", queryParams)}"; - var body = new { token = exchangeToken }; - string bodyJson = JsonSerializer.Serialize(body, JsonOptions); - - return await _http.SendAsync( - HttpMethod.Post, url, bodyJson, null, cancellationToken).ConfigureAwait(false); + return await _client.ExchangeTokenAsync(userId, connectionName, channelId, exchangeToken, cancellationToken).ConfigureAwait(false); } } diff --git a/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs index 9f091fc8..340e1ced 100644 --- a/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs +++ b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs @@ -107,6 +107,7 @@ public virtual async Task GetTokenStatusAsync(string use /// /// Get the token or raw signin link to be sent to the user for signin for a connection. + /// Builds the state parameter internally from the userId and connectionName. /// /// The user ID. /// The connection name. @@ -114,7 +115,7 @@ public virtual async Task GetTokenStatusAsync(string use /// The optional final redirect URL. /// The cancellation token. /// - public virtual async Task GetSignInResource(string userId, string connectionName, string channelId, string? finalRedirect = null, CancellationToken cancellationToken = default) + public virtual Task GetSignInResource(string userId, string connectionName, string channelId, string? finalRedirect = null, CancellationToken cancellationToken = default) { var tokenExchangeState = new { @@ -127,18 +128,63 @@ public virtual async Task GetSignInResource(string user string tokenExchangeStateJson = JsonSerializer.Serialize(tokenExchangeState, _defaultOptions); string state = Convert.ToBase64String(Encoding.UTF8.GetBytes(tokenExchangeStateJson)); - Dictionary queryParams = new() - { - { "state", state } - }; + Uri? finalRedirectUri = finalRedirect is not null ? new Uri(finalRedirect) : null; + return GetSignInResourceAsync(state, finalRedirect: finalRedirectUri, cancellationToken: cancellationToken); + } - if (!string.IsNullOrEmpty(finalRedirect)) - { - queryParams.Add("finalRedirect", finalRedirect); - } + /// + /// Gets the sign-in URL for the given state. + /// + /// The encoded state parameter. + /// The optional code challenge for PKCE. + /// The optional emulator URL. + /// The optional final redirect URL. + /// The cancellation token. + /// The sign-in URL, or null if not available. + public virtual async Task GetSignInUrlAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() { { "state", state } }; - _logger.LogInformationGuarded("Calling API endpoint: {Endpoint}", "api/botsignin/GetSignInResource"); + if (!string.IsNullOrEmpty(codeChallenge)) + queryParams.Add("code_challenge", codeChallenge); + if (emulatorUrl is not null) + queryParams.Add("emulatorUrl", emulatorUrl.ToString()); + if (finalRedirect is not null) + queryParams.Add("finalRedirect", finalRedirect.ToString()); + + _logger.LogInformationGuarded("Calling API endpoint: {Endpoint}", "api/botsignin/GetSignInUrl"); + + return await _botHttpClient.SendAsync( + HttpMethod.Get, + _apiEndpoint, + "api/botsignin/GetSignInUrl", + queryParams, + body: null, + CreateRequestOptions("getting sign-in URL"), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the sign-in resource for the given state. + /// + /// The encoded state parameter. + /// The optional code challenge for PKCE. + /// The optional emulator URL. + /// The optional final redirect URL. + /// The cancellation token. + /// The sign-in resource result. + public virtual async Task GetSignInResourceAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() { { "state", state } }; + + if (!string.IsNullOrEmpty(codeChallenge)) + queryParams.Add("code_challenge", codeChallenge); + if (emulatorUrl is not null) + queryParams.Add("emulatorUrl", emulatorUrl.ToString()); + if (finalRedirect is not null) + queryParams.Add("finalRedirect", finalRedirect.ToString()); + _logger.LogInformationGuarded("Calling API endpoint: {Endpoint}", "api/botsignin/GetSignInResource"); return (await _botHttpClient.SendAsync( HttpMethod.Get, diff --git a/core/test/ABSTokenServiceClient/UserTokenCLIService.cs b/core/test/ABSTokenServiceClient/UserTokenCLIService.cs index 4271915f..cccb5fa7 100644 --- a/core/test/ABSTokenServiceClient/UserTokenCLIService.cs +++ b/core/test/ABSTokenServiceClient/UserTokenCLIService.cs @@ -22,8 +22,8 @@ public Task StopAsync(CancellationToken cancellationToken) protected async Task ExecuteAsync(CancellationToken cancellationToken) { - const string userId = "your-user-id"; - const string connectionName = "graph"; + string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new ArgumentNullException("TEST_USER_ID not found"); + string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") ?? throw new ArgumentNullException("TEST Connection name nor found"); const string channelId = "msteams"; logger.LogInformation("Application started"); diff --git a/core/test/IntegrationTests.slnx b/core/test/IntegrationTests.slnx index 73b1cb1c..ae113bbf 100644 --- a/core/test/IntegrationTests.slnx +++ b/core/test/IntegrationTests.slnx @@ -7,5 +7,6 @@ + diff --git a/core/test/IntegrationTests/ApiClientTests.cs b/core/test/IntegrationTests/ApiClientTests.cs index b25af4cc..d2529b97 100644 --- a/core/test/IntegrationTests/ApiClientTests.cs +++ b/core/test/IntegrationTests/ApiClientTests.cs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text; +using System.Text.Json; using Microsoft.Teams.Bot.Apps.Api.Clients; +using Microsoft.Teams.Bot.Apps.Handlers.MessageExtension; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Schema; @@ -347,19 +350,22 @@ public async Task Meetings_GetParticipantAsync() #region Bots — SignIn - [Fact(Skip = "Requires a valid OAuth connection name configured for the bot")] + [Fact] public async Task Bots_SignIn_GetUrlAsync() { string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); - // State must be a proper Bot Framework sign-in state JSON - string state = System.Text.Json.JsonSerializer.Serialize(new + var tokenExchangeState = new { ConnectionName = connectionName, - Conversation = new { Id = _f.ConversationId }, - MsAppId = _f.BotAppId - }); + Conversation = new + { + User = new ConversationAccount { Id = _f.UserId }, + } + }; + string tokenExchangeStateJson = JsonSerializer.Serialize(tokenExchangeState); + string state = Convert.ToBase64String(Encoding.UTF8.GetBytes(tokenExchangeStateJson)); string? url = await _api.Bots.SignIn.GetUrlAsync(state); @@ -368,18 +374,23 @@ public async Task Bots_SignIn_GetUrlAsync() _output.WriteLine($"SignIn URL: {url}"); } - [Fact(Skip = "Requires a valid OAuth connection name configured for the bot")] + [Fact] public async Task Bots_SignIn_GetResourceAsync() { string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); - string state = System.Text.Json.JsonSerializer.Serialize(new + var tokenExchangeState = new { ConnectionName = connectionName, - Conversation = new { Id = _f.ConversationId }, - MsAppId = _f.BotAppId - }); + Conversation = new + { + User = new ConversationAccount { Id = _f.UserId }, + } + }; + string tokenExchangeStateJson = JsonSerializer.Serialize(tokenExchangeState); + string state = Convert.ToBase64String(Encoding.UTF8.GetBytes(tokenExchangeStateJson)); + var resource = await _api.Bots.SignIn.GetResourceAsync(state); @@ -412,7 +423,7 @@ public async Task Users_Token_GetStatusAsync() } } - [Fact(Skip = "Requires TEST_CONNECTION_NAME to be configured with an OAuth connection")] + [Fact] public async Task Users_Token_GetAsync() { string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") @@ -425,7 +436,7 @@ public async Task Users_Token_GetAsync() _output.WriteLine($"Token: {(result is not null ? "acquired" : "not available")}"); } - [Fact(Skip = "Requires TEST_CONNECTION_NAME to be configured with an OAuth connection")] + [Fact] public async Task Users_Token_SignOutAsync() { string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") diff --git a/core/test/IntegrationTests/CompatTeamsInfoTests.cs b/core/test/IntegrationTests/CompatTeamsInfoTests.cs index 5c3c807d..0f22e117 100644 --- a/core/test/IntegrationTests/CompatTeamsInfoTests.cs +++ b/core/test/IntegrationTests/CompatTeamsInfoTests.cs @@ -192,7 +192,7 @@ public async Task GetTeamMembersAsync_ReturnsMembers() IEnumerable result = await CompatTeamsInfo.GetTeamMembersAsync(ctx, _f.TeamId); Assert.NotNull(result); - List members = result.ToList(); + List members = [.. result]; Assert.NotEmpty(members); foreach (TeamsChannelAccount m in members) diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs index 5ed759bf..0a43590a 100644 --- a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs @@ -79,7 +79,7 @@ private static (CompatAdapter, ApiClient) CreateCompatAdapter() mockConfig.Setup(c => c["UserTokenApiEndpoint"]).Returns("https://token.botframework.com"); UserTokenClient userTokenClient = new(httpClient, mockConfig.Object, NullLogger.Instance); - ApiClient teamsApiClient = new(new Uri("https://service.url"), httpClient, conversationClient, NullLogger.Instance); + ApiClient teamsApiClient = new(new Uri("https://service.url"), httpClient, conversationClient, userTokenClient, NullLogger.Instance); TeamsBotApplication teamsBotApplication = new( conversationClient, diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs index 19c58dae..658d4b4f 100644 --- a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatBotAdapterTests.cs @@ -260,6 +260,7 @@ private static Mock CreateMockTeamsBotApplication() new Uri("https://service.url"), new HttpClient(), mockConversationClient.Object, + mockUserTokenClient.Object, NullLogger.Instance); Mock mock = new( @@ -282,6 +283,7 @@ private static CompatBotAdapter CreateCompatBotAdapter(ConversationClient conver new Uri("https://service.url"), new HttpClient(), conversationClient, + mockUserTokenClient.Object, NullLogger.Instance); TeamsBotApplication teamsBotApplication = new( conversationClient, From 46a76f916153fbfa3b37e202d542a7b697b77f77 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Mon, 20 Apr 2026 12:59:53 -0700 Subject: [PATCH 09/22] add design doc for sso --- core/docs/OAuthFlow-Design.md | 446 ++++++++++++++++++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 core/docs/OAuthFlow-Design.md diff --git a/core/docs/OAuthFlow-Design.md b/core/docs/OAuthFlow-Design.md new file mode 100644 index 00000000..e17943f5 --- /dev/null +++ b/core/docs/OAuthFlow-Design.md @@ -0,0 +1,446 @@ +# OAuthFlow Design Document + +## Overview + +`OAuthFlow` provides a high-level abstraction for Teams Bot SSO (Single Sign-On) authentication. It encapsulates the full OAuth lifecycle -- silent token acquisition, SSO token exchange, fallback sign-in, and sign-out -- so developers can add user authentication with minimal plumbing. + +The design builds on top of the existing `UserTokenClient` (core) and `UserTokenApiClient` / `BotSignInClient` (Apps layer), and follows the handler-based routing pattern established by `AdaptiveCardExtensions`, `TaskExtensions`, etc. + +## Motivation + +Teams SSO requires coordinating multiple moving parts: + +1. Checking the Bot Framework Token Store for an existing token +2. Sending an OAuthCard with a `TokenExchangeResource` to trigger silent SSO +3. Handling `signin/tokenExchange` invoke activities (with deduplication) +4. Handling `signin/verifyState` invoke activities (fallback magic-code flow) +5. Calling `UserTokenClient.ExchangeTokenAsync` to complete the on-behalf-of exchange + +Without an abstraction, every bot developer must wire this up manually. `OAuthFlow` reduces it to a few method calls. + +## Architecture + +``` +TeamsBotApplication +├── Router +│ ├── ... existing routes ... +│ ├── invoke/signin/tokenExchange ← registered by OAuthFlow +│ └── invoke/signin/verifyState ← registered by OAuthFlow +└── OAuthFlow (one per connection) + ├── SignInAsync() → silent token check + OAuthCard + ├── SignOutAsync() → revoke token + ├── IsSignedInAsync() → check token store + ├── GetTokenAsync() → silent-only token retrieval + ├── OnSignInComplete() → callback after successful exchange + └── OnSignInFailure() → callback on exchange failure +``` + +### Relationship to existing clients + +``` +OAuthFlow (Apps layer - developer-facing) + │ + ├── UserTokenApiClient.GetAsync() → silent token check + ├── UserTokenApiClient.ExchangeAsync() → SSO token exchange + ├── UserTokenApiClient.GetStatusAsync() → connection discovery & status + ├── UserTokenApiClient.SignOutAsync() → sign-out + └── BotSignInClient.GetResourceAsync() → sign-in resource (OAuthCard data) +``` + +`OAuthFlow` does **not** replace these clients. It orchestrates them into a cohesive flow and auto-registers the invoke handlers that the SSO protocol requires. + +## API Surface + +### Registration + +```csharp +public static class OAuthFlowExtensions +{ + /// Register an OAuthFlow with an explicit connection name. + public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connectionName); + + /// Register an OAuthFlow that auto-discovers the connection name + /// via GetTokenStatus on first use. + public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app); +} +``` + +`AddOAuthFlow` registers two invoke routes on the app's `Router`: + +| Route name | Invoke name | Purpose | +|---|---|---| +| `invoke/signin/tokenExchange` | `signin/tokenExchange` | SSO silent token exchange | +| `invoke/signin/verifyState` | `signin/verifyState` | Fallback magic-code verification | + +When multiple `OAuthFlow` instances are registered (multi-connection), the invoke handlers dispatch to the correct flow by matching the `connectionName` in the invoke value. + +### OAuthFlow Class + +```csharp +public class OAuthFlow +{ + /// The OAuth connection name. Null until resolved (auto-discovery mode). + public string? ConnectionName { get; } + + /// Attempt silent token acquisition from the token store. + /// Returns the access token string, or null if no token is cached. + public Task GetTokenAsync( + Context context, + CancellationToken cancellationToken = default) where TActivity : TeamsActivity; + + /// Attempt silent token acquisition; if no token is available, + /// send an OAuthCard to initiate the SSO flow. + /// Returns the token if already cached, or null if SSO was initiated + /// (the result will arrive via OnSignInComplete). + public Task SignInAsync( + Context context, + CancellationToken cancellationToken = default) where TActivity : TeamsActivity; + + /// Sign the user out, revoking their token from the token store. + public Task SignOutAsync( + Context context, + CancellationToken cancellationToken = default) where TActivity : TeamsActivity; + + /// Check whether the user has a valid cached token. + public Task IsSignedInAsync( + Context context, + CancellationToken cancellationToken = default) where TActivity : TeamsActivity; + + /// Get the token status for all configured OAuth connections. + /// This calls GetTokenStatus which returns every connection + /// registered on the bot, so the developer never needs to + /// enumerate connection names manually. + public Task> GetConnectionStatusAsync( + Context context, + CancellationToken cancellationToken = default) where TActivity : TeamsActivity; + + /// Register a callback invoked after a successful token exchange + /// (SSO or fallback sign-in). + public OAuthFlow OnSignInComplete(SignInCompleteHandler handler); + + /// Register a callback invoked when token exchange fails. + public OAuthFlow OnSignInFailure(SignInFailureHandler handler); +} +``` + +### Delegates + +```csharp +public delegate Task SignInCompleteHandler( + Context context, + GetTokenResult tokenResponse, + CancellationToken cancellationToken); + +public delegate Task SignInFailureHandler( + Context context, + CancellationToken cancellationToken); +``` + +### Value Types + +```csharp +/// Value payload of the signin/tokenExchange invoke activity. +public class SignInTokenExchangeValue +{ + public string? Id { get; set; } + public string? ConnectionName { get; set; } + public string? Token { get; set; } +} + +/// Value payload of the signin/verifyState invoke activity. +public class SignInVerifyStateValue +{ + public string? State { get; set; } +} +``` + +## Internal Flow + +### SignInAsync Sequence + +``` +Developer calls oauth.SignInAsync(context) + │ + ├─ 1. Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId) + │ ├─ Token exists → return token string + │ └─ No token ↓ + │ + ├─ 2. Call UserTokenClient.GetSignInResource(userId, connectionName, channelId) + │ Returns: SignInLink, TokenExchangeResource, TokenPostResource + │ + ├─ 3. Build OAuthCard attachment: + │ { + │ contentType: "application/vnd.microsoft.card.oauth", + │ content: { + │ buttons: [{ type: "signin", title: "Sign In", value: signInLink }], + │ connectionName: connectionName, + │ tokenExchangeResource: { id, uri, providerId }, + │ tokenPostResource: { sasUrl } + │ } + │ } + │ + ├─ 4. Send activity with OAuthCard attachment + │ + └─ 5. Return null (SSO exchange pending) +``` + +### signin/tokenExchange Invoke Handler + +``` +Teams client sends invoke: signin/tokenExchange + │ + ├─ 1. Deserialize value → SignInTokenExchangeValue { Id, ConnectionName, Token } + │ + ├─ 2. Deduplication check (by value.Id) + │ ├─ Already processed → respond 200 (no-op) + │ └─ New ↓ + │ + ├─ 3. Resolve OAuthFlow by ConnectionName + │ + ├─ 4. Call UserTokenClient.ExchangeTokenAsync(userId, connectionName, channelId, token) + │ ├─ Success → fire OnSignInComplete, respond InvokeResponse(200) + │ └─ Failure → fire OnSignInFailure, respond InvokeResponse(412) + │ (412 tells Teams to show the sign-in card as fallback) + │ + └─ 5. Record exchange Id as processed (dedup) +``` + +### signin/verifyState Invoke Handler + +``` +Teams client sends invoke: signin/verifyState + │ + ├─ 1. Deserialize value → SignInVerifyStateValue { State } + │ (State contains the magic code from fallback sign-in) + │ + ├─ 2. Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId, code: state) + │ ├─ Token returned → fire OnSignInComplete, respond InvokeResponse(200) + │ └─ No token → fire OnSignInFailure, respond InvokeResponse(400) + │ + └─ Done +``` + +### Deduplication + +Teams may send duplicate `signin/tokenExchange` invokes (the user can have multiple active endpoints -- mobile, desktop, web). The `OAuthFlow` deduplicates by tracking processed exchange IDs in a `ConcurrentDictionary` with a short TTL. This is an in-process, per-instance cache -- sufficient because duplicates arrive within milliseconds of each other to the same bot instance. + +### Auto-Discovery (no connection name) + +When `AddOAuthFlow()` is called without a connection name: + +1. On first call to `SignInAsync` / `GetTokenAsync` / `IsSignedInAsync`, calls `UserTokenClient.GetTokenStatusAsync(userId, channelId)`. +2. `GetTokenStatus` returns **all** configured OAuth connections on the bot (regardless of whether the user has a token). +3. If exactly one connection exists, uses it automatically. +4. If multiple connections exist, throws `InvalidOperationException` with a message listing the available connections and asking the developer to specify one. +5. The resolved connection name is cached for subsequent calls. + +This eliminates the need for developers to hard-code connection names when only one connection is configured, which is the common case. + +## Multi-Connection Sample + +A bot that uses **two** OAuth connections: one for Microsoft Graph (user profile, calendar) and one for a third-party API (e.g., Salesforce). + +### Configuration + +Azure Bot resource has two OAuth connection settings: + +| Connection name | Provider | Scopes | +|---|---|---| +| `GraphConnection` | Azure AD v2 | `User.Read Calendars.Read` | +| `GitHubConnection` | GitHub | `repo read:user` | + +### Registration + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddTeams("AzureAd"); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +var bot = app.UseTeams("api/messages"); + +// Register two OAuthFlow instances, one per connection +OAuthFlow graphAuth = bot.AddOAuthFlow("GraphConnection"); +OAuthFlow githubAuth = bot.AddOAuthFlow("GitHubConnection"); + +// --- Sign-in complete callbacks --- + +graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync("Connected to Microsoft Graph!", ct); +}); + +githubAuth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync("Connected to GitHub!", ct); +}); + +// --- Message handlers --- + +bot.OnMessage(@"^login graph$", async (context, ct) => +{ + string? token = await graphAuth.SignInAsync(context, ct); + if (token != null) + { + await context.SendActivityAsync("Already signed in to Graph.", ct); + } + // else: OAuthCard sent, SSO in progress +}); + +bot.OnMessage(@"^login github$", async (context, ct) => +{ + string? token = await githubAuth.SignInAsync(context, ct); + if (token != null) + { + await context.SendActivityAsync("Already signed in to GitHub.", ct); + } +}); + +bot.OnMessage(@"^status$", async (context, ct) => +{ + // GetConnectionStatusAsync returns ALL connections -- no names needed + var statuses = await graphAuth.GetConnectionStatusAsync(context, ct); + var lines = statuses.Select(s => + $"- **{s.ConnectionName}** ({s.ServiceProviderDisplayName}): " + + $"{(s.HasToken == true ? "connected" : "not connected")}"); + + await context.SendActivityAsync( + "OAuth connections:\n" + string.Join("\n", lines), ct); +}); + +bot.OnMessage(@"^my calendar$", async (context, ct) => +{ + string? token = await graphAuth.SignInAsync(context, ct); + if (token == null) return; + + // Call Graph API with the token + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + var response = await http.GetStringAsync( + "https://graph.microsoft.com/v1.0/me/events?$top=3", ct); + + await context.SendActivityAsync($"Your next events:\n{response}", ct); +}); + +bot.OnMessage(@"^my repos$", async (context, ct) => +{ + string? token = await githubAuth.SignInAsync(context, ct); + if (token == null) return; + + // Call GitHub API with the token + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + http.DefaultRequestHeaders.UserAgent.ParseAdd("TeamsBot/1.0"); + var response = await http.GetStringAsync( + "https://api.github.com/user/repos?sort=updated&per_page=5", ct); + + await context.SendActivityAsync($"Your recent repos:\n{response}", ct); +}); + +bot.OnMessage(@"^logout$", async (context, ct) => +{ + // Sign out from both connections + await graphAuth.SignOutAsync(context, ct); + await githubAuth.SignOutAsync(context, ct); + await context.SendActivityAsync("Signed out from all services.", ct); +}); + +bot.OnMessage(@"^logout graph$", async (context, ct) => +{ + await graphAuth.SignOutAsync(context, ct); + await context.SendActivityAsync("Signed out from Graph.", ct); +}); + +bot.OnMessage(@"^logout github$", async (context, ct) => +{ + await githubAuth.SignOutAsync(context, ct); + await context.SendActivityAsync("Signed out from GitHub.", ct); +}); + +app.Run(); +``` + +### How Multi-Connection Invoke Routing Works + +When multiple `OAuthFlow` instances are registered, both `signin/tokenExchange` and `signin/verifyState` invoke routes are registered **once** (shared). The shared handler dispatches to the correct `OAuthFlow` instance by matching `connectionName` from the invoke value: + +``` +signin/tokenExchange invoke arrives + │ + ├─ value.ConnectionName == "GraphConnection" + │ → dispatch to graphAuth + │ + └─ value.ConnectionName == "GitHubConnection" + → dispatch to githubAuth +``` + +This is handled internally by a registry (`Dictionary`) keyed by connection name. + +## Single-Connection Sample (Auto-Discovery) + +When only one OAuth connection is configured, the developer can omit the connection name entirely: + +```csharp +var bot = app.UseTeams("api/messages"); + +// No connection name -- auto-discovered via GetTokenStatus +OAuthFlow auth = bot.AddOAuthFlow(); + +auth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync($"Signed in via {tokenResponse.ConnectionName}!", ct); +}); + +bot.OnMessage(@"^login$", async (context, ct) => +{ + string? token = await auth.SignInAsync(context, ct); + if (token != null) + { + await context.SendActivityAsync("You're already signed in.", ct); + } +}); + +bot.OnMessage(@"^logout$", async (context, ct) => +{ + await auth.SignOutAsync(context, ct); + await context.SendActivityAsync("Signed out.", ct); +}); + +bot.OnMessage(@"^whoami$", async (context, ct) => +{ + string? token = await auth.SignInAsync(context, ct); + if (token == null) return; + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + var me = await http.GetStringAsync("https://graph.microsoft.com/v1.0/me", ct); + await context.SendActivityAsync(me, ct); +}); + +app.Run(); +``` + +## File Placement + +| File | Location | +|---|---| +| `OAuthFlow.cs` | `Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs` | +| `OAuthFlowExtensions.cs` | `Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs` | +| `SignInTokenExchangeValue.cs` | `Microsoft.Teams.Bot.Apps/Auth/SignInTokenExchangeValue.cs` | +| `SignInVerifyStateValue.cs` | `Microsoft.Teams.Bot.Apps/Auth/SignInVerifyStateValue.cs` | +| `OAuthCard.cs` | `Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs` | + +## Edge Cases & Constraints + +| Scenario | Behavior | +|---|---| +| SSO not supported (channel scope) | SSO only works in personal and group chat. In channels, the OAuthCard shows the sign-in button directly (no token exchange). | +| User denies consent | Teams sends `signin/tokenExchange` but exchange fails. OAuthFlow responds 412, Teams shows sign-in button fallback. `OnSignInFailure` fires. | +| Duplicate `signin/tokenExchange` | Deduplicated by exchange ID. First wins, duplicates get 200 no-op. | +| Token expired | `GetTokenAsync` returns null (token store returns 404). `SignInAsync` re-initiates the flow. | +| Auto-discovery with multiple connections | Throws `InvalidOperationException` listing available connections. | +| `signin/verifyState` with invalid code | `GetTokenAsync` with code returns null. `OnSignInFailure` fires. Response 400. | From e685f8a7cff60d7306e2f9858e612ffba5d111b6 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Mon, 20 Apr 2026 23:41:46 -0700 Subject: [PATCH 10/22] SSO with OAuth Flows --- core/core.slnx | 1 + core/docs/OAuthFlow-Design.md | 473 ++++++++++-------- core/samples/OAuthFlowBot/OAuthFlowBot.csproj | 13 + core/samples/OAuthFlowBot/Program.cs | 171 +++++++ core/samples/OAuthFlowBot/appsettings.json | 9 + .../Auth/OAuthFlow.cs | 412 +++++++++++++++ .../Auth/OAuthFlowExtensions.cs | 287 +++++++++++ .../Auth/OAuthOptions.cs | 25 + .../Auth/SignInTokenExchangeValue.cs | 30 ++ .../Auth/SignInVerifyStateValue.cs | 18 + core/src/Microsoft.Teams.Bot.Apps/Context.cs | 94 ++++ .../Handlers/MessageHandler.cs | 4 +- .../Schema/MessageActivity.cs | 25 + .../Schema/OAuthCard.cs | 44 ++ .../Schema/TeamsAttachment.cs | 5 + .../TeamsBotApplication.cs | 6 + .../CompatConversations.cs | 2 +- .../CompatTeamsSSOTokenExchangeMiddleware.cs | 195 ++++++++ .../BotApplication.cs | 7 + 19 files changed, 1599 insertions(+), 222 deletions(-) create mode 100644 core/samples/OAuthFlowBot/OAuthFlowBot.csproj create mode 100644 core/samples/OAuthFlowBot/Program.cs create mode 100644 core/samples/OAuthFlowBot/appsettings.json create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Auth/SignInTokenExchangeValue.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Auth/SignInVerifyStateValue.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs create mode 100644 core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs diff --git a/core/core.slnx b/core/core.slnx index 12934ad3..5c1e8a7c 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -18,6 +18,7 @@ + diff --git a/core/docs/OAuthFlow-Design.md b/core/docs/OAuthFlow-Design.md index e17943f5..e8bed2b0 100644 --- a/core/docs/OAuthFlow-Design.md +++ b/core/docs/OAuthFlow-Design.md @@ -13,8 +13,9 @@ Teams SSO requires coordinating multiple moving parts: 1. Checking the Bot Framework Token Store for an existing token 2. Sending an OAuthCard with a `TokenExchangeResource` to trigger silent SSO 3. Handling `signin/tokenExchange` invoke activities (with deduplication) -4. Handling `signin/verifyState` invoke activities (fallback magic-code flow) -5. Calling `UserTokenClient.ExchangeTokenAsync` to complete the on-behalf-of exchange +4. Handling `signin/verifyState` invoke activities (fallback sign-in flow) +5. Handling magic codes arriving as plain messages (non-AAD providers) +6. Calling `UserTokenClient.ExchangeTokenAsync` to complete the on-behalf-of exchange Without an abstraction, every bot developer must wire this up manually. `OAuthFlow` reduces it to a few method calls. @@ -22,10 +23,13 @@ Without an abstraction, every bot developer must wire this up manually. `OAuthFl ``` TeamsBotApplication +├── AppId ← from BotConfig.ClientId +├── OAuthRegistry ← holds all OAuthFlow instances ├── Router │ ├── ... existing routes ... -│ ├── invoke/signin/tokenExchange ← registered by OAuthFlow -│ └── invoke/signin/verifyState ← registered by OAuthFlow +│ ├── message/oauth/magicCode ← registered by OAuthFlow (magic code interception) +│ ├── invoke/signin/tokenExchange ← registered by OAuthFlow +│ └── invoke/signin/verifyState ← registered by OAuthFlow └── OAuthFlow (one per connection) ├── SignInAsync() → silent token check + OAuthCard ├── SignOutAsync() → revoke token @@ -35,20 +39,110 @@ TeamsBotApplication └── OnSignInFailure() → callback on exchange failure ``` +### Two API Layers + +Developers can use **either** the context-level API (simple, matches Teams SDK v2 pattern) or the OAuthFlow-instance API (advanced, explicit per-connection control): + +| Scenario | Context API (simple) | OAuthFlow API (advanced) | +|---|---|---| +| Sign in | `context.SignIn(new OAuthOptions { ConnectionName = "gh" })` | `githubAuth.SignInAsync(context)` | +| Sign out | `context.SignOut("gh")` | `githubAuth.SignOutAsync(context)` | +| Check status | `context.IsSignedInAsync("gh")` | `githubAuth.IsSignedInAsync(context)` | +| All connections | `context.GetConnectionStatusAsync()` | `graphAuth.GetConnectionStatusAsync(context)` | +| Single connection | `context.SignIn()` / `context.IsSignedIn` | `auth.SignInAsync(context)` | + ### Relationship to existing clients ``` OAuthFlow (Apps layer - developer-facing) │ - ├── UserTokenApiClient.GetAsync() → silent token check - ├── UserTokenApiClient.ExchangeAsync() → SSO token exchange - ├── UserTokenApiClient.GetStatusAsync() → connection discovery & status - ├── UserTokenApiClient.SignOutAsync() → sign-out - └── BotSignInClient.GetResourceAsync() → sign-in resource (OAuthCard data) + ├── UserTokenClient.GetTokenAsync() → silent token check + ├── UserTokenClient.ExchangeTokenAsync() → SSO token exchange + ├── UserTokenClient.GetTokenStatusAsync() → connection discovery & status + ├── UserTokenClient.SignOutUserAsync() → sign-out + └── UserTokenClient.GetSignInResourceAsync() → sign-in resource (OAuthCard data) ``` `OAuthFlow` does **not** replace these clients. It orchestrates them into a cohesive flow and auto-registers the invoke handlers that the SSO protocol requires. +## Breaking Changes from Teams SDK v2 (Spark) + +### Delegate signature: `Context` instead of typed context + +The Teams SDK v2 `OnSignIn` callback receives a typed `IContext`. Our `SignInCompleteHandler` and `SignInFailureHandler` delegates use `Context` (the base type) because the sign-in completion can originate from three different activity types: + +- `InvokeActivity` -- SSO token exchange (`signin/tokenExchange`) +- `InvokeActivity` -- verify state (`signin/verifyState`) +- `MessageActivity` -- magic code redemption + +```csharp +// Teams SDK v2 +app.OnSignIn(async (plugin, @event, cancellationToken) => { + var token = @event.Token; + var context = @event.Context; // IContext +}); + +// OAuthFlow +graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => { + // context is Context (base type) + string token = tokenResponse.Token; +}); +``` + +### `IsSignedIn` is synchronous (sync-over-async) + +The Teams SDK v2 `context.IsSignedIn` is set by the framework during activity processing. Our `IsSignedIn` property makes a synchronous call to the token store (`GetAwaiter().GetResult()`). + +For new code, prefer the async `IsSignedInAsync(connectionName?)` method: + +```csharp +// Backwards-compatible (sync, single/default connection only) +if (!context.IsSignedIn) { ... } + +// Preferred (async, connection-aware) +if (!await context.IsSignedInAsync("gh", ct)) { ... } +``` + +When multiple connections are registered, `IsSignedIn` checks the **first** registered connection and logs a warning via `Trace.TraceWarning`. + +### `context.SignIn()` returns `Task` not `Task` + +The Teams SDK v2 `context.SignIn()` returns `Task` (void). Our `context.SignIn()` returns `Task` -- the cached token if available, or `null` if the sign-in flow was initiated: + +```csharp +// Teams SDK v2 +await context.SignIn(new OAuthOptions { ... }, cancellationToken); +// must check context.IsSignedIn separately + +// OAuthFlow +string? token = await context.SignIn(new OAuthOptions { ... }, ct); +if (token is not null) { /* already signed in, use token */ } +// else: OAuthCard sent, token arrives via OnSignInComplete +``` + +### No `OnSignInFailure` on context -- use OAuthFlow instance + +The Teams SDK v2 has `app.OnSignInFailure(handler)` at the app level. In OAuthFlow, failure handlers are per-connection on the `OAuthFlow` instance: + +```csharp +// Teams SDK v2 +teams.OnSignInFailure(async (context, cancellationToken) => { ... }); + +// OAuthFlow +graphAuth.OnSignInFailure(async (context, ct) => { ... }); +``` + +### `OAuthOptions` namespace + +Teams SDK v2: `Microsoft.Teams.Apps.OAuthOptions` +OAuthFlow: `Microsoft.Teams.Bot.Apps.Auth.OAuthOptions` + +Same shape: `ConnectionName`, `OAuthCardText`, `SignInButtonText`. + +### Message routing strips bot mentions + +`OnMessage` pattern matching now uses `MessageActivity.TextWithoutMentions` instead of `Text`. This means `@botname help` correctly matches the pattern `^help$`. The raw `Text` property still contains the full text with mentions for handlers that need it. + ## API Surface ### Registration @@ -65,125 +159,117 @@ public static class OAuthFlowExtensions } ``` -`AddOAuthFlow` registers two invoke routes on the app's `Router`: +`AddOAuthFlow` registers three routes on the app's `Router`: -| Route name | Invoke name | Purpose | +| Route name | Activity type | Purpose | |---|---|---| -| `invoke/signin/tokenExchange` | `signin/tokenExchange` | SSO silent token exchange | -| `invoke/signin/verifyState` | `signin/verifyState` | Fallback magic-code verification | +| `message/oauth/magicCode` | Message (4-8 digit text) | Magic code interception for non-AAD providers | +| `invoke/signin/tokenExchange` | Invoke | SSO silent token exchange | +| `invoke/signin/verifyState` | Invoke | Fallback sign-in verification | + +### Context Methods + +```csharp +public class Context where TActivity : TeamsActivity +{ + /// Trigger sign-in flow. Returns cached token or null if OAuthCard sent. + public Task SignIn(OAuthOptions? options = null, CancellationToken ct = default); + + /// Sign the user out from a connection. + public Task SignOut(string? connectionName = null, CancellationToken ct = default); + + /// Check if user has a cached token (async, connection-aware). + public Task IsSignedInAsync(string? connectionName = null, CancellationToken ct = default); -When multiple `OAuthFlow` instances are registered (multi-connection), the invoke handlers dispatch to the correct flow by matching the `connectionName` in the invoke value. + /// Check if user has a cached token (sync, backwards-compat, default connection). + public bool IsSignedIn { get; } + + /// Get token status for all configured connections. + public Task> GetConnectionStatusAsync(CancellationToken ct = default); +} +``` ### OAuthFlow Class ```csharp public class OAuthFlow { - /// The OAuth connection name. Null until resolved (auto-discovery mode). public string? ConnectionName { get; } - /// Attempt silent token acquisition from the token store. - /// Returns the access token string, or null if no token is cached. - public Task GetTokenAsync( - Context context, - CancellationToken cancellationToken = default) where TActivity : TeamsActivity; - - /// Attempt silent token acquisition; if no token is available, - /// send an OAuthCard to initiate the SSO flow. - /// Returns the token if already cached, or null if SSO was initiated - /// (the result will arrive via OnSignInComplete). - public Task SignInAsync( - Context context, - CancellationToken cancellationToken = default) where TActivity : TeamsActivity; - - /// Sign the user out, revoking their token from the token store. - public Task SignOutAsync( - Context context, - CancellationToken cancellationToken = default) where TActivity : TeamsActivity; - - /// Check whether the user has a valid cached token. - public Task IsSignedInAsync( - Context context, - CancellationToken cancellationToken = default) where TActivity : TeamsActivity; - - /// Get the token status for all configured OAuth connections. - /// This calls GetTokenStatus which returns every connection - /// registered on the bot, so the developer never needs to - /// enumerate connection names manually. - public Task> GetConnectionStatusAsync( - Context context, - CancellationToken cancellationToken = default) where TActivity : TeamsActivity; - - /// Register a callback invoked after a successful token exchange - /// (SSO or fallback sign-in). - public OAuthFlow OnSignInComplete(SignInCompleteHandler handler); + public Task GetTokenAsync(Context context, CancellationToken ct = default); + public Task SignInAsync(Context context, CancellationToken ct = default); + public Task SignInAsync(Context context, OAuthOptions? options, CancellationToken ct = default); + public Task SignOutAsync(Context context, CancellationToken ct = default); + public Task IsSignedInAsync(Context context, CancellationToken ct = default); + public Task> GetConnectionStatusAsync(Context context, CancellationToken ct = default); - /// Register a callback invoked when token exchange fails. + public OAuthFlow OnSignInComplete(SignInCompleteHandler handler); public OAuthFlow OnSignInFailure(SignInFailureHandler handler); } ``` +### OAuthOptions + +```csharp +public class OAuthOptions +{ + public string? ConnectionName { get; set; } + public string OAuthCardText { get; set; } = "Please Sign In"; + public string SignInButtonText { get; set; } = "Sign In"; +} +``` + ### Delegates ```csharp public delegate Task SignInCompleteHandler( - Context context, + Context context, GetTokenResult tokenResponse, CancellationToken cancellationToken); public delegate Task SignInFailureHandler( - Context context, + Context context, CancellationToken cancellationToken); ``` -### Value Types - -```csharp -/// Value payload of the signin/tokenExchange invoke activity. -public class SignInTokenExchangeValue -{ - public string? Id { get; set; } - public string? ConnectionName { get; set; } - public string? Token { get; set; } -} - -/// Value payload of the signin/verifyState invoke activity. -public class SignInVerifyStateValue -{ - public string? State { get; set; } -} -``` - ## Internal Flow ### SignInAsync Sequence ``` -Developer calls oauth.SignInAsync(context) +Developer calls context.SignIn(options) or oauth.SignInAsync(context) │ - ├─ 1. Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId) + ├─ 1. Check if message text is a magic code (4-8 digits) + │ ├─ Yes → call GetTokenAsync(code) → return token if redeemed + │ └─ No ↓ + │ + ├─ 2. Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId) │ ├─ Token exists → return token string │ └─ No token ↓ │ - ├─ 2. Call UserTokenClient.GetSignInResource(userId, connectionName, channelId) + ├─ 3. Build token exchange state with MsAppId (from BotApplication.AppId) + │ Call UserTokenClient.GetSignInResourceAsync(state) │ Returns: SignInLink, TokenExchangeResource, TokenPostResource │ - ├─ 3. Build OAuthCard attachment: + ├─ 4. Build OAuthCard attachment (serialized as JsonElement for AOT compat): │ { │ contentType: "application/vnd.microsoft.card.oauth", │ content: { - │ buttons: [{ type: "signin", title: "Sign In", value: signInLink }], + │ text: options.OAuthCardText, + │ buttons: [{ type: "signin", title: options.SignInButtonText, value: signInLink }], │ connectionName: connectionName, │ tokenExchangeResource: { id, uri, providerId }, │ tokenPostResource: { sasUrl } │ } │ } │ - ├─ 4. Send activity with OAuthCard attachment + ├─ 5. Send activity with OAuthCard attachment │ - └─ 5. Return null (SSO exchange pending) + └─ 6. Return null (sign-in pending) ``` +**Critical**: The state must include `MsAppId` (from `BotApplication.AppId`, sourced from `BotConfig.ClientId`). Without it, the Token Service returns `tokenExchangeResource: null` and Teams cannot perform SSO or automatic verify-state after popup sign-in. + ### signin/tokenExchange Invoke Handler ``` @@ -211,18 +297,51 @@ Teams client sends invoke: signin/tokenExchange Teams client sends invoke: signin/verifyState │ ├─ 1. Deserialize value → SignInVerifyStateValue { State } - │ (State contains the magic code from fallback sign-in) + │ (State is the code from the popup sign-in redirect) │ - ├─ 2. Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId, code: state) - │ ├─ Token returned → fire OnSignInComplete, respond InvokeResponse(200) - │ └─ No token → fire OnSignInFailure, respond InvokeResponse(400) + ├─ 2. Try each registered OAuthFlow (verifyState has no connectionName): + │ For each flow: + │ Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId, code: state) + │ ├─ Token returned → fire OnSignInComplete, respond InvokeResponse(200), stop + │ └─ No token → try next flow + │ + ├─ 3. If no flow succeeded → respond InvokeResponse(400) │ └─ Done ``` +### Magic Code Message Handler + +``` +Message activity with 4-8 digit numeric text arrives + │ + ├─ 1. Try each registered OAuthFlow: + │ Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId, code: text) + │ ├─ Token returned → fire OnSignInComplete via HandleMagicCodeRedeemAsync, stop + │ └─ No token → try next flow + │ + └─ 2. If no flow redeemed the code → message continues to other handlers +``` + ### Deduplication -Teams may send duplicate `signin/tokenExchange` invokes (the user can have multiple active endpoints -- mobile, desktop, web). The `OAuthFlow` deduplicates by tracking processed exchange IDs in a `ConcurrentDictionary` with a short TTL. This is an in-process, per-instance cache -- sufficient because duplicates arrive within milliseconds of each other to the same bot instance. +Teams may send duplicate `signin/tokenExchange` invokes because the user can have multiple active endpoints (mobile, desktop, web) and Teams sends the exchange request from each one. The `OAuthFlow` deduplicates by tracking processed exchange IDs. + +**Default implementation**: In-process `ConcurrentDictionary` with a 5-minute TTL. This works for single-instance deployments and development. + +**Production consideration**: When the bot is deployed behind a load balancer with multiple instances (e.g., Azure App Service scaled to N nodes), duplicate `signin/tokenExchange` invokes may arrive at **different instances**. The in-process cache cannot deduplicate across instances, so the token exchange may be attempted multiple times. While the Token Service is idempotent (duplicate exchanges succeed harmlessly), the `OnSignInComplete` callback may fire more than once. + +For production multi-instance deployments, the deduplication store should be replaced with a distributed cache (e.g., Redis, Azure Cache). This is a future extensibility point -- the `OAuthFlow` should accept an `IDistributedCache` or similar abstraction to allow external storage: + +```csharp +// Future API (not yet implemented) +bot.AddOAuthFlow("GraphConnection", options => +{ + options.DeduplicationStore = new RedisDeduplicationStore(redisConnection); +}); +``` + +Until this is implemented, multi-instance deployments should be aware that `OnSignInComplete` may fire on more than one instance for the same sign-in. Handlers should be idempotent. ### Auto-Discovery (no connection name) @@ -234,11 +353,9 @@ When `AddOAuthFlow()` is called without a connection name: 4. If multiple connections exist, throws `InvalidOperationException` with a message listing the available connections and asking the developer to specify one. 5. The resolved connection name is cached for subsequent calls. -This eliminates the need for developers to hard-code connection names when only one connection is configured, which is the common case. - ## Multi-Connection Sample -A bot that uses **two** OAuth connections: one for Microsoft Graph (user profile, calendar) and one for a third-party API (e.g., Salesforce). +A bot that uses **two** OAuth connections: one for Microsoft Graph and one for GitHub. ### Configuration @@ -249,180 +366,83 @@ Azure Bot resource has two OAuth connection settings: | `GraphConnection` | Azure AD v2 | `User.Read Calendars.Read` | | `GitHubConnection` | GitHub | `repo read:user` | -### Registration +### Registration (using context API) ```csharp -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddTeams("AzureAd"); - +var builder = WebApplication.CreateSlimBuilder(args); +builder.Services.AddTeamsBotApplication(); var app = builder.Build(); +TeamsBotApplication bot = app.UseTeamsBotApplication(); -app.UseAuthentication(); -app.UseAuthorization(); - -var bot = app.UseTeams("api/messages"); - -// Register two OAuthFlow instances, one per connection +// Register two OAuthFlow instances OAuthFlow graphAuth = bot.AddOAuthFlow("GraphConnection"); OAuthFlow githubAuth = bot.AddOAuthFlow("GitHubConnection"); -// --- Sign-in complete callbacks --- - +// Sign-in complete callbacks graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => { - await context.SendActivityAsync("Connected to Microsoft Graph!", ct); + await context.SendActivityAsync($"Connected to Graph ({tokenResponse.ConnectionName})!", ct); }); githubAuth.OnSignInComplete(async (context, tokenResponse, ct) => { - await context.SendActivityAsync("Connected to GitHub!", ct); + await context.SendActivityAsync($"Connected to GitHub ({tokenResponse.ConnectionName})!", ct); }); -// --- Message handlers --- - -bot.OnMessage(@"^login graph$", async (context, ct) => +// Context-based API -- connection specified per-call +bot.OnMessage(@"(?i)^login graph$", async (context, ct) => { - string? token = await graphAuth.SignInAsync(context, ct); - if (token != null) + string? token = await context.SignIn(new OAuthOptions { + ConnectionName = "GraphConnection", + OAuthCardText = "Sign in to your Microsoft account", + SignInButtonText = "Sign In to Graph" + }, ct); + + if (token is not null) await context.SendActivityAsync("Already signed in to Graph.", ct); - } - // else: OAuthCard sent, SSO in progress }); -bot.OnMessage(@"^login github$", async (context, ct) => +bot.OnMessage(@"(?i)^login github$", async (context, ct) => { - string? token = await githubAuth.SignInAsync(context, ct); - if (token != null) + string? token = await context.SignIn(new OAuthOptions { + ConnectionName = "GitHubConnection", + OAuthCardText = "Sign in to your GitHub account", + SignInButtonText = "Sign In to GitHub" + }, ct); + + if (token is not null) await context.SendActivityAsync("Already signed in to GitHub.", ct); - } }); -bot.OnMessage(@"^status$", async (context, ct) => +bot.OnMessage(@"(?i)^status$", async (context, ct) => { - // GetConnectionStatusAsync returns ALL connections -- no names needed - var statuses = await graphAuth.GetConnectionStatusAsync(context, ct); + var statuses = await context.GetConnectionStatusAsync(ct); var lines = statuses.Select(s => $"- **{s.ConnectionName}** ({s.ServiceProviderDisplayName}): " + $"{(s.HasToken == true ? "connected" : "not connected")}"); - await context.SendActivityAsync( - "OAuth connections:\n" + string.Join("\n", lines), ct); + await context.SendActivityAsync("OAuth connections:\n" + string.Join("\n", lines), ct); }); -bot.OnMessage(@"^my calendar$", async (context, ct) => +bot.OnMessage(@"(?i)^logout$", async (context, ct) => { - string? token = await graphAuth.SignInAsync(context, ct); - if (token == null) return; - - // Call Graph API with the token - using var http = new HttpClient(); - http.DefaultRequestHeaders.Authorization = new("Bearer", token); - var response = await http.GetStringAsync( - "https://graph.microsoft.com/v1.0/me/events?$top=3", ct); - - await context.SendActivityAsync($"Your next events:\n{response}", ct); -}); - -bot.OnMessage(@"^my repos$", async (context, ct) => -{ - string? token = await githubAuth.SignInAsync(context, ct); - if (token == null) return; - - // Call GitHub API with the token - using var http = new HttpClient(); - http.DefaultRequestHeaders.Authorization = new("Bearer", token); - http.DefaultRequestHeaders.UserAgent.ParseAdd("TeamsBot/1.0"); - var response = await http.GetStringAsync( - "https://api.github.com/user/repos?sort=updated&per_page=5", ct); - - await context.SendActivityAsync($"Your recent repos:\n{response}", ct); -}); - -bot.OnMessage(@"^logout$", async (context, ct) => -{ - // Sign out from both connections - await graphAuth.SignOutAsync(context, ct); - await githubAuth.SignOutAsync(context, ct); + await context.SignOut("GraphConnection", ct); + await context.SignOut("GitHubConnection", ct); await context.SendActivityAsync("Signed out from all services.", ct); }); -bot.OnMessage(@"^logout graph$", async (context, ct) => -{ - await graphAuth.SignOutAsync(context, ct); - await context.SendActivityAsync("Signed out from Graph.", ct); -}); - -bot.OnMessage(@"^logout github$", async (context, ct) => -{ - await githubAuth.SignOutAsync(context, ct); - await context.SendActivityAsync("Signed out from GitHub.", ct); -}); - app.Run(); ``` ### How Multi-Connection Invoke Routing Works -When multiple `OAuthFlow` instances are registered, both `signin/tokenExchange` and `signin/verifyState` invoke routes are registered **once** (shared). The shared handler dispatches to the correct `OAuthFlow` instance by matching `connectionName` from the invoke value: - -``` -signin/tokenExchange invoke arrives - │ - ├─ value.ConnectionName == "GraphConnection" - │ → dispatch to graphAuth - │ - └─ value.ConnectionName == "GitHubConnection" - → dispatch to githubAuth -``` - -This is handled internally by a registry (`Dictionary`) keyed by connection name. - -## Single-Connection Sample (Auto-Discovery) +When multiple `OAuthFlow` instances are registered, invoke routes are registered **once** (shared). The dispatch logic differs by invoke type: -When only one OAuth connection is configured, the developer can omit the connection name entirely: - -```csharp -var bot = app.UseTeams("api/messages"); - -// No connection name -- auto-discovered via GetTokenStatus -OAuthFlow auth = bot.AddOAuthFlow(); - -auth.OnSignInComplete(async (context, tokenResponse, ct) => -{ - await context.SendActivityAsync($"Signed in via {tokenResponse.ConnectionName}!", ct); -}); - -bot.OnMessage(@"^login$", async (context, ct) => -{ - string? token = await auth.SignInAsync(context, ct); - if (token != null) - { - await context.SendActivityAsync("You're already signed in.", ct); - } -}); - -bot.OnMessage(@"^logout$", async (context, ct) => -{ - await auth.SignOutAsync(context, ct); - await context.SendActivityAsync("Signed out.", ct); -}); - -bot.OnMessage(@"^whoami$", async (context, ct) => -{ - string? token = await auth.SignInAsync(context, ct); - if (token == null) return; - - using var http = new HttpClient(); - http.DefaultRequestHeaders.Authorization = new("Bearer", token); - var me = await http.GetStringAsync("https://graph.microsoft.com/v1.0/me", ct); - await context.SendActivityAsync(me, ct); -}); - -app.Run(); -``` +- **`signin/tokenExchange`**: dispatches by `connectionName` from the invoke value (exact match). +- **`signin/verifyState`**: tries each registered flow sequentially (no connection name in the payload). +- **`message/oauth/magicCode`**: tries each registered flow sequentially (magic code has no connection context). ## File Placement @@ -430,10 +450,20 @@ app.Run(); |---|---| | `OAuthFlow.cs` | `Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs` | | `OAuthFlowExtensions.cs` | `Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs` | +| `OAuthOptions.cs` | `Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs` | | `SignInTokenExchangeValue.cs` | `Microsoft.Teams.Bot.Apps/Auth/SignInTokenExchangeValue.cs` | | `SignInVerifyStateValue.cs` | `Microsoft.Teams.Bot.Apps/Auth/SignInVerifyStateValue.cs` | | `OAuthCard.cs` | `Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs` | +## Changes to Core + +| File | Change | +|---|---| +| `BotApplication.cs` | Added `AppId` public property (from `BotApplicationOptions.AppId`) | +| `MessageHandler.cs` | Selectors now match against `TextWithoutMentions` instead of `Text` | +| `MessageActivity.cs` | Added `TextWithoutMentions` computed property (strips bot @mention) | +| `TeamsAttachment.cs` | Added `AttachmentContentType.OAuthCard` constant | + ## Edge Cases & Constraints | Scenario | Behavior | @@ -443,4 +473,9 @@ app.Run(); | Duplicate `signin/tokenExchange` | Deduplicated by exchange ID. First wins, duplicates get 200 no-op. | | Token expired | `GetTokenAsync` returns null (token store returns 404). `SignInAsync` re-initiates the flow. | | Auto-discovery with multiple connections | Throws `InvalidOperationException` listing available connections. | -| `signin/verifyState` with invalid code | `GetTokenAsync` with code returns null. `OnSignInFailure` fires. Response 400. | +| `signin/verifyState` with multiple connections | Tries each registered flow until one succeeds (200). Returns 400 if none match. | +| `IsSignedIn` with multiple connections | Checks the first registered connection, logs `Trace.TraceWarning`. Prefer `IsSignedInAsync(connectionName)`. | +| Magic code in message | Intercepted by `message/oauth/magicCode` route. Tries each flow. If none redeem it, the message continues to other handlers. | +| Missing `MsAppId` in sign-in state | Token Service returns `tokenExchangeResource: null`. SSO and automatic verify-state fail. OAuthFlow includes `MsAppId` from `BotApplication.AppId` to prevent this. | +| Non-AAD providers (GitHub, etc.) | No `tokenExchangeResource` returned regardless of `MsAppId`. Sign-in completes via popup + `signin/verifyState` or magic code. | +| OAuthCard JSON serialization | `OAuthCard` is serialized to `JsonElement` before attaching, to avoid `NotSupportedException` from the source-generated `TeamsActivityJsonContext`. | diff --git a/core/samples/OAuthFlowBot/OAuthFlowBot.csproj b/core/samples/OAuthFlowBot/OAuthFlowBot.csproj new file mode 100644 index 00000000..965b246f --- /dev/null +++ b/core/samples/OAuthFlowBot/OAuthFlowBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/OAuthFlowBot/Program.cs b/core/samples/OAuthFlowBot/Program.cs new file mode 100644 index 00000000..4bc01ff3 --- /dev/null +++ b/core/samples/OAuthFlowBot/Program.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates how to use OAuthFlow with two OAuth connections: +// - GraphConnection: Microsoft Graph (Azure AD v2) for user profile and calendar +// - GitHubConnection: GitHub for repositories +// +// Azure Bot resource must have two OAuth connection settings configured: +// | Connection name | Provider | Scopes | +// |-------------------|--------------|---------------------------| +// | GraphConnection | Azure AD v2 | User.Read Calendars.Read | +// | GitHubConnection | GitHub | repo read:user | + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Auth; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication bot = webApp.UseTeamsBotApplication(); + +// ==================== OAUTH FLOW SETUP ==================== + +// Register two OAuthFlow instances, one per connections +OAuthFlow graphAuth = bot.AddOAuthFlow("teamsgraph"); +OAuthFlow githubAuth = bot.AddOAuthFlow("gh"); + +// Sign-in complete callbacks +graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync($"Connected to Microsoft Graph ({tokenResponse.ConnectionName})!", ct); +}); + +githubAuth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync($"Connected to GitHub ({tokenResponse.ConnectionName})!", ct); +}); + +// ==================== MESSAGE HANDLERS ==================== + +bot.OnMessage("(?i)^help$", async (context, ct) => +{ + string helpText = """ + **OAuthFlow Bot** - Multi-connection OAuth sample + + Commands: + - `login graph` - Sign in to Microsoft Graph + - `login github` - Sign in to GitHub + - `status` - Show OAuth connection status + - `my ad user` - Get your Azure AD user (requires Graph) + - `my gh user` - Get your GitHub user (requires GitHub) + - `logout` - Sign out from all connections + - `logout graph` - Sign out from Graph only + - `logout github` - Sign out from GitHub only + - `help` - Show this message + """; + + await context.SendActivityAsync( + new MessageActivity(helpText) { TextFormat = TextFormats.Markdown }, ct); +}); + +bot.OnMessage("(?i)^login graph$", async (context, ct) => +{ + string? token = await graphAuth.SignInAsync(context, ct); + if (token is not null) + { + await context.SendActivityAsync("Already signed in to Graph.", ct); + } + // else: OAuthCard sent, SSO in progress +}); + +bot.OnMessage("(?i)^login github$", async (context, ct) => +{ + string? token = await githubAuth.SignInAsync(context, ct); + if (token is not null) + { + await context.SendActivityAsync("Already signed in to GitHub.", ct); + } +}); + +bot.OnMessage("(?i)^status$", async (context, ct) => +{ + // GetConnectionStatusAsync returns ALL connections -- no names needed + var statuses = await graphAuth.GetConnectionStatusAsync(context, ct); + var lines = statuses.Select(s => + $"- **{s.ConnectionName}** ({s.ServiceProviderDisplayName}): " + + $"{(s.HasToken == true ? "connected" : "not connected")}"); + + await context.SendActivityAsync( + new MessageActivity("OAuth connections:\n" + string.Join("\n", lines)) + { + TextFormat = TextFormats.Markdown + }, ct); +}); + +bot.OnMessage("(?i)^my ad user", async (context, ct) => +{ + string? token = await graphAuth.SignInAsync(context, ct); + if (token is null) return; + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + + try + { + string response = await http.GetStringAsync( + "https://graph.microsoft.com/v1.0/me", ct); + await context.SendActivityAsync($"Your Azure AD user :\n```json\n{response}\n```", ct); + } + catch (HttpRequestException ex) + { + await context.SendActivityAsync($"Failed to fetch Azure AD user: {ex.Message}", ct); + } +}); + +bot.OnMessage("(?i)^my gh user$", async (context, ct) => +{ + string? token = await githubAuth.SignInAsync(context, ct); + if (token is null) return; + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + http.DefaultRequestHeaders.UserAgent.ParseAdd("TeamsBot/1.0"); + + try + { + string response = await http.GetStringAsync( + "https://api.github.com/user", ct); + await context.SendActivityAsync($"Your GitHub user :\n```json\n{response}\n```", ct); + } + catch (HttpRequestException ex) + { + await context.SendActivityAsync($"Failed to fetch GitHub user: {ex.Message}", ct); + } +}); + +bot.OnMessage("(?i)^logout$", async (context, ct) => +{ + await graphAuth.SignOutAsync(context, ct); + await githubAuth.SignOutAsync(context, ct); + await context.SendActivityAsync("Signed out from all services.", ct); +}); + +bot.OnMessage("(?i)^logout graph$", async (context, ct) => +{ + await graphAuth.SignOutAsync(context, ct); + await context.SendActivityAsync("Signed out from Graph.", ct); +}); + +bot.OnMessage("(?i)^logout github$", async (context, ct) => +{ + await githubAuth.SignOutAsync(context, ct); + await context.SendActivityAsync("Signed out from GitHub.", ct); +}); + +// ==================== INSTALL HANDLER ==================== + +bot.OnInstall(async (context, ct) => +{ + await context.SendActivityAsync( + new MessageActivity("Welcome to the **OAuthFlow Bot**! Type `help` to see available commands.") + { + TextFormat = TextFormats.Markdown + }, ct); +}); + +webApp.Run(); diff --git a/core/samples/OAuthFlowBot/appsettings.json b/core/samples/OAuthFlowBot/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/core/samples/OAuthFlowBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs new file mode 100644 index 00000000..a5f1312b --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Teams.Bot.Apps.Auth; + +/// +/// Delegate invoked after a successful OAuth token exchange, sign-in verification, or magic code redemption. +/// +/// The activity context. May be an invoke context (SSO/verifyState) or a message context (magic code). +/// The token result containing the access token and connection name. +/// A cancellation token. +public delegate Task SignInCompleteHandler(Context context, GetTokenResult tokenResponse, CancellationToken cancellationToken); + +/// +/// Delegate invoked when an OAuth token exchange or sign-in verification fails. +/// +/// The activity context. +/// A cancellation token. +public delegate Task SignInFailureHandler(Context context, CancellationToken cancellationToken); + +/// +/// Provides a high-level abstraction for Teams Bot SSO authentication. +/// Encapsulates silent token acquisition, SSO token exchange, fallback sign-in, and sign-out. +/// +public class OAuthFlow +{ + private readonly TeamsBotApplication _app; + private readonly ILogger _logger; + private string? _connectionName; + private bool _connectionResolved; + private SignInCompleteHandler? _onSignInComplete; + private SignInFailureHandler? _onSignInFailure; + + // Deduplication cache for signin/tokenExchange invoke activities. + // Teams may send duplicates from multiple endpoints (mobile, desktop, web). + private readonly ConcurrentDictionary _processedExchanges = new(); + + internal OAuthFlow(TeamsBotApplication app, string? connectionName, ILogger logger) + { + _app = app; + _connectionName = connectionName; + _connectionResolved = connectionName is not null; + _logger = logger; + } + + /// + /// The OAuth connection name. Null until resolved when using auto-discovery mode. + /// + public string? ConnectionName => _connectionName; + + /// + /// Register a callback invoked after a successful token exchange (SSO or fallback sign-in). + /// + /// The handler to invoke on successful sign-in. + /// This instance for chaining. + public OAuthFlow OnSignInComplete(SignInCompleteHandler handler) + { + _onSignInComplete = handler; + return this; + } + + /// + /// Register a callback invoked when token exchange fails. + /// + /// The handler to invoke on sign-in failure. + /// This instance for chaining. + public OAuthFlow OnSignInFailure(SignInFailureHandler handler) + { + _onSignInFailure = handler; + return this; + } + + /// + /// Attempt silent token acquisition from the Bot Framework Token Store. + /// + /// The activity type. + /// The current turn context. + /// A cancellation token. + /// The access token string, or null if no token is cached. + public async Task GetTokenAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + { + ArgumentNullException.ThrowIfNull(context); + string connectionName = await ResolveConnectionNameAsync(context, cancellationToken).ConfigureAwait(false); + string userId = GetUserId(context); + string channelId = GetChannelId(context); + + GetTokenResult? result = await _app.UserTokenClient.GetTokenAsync(userId, connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); + return result?.Token; + } + + /// + /// Attempt silent token acquisition; if no token is available, send an OAuthCard to initiate the SSO flow. + /// + /// The activity type. + /// The current turn context. + /// A cancellation token. + /// The token if already cached, or null if SSO was initiated (the result will arrive via ). + public Task SignInAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + => SignInAsync(context, options: null, cancellationToken); + + /// + /// Attempt silent token acquisition; if no token is available, send an OAuthCard to initiate the SSO flow. + /// + /// The activity type. + /// The current turn context. + /// OAuth options for customizing the sign-in card text. + /// A cancellation token. + /// The token if already cached, or null if SSO was initiated (the result will arrive via ). + public async Task SignInAsync(Context context, OAuthOptions? options, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + { + ArgumentNullException.ThrowIfNull(context); + options ??= new OAuthOptions(); + string connectionName = await ResolveConnectionNameAsync(context, cancellationToken).ConfigureAwait(false); + string userId = GetUserId(context); + string channelId = GetChannelId(context); + + // 1. Check if the message text is a magic code (fallback sign-in for non-AAD providers) + string? messageText = context.Activity.Properties.TryGetValue("text", out object? textObj) ? textObj?.ToString()?.Trim() : null; + if (IsMagicCode(messageText)) + { + _logger.LogDebug("Detected magic code in message text for connection '{ConnectionName}'. Attempting to redeem.", connectionName); + GetTokenResult? codeToken = await _app.UserTokenClient + .GetTokenAsync(userId, connectionName, channelId, code: messageText, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (codeToken?.Token is not null) + { + _logger.LogDebug("Magic code redeemed successfully for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + return codeToken.Token; + } + } + + // 2. Try silent token acquisition + GetTokenResult? existingToken = await _app.UserTokenClient.GetTokenAsync(userId, connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); + if (existingToken?.Token is not null) + { + _logger.LogDebug("Token found in store for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + return existingToken.Token; + } + + // 3. No token - get sign-in resource and send OAuthCard + _logger.LogDebug("No cached token for connection '{ConnectionName}'. Initiating sign-in flow.", connectionName); + + // Build state with MsAppId so the Token Service returns TokenExchangeResource for SSO + var tokenExchangeState = new + { + ConnectionName = connectionName, + Conversation = new + { + ActivityId = context.Activity.Id, + Bot = new { Id = context.Activity.Recipient?.Id }, + ChannelId = channelId, + Conversation = new { Id = context.Activity.Conversation?.Id }, + ServiceUrl = context.Activity.ServiceUrl?.ToString(), + User = new { Id = userId } + }, + MsAppId = _app.AppId + }; + string state = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(tokenExchangeState)); + + GetSignInResourceResult signInResource = await _app.UserTokenClient + .GetSignInResourceAsync(state, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + OAuthCard oauthCard = new() + { + Text = options.OAuthCardText, + ConnectionName = connectionName, + Buttons = + [ + new SuggestedAction(ActionType.SignIn, options.SignInButtonText) { Value = signInResource.SignInLink } + ], + TokenExchangeResource = signInResource.TokenExchangeResource, + TokenPostResource = signInResource.TokenPostResource + }; + + // Serialize to JsonElement so the source-generated JSON context can handle it + JsonElement oauthCardJson = JsonSerializer.SerializeToElement(oauthCard); + + TeamsAttachment attachment = TeamsAttachment.CreateBuilder() + .WithContentType(AttachmentContentType.OAuthCard) + .WithContent(oauthCardJson) + .Build(); + + TeamsActivity oauthActivity = TeamsActivity.CreateBuilder() + .WithConversationReference(context.Activity) + //.WithRecipient(context.Activity.From, true) + .WithAttachment(attachment) + .Build(); + + await context.SendActivityAsync(oauthActivity, cancellationToken).ConfigureAwait(false); + return null; + } + + /// + /// Sign the user out, revoking their token from the Bot Framework Token Store. + /// + /// The activity type. + /// The current turn context. + /// A cancellation token. + public async Task SignOutAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + { + ArgumentNullException.ThrowIfNull(context); + string connectionName = await ResolveConnectionNameAsync(context, cancellationToken).ConfigureAwait(false); + string userId = GetUserId(context); + string channelId = GetChannelId(context); + + _logger.LogDebug("Signing out user '{UserId}' from connection '{ConnectionName}'.", userId, connectionName); + await _app.UserTokenClient.SignOutUserAsync(userId, connectionName, channelId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Check whether the user has a valid cached token for this flow's connection. + /// + /// The activity type. + /// The current turn context. + /// A cancellation token. + /// True if the user has a valid token; false otherwise. + public async Task IsSignedInAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + { + string? token = await GetTokenAsync(context, cancellationToken).ConfigureAwait(false); + return token is not null; + } + + /// + /// Get the token status for all configured OAuth connections. + /// This calls GetTokenStatus which returns every connection registered on the bot, + /// so the developer never needs to enumerate connection names manually. + /// + /// The activity type. + /// The current turn context. + /// A cancellation token. + /// A list of token status results for all configured connections. + public async Task> GetConnectionStatusAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + { + ArgumentNullException.ThrowIfNull(context); + string userId = GetUserId(context); + string channelId = GetChannelId(context); + + return await _app.UserTokenClient.GetTokenStatusAsync(userId, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Handles the signin/tokenExchange invoke activity. + /// + internal async Task HandleTokenExchangeAsync(Context context, SignInTokenExchangeValue exchangeValue, CancellationToken cancellationToken) + { + string exchangeId = exchangeValue.Id ?? string.Empty; + + // Deduplication: Teams sends duplicate exchanges from multiple endpoints + if (!_processedExchanges.TryAdd(exchangeId, DateTimeOffset.UtcNow)) + { + _logger.LogDebug("Duplicate signin/tokenExchange with Id '{ExchangeId}' - returning 200 no-op.", exchangeId); + return new InvokeResponse(200); + } + + CleanupExpiredExchanges(); + + string userId = GetUserId(context); + string channelId = GetChannelId(context); + string connectionName = exchangeValue.ConnectionName ?? _connectionName ?? throw new InvalidOperationException("Connection name could not be determined from the token exchange value."); + + try + { + GetTokenResult tokenResult = await _app.UserTokenClient + .ExchangeTokenAsync(userId, connectionName, channelId, exchangeValue.Token, cancellationToken) + .ConfigureAwait(false); + + if (tokenResult?.Token is not null) + { + _logger.LogDebug("Token exchange succeeded for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + if (_onSignInComplete is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false); + } + return new InvokeResponse(200); + } + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Token exchange failed for connection '{ConnectionName}', user '{UserId}'. Returning 412 for fallback.", connectionName, userId); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Token exchange failed for connection '{ConnectionName}', user '{UserId}'. Returning 412 for fallback.", connectionName, userId); + } + + if (_onSignInFailure is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInFailure(baseContext, cancellationToken).ConfigureAwait(false); + } + + // 412 tells Teams to show the sign-in card as fallback + return new InvokeResponse(412); + } + + /// + /// Handles a magic code redeemed from a message activity (non-AAD provider fallback). + /// + internal async Task HandleMagicCodeRedeemAsync(Context context, GetTokenResult tokenResult, CancellationToken cancellationToken) + { + _logger.LogDebug("Magic code redeemed for connection '{ConnectionName}', user '{UserId}'.", tokenResult.ConnectionName, context.Activity.From?.Id); + if (_onSignInComplete is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Handles the signin/verifyState invoke activity (fallback magic-code flow). + /// + internal async Task HandleVerifyStateAsync(Context context, SignInVerifyStateValue verifyValue, CancellationToken cancellationToken) + { + string userId = GetUserId(context); + string channelId = GetChannelId(context); + string connectionName = _connectionName ?? throw new InvalidOperationException("Connection name has not been resolved."); + + GetTokenResult? tokenResult = await _app.UserTokenClient + .GetTokenAsync(userId, connectionName, channelId, code: verifyValue.State, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (tokenResult?.Token is not null) + { + _logger.LogDebug("Verify state succeeded for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + if (_onSignInComplete is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false); + } + return new InvokeResponse(200); + } + + _logger.LogWarning("Verify state failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + if (_onSignInFailure is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInFailure(baseContext, cancellationToken).ConfigureAwait(false); + } + + return new InvokeResponse(400); + } + + private async Task ResolveConnectionNameAsync(Context context, CancellationToken cancellationToken) where TActivity : TeamsActivity + { + if (_connectionResolved && _connectionName is not null) + { + return _connectionName; + } + + // Auto-discover: call GetTokenStatus to find configured connections + string userId = GetUserId(context); + string channelId = GetChannelId(context); + + GetTokenStatusResult[] statuses = await _app.UserTokenClient + .GetTokenStatusAsync(userId, channelId, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (statuses.Length == 0) + { + throw new InvalidOperationException("No OAuth connections are configured on this bot. Configure an OAuth connection in the Azure Bot resource settings."); + } + + if (statuses.Length > 1) + { + string connectionNames = string.Join(", ", statuses.Select(s => $"'{s.ConnectionName}'")); + throw new InvalidOperationException( + $"Multiple OAuth connections found: {connectionNames}. " + + $"Specify the connection name explicitly when calling AddOAuthFlow(connectionName)."); + } + + _connectionName = statuses[0].ConnectionName ?? throw new InvalidOperationException("The configured OAuth connection has no name."); + _connectionResolved = true; + _logger.LogDebug("Auto-discovered OAuth connection: '{ConnectionName}'.", _connectionName); + + return _connectionName; + } + + private void CleanupExpiredExchanges() + { + DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddMinutes(-5); + foreach (var kvp in _processedExchanges) + { + if (kvp.Value < cutoff) + { + _processedExchanges.TryRemove(kvp.Key, out _); + } + } + } + + private static string GetUserId(Context context) where TActivity : TeamsActivity + => context.Activity.From?.Id ?? throw new InvalidOperationException("Activity.From.Id is required for OAuth operations."); + + private static string GetChannelId(Context context) where TActivity : TeamsActivity + => context.Activity.ChannelId ?? throw new InvalidOperationException("Activity.ChannelId is required for OAuth operations."); + + /// + /// Magic codes are 4-8 digit numeric strings sent by the user after completing + /// OAuth sign-in in a popup (fallback flow for non-AAD providers like GitHub). + /// + private static bool IsMagicCode([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? text) + => text is not null && text.Length is >= 4 and <= 8 && text.All(char.IsAsciiDigit); +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs new file mode 100644 index 00000000..6f29246d --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Teams.Bot.Apps.Auth; + +/// +/// Extension methods for registering instances on a . +/// +public static class OAuthFlowExtensions +{ + + /// + /// Register an with an explicit OAuth connection name. + /// + /// The Teams bot application. + /// The OAuth connection name configured on the bot. + /// The instance for configuring callbacks. + public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connectionName) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + + OAuthFlowRegistry registry = GetOrCreateRegistry(app); + ILogger logger = GetLogger(app); + + OAuthFlow flow = new(app, connectionName, logger); + registry.Register(connectionName, flow); + + return flow; + } + + /// + /// Register an that auto-discovers the connection name + /// via GetTokenStatus on first use. Use this when only one OAuth connection is configured. + /// + /// The Teams bot application. + /// The instance for configuring callbacks. + public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + OAuthFlowRegistry registry = GetOrCreateRegistry(app); + ILogger logger = GetLogger(app); + + OAuthFlow flow = new(app, connectionName: null, logger); + registry.RegisterAutoDiscover(flow); + + return flow; + } + + private static OAuthFlowRegistry GetOrCreateRegistry(TeamsBotApplication app) + { + if (app.OAuthRegistry is not null) + { + return app.OAuthRegistry; + } + + OAuthFlowRegistry registry = new(); + app.OAuthRegistry = registry; + + // Register shared routes once per app + RegisterRoutes(app, registry); + return registry; + } + + private static void RegisterRoutes(TeamsBotApplication app, OAuthFlowRegistry registry) + { + // Magic code handler: intercepts numeric messages (4-8 digits) that may be OAuth magic codes + // from the fallback sign-in flow (non-AAD providers like GitHub). + // Registered as a message route so it runs alongside other matching message handlers. + app.Router.Register(new Route + { + Name = "message/oauth/magicCode", + Selector = msg => IsMagicCode(msg.Text), + Handler = async (ctx, cancellationToken) => + { + string code = ctx.Activity.Text!.Trim(); + string userId = ctx.Activity.From?.Id ?? throw new InvalidOperationException("Activity.From.Id is required."); + string channelId = ctx.Activity.ChannelId ?? throw new InvalidOperationException("Activity.ChannelId is required."); + + // Try each registered flow to see which one can redeem the code + foreach (OAuthFlow flow in registry.GetAllFlows()) + { + string? connectionName = flow.ConnectionName; + if (connectionName is null) continue; + + GetTokenResult? tokenResult = await app.UserTokenClient + .GetTokenAsync(userId, connectionName, channelId, code: code, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (tokenResult?.Token is not null) + { + await flow.HandleMagicCodeRedeemAsync(ctx, tokenResult, cancellationToken).ConfigureAwait(false); + return; + } + } + } + }); + + // signin/tokenExchange + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.SignInTokenExchange), + Selector = activity => activity.Name == InvokeNames.SignInTokenExchange, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + SignInTokenExchangeValue? exchangeValue = typedActivity.Value; + + if (exchangeValue is null) + { + return new InvokeResponse(400); + } + + OAuthFlow? flow = registry.Resolve(exchangeValue.ConnectionName); + if (flow is null) + { + return new InvokeResponse(400); + } + + return await flow.HandleTokenExchangeAsync(ctx, exchangeValue, cancellationToken).ConfigureAwait(false); + } + }); + + // signin/verifyState + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.SignInVerifyState), + Selector = activity => activity.Name == InvokeNames.SignInVerifyState, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + SignInVerifyStateValue? verifyValue = typedActivity.Value; + + if (verifyValue is null) + { + return new InvokeResponse(400); + } + + // verifyState doesn't carry a connection name, so try each registered flow + foreach (OAuthFlow flow in registry.GetAllFlows()) + { + if (flow.ConnectionName is null) continue; + InvokeResponse response = await flow.HandleVerifyStateAsync(ctx, verifyValue, cancellationToken).ConfigureAwait(false); + if (response.Status == 200) + { + return response; + } + } + + return new InvokeResponse(400); + } + }); + } + + private static NullLogger GetLogger(TeamsBotApplication app) + { + _ = app; // Reserved for future use (e.g., resolving ILoggerFactory from DI) + return NullLogger.Instance; + } + + private static bool IsMagicCode([NotNullWhen(true)] string? text) + { + string? trimmed = text?.Trim(); + return trimmed is not null && trimmed.Length is >= 4 and <= 8 && trimmed.All(char.IsAsciiDigit); + } +} + +/// +/// Internal registry that maps connection names to instances. +/// Handles multi-connection dispatch for shared invoke routes. +/// +internal sealed class OAuthFlowRegistry +{ + private readonly Dictionary _flows = new(StringComparer.OrdinalIgnoreCase); + private OAuthFlow? _autoDiscoverFlow; + + internal void Register(string connectionName, OAuthFlow flow) + { + if (!_flows.TryAdd(connectionName, flow)) + { + throw new InvalidOperationException($"An OAuthFlow is already registered for connection '{connectionName}'."); + } + } + + internal void RegisterAutoDiscover(OAuthFlow flow) + { + if (_autoDiscoverFlow is not null) + { + throw new InvalidOperationException("Only one auto-discover OAuthFlow can be registered. Specify connection names explicitly for multiple connections."); + } + _autoDiscoverFlow = flow; + } + + /// + /// Resolve the OAuthFlow for a given connection name from a token exchange invoke. + /// + internal OAuthFlow? Resolve(string? connectionName) + { + if (connectionName is not null && _flows.TryGetValue(connectionName, out OAuthFlow? flow)) + { + return flow; + } + + // If there's an auto-discover flow, use it + if (_autoDiscoverFlow is not null) + { + return _autoDiscoverFlow; + } + + // If there's exactly one named flow, use it + if (_flows.Count == 1) + { + return _flows.Values.First(); + } + + return null; + } + + /// + /// Returns all registered flows (both named and auto-discover). + /// + internal IEnumerable GetAllFlows() + { + foreach (OAuthFlow flow in _flows.Values) + { + yield return flow; + } + if (_autoDiscoverFlow is not null) + { + yield return _autoDiscoverFlow; + } + } + + /// + /// Resolve when there's no connection name in the payload (e.g., verifyState). + /// + internal OAuthFlow? ResolveSingle() + { + if (_autoDiscoverFlow is not null) + { + return _autoDiscoverFlow; + } + + if (_flows.Count == 1) + { + return _flows.Values.First(); + } + + // Multiple flows and no way to disambiguate + return null; + } + + /// + /// Like but when multiple flows are registered, + /// returns the first one and logs a warning instead of returning null. + /// Used by Context.IsSignedIn for backwards compatibility. + /// + internal OAuthFlow? ResolveSingleWithWarning() + { + OAuthFlow? single = ResolveSingle(); + if (single is not null) + { + return single; + } + + if (_flows.Count > 1) + { + OAuthFlow first = _flows.Values.First(); + System.Diagnostics.Trace.TraceWarning( + $"IsSignedIn: multiple OAuthFlow connections registered. " + + $"Checking '{first.ConnectionName}' only. Use IsSignedInAsync(connectionName) for explicit control."); + return first; + } + + return null; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs new file mode 100644 index 00000000..f1edff42 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Apps.Auth; + +/// +/// Options for the OAuth sign-in flow. +/// +public class OAuthOptions +{ + /// + /// The OAuth connection name to use. If null, uses the default registered connection. + /// + public string? ConnectionName { get; set; } + + /// + /// The text displayed on the OAuthCard. + /// + public string OAuthCardText { get; set; } = "Please Sign In"; + + /// + /// The text displayed on the sign-in button. + /// + public string SignInButtonText { get; set; } = "Sign In"; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/SignInTokenExchangeValue.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/SignInTokenExchangeValue.cs new file mode 100644 index 00000000..e849332a --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/SignInTokenExchangeValue.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Auth; + +/// +/// Value payload of the signin/tokenExchange invoke activity. +/// +public class SignInTokenExchangeValue +{ + /// + /// Unique identifier for this token exchange request, used for deduplication. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The OAuth connection name this exchange targets. + /// + [JsonPropertyName("connectionName")] + public string? ConnectionName { get; set; } + + /// + /// The token provided by the Teams client for exchange. + /// + [JsonPropertyName("token")] + public string? Token { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/SignInVerifyStateValue.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/SignInVerifyStateValue.cs new file mode 100644 index 00000000..c2c67d39 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/SignInVerifyStateValue.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Auth; + +/// +/// Value payload of the signin/verifyState invoke activity. +/// +public class SignInVerifyStateValue +{ + /// + /// The magic code (state) from the fallback sign-in flow. + /// + [JsonPropertyName("state")] + public string? State { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Context.cs b/core/src/Microsoft.Teams.Bot.Apps/Context.cs index 1885abba..c175c7dc 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Context.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Context.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.Teams.Bot.Apps.Api.Clients; +using Microsoft.Teams.Bot.Apps.Auth; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; @@ -70,4 +71,97 @@ public class Context(TeamsBotApplication botApplication, TActivity ac .WithType(TeamsActivityType.Typing) .WithConversationReference(Activity) .Build(), cancellationToken); + + // ==================== OAuth Sign-In ==================== + + /// + /// Trigger user OAuth sign-in flow for the activity sender. + /// Attempts silent token acquisition first; if no token is cached, sends an OAuthCard. + /// + /// OAuth options including connection name and card text. + /// A cancellation token. + /// The existing user token if found, or null if the sign-in flow was initiated. + public Task SignIn(OAuthOptions? options = null, CancellationToken cancellationToken = default) + { + OAuthFlow flow = ResolveOAuthFlow(options?.ConnectionName); + return flow.SignInAsync(this, options, cancellationToken); + } + + /// + /// Sign the user out, revoking their token from the Bot Framework Token Store. + /// + /// The connection name to sign out from. If null, uses the default registered connection. + /// A cancellation token. + public Task SignOut(string? connectionName = null, CancellationToken cancellationToken = default) + { + OAuthFlow flow = ResolveOAuthFlow(connectionName); + return flow.SignOutAsync(this, cancellationToken); + } + + /// + /// Whether the activity sender has a valid cached token. + /// When a single OAuthFlow is registered, checks that connection. + /// When multiple are registered, checks the first one and logs a warning; + /// prefer with an explicit connection name instead. + /// Returns false if no OAuthFlow is registered. + /// + public bool IsSignedIn + { + get + { + OAuthFlowRegistry? registry = TeamsBotApplication.OAuthRegistry; + if (registry is null) return false; + + OAuthFlow? flow = registry.ResolveSingleWithWarning(); + if (flow is null) return false; + + return flow.GetTokenAsync(this).GetAwaiter().GetResult() is not null; + } + } + + /// + /// Check whether the user has a valid cached token for a given OAuth connection. + /// + /// The connection name to check. If null, uses the single registered connection. + /// A cancellation token. + /// True if the user has a valid token; false otherwise. + public Task IsSignedInAsync(string? connectionName = null, CancellationToken cancellationToken = default) + { + OAuthFlow flow = ResolveOAuthFlow(connectionName); + return flow.IsSignedInAsync(this, cancellationToken); + } + + /// + /// Get the token status for all configured OAuth connections. + /// Returns every connection registered on the bot, so the developer + /// never needs to enumerate connection names manually. + /// + /// A cancellation token. + /// A list of token status results for all configured connections. + public Task> GetConnectionStatusAsync(CancellationToken cancellationToken = default) + { + OAuthFlowRegistry registry = TeamsBotApplication.OAuthRegistry + ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow() on the TeamsBotApplication first."); + + // Use any flow -- GetConnectionStatusAsync returns all connections regardless + OAuthFlow flow = registry.ResolveSingle() + ?? registry.GetAllFlows().First(); + + return flow.GetConnectionStatusAsync(this, cancellationToken); + } + + private OAuthFlow ResolveOAuthFlow(string? connectionName) + { + OAuthFlowRegistry registry = TeamsBotApplication.OAuthRegistry + ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow() on the TeamsBotApplication first."); + + if (connectionName is not null) + { + return registry.Resolve(connectionName) + ?? throw new InvalidOperationException($"No OAuthFlow registered for connection '{connectionName}'."); + } + + return registry.ResolveSingle() + ?? throw new InvalidOperationException("Multiple OAuthFlow instances registered. Specify a connection name."); + } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageHandler.cs index 69fc2ce1..5993f1fb 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageHandler.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageHandler.cs @@ -64,7 +64,7 @@ public static TeamsBotApplication OnMessage(this TeamsBotApplication app, string app.Router.Register(new Route { Name = string.Join("/", [TeamsActivityType.Message, pattern]), - Selector = msg => regex.IsMatch(msg.Text ?? ""), + Selector = msg => regex.IsMatch(msg.TextWithoutMentions ?? ""), Handler = async (ctx, cancellationToken) => { await handler(ctx, cancellationToken).ConfigureAwait(false); @@ -91,7 +91,7 @@ public static TeamsBotApplication OnMessage(this TeamsBotApplication app, Regex app.Router.Register(new Route { Name = string.Join("/", [TeamsActivityType.Message, regex.ToString()]), - Selector = msg => regex.IsMatch(msg.Text ?? ""), + Selector = msg => regex.IsMatch(msg.TextWithoutMentions ?? ""), Handler = async (ctx, cancellationToken) => { await handler(ctx, cancellationToken).ConfigureAwait(false); diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivity.cs index 7805e1f0..4c855800 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivity.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Apps.Schema.Entities; using Microsoft.Teams.Bot.Core.Schema; namespace Microsoft.Teams.Bot.Apps.Schema; @@ -123,6 +124,30 @@ protected MessageActivity(CoreActivity activity) : base(activity) /// [JsonPropertyName("text")] public string? Text { get; set; } + + /// + /// Gets the message text with the bot (recipient) @mention removed and trimmed. + /// In group chats, Teams prepends "<at>botname</at>" to the text when the bot is mentioned. + /// This property strips that mention so handlers can match on the user's intent alone. + /// + [JsonIgnore] + public string? TextWithoutMentions + { + get + { + string? text = Text; + if (text is null) return null; + + foreach (MentionEntity mention in this.GetMentions()) + { + if (mention.Mentioned?.Id == Recipient?.Id && mention.Text is not null) + { + text = text.Replace(mention.Text, string.Empty, StringComparison.OrdinalIgnoreCase); + } + } + return text.Trim(); + } + } /// /// Gets or sets the text format. See for common values. /// diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs new file mode 100644 index 00000000..03253a2d --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents an OAuthCard used to initiate an OAuth sign-in flow. +/// +public class OAuthCard +{ + /// + /// The text displayed on the card. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// The OAuth connection name configured on the bot. + /// + [JsonPropertyName("connectionName")] + public string? ConnectionName { get; set; } + + /// + /// The sign-in action buttons. + /// + [JsonPropertyName("buttons")] + public IList? Buttons { get; set; } + + /// + /// The token exchange resource for SSO. + /// When present, the Teams client attempts a silent token exchange before showing the sign-in button. + /// + [JsonPropertyName("tokenExchangeResource")] + public TokenExchangeResource? TokenExchangeResource { get; set; } + + /// + /// The token post resource for posting the token back after sign-in. + /// + [JsonPropertyName("tokenPostResource")] + public TokenPostResource? TokenPostResource { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachment.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachment.cs index 6832acbe..23c79b67 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachment.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachment.cs @@ -43,6 +43,11 @@ public static class AttachmentContentType /// public const string FileInfoCard = "application/vnd.microsoft.teams.card.file.info"; + /// + /// OAuth Card content type, used for initiating OAuth sign-in flows. + /// + public const string OAuthCard = "application/vnd.microsoft.card.oauth"; + //TODO : verify these /* /// diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs index c4b2ab4f..c41938d2 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Teams.Bot.Apps.Api.Clients; +using Microsoft.Teams.Bot.Apps.Auth; using Microsoft.Teams.Bot.Apps.Handlers; using Microsoft.Teams.Bot.Apps.Routing; using Microsoft.Teams.Bot.Apps.Schema; @@ -24,6 +25,11 @@ public class TeamsBotApplication : BotApplication /// internal Router Router { get; } + /// + /// Gets the registry of OAuthFlow instances. Set by AddOAuthFlow. + /// + internal OAuthFlowRegistry? OAuthRegistry { get; set; } + /// /// Gets the client used to interact with the Teams API service. /// diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatConversations.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatConversations.cs index 715b3658..9717c93c 100644 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatConversations.cs +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatConversations.cs @@ -13,7 +13,7 @@ namespace Microsoft.Teams.Bot.Compat { /// /// Provides a compatibility adapter that bridges the Teams Bot Core to the - /// Bot Framework's interface. + /// Bot Framework's class. /// /// /// This adapter enables legacy Bot Framework bots to use the new Teams Bot Core conversation management diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs new file mode 100644 index 00000000..23a9cb6e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// If the activity name is signin/tokenExchange, this middleware will attempt to +/// exchange the token, and deduplicate the incoming call, ensuring only one +/// exchange request is processed. +/// +/// +/// This is a compatibility reimplementation of +/// Microsoft.Bot.Builder.Teams.TeamsSSOTokenExchangeMiddleware that works with +/// the Teams Bot Core SDK via . +/// +/// If a user is signed into multiple Teams clients, the Bot could receive a +/// "signin/tokenExchange" from each client. Each token exchange request for a +/// specific user login will have an identical Activity.Value.Id. +/// +/// Only one of these token exchange requests should be processed by the bot. +/// The others return . +/// For a distributed bot in production, this requires a distributed storage +/// ensuring only one token exchange is processed. This middleware supports +/// CosmosDb storage found in Microsoft.Bot.Builder.Azure, or MemoryStorage for +/// local development. IStorage's ETag implementation for token exchange activity +/// deduplication. +/// +public class CompatTeamsSSOTokenExchangeMiddleware : IMiddleware +{ + private readonly IStorage _storage; + private readonly string _oAuthConnectionName; + + /// + /// Initializes a new instance of the class. + /// + /// The to use for deduplication. + /// The connection name to use for the single sign on token exchange. + public CompatTeamsSSOTokenExchangeMiddleware(IStorage storage, string connectionName) + { + ArgumentNullException.ThrowIfNull(storage); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + _oAuthConnectionName = connectionName; + _storage = storage; + } + + /// + public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + ArgumentNullException.ThrowIfNull(next); + + if (string.Equals(Channels.Msteams, turnContext.Activity.ChannelId, StringComparison.OrdinalIgnoreCase) + && string.Equals(SignInConstants.TokenExchangeOperationName, turnContext.Activity.Name, StringComparison.OrdinalIgnoreCase)) + { + // If the TokenExchange is NOT successful, the response will have already been sent by ExchangedTokenAsync + if (!await ExchangedTokenAsync(turnContext, cancellationToken).ConfigureAwait(false)) + { + return; + } + + // Only one token exchange should proceed from here. Deduplication is performed second because in the case + // of failure due to consent required, every caller needs to receive the response. + if (!await DeduplicatedTokenExchangeIdAsync(turnContext, cancellationToken).ConfigureAwait(false)) + { + // If the token is not exchangeable, do not process this activity further. + return; + } + } + + await next(cancellationToken).ConfigureAwait(false); + } + + private async Task DeduplicatedTokenExchangeIdAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + // Create a StoreItem with Etag of the unique 'signin/tokenExchange' request + var storeItem = new TokenStoreItem + { + ETag = (turnContext.Activity.Value as JObject)!.Value("id") + }; + + var storeItems = new Dictionary { { TokenStoreItem.GetStorageKey(turnContext), storeItem } }; + try + { + // Writing the IStoreItem with ETag of unique id will succeed only once + await _storage.WriteAsync(storeItems, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + + // Memory storage throws a generic exception with a Message of 'Etag conflict. [other error info]' + // CosmosDbPartitionedStorage throws: ex.Message.Contains("pre-condition is not met") + when (ex.Message.StartsWith("Etag conflict", StringComparison.OrdinalIgnoreCase) || ex.Message.Contains("pre-condition is not met", StringComparison.OrdinalIgnoreCase)) + { + // Do NOT proceed processing this message, some other thread or machine already has processed it. + + // Send 200 invoke response. + await SendInvokeResponseAsync(turnContext, cancellationToken: cancellationToken).ConfigureAwait(false); + return false; + } + + return true; + } + + private static async Task SendInvokeResponseAsync(ITurnContext turnContext, object? body = null, HttpStatusCode httpStatusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + { + await turnContext.SendActivityAsync( + new Activity + { + Type = ActivityTypesEx.InvokeResponse, + Value = new InvokeResponse + { + Status = (int)httpStatusCode, + Body = body, + }, + }, cancellationToken).ConfigureAwait(false); + } + + private async Task ExchangedTokenAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + TokenResponse? tokenExchangeResponse = null; + var tokenExchangeRequest = ((JObject)turnContext.Activity.Value)?.ToObject(); + + try + { + var userTokenClient = turnContext.TurnState.Get(); + if (userTokenClient != null) + { + tokenExchangeResponse = await userTokenClient.ExchangeTokenAsync( + turnContext.Activity.From.Id, + _oAuthConnectionName, + turnContext.Activity.ChannelId, + new TokenExchangeRequest { Token = tokenExchangeRequest!.Token }, + cancellationToken).ConfigureAwait(false); + } + else + { + throw new NotSupportedException("Token Exchange is not supported by the current adapter."); + } + } +#pragma warning disable CA1031 // Do not catch general exception types (matching BF SDK behavior) + catch +#pragma warning restore CA1031 + { + // Ignore Exceptions + // If token exchange failed for any reason, tokenExchangeResponse above stays null, + // and hence we send back a failure invoke response to the caller. + } + + if (string.IsNullOrEmpty(tokenExchangeResponse?.Token)) + { + // The token could not be exchanged (which could be due to a consent requirement) + // Notify the sender that PreconditionFailed so they can respond accordingly. + + var invokeResponse = new TokenExchangeInvokeResponse + { + Id = tokenExchangeRequest!.Id, + ConnectionName = _oAuthConnectionName, + FailureDetail = "The bot is unable to exchange token. Proceed with regular login.", + }; + + await SendInvokeResponseAsync(turnContext, invokeResponse, HttpStatusCode.PreconditionFailed, cancellationToken).ConfigureAwait(false); + + return false; + } + + return true; + } + + private class TokenStoreItem : IStoreItem + { + public string? ETag { get; set; } + + public static string GetStorageKey(ITurnContext turnContext) + { + var activity = turnContext.Activity; + var channelId = activity.ChannelId ?? throw new InvalidOperationException("invalid activity-missing channelId"); + var conversationId = activity.Conversation?.Id ?? throw new InvalidOperationException("invalid activity-missing Conversation.Id"); + + var value = activity.Value as JObject; + if (value == null || !value.ContainsKey("id")) + { + throw new InvalidOperationException("Invalid signin/tokenExchange. Missing activity.Value.Id."); + } + + return $"{channelId}/{conversationId}/{value.Value("id")}"; + } + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs index 745a1803..c6743705 100644 --- a/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs @@ -26,6 +26,7 @@ public class BotApplication protected BotApplication() { _logger = NullLogger.Instance; + AppId = string.Empty; MiddleWare = new TurnMiddleware(); } @@ -43,6 +44,7 @@ public BotApplication(ConversationClient conversationClient, UserTokenClient use { options ??= new(); _logger = logger; + AppId = options.AppId; MiddleWare = new TurnMiddleware(); _conversationClient = conversationClient; _userTokenClient = userTokenClient; @@ -50,6 +52,11 @@ public BotApplication(ConversationClient conversationClient, UserTokenClient use } + /// + /// Gets the application (client) ID configured for this bot. + /// + public string AppId { get; } + /// /// Gets the client used to manage and interact with conversations. /// From 8d51e000043e6ce1948cd297cbdaaef604f6a445 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 21 Apr 2026 00:03:35 -0700 Subject: [PATCH 11/22] doc breaking changes --- core/docs/OAuthFlow-Design.md | 207 ++++++++++++++++++++++++++-------- 1 file changed, 162 insertions(+), 45 deletions(-) diff --git a/core/docs/OAuthFlow-Design.md b/core/docs/OAuthFlow-Design.md index e8bed2b0..63300ab1 100644 --- a/core/docs/OAuthFlow-Design.md +++ b/core/docs/OAuthFlow-Design.md @@ -67,81 +67,198 @@ OAuthFlow (Apps layer - developer-facing) ## Breaking Changes from Teams SDK v2 (Spark) -### Delegate signature: `Context` instead of typed context +This section documents every API and behavioral difference between the old `Context` (in `Microsoft.Teams.Apps`) and the new `Context` (in `Microsoft.Teams.Bot.Apps`) related to SSO/Auth. -The Teams SDK v2 `OnSignIn` callback receives a typed `IContext`. Our `SignInCompleteHandler` and `SignInFailureHandler` delegates use `Context` (the base type) because the sign-in completion can originate from three different activity types: +### 1. `context.ConnectionName` removed -- `InvokeActivity` -- SSO token exchange (`signin/tokenExchange`) -- `InvokeActivity` -- verify state (`signin/verifyState`) -- `MessageActivity` -- magic code redemption +**Old (v2)**: `Context` has a `required string ConnectionName` property that holds the app's default connection name (set during context construction, defaults to `"graph"`). `SignIn()` and `SignOut()` fall back to this when no explicit connection name is given. + +**New**: No `ConnectionName` property on context. The default connection is resolved from the `OAuthFlowRegistry` -- if a single `OAuthFlow` is registered, it is used as the default. If multiple are registered, the developer must specify the connection name per-call. ```csharp -// Teams SDK v2 -app.OnSignIn(async (plugin, @event, cancellationToken) => { - var token = @event.Token; - var context = @event.Context; // IContext -}); +// Old (v2) -- default connection baked into context +await context.SignIn(); // uses context.ConnectionName ("graph") -// OAuthFlow -graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => { - // context is Context (base type) - string token = tokenResponse.Token; -}); +// New -- resolved from OAuthFlowRegistry +bot.AddOAuthFlow("graph"); // single flow → becomes the default +await context.SignIn(); // works (single flow auto-resolves) + +// New -- multiple flows, must be explicit +bot.AddOAuthFlow("graph"); +bot.AddOAuthFlow("gh"); +await context.SignIn(new OAuthOptions { ConnectionName = "gh" }); ``` -### `IsSignedIn` is synchronous (sync-over-async) +**Migration**: Replace reads of `context.ConnectionName` with the explicit connection name in `OAuthOptions` or `SignOut(connectionName)`. -The Teams SDK v2 `context.IsSignedIn` is set by the framework during activity processing. Our `IsSignedIn` property makes a synchronous call to the token store (`GetAwaiter().GetResult()`). +### 2. `context.IsSignedIn` semantics changed -For new code, prefer the async `IsSignedInAsync(connectionName?)` method: +**Old (v2)**: `IsSignedIn` is a read/write `bool` property (`{ get; set; }`). It is set to `true` by the framework when a `signin/tokenExchange` invoke completes successfully during the current turn. It is a **per-turn flag**, not a token-store query. It reflects whether the sign-in **just happened** in this turn, not whether a token exists in the store. + +**New**: `IsSignedIn` is a read-only `bool` property that **synchronously queries the token store** (`GetAwaiter().GetResult()`). It checks whether the user has a cached token right now, regardless of what happened during this turn. It cannot be set by the developer. + +| | Old (v2) | New | +|---|---|---| +| Type | `bool { get; set; }` | `bool { get; }` | +| Source of truth | Framework sets it during the turn | Queries token store on each access | +| Async | No (already computed) | No (sync-over-async) | +| Multi-connection | N/A (one default connection) | Checks first registered flow, logs warning if multiple | +| Writable | Yes | No | + +**Recommended migration**: Use `IsSignedInAsync(connectionName?)` for async, connection-aware checks: ```csharp -// Backwards-compatible (sync, single/default connection only) -if (!context.IsSignedIn) { ... } +// Old (v2) +if (!context.IsSignedIn) { await context.SignIn(); return; } + +// New (preferred) +if (!await context.IsSignedInAsync("graph", ct)) { await context.SignIn(new OAuthOptions { ConnectionName = "graph" }, ct); return; } -// Preferred (async, connection-aware) -if (!await context.IsSignedInAsync("gh", ct)) { ... } +// New (backwards-compat, single connection only) +if (!context.IsSignedIn) { await context.SignIn(ct); return; } ``` -When multiple connections are registered, `IsSignedIn` checks the **first** registered connection and logs a warning via `Trace.TraceWarning`. +### 3. `context.UserGraphToken` removed + +**Old (v2)**: `Context` has a `JsonWebToken? UserGraphToken` property set by the framework's `OnTokenExchangeActivity` handler after a successful token exchange. It provides parsed JWT access to the Graph token (claims, expiry, etc.). + +**New**: No `UserGraphToken` property. The token is returned as a raw `string` from `SignIn()` / `GetTokenAsync()` / `OnSignInComplete`. If JWT parsing is needed, the developer must parse it themselves. + +```csharp +// Old (v2) +var graphClient = new SimpleGraphClient(context.UserGraphToken?.ToString()!); + +// New +string? token = await context.SignIn(new OAuthOptions { ConnectionName = "graph" }, ct); +var graphClient = new SimpleGraphClient(token!); +``` + +### 4. `context.SignIn(SSOOptions)` overload removed + +**Old (v2)**: Two `SignIn` overloads exist: +- `SignIn(OAuthOptions?)` -- OAuth flow via Bot Framework Token Service +- `SignIn(SSOOptions)` -- Direct SSO flow with custom scopes and sign-in link (bypasses Token Service, constructs its own `TokenExchangeResource`) + +**New**: Only `SignIn(OAuthOptions?)` is available. The SSO flow is handled transparently when the OAuth connection is configured as Azure AD v2 -- the `TokenExchangeResource` is returned by the Token Service when `MsAppId` is included in the state. + +**Migration**: Remove `SSOOptions` usage. Configure the OAuth connection in Azure Bot settings with the appropriate scopes. The `OAuthFlow` handles SSO automatically for Azure AD connections. -### `context.SignIn()` returns `Task` not `Task` +### 5. `context.SignIn()` return type is the same but semantics differ -The Teams SDK v2 `context.SignIn()` returns `Task` (void). Our `context.SignIn()` returns `Task` -- the cached token if available, or `null` if the sign-in flow was initiated: +**Old (v2)**: `SignIn(OAuthOptions?)` returns `Task`. Returns the cached token if found, otherwise sends OAuthCard and returns `null`. The `SignIn(SSOOptions)` overload returns `Task` (void). +**New**: `SignIn(OAuthOptions?)` returns `Task` with the same semantics -- token if cached, `null` if OAuthCard sent. No void overload. + +This is **API-compatible** for the `OAuthOptions` overload. Breaking only for `SSOOptions` users. + +### 6. `OnSignInComplete` callback signature + +**Old (v2)**: Sign-in success is delivered via an app-level event: ```csharp -// Teams SDK v2 -await context.SignIn(new OAuthOptions { ... }, cancellationToken); -// must check context.IsSignedIn separately - -// OAuthFlow -string? token = await context.SignIn(new OAuthOptions { ... }, ct); -if (token is not null) { /* already signed in, use token */ } -// else: OAuthCard sent, token arrives via OnSignInComplete +// Old (v2) +teams.OnSignIn(async (plugin, @event, cancellationToken) => { + var token = @event.Token; // Token.Response object + var context = @event.Context; // IContext +}); ``` -### No `OnSignInFailure` on context -- use OAuthFlow instance +**New**: Sign-in success is delivered via a per-connection callback: +```csharp +// New +graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => { + string token = tokenResponse.Token!; // GetTokenResult + // context is Context (base type) +}); +``` -The Teams SDK v2 has `app.OnSignInFailure(handler)` at the app level. In OAuthFlow, failure handlers are per-connection on the `OAuthFlow` instance: +Key differences: +- **Scope**: Old is app-level (one handler for all connections). New is per-connection. +- **Context type**: Old provides `IContext`. New provides `Context` because the sign-in can complete from invoke (tokenExchange, verifyState) or message (magic code) activities. +- **Token type**: Old provides `Token.Response` (with `ConnectionName`, `Token`, `Expiration`, `Properties`). New provides `GetTokenResult` (with `ConnectionName`, `Token`). +- **Plugin parameter**: Old receives the plugin instance. New does not -- the context has access to `TeamsBotApplication`. +### 7. `OnSignInFailure` callback signature and scope + +**Old (v2)**: App-level handler receiving the failure activity: ```csharp -// Teams SDK v2 -teams.OnSignInFailure(async (context, cancellationToken) => { ... }); +// Old (v2) +teams.OnSignInFailure(async (context, cancellationToken) => { + var failure = context.Activity.Value; // SignIn.Failure { Code, Message } + await context.Send("Sign-in failed.", cancellationToken); +}); +``` -// OAuthFlow -graphAuth.OnSignInFailure(async (context, ct) => { ... }); +**New**: Per-connection handler on the `OAuthFlow` instance: +```csharp +// New +graphAuth.OnSignInFailure(async (context, ct) => { + // context is Context + await context.SendActivityAsync("Sign-in failed.", ct); +}); ``` -### `OAuthOptions` namespace +Key differences: +- **Scope**: Per-connection instead of app-level. +- **Failure details**: Old provides `SignIn.Failure` with `Code` and `Message` via the activity value. New does not expose structured failure details (the failure is logged internally). +- **`context.Send` → `context.SendActivityAsync`**: Method name change (see below). + +### 8. `context.Send()` → `context.SendActivityAsync()` + +**Old (v2)**: `context.Send(string)` and `context.Send(T activity)`. -Teams SDK v2: `Microsoft.Teams.Apps.OAuthOptions` -OAuthFlow: `Microsoft.Teams.Bot.Apps.Auth.OAuthOptions` +**New**: `context.SendActivityAsync(string)` and `context.SendActivityAsync(TeamsActivity)`. -Same shape: `ConnectionName`, `OAuthCardText`, `SignInButtonText`. +This affects all code inside `OnSignInComplete` and `OnSignInFailure` callbacks. -### Message routing strips bot mentions +### 9. Group chat handling removed from `SignIn` -`OnMessage` pattern matching now uses `MessageActivity.TextWithoutMentions` instead of `Text`. This means `@botname help` correctly matches the pattern `^help$`. The raw `Text` property still contains the full text with mentions for handlers that need it. +**Old (v2)**: `Context.SignIn()` detects group chats (`Activity.Conversation.IsGroup == true`) and automatically creates a 1:1 conversation with the user before sending the OAuthCard, because group chats don't support SSO. + +**New**: `OAuthFlow.SignInAsync()` does not handle the group-chat-to-1:1 conversion. The OAuthCard is sent to the current conversation. For group chats, the sign-in card will show the button (no SSO), but the popup flow still works. + +**Migration**: If group chat SSO is required, the developer must create the 1:1 conversation manually before calling `context.SignIn()`. + +### 10. `OAuthOptions` namespace and defaults + +| | Old (v2) | New | +|---|---|---| +| Namespace | `Microsoft.Teams.Apps` | `Microsoft.Teams.Bot.Apps.Auth` | +| Base class | `SignInOptions` (abstract) | None (standalone class) | +| `OAuthCardText` default | `"Please Sign In..."` | `"Please Sign In"` | +| `SignInButtonText` default | `"Sign In"` | `"Sign In"` | +| `ConnectionName` | Falls back to `context.ConnectionName` | Falls back to single registered `OAuthFlow` | + +### 11. `SSOOptions` class removed + +**Old (v2)**: `SSOOptions : SignInOptions` with `required string[] Scopes` and `required string SignInLink`. + +**New**: Not available. SSO is handled automatically for Azure AD connections via the `TokenExchangeResource` mechanism. + +### 12. No `context.Next()` equivalent in auth handlers + +**Old (v2)**: `context.Next()` continues the middleware/route chain. The `OnSignIn` event handler can call `context.Next()` to continue processing. + +**New**: `OnSignInComplete` and `OnSignInFailure` are terminal callbacks, not middleware. They do not participate in the route chain. + +### Summary Table + +| Feature | Old (v2) `Microsoft.Teams.Apps` | New `Microsoft.Teams.Bot.Apps` | Breaking? | +|---|---|---|---| +| `context.ConnectionName` | `required string` property | Removed (resolved from registry) | Yes | +| `context.IsSignedIn` | `bool { get; set; }` (per-turn flag) | `bool { get; }` (queries token store) | Yes (semantic) | +| `context.UserGraphToken` | `JsonWebToken?` property | Removed | Yes | +| `context.SignIn(OAuthOptions?)` | Returns `Task` | Returns `Task` | No | +| `context.SignIn(SSOOptions)` | Returns `Task` | Removed | Yes | +| `context.SignOut(string?)` | Returns `Task` | Returns `Task` | No | +| `OnSignIn` event | App-level, `SignInEvent` | Per-connection `OnSignInComplete` | Yes | +| `OnSignInFailure` event | App-level, `SignIn.Failure` | Per-connection `OnSignInFailure` | Yes | +| `OAuthOptions` namespace | `Microsoft.Teams.Apps` | `Microsoft.Teams.Bot.Apps.Auth` | Yes | +| `SSOOptions` | Available | Removed | Yes | +| Group chat 1:1 fallback | Automatic | Manual | Yes (behavioral) | +| `context.Send()` | Available | `context.SendActivityAsync()` | Yes (rename) | +| `context.Next()` in auth | Available | Not applicable | Yes | +| `IsSignedInAsync()` | Not available | New method | N/A (addition) | +| `GetConnectionStatusAsync()` | Not available | New method | N/A (addition) | ## API Surface From 7a4502e0338e86596efa1a87bd3cf8cbb8c70d98 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 21 Apr 2026 07:38:31 -0700 Subject: [PATCH 12/22] Add Teams SSO signin/failure invoke support to OAuthFlow - Register signin/failure route and handler in OAuthFlow - Add SignInFailureValue for structured failure details - Update SignInFailureHandler to accept failure info - Log detailed warnings for client-side SSO failures - Fire OnSignInFailure on all flows for signin/failure invokes - Return HTTP 200 for signin/failure, 412 for expected token errors - Add TokenExchangeInvokeResponse for diagnostics on 412 - Remove automatic user/bot token fetch; now explicit - Clarify and implement token exchange deduplication - Update docs, edge cases, and summary tables accordingly --- core/docs/OAuthFlow-Design.md | 137 ++++++++++++++++-- .../Auth/OAuthFlow.cs | 125 +++++++++++++--- .../Auth/OAuthFlowExtensions.cs | 22 ++- .../Auth/SignInFailureValue.cs | 39 +++++ .../Auth/TokenExchangeInvokeResponse.cs | 31 ++++ .../Handlers/InvokeHandler.Activity.cs | 6 + 6 files changed, 326 insertions(+), 34 deletions(-) create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Auth/SignInFailureValue.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Auth/TokenExchangeInvokeResponse.cs diff --git a/core/docs/OAuthFlow-Design.md b/core/docs/OAuthFlow-Design.md index 63300ab1..cb849836 100644 --- a/core/docs/OAuthFlow-Design.md +++ b/core/docs/OAuthFlow-Design.md @@ -14,8 +14,9 @@ Teams SSO requires coordinating multiple moving parts: 2. Sending an OAuthCard with a `TokenExchangeResource` to trigger silent SSO 3. Handling `signin/tokenExchange` invoke activities (with deduplication) 4. Handling `signin/verifyState` invoke activities (fallback sign-in flow) -5. Handling magic codes arriving as plain messages (non-AAD providers) -6. Calling `UserTokenClient.ExchangeTokenAsync` to complete the on-behalf-of exchange +5. Handling `signin/failure` invoke activities (client-side SSO failures) +6. Handling magic codes arriving as plain messages (non-AAD providers) +7. Calling `UserTokenClient.ExchangeTokenAsync` to complete the on-behalf-of exchange Without an abstraction, every bot developer must wire this up manually. `OAuthFlow` reduces it to a few method calls. @@ -29,7 +30,8 @@ TeamsBotApplication │ ├── ... existing routes ... │ ├── message/oauth/magicCode ← registered by OAuthFlow (magic code interception) │ ├── invoke/signin/tokenExchange ← registered by OAuthFlow -│ └── invoke/signin/verifyState ← registered by OAuthFlow +│ ├── invoke/signin/verifyState ← registered by OAuthFlow +│ └── invoke/signin/failure ← registered by OAuthFlow (client-side SSO failures) └── OAuthFlow (one per connection) ├── SignInAsync() → silent token check + OAuthCard ├── SignOutAsync() → revoke token @@ -191,15 +193,19 @@ teams.OnSignInFailure(async (context, cancellationToken) => { **New**: Per-connection handler on the `OAuthFlow` instance: ```csharp // New -graphAuth.OnSignInFailure(async (context, ct) => { +graphAuth.OnSignInFailure(async (context, failure, ct) => { // context is Context - await context.SendActivityAsync("Sign-in failed.", ct); + // failure is non-null for signin/failure invokes (client-side SSO errors) + if (failure is not null) + await context.SendActivityAsync($"Sign-in failed: {failure.Code} — {failure.Message}", ct); + else + await context.SendActivityAsync("Sign-in failed.", ct); }); ``` Key differences: - **Scope**: Per-connection instead of app-level. -- **Failure details**: Old provides `SignIn.Failure` with `Code` and `Message` via the activity value. New does not expose structured failure details (the failure is logged internally). +- **Failure details**: Old provides `SignIn.Failure` with `Code` and `Message` via the activity value. New provides `SignInFailureValue?` — non-null with structured `Code`/`Message` for `signin/failure` invokes (client-side SSO errors), null for server-side token exchange or verify-state failures. - **`context.Send` → `context.SendActivityAsync`**: Method name change (see below). ### 8. `context.Send()` → `context.SendActivityAsync()` @@ -240,6 +246,71 @@ This affects all code inside `OnSignInComplete` and `OnSignInFailure` callbacks. **New**: `OnSignInComplete` and `OnSignInFailure` are terminal callbacks, not middleware. They do not participate in the route chain. +### 13. Automatic user token retrieval on every activity removed + +**Old (v2)**: `App.Process()` (App.cs:299-311) silently calls `api.Users.Token.GetAsync()` for **every** inbound activity, using `OAuth.DefaultConnectionName` (defaults to `"graph"`). If a token exists, it sets `context.IsSignedIn = true` and populates `context.UserGraphToken`. If the call fails, the exception is silently swallowed. This means `IsSignedIn` is always pre-populated by the time the developer's handler runs, even if no OAuth flow was configured. + +**New**: No automatic token retrieval. `IsSignedIn` and `GetTokenAsync` are only called when the developer explicitly invokes them. There is no implicit per-turn token check. + +**Impact**: Old code that relied on `context.IsSignedIn` being `true` on the first message (without calling `SignIn()`) must now explicitly call `await context.IsSignedInAsync()` or `await context.SignIn()` to check for a cached token. + +### 14. Bot token retrieval on startup removed + +**Old (v2)**: `App.Start()` (App.cs:130-141) eagerly calls `Api.Bots.Token.GetAsync(Credentials, TokenClient)` to obtain the bot's own access token at startup. If the call fails, it logs `"Failed to get bot token on app startup."` and continues (non-fatal). A lazy `TokenFactory` (App.cs:64-90) also refreshes the bot token on demand when it expires. + +**New**: Bot-to-service authentication is handled at the Core level (`BotApplication` / `BotConfig.ClientId`) and does not surface in the OAuthFlow layer. There is no explicit bot token fetch on startup in the Apps layer. + +**Impact**: No developer action required -- this is an internal framework change. + +### 15. No deduplication in old SDK + +**Old (v2)**: The `OnTokenExchangeActivity` handler (AppRouting.cs:69-127) has **no deduplication logic**. Every `signin/tokenExchange` invoke triggers a token exchange call to the Token Service. Duplicate exchanges from multiple Teams endpoints (mobile, desktop, web) all hit the Token Service independently. The `OnSignIn` event fires for each. + +**New**: `OAuthFlow` deduplicates `signin/tokenExchange` by exchange ID using an in-process `ConcurrentDictionary` with a 5-minute TTL. Duplicates receive a `200` no-op response without calling the Token Service or firing callbacks. + +**Impact**: Old code that observed multiple `OnSignIn` events per sign-in (one per endpoint) will now only see `OnSignInComplete` fire once (per instance). Handlers that were designed to be idempotent to tolerate duplicates will still work. + +### 16. `signin/failure` invoke handler — now registered (parity achieved) + +**Old (v2)**: `OnSignInFailureActivity` (AppRouting.cs:182-225) handles the `signin/failure` invoke sent by the Teams client when SSO fails. It parses 9 documented failure codes: +- `installappfailed`, `authrequestfailed`, `installedappnotfound`, `invokeerror`, `resourcematchfailed`, `oauthcardnotvalid`, `tokenmissing`, `userconsentrequired`, `interactionrequired` + +Each failure is logged with the user ID, conversation ID, failure code, and message. The handler returns `200` to acknowledge. The `OnSignInFailure` app-level event fires with the structured failure details. + +**New**: A `signin/failure` invoke handler is registered automatically by `AddOAuthFlow`. It logs the failure code and message (with extra guidance for `resourcematchfailed`), then fires the `OnSignInFailure` callback on **all** registered flows (since the invoke carries no connection name). The `SignInFailureHandler` delegate receives a `SignInFailureValue?` parameter containing the structured `Code` and `Message` from the Teams client. + +**Differences from v2**: +- **Scope**: Per-connection `OnSignInFailure` callback (fired on all flows) instead of a single app-level event. +- **Delegate signature**: `SignInFailureHandler(Context, SignInFailureValue?, CancellationToken)`. The `SignInFailureValue` parameter is non-null for `signin/failure` invokes and null for server-side token exchange / verify-state failures. + +### 17. Token exchange error response mapping — now matches v2 (parity achieved) + +**Old (v2)**: The `OnTokenExchangeActivity` handler (AppRouting.cs:102-127) catches `HttpException` and maps error codes selectively: +- `NotFound`, `BadRequest`, `PreconditionFailed` → responds with `PreconditionFailed` (412) and `TokenExchange.InvokeResponse` body containing `Id`, `ConnectionName`, `FailureDetail` +- All other status codes (e.g., `Unauthorized`, `Forbidden`) → responds with the **original** HTTP status code + +**New**: `OAuthFlow.HandleTokenExchangeAsync` now uses the same selective mapping: +- `NotFound`, `BadRequest`, `PreconditionFailed` (or null status code) → responds with `InvokeResponse(412)` and a `TokenExchangeInvokeResponse` body containing `Id`, `ConnectionName`, `FailureDetail` +- All other status codes → responds with the **original** HTTP status code + +**Differences from v2**: +- `FailureDetail` contains `ex.Message` (concise) instead of `ex.ToString()` (full stack trace). This avoids leaking internal implementation details in the invoke response while still providing diagnostic information. + +### 18. `signin/verifyState` error response — now matches v2 (parity achieved) + +**Old (v2)**: The `OnVerifyStateActivity` handler (AppRouting.cs:129-180): +- Missing `State` parameter → returns `NotFound` (404) with a log warning +- Token exchange failure (`NotFound`, `BadRequest`, `PreconditionFailed`) → returns `PreconditionFailed` (412) +- Other HTTP errors → returns the original status code + +**New**: `OAuthFlow.HandleVerifyStateAsync` now uses the same error mapping: +- Null invoke payload → returns `404` (at route level) +- Null `State` parameter → returns `404` with a log warning +- No token returned → returns `412` +- HTTP failure (`NotFound`, `BadRequest`, `PreconditionFailed`) → returns `412` +- Other HTTP errors → returns the original status code +- No registered flow matched → returns `404` + ### Summary Table | Feature | Old (v2) `Microsoft.Teams.Apps` | New `Microsoft.Teams.Bot.Apps` | Breaking? | @@ -259,6 +330,12 @@ This affects all code inside `OnSignInComplete` and `OnSignInFailure` callbacks. | `context.Next()` in auth | Available | Not applicable | Yes | | `IsSignedInAsync()` | Not available | New method | N/A (addition) | | `GetConnectionStatusAsync()` | Not available | New method | N/A (addition) | +| User token pre-fetch per activity | Automatic (silent, every turn) | On-demand only | Yes (behavioral) | +| Bot token fetch on startup | `App.Start()` fetches eagerly | Handled at Core level | No (internal) | +| Token exchange deduplication | None (every invoke hits Token Service) | `ConcurrentDictionary` by exchange ID, 5-min TTL | Yes (behavioral) | +| `signin/failure` invoke | App-level handler, 9 failure codes | Per-connection `OnSignInFailure` with `SignInFailureValue` | No (parity) | +| Token exchange error response | 412 + body for expected, original for others | 412 + `TokenExchangeInvokeResponse` for expected, original for others | No (parity) | +| `signin/verifyState` error response | 404 (missing state), 412 (exchange failure) | 404 (missing state), 412 (exchange failure) | No (parity) | ## API Surface @@ -276,13 +353,14 @@ public static class OAuthFlowExtensions } ``` -`AddOAuthFlow` registers three routes on the app's `Router`: +`AddOAuthFlow` registers four routes on the app's `Router`: | Route name | Activity type | Purpose | |---|---|---| | `message/oauth/magicCode` | Message (4-8 digit text) | Magic code interception for non-AAD providers | | `invoke/signin/tokenExchange` | Invoke | SSO silent token exchange | | `invoke/signin/verifyState` | Invoke | Fallback sign-in verification | +| `invoke/signin/failure` | Invoke | Teams client-side SSO failure notification | ### Context Methods @@ -346,6 +424,7 @@ public delegate Task SignInCompleteHandler( public delegate Task SignInFailureHandler( Context context, + SignInFailureValue? failure, CancellationToken cancellationToken); ``` @@ -402,8 +481,9 @@ Teams client sends invoke: signin/tokenExchange │ ├─ 4. Call UserTokenClient.ExchangeTokenAsync(userId, connectionName, channelId, token) │ ├─ Success → fire OnSignInComplete, respond InvokeResponse(200) - │ └─ Failure → fire OnSignInFailure, respond InvokeResponse(412) - │ (412 tells Teams to show the sign-in card as fallback) + │ └─ Failure → fire OnSignInFailure(context, null, ct): + │ ├─ NotFound/BadRequest/PreconditionFailed → respond 412 + TokenExchangeInvokeResponse body + │ └─ Other status codes (401, 403, etc.) → respond with original status code │ └─ 5. Record exchange Id as processed (dedup) ``` @@ -414,15 +494,19 @@ Teams client sends invoke: signin/tokenExchange Teams client sends invoke: signin/verifyState │ ├─ 1. Deserialize value → SignInVerifyStateValue { State } - │ (State is the code from the popup sign-in redirect) + │ ├─ Null payload → respond 404 + │ └─ Parsed ↓ │ ├─ 2. Try each registered OAuthFlow (verifyState has no connectionName): │ For each flow: - │ Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId, code: state) - │ ├─ Token returned → fire OnSignInComplete, respond InvokeResponse(200), stop - │ └─ No token → try next flow + │ ├─ Null State → respond 404 + │ └─ Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId, code: state) + │ ├─ Token returned → fire OnSignInComplete, respond InvokeResponse(200), stop + │ ├─ HttpException (expected) → fire OnSignInFailure, respond 412 + │ ├─ HttpException (other) → fire OnSignInFailure, respond original status code + │ └─ No token → fire OnSignInFailure, respond 412 │ - ├─ 3. If no flow succeeded → respond InvokeResponse(400) + ├─ 3. If no flow succeeded → respond 404 │ └─ Done ``` @@ -440,6 +524,23 @@ Message activity with 4-8 digit numeric text arrives └─ 2. If no flow redeemed the code → message continues to other handlers ``` +### signin/failure Invoke Handler + +``` +Teams client sends invoke: signin/failure + │ + ├─ 1. Deserialize value → SignInFailureValue { Code, Message } + │ (e.g., Code="resourcematchfailed", Message="...") + │ + ├─ 2. Log warning with user ID, conversation ID, failure code, and message. + │ Extra guidance logged for "resourcematchfailed" (check Entra app Expose an API). + │ + ├─ 3. Fire OnSignInFailure(context, failureValue, ct) on ALL registered flows + │ (no connection name in payload → notify all) + │ + └─ 4. Respond InvokeResponse(200) to acknowledge +``` + ### Deduplication Teams may send duplicate `signin/tokenExchange` invokes because the user can have multiple active endpoints (mobile, desktop, web) and Teams sends the exchange request from each one. The `OAuthFlow` deduplicates by tracking processed exchange IDs. @@ -559,6 +660,7 @@ When multiple `OAuthFlow` instances are registered, invoke routes are registered - **`signin/tokenExchange`**: dispatches by `connectionName` from the invoke value (exact match). - **`signin/verifyState`**: tries each registered flow sequentially (no connection name in the payload). +- **`signin/failure`**: fires `OnSignInFailure` on all registered flows (no connection name in the payload). - **`message/oauth/magicCode`**: tries each registered flow sequentially (magic code has no connection context). ## File Placement @@ -570,6 +672,8 @@ When multiple `OAuthFlow` instances are registered, invoke routes are registered | `OAuthOptions.cs` | `Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs` | | `SignInTokenExchangeValue.cs` | `Microsoft.Teams.Bot.Apps/Auth/SignInTokenExchangeValue.cs` | | `SignInVerifyStateValue.cs` | `Microsoft.Teams.Bot.Apps/Auth/SignInVerifyStateValue.cs` | +| `SignInFailureValue.cs` | `Microsoft.Teams.Bot.Apps/Auth/SignInFailureValue.cs` | +| `TokenExchangeInvokeResponse.cs` | `Microsoft.Teams.Bot.Apps/Auth/TokenExchangeInvokeResponse.cs` | | `OAuthCard.cs` | `Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs` | ## Changes to Core @@ -586,11 +690,12 @@ When multiple `OAuthFlow` instances are registered, invoke routes are registered | Scenario | Behavior | |---|---| | SSO not supported (channel scope) | SSO only works in personal and group chat. In channels, the OAuthCard shows the sign-in button directly (no token exchange). | -| User denies consent | Teams sends `signin/tokenExchange` but exchange fails. OAuthFlow responds 412, Teams shows sign-in button fallback. `OnSignInFailure` fires. | +| User denies consent | Teams sends `signin/tokenExchange` but exchange fails. OAuthFlow responds 412 with `TokenExchangeInvokeResponse` body, Teams shows sign-in button fallback. `OnSignInFailure` fires with `failure: null`. | +| Teams SSO client failure | Teams sends `signin/failure` invoke with structured `Code`/`Message`. OAuthFlow logs the failure, fires `OnSignInFailure` on all flows with `failure: SignInFailureValue`, responds 200. | | Duplicate `signin/tokenExchange` | Deduplicated by exchange ID. First wins, duplicates get 200 no-op. | | Token expired | `GetTokenAsync` returns null (token store returns 404). `SignInAsync` re-initiates the flow. | | Auto-discovery with multiple connections | Throws `InvalidOperationException` listing available connections. | -| `signin/verifyState` with multiple connections | Tries each registered flow until one succeeds (200). Returns 400 if none match. | +| `signin/verifyState` with multiple connections | Tries each registered flow until one succeeds (200). Returns 404 if none match. | | `IsSignedIn` with multiple connections | Checks the first registered connection, logs `Trace.TraceWarning`. Prefer `IsSignedInAsync(connectionName)`. | | Magic code in message | Intercepted by `message/oauth/magicCode` route. Tries each flow. If none redeem it, the message continues to other handlers. | | Missing `MsAppId` in sign-in state | Token Service returns `tokenExchangeResource: null`. SSO and automatic verify-state fail. OAuthFlow includes `MsAppId` from `BotApplication.AppId` to prevent this. | diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs index a5f1312b..436a7394 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs @@ -22,8 +22,11 @@ namespace Microsoft.Teams.Bot.Apps.Auth; /// Delegate invoked when an OAuth token exchange or sign-in verification fails. /// /// The activity context. +/// Optional failure details. Non-null when the failure originates from a Teams client-side +/// signin/failure invoke (contains the structured failure code and message). +/// Null when the failure is a server-side token exchange or verify-state failure. /// A cancellation token. -public delegate Task SignInFailureHandler(Context context, CancellationToken cancellationToken); +public delegate Task SignInFailureHandler(Context context, SignInFailureValue? failure, CancellationToken cancellationToken); /// /// Provides a high-level abstraction for Teams Bot SSO authentication. @@ -286,21 +289,50 @@ internal async Task HandleTokenExchangeAsync(Context HandleTokenExchangeFailureAsync( + Context context, + SignInTokenExchangeValue exchangeValue, + System.Net.HttpStatusCode? statusCode, + string? failureDetail, + CancellationToken cancellationToken) + { if (_onSignInFailure is not null) { Context baseContext = new(context.TeamsBotApplication, context.Activity); - await _onSignInFailure(baseContext, cancellationToken).ConfigureAwait(false); + await _onSignInFailure(baseContext, null, cancellationToken).ConfigureAwait(false); } - // 412 tells Teams to show the sign-in card as fallback - return new InvokeResponse(412); + // For unexpected status codes (e.g., 401 Unauthorized, 403 Forbidden), + // return the original status code so the caller can distinguish the failure. + if (statusCode.HasValue + && statusCode.Value != System.Net.HttpStatusCode.NotFound + && statusCode.Value != System.Net.HttpStatusCode.BadRequest + && statusCode.Value != System.Net.HttpStatusCode.PreconditionFailed) + { + return new InvokeResponse((int)statusCode.Value); + } + + // 412 tells Teams to show the sign-in card as fallback. + // Include a response body with the exchange ID and failure detail for diagnostics. + return new InvokeResponse(412, new TokenExchangeInvokeResponse + { + Id = exchangeValue.Id, + ConnectionName = exchangeValue.ConnectionName, + FailureDetail = failureDetail + }); } /// @@ -321,33 +353,92 @@ internal async Task HandleMagicCodeRedeemAsync(Context context, /// internal async Task HandleVerifyStateAsync(Context context, SignInVerifyStateValue verifyValue, CancellationToken cancellationToken) { + if (verifyValue.State is null) + { + _logger.LogWarning( + "Verify state: state parameter is null for conversation '{ConversationId}', user '{UserId}'.", + context.Activity.Conversation?.Id, + context.Activity.From?.Id); + return new InvokeResponse(404); + } + string userId = GetUserId(context); string channelId = GetChannelId(context); string connectionName = _connectionName ?? throw new InvalidOperationException("Connection name has not been resolved."); - GetTokenResult? tokenResult = await _app.UserTokenClient - .GetTokenAsync(userId, connectionName, channelId, code: verifyValue.State, cancellationToken: cancellationToken) - .ConfigureAwait(false); + try + { + GetTokenResult? tokenResult = await _app.UserTokenClient + .GetTokenAsync(userId, connectionName, channelId, code: verifyValue.State, cancellationToken: cancellationToken) + .ConfigureAwait(false); - if (tokenResult?.Token is not null) + if (tokenResult?.Token is not null) + { + _logger.LogDebug("Verify state succeeded for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + if (_onSignInComplete is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false); + } + return new InvokeResponse(200); + } + } + catch (HttpRequestException ex) { - _logger.LogDebug("Verify state succeeded for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); - if (_onSignInComplete is not null) + _logger.LogWarning(ex, "Verify state failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + + if (_onSignInFailure is not null) { Context baseContext = new(context.TeamsBotApplication, context.Activity); - await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false); + await _onSignInFailure(baseContext, null, cancellationToken).ConfigureAwait(false); } - return new InvokeResponse(200); + + // For unexpected status codes, return the original code + if (ex.StatusCode.HasValue + && ex.StatusCode.Value != System.Net.HttpStatusCode.NotFound + && ex.StatusCode.Value != System.Net.HttpStatusCode.BadRequest + && ex.StatusCode.Value != System.Net.HttpStatusCode.PreconditionFailed) + { + return new InvokeResponse((int)ex.StatusCode.Value); + } + + // 412 tells Teams to fall back to the sign-in card + return new InvokeResponse(412); + } + + _logger.LogWarning("Verify state failed for connection '{ConnectionName}', user '{UserId}'. No token returned.", connectionName, userId); + if (_onSignInFailure is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInFailure(baseContext, null, cancellationToken).ConfigureAwait(false); } - _logger.LogWarning("Verify state failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + // 412 tells Teams to fall back to the sign-in card + return new InvokeResponse(412); + } + + /// + /// Handles the signin/failure invoke activity sent by the Teams client when SSO fails client-side. + /// + internal async Task HandleSignInFailureAsync(Context context, SignInFailureValue failureValue, CancellationToken cancellationToken) + { + _logger.LogWarning( + "Sign-in failed for user '{UserId}' in conversation '{ConversationId}': {FailureCode} — {FailureMessage}.{Guidance}", + context.Activity.From?.Id, + context.Activity.Conversation?.Id, + failureValue.Code, + failureValue.Message, + string.Equals(failureValue.Code, "resourcematchfailed", StringComparison.OrdinalIgnoreCase) + ? " Verify that your Entra app registration has 'Expose an API' configured with the correct Application ID URI matching your OAuth connection's Token Exchange URL." + : string.Empty); + if (_onSignInFailure is not null) { Context baseContext = new(context.TeamsBotApplication, context.Activity); - await _onSignInFailure(baseContext, cancellationToken).ConfigureAwait(false); + await _onSignInFailure(baseContext, failureValue, cancellationToken).ConfigureAwait(false); } - return new InvokeResponse(400); + return new InvokeResponse(200); } private async Task ResolveConnectionNameAsync(Context context, CancellationToken cancellationToken) where TActivity : TeamsActivity diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs index 6f29246d..1ad3196b 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs @@ -131,6 +131,26 @@ private static void RegisterRoutes(TeamsBotApplication app, OAuthFlowRegistry re } }); + // signin/failure - Teams client-side SSO failure notification + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.SignInFailure), + Selector = activity => activity.Name == InvokeNames.SignInFailure, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + SignInFailureValue failureValue = typedActivity.Value ?? new SignInFailureValue(); + + // signin/failure doesn't carry a connection name, so notify all registered flows + foreach (OAuthFlow flow in registry.GetAllFlows()) + { + await flow.HandleSignInFailureAsync(ctx, failureValue, cancellationToken).ConfigureAwait(false); + } + + return new InvokeResponse(200); + } + }); + // signin/verifyState app.Router.Register(new Route { @@ -143,7 +163,7 @@ private static void RegisterRoutes(TeamsBotApplication app, OAuthFlowRegistry re if (verifyValue is null) { - return new InvokeResponse(400); + return new InvokeResponse(404); } // verifyState doesn't carry a connection name, so try each registered flow diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/SignInFailureValue.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/SignInFailureValue.cs new file mode 100644 index 00000000..4fd9d247 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/SignInFailureValue.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Auth; + +/// +/// Value payload of the signin/failure invoke activity. +/// Sent by the Teams client when SSO token exchange fails client-side. +/// +/// +/// Known failure codes: +/// +/// installappfailedFailed to install the app in the user's personal scope. +/// authrequestfailedThe SSO auth request failed after app installation. +/// installedappnotfoundThe bot app is not installed for the user or group chat. +/// invokeerrorA generic error occurred during the SSO invoke flow. +/// resourcematchfailedThe token exchange resource URI does not match the Application ID URI in the Entra app's "Expose an API" section. +/// oauthcardnotvalidThe bot's OAuthCard could not be parsed. +/// tokenmissingAAD token acquisition failed. +/// userconsentrequiredThe user needs to consent (usually handled via OAuth card fallback). +/// interactionrequiredUser interaction is required (usually handled via OAuth card fallback). +/// +/// +public class SignInFailureValue +{ + /// + /// The failure code identifying the type of SSO failure. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + + /// + /// A human-readable description of the failure. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/TokenExchangeInvokeResponse.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/TokenExchangeInvokeResponse.cs new file mode 100644 index 00000000..bdad497d --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/TokenExchangeInvokeResponse.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Auth; + +/// +/// Response body returned in the invoke response for a failed signin/tokenExchange. +/// Sent with HTTP 412 (PreconditionFailed) to tell Teams to fall back to the sign-in card. +/// +public class TokenExchangeInvokeResponse +{ + /// + /// The token exchange request ID (echoed from the invoke value). + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The OAuth connection name (echoed from the invoke value). + /// + [JsonPropertyName("connectionName")] + public string? ConnectionName { get; set; } + + /// + /// Details about why the token exchange failed. + /// + [JsonPropertyName("failureDetail")] + public string? FailureDetail { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/InvokeHandler.Activity.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/InvokeHandler.Activity.cs index 5d1a181d..09df8e97 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Handlers/InvokeHandler.Activity.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/InvokeHandler.Activity.cs @@ -153,6 +153,12 @@ public static class InvokeNames /// public const string SignInVerifyState = "signin/verifyState"; + /// + /// Sign-in failure invoke name. Sent by the Teams client when SSO token exchange + /// fails client-side (e.g., misconfigured Entra app registration). + /// + public const string SignInFailure = "signin/failure"; + /// /// Message extension anonymous query link invoke name. /// From d43e4416d8589143d6f048e003525f7a3db4ba8a Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 21 Apr 2026 09:13:38 -0700 Subject: [PATCH 13/22] Improve OAuth thread safety and nullability handling Refactored OAuth and token client classes for better thread safety and nullability. - BotSignInClient: GetResourceAsync now returns a non-nullable result. - UserTokenApiClient: Added async/await wrappers to align nullability and types, with explanatory remarks. - OAuthFlow: Added SemaphoreSlim for serialized connection name discovery, suppressed disposal warning, and improved thread safety with volatile flag and double-checked locking. - Context: Marked IsSignedIn as obsolete and documented thread-pool starvation risk, recommending async usage. --- .../Api/Clients/BotSignInClient.cs | 4 +- .../Api/Clients/UserTokenApiClient.cs | 12 ++++ .../Auth/OAuthFlow.cs | 67 +++++++++++++------ core/src/Microsoft.Teams.Bot.Apps/Context.cs | 6 ++ 4 files changed, 65 insertions(+), 24 deletions(-) diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs index 587b62f6..497561ec 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/BotSignInClient.cs @@ -31,8 +31,8 @@ internal BotSignInClient(CoreUserTokenClient client) /// /// Get the sign-in resource for a connection. /// - public Task GetResourceAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + public Task GetResourceAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) { - return _client.GetSignInResourceAsync(state, codeChallenge, emulatorUrl, finalRedirect, cancellationToken)!; + return _client.GetSignInResourceAsync(state, codeChallenge, emulatorUrl, finalRedirect, cancellationToken); } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs index 9fba87fb..18e2b24b 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/Clients/UserTokenApiClient.cs @@ -31,6 +31,10 @@ internal UserTokenApiClient(CoreUserTokenClient client) /// /// Get AAD tokens for specified resources. /// + /// + /// The async/await here bridges the nullability difference between the core client + /// (non-nullable return) and this API surface (nullable return). + /// public async Task?> GetAadAsync(string userId, string connectionName, string channelId, IList? resourceUrls = null, CancellationToken cancellationToken = default) { return await _client.GetAadTokensAsync(userId, connectionName, channelId, resourceUrls?.ToArray(), cancellationToken).ConfigureAwait(false); @@ -39,6 +43,10 @@ internal UserTokenApiClient(CoreUserTokenClient client) /// /// Get the token status for a user's connections. /// + /// + /// The async/await here bridges the type difference between the core client + /// (GetTokenStatusResult[]) and this API surface (IList, nullable). + /// public async Task?> GetStatusAsync(string userId, string channelId, string? includeFilter = null, CancellationToken cancellationToken = default) { return await _client.GetTokenStatusAsync(userId, channelId, includeFilter, cancellationToken).ConfigureAwait(false); @@ -55,6 +63,10 @@ public Task SignOutAsync(string userId, string connectionName, string channelId, /// /// Exchange a token for another token. /// + /// + /// The async/await here bridges the nullability difference between the core client + /// (non-nullable return) and this API surface (nullable return). + /// public async Task ExchangeAsync(string userId, string connectionName, string channelId, string exchangeToken, CancellationToken cancellationToken = default) { return await _client.ExchangeTokenAsync(userId, connectionName, channelId, exchangeToken, cancellationToken).ConfigureAwait(false); diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs index 436a7394..656e223c 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs @@ -32,12 +32,19 @@ namespace Microsoft.Teams.Bot.Apps.Auth; /// Provides a high-level abstraction for Teams Bot SSO authentication. /// Encapsulates silent token acquisition, SSO token exchange, fallback sign-in, and sign-out. /// +/// +/// This class owns a for connection name auto-discovery, +/// but is not disposable because it lives for the lifetime of the application. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", + Justification = "SemaphoreSlim lives for the app lifetime and does not need explicit disposal.")] public class OAuthFlow { private readonly TeamsBotApplication _app; private readonly ILogger _logger; private string? _connectionName; - private bool _connectionResolved; + private volatile bool _connectionResolved; + private readonly SemaphoreSlim _connectionResolveLock = new(1, 1); private SignInCompleteHandler? _onSignInComplete; private SignInFailureHandler? _onSignInFailure; @@ -443,37 +450,53 @@ internal async Task HandleSignInFailureAsync(Context ResolveConnectionNameAsync(Context context, CancellationToken cancellationToken) where TActivity : TeamsActivity { + // Fast path: already resolved (volatile read ensures visibility across cores) if (_connectionResolved && _connectionName is not null) { return _connectionName; } - // Auto-discover: call GetTokenStatus to find configured connections - string userId = GetUserId(context); - string channelId = GetChannelId(context); + // Serialize auto-discovery so only one concurrent call hits the Token Service + await _connectionResolveLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Double-check after acquiring lock + if (_connectionResolved && _connectionName is not null) + { + return _connectionName; + } - GetTokenStatusResult[] statuses = await _app.UserTokenClient - .GetTokenStatusAsync(userId, channelId, cancellationToken: cancellationToken) - .ConfigureAwait(false); + // Auto-discover: call GetTokenStatus to find configured connections + string userId = GetUserId(context); + string channelId = GetChannelId(context); - if (statuses.Length == 0) - { - throw new InvalidOperationException("No OAuth connections are configured on this bot. Configure an OAuth connection in the Azure Bot resource settings."); - } + GetTokenStatusResult[] statuses = await _app.UserTokenClient + .GetTokenStatusAsync(userId, channelId, cancellationToken: cancellationToken) + .ConfigureAwait(false); - if (statuses.Length > 1) - { - string connectionNames = string.Join(", ", statuses.Select(s => $"'{s.ConnectionName}'")); - throw new InvalidOperationException( - $"Multiple OAuth connections found: {connectionNames}. " + - $"Specify the connection name explicitly when calling AddOAuthFlow(connectionName)."); - } + if (statuses.Length == 0) + { + throw new InvalidOperationException("No OAuth connections are configured on this bot. Configure an OAuth connection in the Azure Bot resource settings."); + } + + if (statuses.Length > 1) + { + string connectionNames = string.Join(", ", statuses.Select(s => $"'{s.ConnectionName}'")); + throw new InvalidOperationException( + $"Multiple OAuth connections found: {connectionNames}. " + + $"Specify the connection name explicitly when calling AddOAuthFlow(connectionName)."); + } - _connectionName = statuses[0].ConnectionName ?? throw new InvalidOperationException("The configured OAuth connection has no name."); - _connectionResolved = true; - _logger.LogDebug("Auto-discovered OAuth connection: '{ConnectionName}'.", _connectionName); + _connectionName = statuses[0].ConnectionName ?? throw new InvalidOperationException("The configured OAuth connection has no name."); + _connectionResolved = true; + _logger.LogDebug("Auto-discovered OAuth connection: '{ConnectionName}'.", _connectionName); - return _connectionName; + return _connectionName; + } + finally + { + _connectionResolveLock.Release(); + } } private void CleanupExpiredExchanges() diff --git a/core/src/Microsoft.Teams.Bot.Apps/Context.cs b/core/src/Microsoft.Teams.Bot.Apps/Context.cs index c175c7dc..4f97e111 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Context.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Context.cs @@ -105,6 +105,12 @@ public Task SignOut(string? connectionName = null, CancellationToken cancellatio /// prefer with an explicit connection name instead. /// Returns false if no OAuthFlow is registered. /// + /// + /// This property blocks the calling thread (sync-over-async) while querying + /// the Bot Framework Token Service. Under high concurrency this can cause + /// thread-pool starvation. Prefer in new code. + /// + [Obsolete("Use IsSignedInAsync() instead. This property blocks the calling thread and can cause thread-pool starvation under load.")] public bool IsSignedIn { get From 2ffd5766cf4c2af70cd898efd114a60cb1ba1b0e Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 21 Apr 2026 12:07:59 -0700 Subject: [PATCH 14/22] add another sample --- core/core.slnx | 1 + core/docs/OAuthFlow-Design.md | 40 ++---- core/samples/SsoBot/Program.cs | 135 ++++++++++++++++++ core/samples/SsoBot/SsoBot.csproj | 13 ++ core/samples/SsoBot/appsettings.json | 9 ++ .../Auth/OAuthFlow.cs | 47 +----- .../Auth/OAuthFlowExtensions.cs | 40 ------ 7 files changed, 173 insertions(+), 112 deletions(-) create mode 100644 core/samples/SsoBot/Program.cs create mode 100644 core/samples/SsoBot/SsoBot.csproj create mode 100644 core/samples/SsoBot/appsettings.json diff --git a/core/core.slnx b/core/core.slnx index 5c1e8a7c..c726cc7c 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -20,6 +20,7 @@ + diff --git a/core/docs/OAuthFlow-Design.md b/core/docs/OAuthFlow-Design.md index cb849836..f5bbd1b5 100644 --- a/core/docs/OAuthFlow-Design.md +++ b/core/docs/OAuthFlow-Design.md @@ -15,8 +15,7 @@ Teams SSO requires coordinating multiple moving parts: 3. Handling `signin/tokenExchange` invoke activities (with deduplication) 4. Handling `signin/verifyState` invoke activities (fallback sign-in flow) 5. Handling `signin/failure` invoke activities (client-side SSO failures) -6. Handling magic codes arriving as plain messages (non-AAD providers) -7. Calling `UserTokenClient.ExchangeTokenAsync` to complete the on-behalf-of exchange +6. Calling `UserTokenClient.ExchangeTokenAsync` to complete the on-behalf-of exchange Without an abstraction, every bot developer must wire this up manually. `OAuthFlow` reduces it to a few method calls. @@ -28,7 +27,6 @@ TeamsBotApplication ├── OAuthRegistry ← holds all OAuthFlow instances ├── Router │ ├── ... existing routes ... -│ ├── message/oauth/magicCode ← registered by OAuthFlow (magic code interception) │ ├── invoke/signin/tokenExchange ← registered by OAuthFlow │ ├── invoke/signin/verifyState ← registered by OAuthFlow │ └── invoke/signin/failure ← registered by OAuthFlow (client-side SSO failures) @@ -175,7 +173,7 @@ graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => { Key differences: - **Scope**: Old is app-level (one handler for all connections). New is per-connection. -- **Context type**: Old provides `IContext`. New provides `Context` because the sign-in can complete from invoke (tokenExchange, verifyState) or message (magic code) activities. +- **Context type**: Old provides `IContext`. New provides `Context` because the sign-in can complete from invoke (tokenExchange, verifyState) activities. - **Token type**: Old provides `Token.Response` (with `ConnectionName`, `Token`, `Expiration`, `Properties`). New provides `GetTokenResult` (with `ConnectionName`, `Token`). - **Plugin parameter**: Old receives the plugin instance. New does not -- the context has access to `TeamsBotApplication`. @@ -353,11 +351,10 @@ public static class OAuthFlowExtensions } ``` -`AddOAuthFlow` registers four routes on the app's `Router`: +`AddOAuthFlow` registers three routes on the app's `Router`: | Route name | Activity type | Purpose | |---|---|---| -| `message/oauth/magicCode` | Message (4-8 digit text) | Magic code interception for non-AAD providers | | `invoke/signin/tokenExchange` | Invoke | SSO silent token exchange | | `invoke/signin/verifyState` | Invoke | Fallback sign-in verification | | `invoke/signin/failure` | Invoke | Teams client-side SSO failure notification | @@ -435,19 +432,15 @@ public delegate Task SignInFailureHandler( ``` Developer calls context.SignIn(options) or oauth.SignInAsync(context) │ - ├─ 1. Check if message text is a magic code (4-8 digits) - │ ├─ Yes → call GetTokenAsync(code) → return token if redeemed - │ └─ No ↓ - │ - ├─ 2. Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId) + ├─ 1. Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId) │ ├─ Token exists → return token string │ └─ No token ↓ │ - ├─ 3. Build token exchange state with MsAppId (from BotApplication.AppId) + ├─ 2. Build token exchange state with MsAppId (from BotApplication.AppId) │ Call UserTokenClient.GetSignInResourceAsync(state) │ Returns: SignInLink, TokenExchangeResource, TokenPostResource │ - ├─ 4. Build OAuthCard attachment (serialized as JsonElement for AOT compat): + ├─ 3. Build OAuthCard attachment (serialized as JsonElement for AOT compat): │ { │ contentType: "application/vnd.microsoft.card.oauth", │ content: { @@ -459,9 +452,9 @@ Developer calls context.SignIn(options) or oauth.SignInAsync(context) │ } │ } │ - ├─ 5. Send activity with OAuthCard attachment + ├─ 4. Send activity with OAuthCard attachment │ - └─ 6. Return null (sign-in pending) + └─ 5. Return null (sign-in pending) ``` **Critical**: The state must include `MsAppId` (from `BotApplication.AppId`, sourced from `BotConfig.ClientId`). Without it, the Token Service returns `tokenExchangeResource: null` and Teams cannot perform SSO or automatic verify-state after popup sign-in. @@ -511,19 +504,6 @@ Teams client sends invoke: signin/verifyState └─ Done ``` -### Magic Code Message Handler - -``` -Message activity with 4-8 digit numeric text arrives - │ - ├─ 1. Try each registered OAuthFlow: - │ Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId, code: text) - │ ├─ Token returned → fire OnSignInComplete via HandleMagicCodeRedeemAsync, stop - │ └─ No token → try next flow - │ - └─ 2. If no flow redeemed the code → message continues to other handlers -``` - ### signin/failure Invoke Handler ``` @@ -661,7 +641,6 @@ When multiple `OAuthFlow` instances are registered, invoke routes are registered - **`signin/tokenExchange`**: dispatches by `connectionName` from the invoke value (exact match). - **`signin/verifyState`**: tries each registered flow sequentially (no connection name in the payload). - **`signin/failure`**: fires `OnSignInFailure` on all registered flows (no connection name in the payload). -- **`message/oauth/magicCode`**: tries each registered flow sequentially (magic code has no connection context). ## File Placement @@ -697,7 +676,6 @@ When multiple `OAuthFlow` instances are registered, invoke routes are registered | Auto-discovery with multiple connections | Throws `InvalidOperationException` listing available connections. | | `signin/verifyState` with multiple connections | Tries each registered flow until one succeeds (200). Returns 404 if none match. | | `IsSignedIn` with multiple connections | Checks the first registered connection, logs `Trace.TraceWarning`. Prefer `IsSignedInAsync(connectionName)`. | -| Magic code in message | Intercepted by `message/oauth/magicCode` route. Tries each flow. If none redeem it, the message continues to other handlers. | | Missing `MsAppId` in sign-in state | Token Service returns `tokenExchangeResource: null`. SSO and automatic verify-state fail. OAuthFlow includes `MsAppId` from `BotApplication.AppId` to prevent this. | -| Non-AAD providers (GitHub, etc.) | No `tokenExchangeResource` returned regardless of `MsAppId`. Sign-in completes via popup + `signin/verifyState` or magic code. | +| Non-AAD providers (GitHub, etc.) | No `tokenExchangeResource` returned regardless of `MsAppId`. Sign-in completes via popup + `signin/verifyState`. | | OAuthCard JSON serialization | `OAuthCard` is serialized to `JsonElement` before attaching, to avoid `NotSupportedException` from the source-generated `TeamsActivityJsonContext`. | diff --git a/core/samples/SsoBot/Program.cs b/core/samples/SsoBot/Program.cs new file mode 100644 index 00000000..b7db280b --- /dev/null +++ b/core/samples/SsoBot/Program.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates Teams SSO using the context-level API with a single OAuth connection. +// The context API is the simplest way to add authentication -- when only one OAuthFlow is registered, +// context.SignIn() and context.SignOut() automatically resolve to it without specifying a connection name. +// +// Azure Bot resource must have one OAuth connection setting configured: +// | Connection name | Provider | Scopes | +// |-------------------|-------------|--------------------------| +// | GraphConnection | Azure AD v2 | User.Read Calendars.Read | + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Auth; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication bot = webApp.UseTeamsBotApplication(); + +// Register a single OAuthFlow -- this becomes the default for all context.SignIn/SignOut calls +OAuthFlow auth = bot.AddOAuthFlow("sso"); + +auth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync("You're now signed in! Try `profile` or `calendar`.", ct); +}); + +auth.OnSignInFailure(async (context, failure, ct) => +{ + string message = failure is not null + ? $"Sign-in failed: {failure.Code} — {failure.Message}" + : "Sign-in failed. Please try again."; + await context.SendActivityAsync(message, ct); +}); + +// ==================== MESSAGE HANDLERS ==================== + +bot.OnMessage("(?i)^login$", async (context, ct) => +{ + // context.SignIn() resolves to the single registered OAuthFlow automatically + string? token = await context.SignIn(cancellationToken: ct); + if (token is not null) + { + await context.SendActivityAsync("You're already signed in.", ct); + } + // else: OAuthCard sent, SSO flow in progress -- OnSignInComplete will fire +}); + +bot.OnMessage("(?i)^profile$", async (context, ct) => +{ + // SignIn doubles as "get token if cached, else start sign-in" + string? token = await context.SignIn(cancellationToken: ct); + if (token is null) return; // sign-in card sent, wait for completion + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + + try + { + string json = await http.GetStringAsync("https://graph.microsoft.com/v1.0/me", ct); + await context.SendActivityAsync($"```json\n{json}\n```", ct); + } + catch (HttpRequestException ex) + { + await context.SendActivityAsync($"Graph call failed: {ex.Message}", ct); + } +}); + +bot.OnMessage("(?i)^calendar$", async (context, ct) => +{ + string? token = await context.SignIn(cancellationToken: ct); + if (token is null) return; + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + + try + { + string json = await http.GetStringAsync( + "https://graph.microsoft.com/v1.0/me/events?$top=3&$select=subject,start,end&$orderby=start/dateTime", ct); + await context.SendActivityAsync($"```json\n{json}\n```", ct); + } + catch (HttpRequestException ex) + { + await context.SendActivityAsync($"Graph call failed: {ex.Message}", ct); + } +}); + +bot.OnMessage("(?i)^logout$", async (context, ct) => +{ + await context.SignOut(cancellationToken: ct); + await context.SendActivityAsync("Signed out.", ct); +}); + +bot.OnMessage("(?i)^status$", async (context, ct) => +{ + bool signedIn = await context.IsSignedInAsync(cancellationToken: ct); + await context.SendActivityAsync(signedIn ? "Signed in." : "Not signed in.", ct); +}); + +bot.OnMessage("(?i)^help$", async (context, ct) => +{ + string helpText = """ + **SSO Bot** - Single-connection SSO sample + + Commands: + - `login` - Sign in with SSO + - `profile` - Get your Azure AD profile (signs in if needed) + - `calendar` - Get your next 3 calendar events (signs in if needed) + - `status` - Check sign-in status + - `logout` - Sign out + - `help` - Show this message + """; + + await context.SendActivityAsync( + new MessageActivity(helpText) { TextFormat = TextFormats.Markdown }, ct); +}); + +// ==================== INSTALL HANDLER ==================== + +bot.OnInstall(async (context, ct) => +{ + await context.SendActivityAsync( + new MessageActivity("Welcome to **SSO Bot**! Type `help` to see available commands.") + { + TextFormat = TextFormats.Markdown + }, ct); +}); + +webApp.Run(); diff --git a/core/samples/SsoBot/SsoBot.csproj b/core/samples/SsoBot/SsoBot.csproj new file mode 100644 index 00000000..965b246f --- /dev/null +++ b/core/samples/SsoBot/SsoBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/SsoBot/appsettings.json b/core/samples/SsoBot/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/core/samples/SsoBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs index 656e223c..af08a345 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs @@ -11,9 +11,9 @@ namespace Microsoft.Teams.Bot.Apps.Auth; /// -/// Delegate invoked after a successful OAuth token exchange, sign-in verification, or magic code redemption. +/// Delegate invoked after a successful OAuth token exchange or sign-in verification. /// -/// The activity context. May be an invoke context (SSO/verifyState) or a message context (magic code). +/// The activity context (invoke context from SSO or verifyState). /// The token result containing the access token and connection name. /// A cancellation token. public delegate Task SignInCompleteHandler(Context context, GetTokenResult tokenResponse, CancellationToken cancellationToken); @@ -131,23 +131,7 @@ public OAuthFlow OnSignInFailure(SignInFailureHandler handler) string userId = GetUserId(context); string channelId = GetChannelId(context); - // 1. Check if the message text is a magic code (fallback sign-in for non-AAD providers) - string? messageText = context.Activity.Properties.TryGetValue("text", out object? textObj) ? textObj?.ToString()?.Trim() : null; - if (IsMagicCode(messageText)) - { - _logger.LogDebug("Detected magic code in message text for connection '{ConnectionName}'. Attempting to redeem.", connectionName); - GetTokenResult? codeToken = await _app.UserTokenClient - .GetTokenAsync(userId, connectionName, channelId, code: messageText, cancellationToken: cancellationToken) - .ConfigureAwait(false); - - if (codeToken?.Token is not null) - { - _logger.LogDebug("Magic code redeemed successfully for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); - return codeToken.Token; - } - } - - // 2. Try silent token acquisition + // 1. Try silent token acquisition GetTokenResult? existingToken = await _app.UserTokenClient.GetTokenAsync(userId, connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); if (existingToken?.Token is not null) { @@ -155,7 +139,7 @@ public OAuthFlow OnSignInFailure(SignInFailureHandler handler) return existingToken.Token; } - // 3. No token - get sign-in resource and send OAuthCard + // 2. No token - get sign-in resource and send OAuthCard _logger.LogDebug("No cached token for connection '{ConnectionName}'. Initiating sign-in flow.", connectionName); // Build state with MsAppId so the Token Service returns TokenExchangeResource for SSO @@ -201,7 +185,7 @@ public OAuthFlow OnSignInFailure(SignInFailureHandler handler) TeamsActivity oauthActivity = TeamsActivity.CreateBuilder() .WithConversationReference(context.Activity) - //.WithRecipient(context.Activity.From, true) + .WithRecipient(context.Activity.From, true) .WithAttachment(attachment) .Build(); @@ -343,20 +327,7 @@ private async Task HandleTokenExchangeFailureAsync( } /// - /// Handles a magic code redeemed from a message activity (non-AAD provider fallback). - /// - internal async Task HandleMagicCodeRedeemAsync(Context context, GetTokenResult tokenResult, CancellationToken cancellationToken) - { - _logger.LogDebug("Magic code redeemed for connection '{ConnectionName}', user '{UserId}'.", tokenResult.ConnectionName, context.Activity.From?.Id); - if (_onSignInComplete is not null) - { - Context baseContext = new(context.TeamsBotApplication, context.Activity); - await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false); - } - } - - /// - /// Handles the signin/verifyState invoke activity (fallback magic-code flow). + /// Handles the signin/verifyState invoke activity. /// internal async Task HandleVerifyStateAsync(Context context, SignInVerifyStateValue verifyValue, CancellationToken cancellationToken) { @@ -517,10 +488,4 @@ private static string GetUserId(Context context) where TAc private static string GetChannelId(Context context) where TActivity : TeamsActivity => context.Activity.ChannelId ?? throw new InvalidOperationException("Activity.ChannelId is required for OAuth operations."); - /// - /// Magic codes are 4-8 digit numeric strings sent by the user after completing - /// OAuth sign-in in a popup (fallback flow for non-AAD providers like GitHub). - /// - private static bool IsMagicCode([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? text) - => text is not null && text.Length is >= 4 and <= 8 && text.All(char.IsAsciiDigit); } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs index 1ad3196b..842b0a1c 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Bot.Apps.Handlers; @@ -74,38 +72,6 @@ private static OAuthFlowRegistry GetOrCreateRegistry(TeamsBotApplication app) private static void RegisterRoutes(TeamsBotApplication app, OAuthFlowRegistry registry) { - // Magic code handler: intercepts numeric messages (4-8 digits) that may be OAuth magic codes - // from the fallback sign-in flow (non-AAD providers like GitHub). - // Registered as a message route so it runs alongside other matching message handlers. - app.Router.Register(new Route - { - Name = "message/oauth/magicCode", - Selector = msg => IsMagicCode(msg.Text), - Handler = async (ctx, cancellationToken) => - { - string code = ctx.Activity.Text!.Trim(); - string userId = ctx.Activity.From?.Id ?? throw new InvalidOperationException("Activity.From.Id is required."); - string channelId = ctx.Activity.ChannelId ?? throw new InvalidOperationException("Activity.ChannelId is required."); - - // Try each registered flow to see which one can redeem the code - foreach (OAuthFlow flow in registry.GetAllFlows()) - { - string? connectionName = flow.ConnectionName; - if (connectionName is null) continue; - - GetTokenResult? tokenResult = await app.UserTokenClient - .GetTokenAsync(userId, connectionName, channelId, code: code, cancellationToken: cancellationToken) - .ConfigureAwait(false); - - if (tokenResult?.Token is not null) - { - await flow.HandleMagicCodeRedeemAsync(ctx, tokenResult, cancellationToken).ConfigureAwait(false); - return; - } - } - } - }); - // signin/tokenExchange app.Router.Register(new Route { @@ -187,12 +153,6 @@ private static NullLogger GetLogger(TeamsBotApplication app) _ = app; // Reserved for future use (e.g., resolving ILoggerFactory from DI) return NullLogger.Instance; } - - private static bool IsMagicCode([NotNullWhen(true)] string? text) - { - string? trimmed = text?.Trim(); - return trimmed is not null && trimmed.Length is >= 4 and <= 8 && trimmed.All(char.IsAsciiDigit); - } } /// From 1c503584a7de717838599dc6867d6f3ca52ec802 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 21 Apr 2026 17:04:15 -0700 Subject: [PATCH 15/22] remove autodiscovery --- core/docs/OAuthFlow-Design.md | 20 +--- .../Auth/OAuthFlow.cs | 91 +++---------------- .../Auth/OAuthFlowExtensions.cs | 67 ++------------ core/src/Microsoft.Teams.Bot.Apps/Context.cs | 17 +++- 4 files changed, 39 insertions(+), 156 deletions(-) diff --git a/core/docs/OAuthFlow-Design.md b/core/docs/OAuthFlow-Design.md index f5bbd1b5..52028d30 100644 --- a/core/docs/OAuthFlow-Design.md +++ b/core/docs/OAuthFlow-Design.md @@ -83,7 +83,7 @@ await context.SignIn(); // uses context.ConnectionName ("graph") bot.AddOAuthFlow("graph"); // single flow → becomes the default await context.SignIn(); // works (single flow auto-resolves) -// New -- multiple flows, must be explicit +// New -- multiple flows, must specify connection bot.AddOAuthFlow("graph"); bot.AddOAuthFlow("gh"); await context.SignIn(new OAuthOptions { ConnectionName = "gh" }); @@ -344,10 +344,6 @@ public static class OAuthFlowExtensions { /// Register an OAuthFlow with an explicit connection name. public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connectionName); - - /// Register an OAuthFlow that auto-discovers the connection name - /// via GetTokenStatus on first use. - public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app); } ``` @@ -386,7 +382,7 @@ public class Context where TActivity : TeamsActivity ```csharp public class OAuthFlow { - public string? ConnectionName { get; } + public string ConnectionName { get; } public Task GetTokenAsync(Context context, CancellationToken ct = default); public Task SignInAsync(Context context, CancellationToken ct = default); @@ -541,16 +537,6 @@ bot.AddOAuthFlow("GraphConnection", options => Until this is implemented, multi-instance deployments should be aware that `OnSignInComplete` may fire on more than one instance for the same sign-in. Handlers should be idempotent. -### Auto-Discovery (no connection name) - -When `AddOAuthFlow()` is called without a connection name: - -1. On first call to `SignInAsync` / `GetTokenAsync` / `IsSignedInAsync`, calls `UserTokenClient.GetTokenStatusAsync(userId, channelId)`. -2. `GetTokenStatus` returns **all** configured OAuth connections on the bot (regardless of whether the user has a token). -3. If exactly one connection exists, uses it automatically. -4. If multiple connections exist, throws `InvalidOperationException` with a message listing the available connections and asking the developer to specify one. -5. The resolved connection name is cached for subsequent calls. - ## Multi-Connection Sample A bot that uses **two** OAuth connections: one for Microsoft Graph and one for GitHub. @@ -673,7 +659,7 @@ When multiple `OAuthFlow` instances are registered, invoke routes are registered | Teams SSO client failure | Teams sends `signin/failure` invoke with structured `Code`/`Message`. OAuthFlow logs the failure, fires `OnSignInFailure` on all flows with `failure: SignInFailureValue`, responds 200. | | Duplicate `signin/tokenExchange` | Deduplicated by exchange ID. First wins, duplicates get 200 no-op. | | Token expired | `GetTokenAsync` returns null (token store returns 404). `SignInAsync` re-initiates the flow. | -| Auto-discovery with multiple connections | Throws `InvalidOperationException` listing available connections. | +| Missing connection name with multiple flows | Throws `InvalidOperationException` listing registered connections. | | `signin/verifyState` with multiple connections | Tries each registered flow until one succeeds (200). Returns 404 if none match. | | `IsSignedIn` with multiple connections | Checks the first registered connection, logs `Trace.TraceWarning`. Prefer `IsSignedInAsync(connectionName)`. | | Missing `MsAppId` in sign-in state | Token Service returns `tokenExchangeResource: null`. SSO and automatic verify-state fail. OAuthFlow includes `MsAppId` from `BotApplication.AppId` to prevent this. | diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs index af08a345..b4ebdb14 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs @@ -32,19 +32,11 @@ namespace Microsoft.Teams.Bot.Apps.Auth; /// Provides a high-level abstraction for Teams Bot SSO authentication. /// Encapsulates silent token acquisition, SSO token exchange, fallback sign-in, and sign-out. /// -/// -/// This class owns a for connection name auto-discovery, -/// but is not disposable because it lives for the lifetime of the application. -/// -[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", - Justification = "SemaphoreSlim lives for the app lifetime and does not need explicit disposal.")] public class OAuthFlow { private readonly TeamsBotApplication _app; private readonly ILogger _logger; - private string? _connectionName; - private volatile bool _connectionResolved; - private readonly SemaphoreSlim _connectionResolveLock = new(1, 1); + private readonly string _connectionName; private SignInCompleteHandler? _onSignInComplete; private SignInFailureHandler? _onSignInFailure; @@ -52,18 +44,17 @@ public class OAuthFlow // Teams may send duplicates from multiple endpoints (mobile, desktop, web). private readonly ConcurrentDictionary _processedExchanges = new(); - internal OAuthFlow(TeamsBotApplication app, string? connectionName, ILogger logger) + internal OAuthFlow(TeamsBotApplication app, string connectionName, ILogger logger) { _app = app; _connectionName = connectionName; - _connectionResolved = connectionName is not null; _logger = logger; } /// - /// The OAuth connection name. Null until resolved when using auto-discovery mode. + /// The OAuth connection name. /// - public string? ConnectionName => _connectionName; + public string ConnectionName => _connectionName; /// /// Register a callback invoked after a successful token exchange (SSO or fallback sign-in). @@ -97,11 +88,10 @@ public OAuthFlow OnSignInFailure(SignInFailureHandler handler) public async Task GetTokenAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity { ArgumentNullException.ThrowIfNull(context); - string connectionName = await ResolveConnectionNameAsync(context, cancellationToken).ConfigureAwait(false); string userId = GetUserId(context); string channelId = GetChannelId(context); - GetTokenResult? result = await _app.UserTokenClient.GetTokenAsync(userId, connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); + GetTokenResult? result = await _app.UserTokenClient.GetTokenAsync(userId, _connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); return result?.Token; } @@ -127,25 +117,24 @@ public OAuthFlow OnSignInFailure(SignInFailureHandler handler) { ArgumentNullException.ThrowIfNull(context); options ??= new OAuthOptions(); - string connectionName = await ResolveConnectionNameAsync(context, cancellationToken).ConfigureAwait(false); string userId = GetUserId(context); string channelId = GetChannelId(context); // 1. Try silent token acquisition - GetTokenResult? existingToken = await _app.UserTokenClient.GetTokenAsync(userId, connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); + GetTokenResult? existingToken = await _app.UserTokenClient.GetTokenAsync(userId, _connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); if (existingToken?.Token is not null) { - _logger.LogDebug("Token found in store for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + _logger.LogDebug("Token found in store for connection '{ConnectionName}', user '{UserId}'.", _connectionName, userId); return existingToken.Token; } // 2. No token - get sign-in resource and send OAuthCard - _logger.LogDebug("No cached token for connection '{ConnectionName}'. Initiating sign-in flow.", connectionName); + _logger.LogDebug("No cached token for connection '{ConnectionName}'. Initiating sign-in flow.", _connectionName); // Build state with MsAppId so the Token Service returns TokenExchangeResource for SSO var tokenExchangeState = new { - ConnectionName = connectionName, + ConnectionName = _connectionName, Conversation = new { ActivityId = context.Activity.Id, @@ -166,7 +155,7 @@ public OAuthFlow OnSignInFailure(SignInFailureHandler handler) OAuthCard oauthCard = new() { Text = options.OAuthCardText, - ConnectionName = connectionName, + ConnectionName = _connectionName, Buttons = [ new SuggestedAction(ActionType.SignIn, options.SignInButtonText) { Value = signInResource.SignInLink } @@ -202,12 +191,11 @@ public OAuthFlow OnSignInFailure(SignInFailureHandler handler) public async Task SignOutAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity { ArgumentNullException.ThrowIfNull(context); - string connectionName = await ResolveConnectionNameAsync(context, cancellationToken).ConfigureAwait(false); string userId = GetUserId(context); string channelId = GetChannelId(context); - _logger.LogDebug("Signing out user '{UserId}' from connection '{ConnectionName}'.", userId, connectionName); - await _app.UserTokenClient.SignOutUserAsync(userId, connectionName, channelId, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Signing out user '{UserId}' from connection '{ConnectionName}'.", userId, _connectionName); + await _app.UserTokenClient.SignOutUserAsync(userId, _connectionName, channelId, cancellationToken).ConfigureAwait(false); } /// @@ -259,7 +247,7 @@ internal async Task HandleTokenExchangeAsync(Context HandleVerifyStateAsync(Context HandleSignInFailureAsync(Context ResolveConnectionNameAsync(Context context, CancellationToken cancellationToken) where TActivity : TeamsActivity - { - // Fast path: already resolved (volatile read ensures visibility across cores) - if (_connectionResolved && _connectionName is not null) - { - return _connectionName; - } - - // Serialize auto-discovery so only one concurrent call hits the Token Service - await _connectionResolveLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - // Double-check after acquiring lock - if (_connectionResolved && _connectionName is not null) - { - return _connectionName; - } - - // Auto-discover: call GetTokenStatus to find configured connections - string userId = GetUserId(context); - string channelId = GetChannelId(context); - - GetTokenStatusResult[] statuses = await _app.UserTokenClient - .GetTokenStatusAsync(userId, channelId, cancellationToken: cancellationToken) - .ConfigureAwait(false); - - if (statuses.Length == 0) - { - throw new InvalidOperationException("No OAuth connections are configured on this bot. Configure an OAuth connection in the Azure Bot resource settings."); - } - - if (statuses.Length > 1) - { - string connectionNames = string.Join(", ", statuses.Select(s => $"'{s.ConnectionName}'")); - throw new InvalidOperationException( - $"Multiple OAuth connections found: {connectionNames}. " + - $"Specify the connection name explicitly when calling AddOAuthFlow(connectionName)."); - } - - _connectionName = statuses[0].ConnectionName ?? throw new InvalidOperationException("The configured OAuth connection has no name."); - _connectionResolved = true; - _logger.LogDebug("Auto-discovered OAuth connection: '{ConnectionName}'.", _connectionName); - - return _connectionName; - } - finally - { - _connectionResolveLock.Release(); - } - } - private void CleanupExpiredExchanges() { DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddMinutes(-5); diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs index 842b0a1c..84f36d54 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs @@ -36,25 +36,6 @@ public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connec return flow; } - /// - /// Register an that auto-discovers the connection name - /// via GetTokenStatus on first use. Use this when only one OAuth connection is configured. - /// - /// The Teams bot application. - /// The instance for configuring callbacks. - public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app) - { - ArgumentNullException.ThrowIfNull(app); - - OAuthFlowRegistry registry = GetOrCreateRegistry(app); - ILogger logger = GetLogger(app); - - OAuthFlow flow = new(app, connectionName: null, logger); - registry.RegisterAutoDiscover(flow); - - return flow; - } - private static OAuthFlowRegistry GetOrCreateRegistry(TeamsBotApplication app) { if (app.OAuthRegistry is not null) @@ -135,7 +116,6 @@ private static void RegisterRoutes(TeamsBotApplication app, OAuthFlowRegistry re // verifyState doesn't carry a connection name, so try each registered flow foreach (OAuthFlow flow in registry.GetAllFlows()) { - if (flow.ConnectionName is null) continue; InvokeResponse response = await flow.HandleVerifyStateAsync(ctx, verifyValue, cancellationToken).ConfigureAwait(false); if (response.Status == 200) { @@ -162,7 +142,6 @@ private static NullLogger GetLogger(TeamsBotApplication app) internal sealed class OAuthFlowRegistry { private readonly Dictionary _flows = new(StringComparer.OrdinalIgnoreCase); - private OAuthFlow? _autoDiscoverFlow; internal void Register(string connectionName, OAuthFlow flow) { @@ -172,15 +151,6 @@ internal void Register(string connectionName, OAuthFlow flow) } } - internal void RegisterAutoDiscover(OAuthFlow flow) - { - if (_autoDiscoverFlow is not null) - { - throw new InvalidOperationException("Only one auto-discover OAuthFlow can be registered. Specify connection names explicitly for multiple connections."); - } - _autoDiscoverFlow = flow; - } - /// /// Resolve the OAuthFlow for a given connection name from a token exchange invoke. /// @@ -191,12 +161,6 @@ internal void RegisterAutoDiscover(OAuthFlow flow) return flow; } - // If there's an auto-discover flow, use it - if (_autoDiscoverFlow is not null) - { - return _autoDiscoverFlow; - } - // If there's exactly one named flow, use it if (_flows.Count == 1) { @@ -207,36 +171,21 @@ internal void RegisterAutoDiscover(OAuthFlow flow) } /// - /// Returns all registered flows (both named and auto-discover). + /// Returns all registered flows. /// - internal IEnumerable GetAllFlows() - { - foreach (OAuthFlow flow in _flows.Values) - { - yield return flow; - } - if (_autoDiscoverFlow is not null) - { - yield return _autoDiscoverFlow; - } - } + internal IEnumerable GetAllFlows() => _flows.Values; /// /// Resolve when there's no connection name in the payload (e.g., verifyState). + /// Returns the single registered flow, or null if zero or multiple flows exist. /// internal OAuthFlow? ResolveSingle() { - if (_autoDiscoverFlow is not null) - { - return _autoDiscoverFlow; - } - if (_flows.Count == 1) { return _flows.Values.First(); } - // Multiple flows and no way to disambiguate return null; } @@ -247,10 +196,9 @@ internal IEnumerable GetAllFlows() /// internal OAuthFlow? ResolveSingleWithWarning() { - OAuthFlow? single = ResolveSingle(); - if (single is not null) + if (_flows.Count == 1) { - return single; + return _flows.Values.First(); } if (_flows.Count > 1) @@ -264,4 +212,9 @@ internal IEnumerable GetAllFlows() return null; } + + /// + /// Returns all registered connection names, for use in error messages. + /// + internal IEnumerable GetRegisteredConnectionNames() => _flows.Keys; } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Context.cs b/core/src/Microsoft.Teams.Bot.Apps/Context.cs index 4f97e111..fbc5af77 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Context.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Context.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Linq; using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Apps.Auth; using Microsoft.Teams.Bot.Apps.Schema; @@ -147,7 +148,7 @@ public Task IsSignedInAsync(string? connectionName = null, CancellationTok public Task> GetConnectionStatusAsync(CancellationToken cancellationToken = default) { OAuthFlowRegistry registry = TeamsBotApplication.OAuthRegistry - ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow() on the TeamsBotApplication first."); + ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow(connectionName) on the TeamsBotApplication first."); // Use any flow -- GetConnectionStatusAsync returns all connections regardless OAuthFlow flow = registry.ResolveSingle() @@ -159,15 +160,21 @@ public Task> GetConnectionStatusAsync(CancellationTo private OAuthFlow ResolveOAuthFlow(string? connectionName) { OAuthFlowRegistry registry = TeamsBotApplication.OAuthRegistry - ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow() on the TeamsBotApplication first."); + ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow(connectionName) on the TeamsBotApplication first."); if (connectionName is not null) { - return registry.Resolve(connectionName) - ?? throw new InvalidOperationException($"No OAuthFlow registered for connection '{connectionName}'."); + OAuthFlow? flow = registry.Resolve(connectionName); + if (flow is not null) return flow; + + string registered = string.Join(", ", registry.GetRegisteredConnectionNames().Select(n => $"'{n}'")); + throw new InvalidOperationException( + $"No OAuthFlow registered for connection '{connectionName}'. " + + $"Registered connections: {(registered.Length > 0 ? registered : "(none)")}."); } return registry.ResolveSingle() - ?? throw new InvalidOperationException("Multiple OAuthFlow instances registered. Specify a connection name."); + ?? throw new InvalidOperationException( + "Multiple OAuthFlow instances registered. Specify a connection name in OAuthOptions or SignOut(connectionName)."); } } From 8fc9dbeb728123f696866c4df6504815fc49953d Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 21 Apr 2026 20:39:41 -0700 Subject: [PATCH 16/22] Add sequence diagrams and trace summary for SsoBot Silent SSO flows - Created `sso-trace-2026-04-22-sequence-diagrams.md` to document the login, profile, and logout flows using sequence diagrams. - Added `sso-trace-2026-04-22-summary.md` to provide a detailed trace summary of the SsoBot interactions, including identity references, request summaries, and MSAL token acquisitions. --- core/core.slnx | 12 +- core/docs/{ => sso}/OAuthFlow-Design.md | 0 ...wbot-trace-2026-04-22-sequence-diagrams.md | 167 ++++++++ .../oauthflowbot-trace-2026-04-22-summary.md | 355 ++++++++++++++++++ .../security-audit-oauthflow-2026-04-22.md | 225 +++++++++++ .../sso-trace-2026-04-22-sequence-diagrams.md | 161 ++++++++ core/docs/sso/sso-trace-2026-04-22-summary.md | 347 +++++++++++++++++ .../Schema/OAuthCard.cs | 2 + 8 files changed, 1266 insertions(+), 3 deletions(-) rename core/docs/{ => sso}/OAuthFlow-Design.md (100%) create mode 100644 core/docs/sso/oauthflowbot-trace-2026-04-22-sequence-diagrams.md create mode 100644 core/docs/sso/oauthflowbot-trace-2026-04-22-summary.md create mode 100644 core/docs/sso/security-audit-oauthflow-2026-04-22.md create mode 100644 core/docs/sso/sso-trace-2026-04-22-sequence-diagrams.md create mode 100644 core/docs/sso/sso-trace-2026-04-22-summary.md diff --git a/core/core.slnx b/core/core.slnx index c726cc7c..97659bfa 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -18,9 +18,13 @@ - + + + - + + + @@ -46,6 +50,8 @@ - + + + diff --git a/core/docs/OAuthFlow-Design.md b/core/docs/sso/OAuthFlow-Design.md similarity index 100% rename from core/docs/OAuthFlow-Design.md rename to core/docs/sso/OAuthFlow-Design.md diff --git a/core/docs/sso/oauthflowbot-trace-2026-04-22-sequence-diagrams.md b/core/docs/sso/oauthflowbot-trace-2026-04-22-sequence-diagrams.md new file mode 100644 index 00000000..a57c2ffa --- /dev/null +++ b/core/docs/sso/oauthflowbot-trace-2026-04-22-sequence-diagrams.md @@ -0,0 +1,167 @@ +# 🔐 OAuthFlowBot — Sequence Diagrams (Popup Fallback) + +Trace from 2026-04-22 03:12 UTC. Connection `teamsgraph` (Azure AD v2, no SSO configured). +Sign-in completes via **popup window** + `signin/verifyState` — no silent SSO. + +--- + +## 🔑 Login Flow (Popup Sign-In) + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant AAD as 🔵 Azure AD + participant TBS as 🟠 Token Service + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "login graph" + Teams->>Bot: 📥 POST /api/messages
type=message, text="login graph" + Note over Bot: 🛡️ JWT validated + Note over Bot: 🔀 Route: message/^login graph$ + + rect rgb(240, 248, 255) + Note over Bot,TBS: Step 1 — Silent token check (miss) + Bot->>MSAL: AcquireTokenForClient + MSAL->>AAD: POST /oauth2/v2.0/token + AAD-->>MSAL: 🔑 App token + Bot->>TBS: 📤 GET /api/usertoken/GetToken
connectionName=teamsgraph + TBS-->>Bot: ❌ 404 No cached token + end + + rect rgb(255, 248, 240) + Note over Bot,TBS: Step 2 — Get sign-in resource + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 GET /api/botsignin/GetSignInResource
state={MsAppId, ConnectionName=teamsgraph} + TBS-->>Bot: ✅ 200 signInLink + tokenPostResource
⚠️ No tokenExchangeResource (no SSO) + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 3 — Send OAuthCard (popup only) + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
🃏 OAuthCard (no tokenExchangeResource)
buttons: [Sign In → popup link] + BFC-->>Bot: ✅ 200 + end + + Bot-->>Teams: ✅ 200 + Teams->>User: Shows Sign In button + + rect rgb(255, 250, 230) + Note over User,AAD: Step 4 — User signs in via popup + User->>Teams: Clicks "Sign In" button + Teams->>AAD: Opens popup → AAD login + AAD-->>Teams: Auth code / consent + Teams->>TBS: Posts token via SasUrl + end + + rect rgb(245, 240, 255) + Note over Teams,Bot: Step 5 — Teams sends verifyState invoke + Teams->>Bot: 📥 POST /api/messages
type=invoke, name=signin/verifyState
value={ state: "745254" } + Note over Bot: 🛡️ JWT validated + Note over Bot: 🔀 Route: invoke/signin/verifyState + end + + rect rgb(255, 245, 245) + Note over Bot,TBS: Step 6 — Verify state and get token + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 GET /api/usertoken/GetToken
connectionName=teamsgraph&code=745254 + TBS-->>Bot: ✅ 200 User token returned + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 7 — 🎉 OnSignInComplete + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
"Connected to Microsoft Graph (teamsgraph)!" + BFC-->>Bot: ✅ 201 + end + + Bot-->>Teams: ✅ 200 invoke response + Teams->>User: "Connected to Microsoft Graph!" +``` + +--- + +## 👤 "my ad user" Flow (token cached) + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant TBS as 🟠 Token Service + participant Graph as 📊 Graph + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "my ad user" + Teams->>Bot: 📥 POST /api/messages
type=message, text="my ad user" + Note over Bot: 🔀 Route: message/^my ad user + + rect rgb(240, 248, 255) + Note over Bot,TBS: Step 1 — Silent token check (hit) + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 GET /api/usertoken/GetToken
connectionName=teamsgraph + TBS-->>Bot: ✅ 200 Cached user token + end + + rect rgb(245, 240, 255) + Note over Bot,Graph: Step 2 — Call Graph API + Bot->>Graph: 📤 GET /v1.0/me
🔑 Bearer {user_token} + Graph-->>Bot: ✅ 200 {displayName:"Rido", mail:"rido@teamssdk..."} + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 3 — Send profile to user + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
📄 Graph /me JSON + BFC-->>Bot: ✅ 201 + end + + Bot-->>Teams: ✅ 200 + Teams->>User: Shows AD user JSON +``` + +--- + +## 🚪 Logout Flow + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant TBS as 🟠 Token Service + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "logout graph" + Teams->>Bot: 📥 POST /api/messages
type=message, text="logout graph" + Note over Bot: 🔀 Route: message/^logout graph$ + + rect rgb(255, 240, 240) + Note over Bot,TBS: Step 1 — Revoke user token + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 DELETE /api/usertoken/SignOut
connectionName=teamsgraph + TBS-->>Bot: ✅ 200 Token revoked + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 2 — Send confirmation + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
"Signed out from Graph." + BFC-->>Bot: ✅ 201 + end + + Bot-->>Teams: ✅ 200 + Teams->>User: "Signed out from Graph." +``` diff --git a/core/docs/sso/oauthflowbot-trace-2026-04-22-summary.md b/core/docs/sso/oauthflowbot-trace-2026-04-22-summary.md new file mode 100644 index 00000000..92a98441 --- /dev/null +++ b/core/docs/sso/oauthflowbot-trace-2026-04-22-summary.md @@ -0,0 +1,355 @@ +# 🔐 OAuthFlowBot Trace Summary (Popup Fallback) + +**Date**: 2026-04-22 03:12:00 UTC +**Bot**: my-bot-sso (AppID: `e3cb1c84-14e3-419c-b39c-1c06097b55fd`) +**User**: Rido (aadObjectId: `03500558-e554-416c-90c3-a061cdcd012b`) +**Connection**: `teamsgraph` (Azure AD v2, no SSO — popup fallback) +**Platform**: 🌐 Web (Teams) +**SDK Version**: `0.0.1-alpha-0107-g1c503584a7` +**Result**: ✅ SUCCESS (login graph + my ad user + logout graph) + +> **Key difference from SsoBot**: This connection does not have `tokenExchangeResource` (SSO not configured). +> Login completes via **popup sign-in** + `signin/verifyState` instead of silent `signin/tokenExchange`. + +### 🆔 Identity Reference + +| Identity | MRI / Value | +|----------|-------------| +| User MRI | `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` | +| User AAD ObjectId | `03500558-e554-416c-90c3-a061cdcd012b` | +| Bot MRI | `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` | +| Bot AppId | `e3cb1c84-14e3-419c-b39c-1c06097b55fd` | +| Tenant Id | `3f3d1cea-7a18-41af-872b-cfbbd5140984` | +| Conversation Id | `a:1xH4HncZ6lyZnMVYp9rTKoRyS44qDCikYZ1u-Q0VNmZqyceL6nKfe5ZKG9CqOi2WuXNDJyLBAaDgVChKMxKFPlAZ5bsy0_8RhvPYYi5ZJJKCiia_SEd_e8WJVlSHOIM3Z` | +| Service URL | `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` | + +--- + +## 🔑 Login Flow (Popup Fallback — no SSO) + +### Step 1 — User sends "login graph" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` +- **Activity**: + - `type`: `message` + - `id`: `1776827520098` + - `channelId`: `msteams` + - `text`: `"login graph"` + - `textFormat`: `plain` + - `timestamp`: `2026-04-22T03:12:00.1176725Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.name`: `Rido` + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `recipient.name`: `my-bot-sso` + - `conversation.id`: `a:1xH4HncZ6ly...OIM3Z` + - `conversation.conversationType`: `personal` + - `conversation.tenantId`: `3f3d1cea-7a18-41af-872b-cfbbd5140984` + - `serviceUrl`: `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` + - `entities[0]`: `{ locale: "en-US", country: "US", platform: "Web", timezone: "America/Los_Angeles", type: "clientInfo" }` + - `MSCV`: `4+YxTIufBEq78SeAVHsSdQ.1.1.1.485196365.1.1` +- 🛡️ JWT validated (AzureAd scheme) +- 🔀 Route: `message/(?i)^login graph$` + +### Step 2 — Silent token check (no cached token) + +📤 **OUTGOING** `GET https://token.botframework.com/api/usertoken/GetToken` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `teamsgraph` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL AcquireTokenForClient (source: IdentityProvider) — first token from AAD +- ❌ **Response**: `404` — no cached user token + +### Step 3 — Get sign-in resource + +📤 **OUTGOING** `GET https://token.botframework.com/api/botsignin/GetSignInResource` +- **Query Parameters**: + - `state`: base64-encoded JSON: + ```json + { + "ConnectionName": "teamsgraph", + "Conversation": { + "ActivityId": "1776827520098", + "Bot": { "Id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd" }, + "ChannelId": "msteams", + "Conversation": { "Id": "a:1xH4HncZ6ly...OIM3Z" }, + "ServiceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "User": { "Id": "29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ" } + }, + "MsAppId": "e3cb1c84-14e3-419c-b39c-1c06097b55fd" + } + ``` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache +- ✅ **Response**: `200` — returns signInLink + tokenPostResource, **⚠️ NO tokenExchangeResource** (SSO not configured) + +### Step 4 — Send OAuthCard to user (popup only, no SSO) + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776827520098` +- **Auth**: 🔑 MSAL from cache +- **Request Body**: + ```json + { + "from": { + "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", + "name": "my-bot-sso" + }, + "recipient": { + "id": "29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ", + "name": "Rido", + "aadObjectId": "03500558-e554-416c-90c3-a061cdcd012b" + }, + "conversation": { + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984", + "conversationType": "personal", + "id": "a:1xH4HncZ6ly...OIM3Z" + }, + "attachments": [{ + "contentType": "application/vnd.microsoft.card.oauth", + "content": { + "text": "Please Sign In", + "connectionName": "teamsgraph", + "buttons": [{ + "type": "signin", + "title": "Sign In", + "value": "https://token.botframework.com/api/oauth/signin?signin=02706e367e884e8ea4e86472cbd71932" + }], + "tokenPostResource": { + "SasUrl": "https://token.botframework.com/api/sas/postToken?expiry=1776827583&id=key2&state=02706e367e884e8ea4e86472cbd71932&hmac=..." + } + } + }], + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776827520098" + } + ``` + > **Note**: `tokenExchangeResource` is **omitted** (not sent as null). This is the fix applied in this run — previously it was serialized as `"tokenExchangeResource": null` which caused Teams to reject with `BadRequest`. +- ✅ **Response**: `200` + +🏁 **HTTP Response to Teams**: `200` + +### Step 5 — User completes popup sign-in, Teams sends signin/verifyState + +📥 **INCOMING** `POST http://localhost:3978/api/messages` +- **Activity**: + - `type`: `invoke` + - `name`: `signin/verifyState` + - `id`: `f:7d1e8ec2-5897-396e-aa7b-f579ad2fac9f` + - `channelId`: `msteams` + - `timestamp`: `2026-04-22T03:12:09.445Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.name`: `Rido` + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `recipient.name`: `my-bot-sso` + - `conversation.id`: `a:1xH4HncZ6ly...OIM3Z` + - `conversation.conversationType`: `personal` + - `conversation.tenantId`: `3f3d1cea-7a18-41af-872b-cfbbd5140984` + - `serviceUrl`: `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` + - `replyToId`: `1776827524158` + - `channelData.source.name`: `message` + - `channelData.legacy.replyToId`: `1:1m2Cdy7qBU0p3417d81g04kt7MXJrjQC-X21CRiZVWzk` + - `value`: `{ "state": "745254" }` *(verification code from popup)* + - `MSCV`: `FvSbzMYrUE+OReNyK+4lyg.1.3` +- 🛡️ JWT validated (AzureAd scheme) +- 🔀 Route: `invoke/signin/verifyState` + +### Step 6 — Verify state and get token + +📤 **OUTGOING** `GET https://token.botframework.com/api/usertoken/GetToken` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `teamsgraph` + - `channelId`: `msteams` + - `code`: `745254` *(verification code from verifyState)* +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache +- ✅ **Response**: `200` — user token returned + +### Step 7 — 🎉 OnSignInComplete fires, bot sends confirmation + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/.../v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/f:7d1e8ec2-5897-396e-aa7b-f579ad2fac9f` +- **Auth**: 🔑 MSAL from cache +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "f:7d1e8ec2-5897-396e-aa7b-f579ad2fac9f", + "text": "Connected to Microsoft Graph (teamsgraph)!", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` + +🏁 **Invoke Response**: `200` (body: null) + +--- + +## 👤 "my ad user" Flow (token cached) + +### Step 8 — User sends "my ad user" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` +- **Activity**: + - `type`: `message` + - `id`: `1776827541160` + - `text`: `"my ad user"` + - `textFormat`: `plain` + - `timestamp`: `2026-04-22T03:12:21.179708Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `attachments[0]`: `{ contentType: "text/html", content: "
my ad user
" }` + - `MSCV`: `eTD3PgxXhEiK0mFFc7QunQ.1.1.1.485974193.1.1` +- 🔀 Route: `message/(?i)^my ad user` + +### Step 9 — Silent token check (token exists) + +📤 **OUTGOING** `GET https://token.botframework.com/api/usertoken/GetToken` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `teamsgraph` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache +- ✅ **Response**: `200` — cached user token returned + +### Step 10 — Call Graph API with token + +📤 **OUTGOING** `GET https://graph.microsoft.com/v1.0/me` +- **Auth**: `Authorization: Bearer {user_token}` +- ✅ **Response**: `200` — `{ displayName: "Rido", mail: "rido@teamssdk.onmicrosoft.com", id: "03500558-e554-416c-90c3-a061cdcd012b" }` + +### Step 11 — Send profile result + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/.../v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776827541160` +- **Auth**: 🔑 MSAL from cache +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776827541160", + "text": "Your Azure AD user :\n```json\n{\"displayName\":\"Rido\",\"givenName\":\"Rido\",\"jobTitle\":\"Not an architect\",\"mail\":\"rido@teamssdk.onmicrosoft.com\",...}\n```", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` + +--- + +## 🚪 Logout Flow + +### Step 12 — User sends "logout graph" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` +- **Activity**: + - `type`: `message` + - `id`: `1776827548949` + - `text`: `"logout graph"` + - `textFormat`: `plain` + - `timestamp`: `2026-04-22T03:12:28.9762671Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `MSCV`: `ymRk2x/XZ0CFG0QGeNHrOg.1.1.1.486335532.1.1` +- 🔀 Route: `message/(?i)^logout graph$` + +### Step 13 — Sign out user + +📤 **OUTGOING** `DELETE https://token.botframework.com/api/usertoken/SignOut` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `teamsgraph` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache +- ✅ **Response**: `200` — token revoked + +### Step 14 — Send confirmation + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/.../v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776827548949` +- **Auth**: 🔑 MSAL from cache +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776827548949", + "text": "Signed out from Graph.", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` + +--- + +## 📊 Request Summary Table + +| # | Direction | Method | Endpoint | Status | Purpose | +|---|-----------|--------|----------|--------|---------| +| 1 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | 💬 "login graph" message | +| 2 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/usertoken/GetToken` | ❌ 404 | 🔍 Silent token check (miss) | +| 3 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/botsignin/GetSignInResource` | ✅ 200 | 🔗 Get sign-in resource (no tokenExchangeResource) | +| 4 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 200 | 🃏 Send OAuthCard (popup only, no SSO) | +| 5 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | 🔄 signin/verifyState invoke (code=745254) | +| 6 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/usertoken/GetToken` | ✅ 200 | 🔐 Verify state + get token (code=745254) | +| 7 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | 🎉 "Connected to Microsoft Graph!" | +| 8 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | 💬 "my ad user" message | +| 9 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/usertoken/GetToken` | ✅ 200 | 🔍 Silent token check (hit) | +| 10 | 📤 ⬆️ OUT | GET | `graph.microsoft.com/v1.0/me` | ✅ 200 | 👤 Graph API call | +| 11 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | 📄 Profile response | +| 12 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | 💬 "logout graph" message | +| 13 | 📤 ⬆️ OUT | DELETE | `token.botframework.com/api/usertoken/SignOut` | ✅ 200 | 🚪 Revoke token | +| 14 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | 💬 "Signed out from Graph." | + +## 🆔 User MRI Usage Across Requests + +| Request | Where User MRI appears | Format | +|---------|----------------------|--------| +| Step 1 (incoming message) | `activity.from.id` | `29:1cgsv1oFLAoTflZ-...` | +| Step 2 (GetToken) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 3 (GetSignInResource) | `state.Conversation.User.Id` (base64 JSON) | `29:1cgsv1oFLAoTflZ-...` | +| Step 4 (Send OAuthCard) | `recipient.id` (reply to user) | `29:1cgsv1oFLAoTflZ-...` | +| Step 5 (verifyState invoke) | `activity.from.id` | `29:1cgsv1oFLAoTflZ-...` | +| Step 6 (GetToken + code) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 9 (GetToken cached) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 13 (SignOut) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | + +> **Note**: The User MRI (`29:...`) is the Teams-specific identifier. It is used as `userid` in all Token Bot Service calls (GetToken, SignOut) and appears in `from.id` on incoming activities and `recipient.id` on outgoing replies. The AAD ObjectId (`03500558-...`) appears separately in `from.aadObjectId` and in the outgoing `recipient.aadObjectId`. + +--- + +## 🔑 vs SsoBot: Key Differences + +| Aspect | SsoBot (`sso` connection) | OAuthFlowBot (`teamsgraph` connection) | +|--------|--------------------------|----------------------------------------| +| SSO support | ✅ `tokenExchangeResource` present | ❌ `tokenExchangeResource` omitted | +| Sign-in invoke | `signin/tokenExchange` (silent) | `signin/verifyState` (popup + code) | +| Token acquisition | `POST /api/usertoken/exchange` with SSO JWT | `GET /api/usertoken/GetToken` with `code` param | +| User interaction | None (fully silent) | Popup window + consent | +| OAuthFlow API | Context API (`context.SignIn()`) | Instance API (`graphAuth.SignInAsync(context)`) | +| verifyState value | N/A | `{ "state": "745254" }` | +| tokenExchange value | `{ id, connectionName, token }` | N/A | + +## 🐛 Bug Fixed During This Run + +**Issue**: `OAuthCard` serialized `"tokenExchangeResource": null` explicitly in JSON. Teams rejected this with `BadRequest: {"error":{"code":"ServiceError","message":"Unknown"}}`. + +**Fix**: Added `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` to `TokenExchangeResource` and `TokenPostResource` properties in `OAuthCard.cs`. When null, these properties are now omitted from the JSON instead of being sent as explicit nulls. + +**File**: `src/Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs` diff --git a/core/docs/sso/security-audit-oauthflow-2026-04-22.md b/core/docs/sso/security-audit-oauthflow-2026-04-22.md new file mode 100644 index 00000000..ef7f6685 --- /dev/null +++ b/core/docs/sso/security-audit-oauthflow-2026-04-22.md @@ -0,0 +1,225 @@ +# Security Audit: OAuthFlow Token Retrieval Attack Surface + +**Date:** 2026-04-22 +**Scope:** OAuthFlow design, implementation, live traffic trace, Azure Bot Service + Entra ID configuration +**Bot App ID:** `e3cb1c84-14e3-419c-b39c-1c06097b55fd` ("my-bot-sso") +**Tenant:** `3f3d1cea-7a18-41af-872b-cfbbd5140984` + +--- + +## Executive Summary + +The Bot Framework Token Service (`token.botframework.com`) acts as a **centralized token vault** for all user tokens acquired through OAuth connections. **Any caller that can authenticate as the bot** (i.e., possesses the bot's `AppId` + client secret) can retrieve any user's cached token by calling a single unauthenticated-beyond-app-identity API. The only inputs needed are: + +- The bot's credentials (AppId + secret) +- A user's Teams MRI (semi-public, visible to anyone in the same org/conversation) +- The connection name (a short string like `"teamsgraph"`) + +This is **by design** in the Bot Framework Token Service protocol. The mitigation is entirely dependent on protecting the bot's client secret. + +--- + +## Detailed Attack Reconstruction + +### What the trace shows + +From the live traffic trace (`oauthflowbot-trace-2026-04-22-raw.log`), the token retrieval call is: + +``` +GET https://token.botframework.com/api/usertoken/GetToken + ?userid=29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ + &connectionName=teamsgraph + &channelId=msteams +Authorization: Bearer +``` + +The authorization token is an **app-only** token (no user context), acquired by the bot using its own credentials: +``` +aud: https://api.botframework.com +appid: e3cb1c84-14e3-419c-b39c-1c06097b55fd +idtyp: app +``` + +### The attack (step by step) + +An attacker with access to the bot's client secret can reproduce this outside the bot: + +1. **Acquire app-only token:** + ```bash + curl -X POST https://login.microsoftonline.com/3f3d1cea-7a18-41af-872b-cfbbd5140984/oauth2/v2.0/token \ + -d "client_id=e3cb1c84-14e3-419c-b39c-1c06097b55fd" \ + -d "client_secret=" \ + -d "scope=https://api.botframework.com/.default" \ + -d "grant_type=client_credentials" + ``` + +2. **Retrieve any user's token:** + ```bash + curl "https://token.botframework.com/api/usertoken/GetToken?\ + userid=29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ\ + &connectionName=teamsgraph\ + &channelId=msteams" \ + -H "Authorization: Bearer " + ``` + +3. **Use the returned delegated token** to call Microsoft Graph, GitHub, etc. as the victim user. + +### What tokens are at risk + +| Connection | Provider App | Scopes | Impact | +|---|---|---|---| +| `teamsgraph` | `9f43e2fb-2cbd-4303-aaf4-c6d209dc2666` ("RidoGraphExperiment") | `ChannelMessage.Read.All TeamMember.Read.All` | **Read ALL channel messages and team members as the user** | +| `sso` | `e3cb1c84-14e3-419c-b39c-1c06097b55fd` (bot itself) | `User.Read Calendars.Read` | Read user profile and calendar | +| `gh` | `Ov23ligyZwD5j1u41P81` (GitHub OAuth App) | `repo pr` | **Full access to user's GitHub repositories** | +| `sso-bad` | Unknown | Unknown | (likely a test connection) | + +### How easy is it to get the inputs? + +| Input | Difficulty | How | +|---|---|---| +| Bot AppId | **Trivial** | Visible in every activity (`recipient.id`), in the OAuthCard, in the base64 state, in bot manifests | +| Client secret | **Medium** | Stored in: app settings, Key Vault, CI/CD pipelines, developer machines, `.env` files. Hint: `a-t`, expires 2028-04-20 | +| User MRI | **Low** | Visible to any user in the same conversation. Format: `29:`. Enumerable via Graph API with `TeamMember.Read.All` | +| Connection name | **Low** | Visible in OAuthCard payload (`connectionName: "teamsgraph"`), guessable, or enumerable if you have the bot's Azure subscription access | + +--- + +## Configuration Findings + +### Finding 1: CRITICAL — Overprivileged OAuth Connection Scopes + +The `teamsgraph` connection requests `ChannelMessage.Read.All` and `TeamMember.Read.All`. These are high-privilege delegated permissions that grant access far beyond what the sample bot uses (it only calls `/me`). + +If a user's token is stolen via the attack above, the attacker gets these broad permissions for free. + +**Recommendation:** Apply least-privilege. The sample only needs `User.Read`. Remove `ChannelMessage.Read.All` and `TeamMember.Read.All` from the connection scopes. + +### Finding 2: HIGH — `teamsgraph` Uses a Separate App Registration + +The `teamsgraph` connection's `clientId` is `9f43e2fb-2cbd-4303-aaf4-c6d209dc2666` ("RidoGraphExperiment") — a **different** app registration than the bot itself. This means: + +- The Token Service performs OBO (on-behalf-of) using this separate app's credentials +- This separate app has its own client secrets (hints: `2PX` expiring 2026-10-17, `Fta` expiring 2027-04-17) +- Two sets of credentials must be protected, doubling the attack surface +- The `RidoGraphExperiment` app's credentials are stored in the Token Service, not in the bot's code, but if the bot credentials are compromised, the stored tokens (already exchanged) are directly accessible + +**Recommendation:** Use the bot's own app ID for the OAuth connection where possible (as the `sso` connection already does). This reduces the number of credential sets to protect. + +### Finding 3: HIGH — `signInAudience` vs `msaAppType` Mismatch + +| Setting | Value | +|---|---| +| Entra App `signInAudience` | `AzureADMultipleOrgs` (any Entra tenant) | +| Bot Service `msaAppType` | `SingleTenant` | + +The Entra app accepts tokens from **any** Azure AD tenant, but the Bot Service is configured as single-tenant. This mismatch means: + +- An attacker from a different tenant could acquire an app-only token against `https://api.botframework.com/.default` using a service principal in their own tenant (if the app is registered as multi-org) +- The Bot Framework Token Service may or may not enforce tenant isolation on the `GetToken` API + +**Recommendation:** Align the Entra app `signInAudience` to `AzureADMyOrg` (single tenant) to match the Bot Service configuration. This restricts token acquisition to the bot's home tenant. + +### Finding 4: MEDIUM — `appRoleAssignmentRequired: false` + +The bot's service principal does not require role assignment. Combined with `AzureADMultipleOrgs`, any user in any tenant can authenticate. While this is typical for bots (they need to accept tokens from the Bot Framework), it should be reviewed. + +### Finding 5: MEDIUM — Dev Tunnel Endpoint in Production Bot Registration + +The messaging endpoint is: +``` +https://klljrqz0-3978.usw2.devtunnels.ms/api/messages +``` + +This is a dev tunnel URL. If this bot registration is also used for testing with real user tokens, those tokens are cached in the Token Service and retrievable even after the dev tunnel is shut down. The tokens persist until they expire or the user signs out. + +### Finding 6: MEDIUM — GitHub Connection Has `repo` Scope + +The `gh` connection grants `repo` scope — full read/write access to all repositories. A stolen GitHub token would allow an attacker to read private code, push malicious commits, or exfiltrate proprietary source code. + +**Recommendation:** Use fine-grained GitHub permissions or the minimum scope needed. + +--- + +## What the SDK Can and Cannot Do + +### Cannot fix (Bot Framework Token Service design) + +The core issue — that `GetToken` only requires bot identity + userId — is a **Token Service protocol property**. The Token Service treats the bot as a trusted party for all its users. This is analogous to how a web app's backend can use its OAuth client credentials to access stored refresh tokens. + +The SDK cannot add additional authorization to the Token Service API. + +### Can mitigate + +| Mitigation | Where | Status | +|---|---|---| +| **Document the threat model** | Design doc, SDK docs | Not done | +| **Warn about credential protection** | Sample README, getting-started guide | Not done | +| **Log token retrieval attempts** | OAuthFlow.cs `GetTokenAsync` | Partially done (debug-level) | +| **Support Managed Identity** | BotConfig.cs | Supported (eliminates client secret) | +| **Support Federated Identity** | BotConfig.cs | Supported (eliminates client secret) | +| **Reduce default log verbosity** | BotAuthenticationHandler.cs | Not done (full claims at Trace) | + +--- + +## Recommendations (Priority Order) + +### 1. Eliminate the client secret (P0) + +The **single most effective mitigation** is to remove the client secret entirely: + +- **Managed Identity**: If the bot runs on Azure (App Service, Container Apps), use system-assigned managed identity. No secret to steal. +- **Federated Identity Credentials**: For non-Azure hosts or CI/CD, use workload identity federation. No secret stored. + +The bot's `BotConfig` already supports both (`Credential.ManagedIdentity`, `Credential.FederatedIdentity`). The sample should demonstrate this. + +### 2. Fix the `signInAudience` mismatch (P0) + +```bash +az ad app update --id e3cb1c84-14e3-419c-b39c-1c06097b55fd \ + --sign-in-audience AzureADMyOrg +``` + +This ensures only the home tenant (`3f3d1cea-...`) can acquire tokens for this app. + +### 3. Apply least-privilege scopes to OAuth connections (P1) + +For `teamsgraph`: change scopes from `ChannelMessage.Read.All TeamMember.Read.All` to `User.Read` (what the sample actually uses). + +For `gh`: change from `repo pr` to `read:user` if only profile info is needed. + +### 4. Consolidate to a single app registration (P1) + +Use the bot's own app ID (`e3cb1c84-...`) for the `teamsgraph` OAuth connection instead of the separate `RidoGraphExperiment` app. This halves the credential surface. + +### 5. Document the Token Service threat model (P1) + +Add to the OAuthFlow design doc: + +> **Security Note:** The Bot Framework Token Service stores user tokens on behalf of the bot. Any entity that can authenticate as the bot (via AppId + credential) can retrieve any user's cached token by calling the Token Service API with the user's ID and connection name. Protect the bot's credentials with the same rigor as a database connection string. Prefer Managed Identity or Federated Identity Credentials over client secrets. + +### 6. Rotate the existing client secret (P1) + +The current secret (hint: `a-t`, created 2026-04-20, expires 2028-04-20) has a **2-year lifetime** — far too long. Rotate immediately and set a shorter expiry (90 days max) as a bridge while migrating to Managed Identity. + +### 7. Clean up the dev tunnel endpoint (P2) + +If this bot registration was used with real users during development, their tokens may still be cached. Either: +- Sign out all users via the Token Service API +- Delete and recreate the bot registration for production use + +--- + +## Appendix: Token Service API Surface (Attack-Relevant) + +All endpoints authenticated with bot's app-only token for `https://api.botframework.com/.default`: + +| Endpoint | Method | What it does | +|---|---|---| +| `/api/usertoken/GetToken?userid=X&connectionName=Y&channelId=Z` | GET | **Returns the user's cached access token** | +| `/api/usertoken/GetToken?userid=X&connectionName=Y&channelId=Z&code=C` | GET | Exchanges a verify-state code for a token | +| `/api/usertoken/SignOut?userid=X&connectionName=Y&channelId=Z` | DELETE | Revokes a user's cached token | +| `/api/usertoken/GetTokenStatus?userid=X&channelId=Z` | GET | Lists all connections and whether tokens exist | +| `/api/usertoken/exchange?userid=X&connectionName=Y&channelId=Z` | POST | Exchanges an SSO token for an access token | +| `/api/botsignin/GetSignInResource?state=X` | GET | Returns sign-in URL + TokenExchangeResource | + +Every one of these is callable by anyone with the bot's credentials. The `GetTokenStatus` endpoint even lets an attacker enumerate which connections a user has tokens for without knowing the connection names. diff --git a/core/docs/sso/sso-trace-2026-04-22-sequence-diagrams.md b/core/docs/sso/sso-trace-2026-04-22-sequence-diagrams.md new file mode 100644 index 00000000..6759125d --- /dev/null +++ b/core/docs/sso/sso-trace-2026-04-22-sequence-diagrams.md @@ -0,0 +1,161 @@ +# 🔐 SsoBot — Sequence Diagrams (Silent SSO) + +Trace from 2026-04-22 02:45 UTC. Connection `sso` (Azure AD v2 with SSO). +Sign-in completes via silent `signin/tokenExchange` — no popup needed. + +--- + +## 🔑 Login Flow + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant AAD as 🔵 Azure AD + participant TBS as 🟠 Token Service + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "login" + Teams->>Bot: 📥 POST /api/messages
type=message, text="login" + Note over Bot: 🛡️ JWT validated + Note over Bot: 🔀 Route: message/^login$ + + rect rgb(240, 248, 255) + Note over Bot,TBS: Step 1 — Silent token check (miss) + Bot->>MSAL: AcquireTokenForClient + MSAL->>AAD: GET /common/discovery/instance + AAD-->>MSAL: Instance metadata + MSAL->>AAD: POST /oauth2/v2.0/token + AAD-->>MSAL: 🔑 App token (⏱️ 535ms) + Bot->>TBS: 📤 GET /api/usertoken/GetToken
connectionName=sso + TBS-->>Bot: ❌ 404 No cached token + end + + rect rgb(255, 248, 240) + Note over Bot,TBS: Step 2 — Get sign-in resource + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 GET /api/botsignin/GetSignInResource
state={MsAppId, ConnectionName, Conversation} + TBS-->>Bot: ✅ 200 signInLink + tokenExchangeResource + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 3 — Send OAuthCard (with SSO) + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
🃏 OAuthCard
tokenExchangeResource.Uri=api://botid-... + BFC-->>Bot: ✅ 202 Accepted (⏱️ 631ms) + end + + Bot-->>Teams: ✅ 200 (⏱️ 3034ms total) + Teams->>User: Shows OAuthCard / triggers silent SSO + + rect rgb(245, 240, 255) + Note over Teams,Bot: Step 4 — Teams sends SSO token + Teams->>Bot: 📥 POST /api/messages
type=invoke, name=signin/tokenExchange
token=SSO JWT (scp=access_as_user) + Note over Bot: 🛡️ JWT validated + Note over Bot: 🔀 Route: invoke/signin/tokenExchange + end + + rect rgb(255, 245, 245) + Note over Bot,TBS: Step 5 — Exchange SSO token + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 POST /api/usertoken/exchange
connectionName=sso, token=SSO JWT + TBS-->>Bot: ✅ 200 User token (⏱️ 903ms) + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 6 — 🎉 OnSignInComplete + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
"You're now signed in!" + BFC-->>Bot: ✅ 201 (⏱️ 366ms) + end + + Bot-->>Teams: ✅ 200 invoke response (⏱️ 1308ms total) + Teams->>User: "You're now signed in!" +``` + +--- + +## 👤 Profile Flow (token cached) + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant TBS as 🟠 Token Service + participant Graph as 📊 Graph + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "profile" + Teams->>Bot: 📥 POST /api/messages
type=message, text="profile" + Note over Bot: 🔀 Route: message/^profile$ + + rect rgb(240, 248, 255) + Note over Bot,TBS: Step 1 — Silent token check (hit) + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 GET /api/usertoken/GetToken
connectionName=sso + TBS-->>Bot: ✅ 200 Cached user token (⏱️ 214ms) + end + + rect rgb(245, 240, 255) + Note over Bot,Graph: Step 2 — Call Graph API + Bot->>Graph: 📤 GET /v1.0/me
🔑 Bearer {user_token} + Graph-->>Bot: ✅ 200 {displayName:"Rido", mail:"rido@teamssdk..."} + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 3 — Send profile to user + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
📄 Graph /me JSON + BFC-->>Bot: ✅ 201 (⏱️ 283ms) + end + + Bot-->>Teams: ✅ 200 (⏱️ 664ms total) + Teams->>User: Shows profile JSON +``` + +--- + +## 🚪 Logout Flow + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant TBS as 🟠 Token Service + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "logout" + Teams->>Bot: 📥 POST /api/messages
type=message, text="logout" + Note over Bot: 🔀 Route: message/^logout$ + + rect rgb(255, 240, 240) + Note over Bot,TBS: Step 1 — Revoke user token + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 DELETE /api/usertoken/SignOut
connectionName=sso + TBS-->>Bot: ✅ 200 Token revoked (⏱️ 313ms) + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 2 — Send confirmation + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
"Signed out." + BFC-->>Bot: ✅ 201 (⏱️ 339ms) + end + + Bot-->>Teams: ✅ 200 (⏱️ 662ms total) + Teams->>User: "Signed out." +``` diff --git a/core/docs/sso/sso-trace-2026-04-22-summary.md b/core/docs/sso/sso-trace-2026-04-22-summary.md new file mode 100644 index 00000000..3323025a --- /dev/null +++ b/core/docs/sso/sso-trace-2026-04-22-summary.md @@ -0,0 +1,347 @@ +# 🔐 SsoBot Trace Summary (Silent SSO) + +**Date**: 2026-04-22 02:45:26 UTC +**Bot**: my-bot-sso (AppID: `e3cb1c84-14e3-419c-b39c-1c06097b55fd`) +**User**: Rido (aadObjectId: `03500558-e554-416c-90c3-a061cdcd012b`) +**Connection**: `sso` +**Platform**: 🌐 Web (Teams) +**SDK Version**: `0.0.1-alpha-0107-g1c503584a7` +**Result**: ✅ SUCCESS (login + profile + logout) + +### 🆔 Identity Reference + +| Identity | MRI / Value | +|----------|-------------| +| User MRI | `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` | +| User AAD ObjectId | `03500558-e554-416c-90c3-a061cdcd012b` | +| Bot MRI | `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` | +| Bot AppId | `e3cb1c84-14e3-419c-b39c-1c06097b55fd` | +| Tenant Id | `3f3d1cea-7a18-41af-872b-cfbbd5140984` | +| Conversation Id | `a:1xH4HncZ6lyZnMVYp9rTKoRyS44qDCikYZ1u-Q0VNmZqyceL6nKfe5ZKG9CqOi2WuXNDJyLBAaDgVChKMxKFPlAZ5bsy0_8RhvPYYi5ZJJKCiia_SEd_e8WJVlSHOIM3Z` | +| Service URL | `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` | + +--- + +## 🔑 Login Flow + +### Step 1 — User sends "login" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` (1017 bytes) +- **Request Headers**: `Content-Type: application/json;+charset=utf-8` +- **Activity**: + - `type`: `message` + - `id`: `1776825925953` + - `channelId`: `msteams` + - `text`: `"login"` + - `textFormat`: `plain` + - `timestamp`: `2026-04-22T02:45:26.0070993Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.name`: `Rido` + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `recipient.name`: `my-bot-sso` + - `conversation.id`: `a:1xH4HncZ6ly...OIM3Z` + - `conversation.conversationType`: `personal` + - `conversation.tenantId`: `3f3d1cea-7a18-41af-872b-cfbbd5140984` + - `serviceUrl`: `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` + - `entities[0]`: `{ locale: "en-US", country: "US", platform: "Web", timezone: "America/Los_Angeles", type: "clientInfo" }` + - `MSCV`: `V3M44DfUokajTFBIXtrInA.1.1.1.422967360.1.1` +- 🛡️ JWT validated (AzureAd scheme) +- 🔀 Route: `message/(?i)^login$` + +### Step 2 — Silent token check (no cached token) + +📤 **OUTGOING** `GET https://token.botframework.com/api/usertoken/GetToken` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `sso` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL AcquireTokenForClient (source: IdentityProvider, ⏱️ 535ms) — first token from AAD +- ❌ **Response**: `404` (⏱️ 568ms) — no cached user token + +### Step 3 — Get sign-in resource + +📤 **OUTGOING** `GET https://token.botframework.com/api/botsignin/GetSignInResource` +- **Query Parameters**: + - `state`: base64-encoded JSON: + ```json + { + "ConnectionName": "sso", + "Conversation": { + "ActivityId": "1776825925953", + "Bot": { "Id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd" }, + "ChannelId": "msteams", + "Conversation": { "Id": "a:1xH4HncZ6ly...OIM3Z" }, + "ServiceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "User": { "Id": "29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ" } + }, + "MsAppId": "e3cb1c84-14e3-419c-b39c-1c06097b55fd" + } + ``` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- ✅ **Response**: `200` (⏱️ 286ms) — returns signInLink, tokenExchangeResource, tokenPostResource + +### Step 4 — Send OAuthCard to user + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776825925953?isTargetedActivity=true` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- **Request Body**: + ```json + { + "from": { + "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", + "name": "my-bot-sso" + }, + "recipient": { + "id": "29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ", + "name": "Rido", + "isTargeted": true, + "aadObjectId": "03500558-e554-416c-90c3-a061cdcd012b" + }, + "conversation": { + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984", + "conversationType": "personal", + "id": "a:1xH4HncZ6ly...OIM3Z" + }, + "attachments": [{ + "contentType": "application/vnd.microsoft.card.oauth", + "content": { + "text": "Please Sign In", + "connectionName": "sso", + "buttons": [{ + "type": "signin", + "title": "Sign In", + "value": "https://token.botframework.com/api/oauth/signin?signin=893cf4ca0d6943fca7754c614f20451c" + }], + "tokenExchangeResource": { + "Id": "fc67c7b5-d0d4-494c-a0e9-3a7ddec999f0", + "ProviderId": "30dd229c-58e3-4a48-bdfd-91ec48eb906c", + "Uri": "api://botid-e3cb1c84-14e3-419c-b39c-1c06097b55fd" + }, + "tokenPostResource": { + "SasUrl": "https://token.botframework.com/api/sas/postToken?expiry=1776825989&id=key1&state=893cf4ca0d6943fca7754c614f20451c&hmac=..." + } + } + }], + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776825925953" + } + ``` +- ✅ **Response**: `202 Accepted` (⏱️ 631ms) + +🏁 **HTTP Response to Teams**: `200` (total ⏱️ 3034ms) + +### Step 5 — Teams sends signin/tokenExchange invoke + +📥 **INCOMING** `POST http://localhost:3978/api/messages` (2731 bytes) +- **Activity**: + - `type`: `invoke` + - `name`: `signin/tokenExchange` + - `id`: `f:9b40df9c-b27c-55a0-7b42-0d2033f7d213` + - `channelId`: `msteams` + - `timestamp`: `2026-04-22T02:45:29.991Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.name`: `Rido` + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `recipient.name`: `my-bot-sso` + - `conversation.id`: `a:1xH4HncZ6ly...OIM3Z` + - `conversation.conversationType`: `personal` + - `conversation.tenantId`: `3f3d1cea-7a18-41af-872b-cfbbd5140984` + - `serviceUrl`: `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` + - `channelData.source.name`: `message` + - `value`: + - `id`: `fc67c7b5-d0d4-494c-a0e9-3a7ddec999f0` *(matches tokenExchangeResource.Id from OAuthCard)* + - `connectionName`: `sso` + - `token`: SSO JWT (`aud=e3cb1c84...`, `iss=login.microsoftonline.com`, `name=Rido`, `scp=access_as_user`, `preferred_username=rido@teamssdk.onmicrosoft.com`) + - `MSCV`: `M1mwQ79zSkClUOfTm5O0ew.1.2.1.423058522.1.1.0.1.1.0.1.3` +- 🛡️ JWT validated (AzureAd scheme) +- 🔀 Route: `invoke/signin/tokenExchange` + +### Step 6 — Exchange SSO token for user token + +📤 **OUTGOING** `POST https://token.botframework.com/api/usertoken/exchange` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `sso` + - `channelId`: `msteams` +- **Request Body**: `{ "token": "" }` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- ✅ **Response**: `200` (⏱️ 903ms) — user token returned + +### Step 7 — 🎉 OnSignInComplete fires, bot sends confirmation + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/f:9b40df9c-b27c-55a0-7b42-0d2033f7d213` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "f:9b40df9c-b27c-55a0-7b42-0d2033f7d213", + "text": "You're now signed in! Try `profile` or `calendar`.", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` (⏱️ 366ms) + +🏁 **Invoke Response**: `200` (body: null) +🏁 **HTTP Response to Teams**: `200` (total ⏱️ 1308ms) + +--- + +## 👤 Profile Flow (token cached) + +### Step 8 — User sends "profile" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` (1019 bytes) +- **Activity**: + - `type`: `message` + - `id`: `1776825937933` + - `text`: `"profile"` + - `timestamp`: `2026-04-22T02:45:37.9548075Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `MSCV`: `wqMomZDl5k2Mdw7S3YUAsQ.1.1.1.423403741.1.1` +- 🔀 Route: `message/(?i)^profile$` + +### Step 9 — Silent token check (token exists) + +📤 **OUTGOING** `GET https://token.botframework.com/api/usertoken/GetToken` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `sso` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- ✅ **Response**: `200` (⏱️ 214ms) — cached user token returned + +### Step 10 — Call Graph API with token + +📤 **OUTGOING** `GET https://graph.microsoft.com/v1.0/me` +- **Auth**: `Authorization: Bearer {user_token}` +- ✅ **Response**: `200` — `{ displayName: "Rido", mail: "rido@teamssdk.onmicrosoft.com", id: "03500558-e554-416c-90c3-a061cdcd012b" }` + +### Step 11 — Send profile result + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/.../v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776825937933` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776825937933", + "text": "```json\n{\"@odata.context\":\"...\",\"displayName\":\"Rido\",\"givenName\":\"Rido\",\"jobTitle\":\"Not an architect\",\"mail\":\"rido@teamssdk.onmicrosoft.com\",...}\n```", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` (⏱️ 283ms) + +🏁 **HTTP Response to Teams**: `200` (total ⏱️ 664ms) + +--- + +## 🚪 Logout Flow + +### Step 12 — User sends "logout" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` (1018 bytes) +- **Activity**: + - `type`: `message` + - `id`: `1776825945288` + - `text`: `"logout"` + - `timestamp`: `2026-04-22T02:45:45.3792484Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `MSCV`: `xflMC1y26keiHnFL8vvL7g.1.1.1.423642628.1.1` +- 🔀 Route: `message/(?i)^logout$` + +### Step 13 — Sign out user + +📤 **OUTGOING** `DELETE https://token.botframework.com/api/usertoken/SignOut` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `sso` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- ✅ **Response**: `200` (⏱️ 313ms) — token revoked + +### Step 14 — Send confirmation + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/.../v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776825945288` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776825945288", + "text": "Signed out.", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` (⏱️ 339ms) + +🏁 **HTTP Response to Teams**: `200` (total ⏱️ 662ms) + +--- + +## 📊 Request Summary Table + +| # | Direction | Method | Endpoint | Status | Latency | Purpose | +|---|-----------|--------|----------|--------|---------|---------| +| 1 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | ⏱️ 3034ms | 💬 "login" message | +| 2 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/usertoken/GetToken` | ❌ 404 | ⏱️ 568ms | 🔍 Silent token check (miss) | +| 3 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/botsignin/GetSignInResource` | ✅ 200 | ⏱️ 286ms | 🔗 Get sign-in resource | +| 4 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 202 | ⏱️ 631ms | 🃏 Send OAuthCard | +| 5 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | ⏱️ 1308ms | 🔄 signin/tokenExchange invoke | +| 6 | 📤 ⬆️ OUT | POST | `token.botframework.com/api/usertoken/exchange` | ✅ 200 | ⏱️ 903ms | 🔐 SSO token exchange | +| 7 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | ⏱️ 366ms | 🎉 "Signed in!" confirmation | +| 8 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | ⏱️ 664ms | 💬 "profile" message | +| 9 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/usertoken/GetToken` | ✅ 200 | ⏱️ 214ms | 🔍 Silent token check (hit) | +| 10 | 📤 ⬆️ OUT | GET | `graph.microsoft.com/v1.0/me` | ✅ 200 | - | 👤 Graph API call | +| 11 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | ⏱️ 283ms | 📄 Profile response | +| 12 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | ⏱️ 662ms | 💬 "logout" message | +| 13 | 📤 ⬆️ OUT | DELETE | `token.botframework.com/api/usertoken/SignOut` | ✅ 200 | ⏱️ 313ms | 🚪 Revoke token | +| 14 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | ⏱️ 339ms | 💬 "Signed out." confirmation | + +## 🆔 User MRI Usage Across Requests + +| Request | Where User MRI appears | Format | +|---------|----------------------|--------| +| Step 1 (incoming message) | `activity.from.id` | `29:1cgsv1oFLAoTflZ-...` | +| Step 2 (GetToken) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 3 (GetSignInResource) | `state.Conversation.User.Id` (base64 JSON) | `29:1cgsv1oFLAoTflZ-...` | +| Step 4 (Send OAuthCard) | `recipient.id` (reply to user) | `29:1cgsv1oFLAoTflZ-...` | +| Step 5 (tokenExchange invoke) | `activity.from.id` | `29:1cgsv1oFLAoTflZ-...` | +| Step 6 (Exchange token) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 9 (GetToken cached) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 13 (SignOut) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | + +> **Note**: The User MRI (`29:...`) is the Teams-specific identifier. It is used as `userid` in all Token Bot Service calls (GetToken, Exchange, SignOut) and appears in `from.id` on incoming activities and `recipient.id` on outgoing replies. The AAD ObjectId (`03500558-...`) appears separately in `from.aadObjectId` and in the outgoing `recipient.aadObjectId`. + +## 🔑 MSAL Token Acquisitions + +| # | Time | Source | Duration | Scope | +|---|------|--------|----------|-------| +| 1 | 02:45:27Z | 🌐 IdentityProvider | ⏱️ 535ms | `api.botframework.com/.default` | +| 2-14 | 02:45:28-46Z | 💾 Cache | ⏱️ 0ms | `api.botframework.com/.default` | + +First acquisition hit AAD (instance discovery + token POST). All subsequent acquisitions served from in-memory MSAL cache. diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs index 03253a2d..b24f1c1e 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/OAuthCard.cs @@ -34,11 +34,13 @@ public class OAuthCard /// When present, the Teams client attempts a silent token exchange before showing the sign-in button. ///
[JsonPropertyName("tokenExchangeResource")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TokenExchangeResource? TokenExchangeResource { get; set; } /// /// The token post resource for posting the token back after sign-in. /// [JsonPropertyName("tokenPostResource")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TokenPostResource? TokenPostResource { get; set; } } From 71f20f76677df9d2b26af839b19a528c3327c7ed Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Wed, 22 Apr 2026 07:41:23 -0700 Subject: [PATCH 17/22] fix slnx --- core/core.slnx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/core/core.slnx b/core/core.slnx index 97659bfa..7c18250a 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -18,13 +18,9 @@ - - - + - - - + @@ -50,8 +46,6 @@ - - - + From a3f0ae79ae810b6171ce1daf1ca7fe3410ab8cab Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Wed, 22 Apr 2026 14:47:30 -0700 Subject: [PATCH 18/22] Update status message, cleanup OAuth, remove SSO middleware - Include user's name in status message response. - Fix recipient type in OAuthFlow and clarify foreach typing. - Remove unused using directives in several files. - Delete CompatTeamsSSOTokenExchangeMiddleware and related logic. --- core/samples/OAuthFlowBot/Program.cs | 2 +- .../Auth/OAuthFlow.cs | 4 +- .../Auth/OAuthFlowExtensions.cs | 1 - core/src/Microsoft.Teams.Bot.Apps/Context.cs | 1 - .../CompatTeamsSSOTokenExchangeMiddleware.cs | 195 ------------------ 5 files changed, 3 insertions(+), 200 deletions(-) delete mode 100644 core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs diff --git a/core/samples/OAuthFlowBot/Program.cs b/core/samples/OAuthFlowBot/Program.cs index 4bc01ff3..552cc3c7 100644 --- a/core/samples/OAuthFlowBot/Program.cs +++ b/core/samples/OAuthFlowBot/Program.cs @@ -91,7 +91,7 @@ await context.SendActivityAsync( $"{(s.HasToken == true ? "connected" : "not connected")}"); await context.SendActivityAsync( - new MessageActivity("OAuth connections:\n" + string.Join("\n", lines)) + new MessageActivity($"OAuth connections for {context.Activity.From?.Name} :\n" + string.Join("\n", lines)) { TextFormat = TextFormats.Markdown }, ct); diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs index b4ebdb14..ebb617af 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs @@ -174,7 +174,7 @@ public OAuthFlow OnSignInFailure(SignInFailureHandler handler) TeamsActivity oauthActivity = TeamsActivity.CreateBuilder() .WithConversationReference(context.Activity) - .WithRecipient(context.Activity.From, true) + .WithRecipient(context.Activity.From, false) .WithAttachment(attachment) .Build(); @@ -410,7 +410,7 @@ internal async Task HandleSignInFailureAsync(Context kvp in _processedExchanges) { if (kvp.Value < cutoff) { diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs index 84f36d54..9c234a0f 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs @@ -6,7 +6,6 @@ using Microsoft.Teams.Bot.Apps.Handlers; using Microsoft.Teams.Bot.Apps.Routing; using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core; namespace Microsoft.Teams.Bot.Apps.Auth; diff --git a/core/src/Microsoft.Teams.Bot.Apps/Context.cs b/core/src/Microsoft.Teams.Bot.Apps/Context.cs index fbc5af77..70729f4c 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Context.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Context.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Linq; using Microsoft.Teams.Bot.Apps.Api.Clients; using Microsoft.Teams.Bot.Apps.Auth; using Microsoft.Teams.Bot.Apps.Schema; diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs deleted file mode 100644 index 23a9cb6e..00000000 --- a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Net; -using Microsoft.Bot.Builder; -using Microsoft.Bot.Connector; -using Microsoft.Bot.Connector.Authentication; -using Microsoft.Bot.Schema; -using Newtonsoft.Json.Linq; - -namespace Microsoft.Teams.Bot.Compat; - -/// -/// If the activity name is signin/tokenExchange, this middleware will attempt to -/// exchange the token, and deduplicate the incoming call, ensuring only one -/// exchange request is processed. -/// -/// -/// This is a compatibility reimplementation of -/// Microsoft.Bot.Builder.Teams.TeamsSSOTokenExchangeMiddleware that works with -/// the Teams Bot Core SDK via . -/// -/// If a user is signed into multiple Teams clients, the Bot could receive a -/// "signin/tokenExchange" from each client. Each token exchange request for a -/// specific user login will have an identical Activity.Value.Id. -/// -/// Only one of these token exchange requests should be processed by the bot. -/// The others return . -/// For a distributed bot in production, this requires a distributed storage -/// ensuring only one token exchange is processed. This middleware supports -/// CosmosDb storage found in Microsoft.Bot.Builder.Azure, or MemoryStorage for -/// local development. IStorage's ETag implementation for token exchange activity -/// deduplication. -/// -public class CompatTeamsSSOTokenExchangeMiddleware : IMiddleware -{ - private readonly IStorage _storage; - private readonly string _oAuthConnectionName; - - /// - /// Initializes a new instance of the class. - /// - /// The to use for deduplication. - /// The connection name to use for the single sign on token exchange. - public CompatTeamsSSOTokenExchangeMiddleware(IStorage storage, string connectionName) - { - ArgumentNullException.ThrowIfNull(storage); - ArgumentException.ThrowIfNullOrEmpty(connectionName); - - _oAuthConnectionName = connectionName; - _storage = storage; - } - - /// - public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(turnContext); - ArgumentNullException.ThrowIfNull(next); - - if (string.Equals(Channels.Msteams, turnContext.Activity.ChannelId, StringComparison.OrdinalIgnoreCase) - && string.Equals(SignInConstants.TokenExchangeOperationName, turnContext.Activity.Name, StringComparison.OrdinalIgnoreCase)) - { - // If the TokenExchange is NOT successful, the response will have already been sent by ExchangedTokenAsync - if (!await ExchangedTokenAsync(turnContext, cancellationToken).ConfigureAwait(false)) - { - return; - } - - // Only one token exchange should proceed from here. Deduplication is performed second because in the case - // of failure due to consent required, every caller needs to receive the response. - if (!await DeduplicatedTokenExchangeIdAsync(turnContext, cancellationToken).ConfigureAwait(false)) - { - // If the token is not exchangeable, do not process this activity further. - return; - } - } - - await next(cancellationToken).ConfigureAwait(false); - } - - private async Task DeduplicatedTokenExchangeIdAsync(ITurnContext turnContext, CancellationToken cancellationToken) - { - // Create a StoreItem with Etag of the unique 'signin/tokenExchange' request - var storeItem = new TokenStoreItem - { - ETag = (turnContext.Activity.Value as JObject)!.Value("id") - }; - - var storeItems = new Dictionary { { TokenStoreItem.GetStorageKey(turnContext), storeItem } }; - try - { - // Writing the IStoreItem with ETag of unique id will succeed only once - await _storage.WriteAsync(storeItems, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - - // Memory storage throws a generic exception with a Message of 'Etag conflict. [other error info]' - // CosmosDbPartitionedStorage throws: ex.Message.Contains("pre-condition is not met") - when (ex.Message.StartsWith("Etag conflict", StringComparison.OrdinalIgnoreCase) || ex.Message.Contains("pre-condition is not met", StringComparison.OrdinalIgnoreCase)) - { - // Do NOT proceed processing this message, some other thread or machine already has processed it. - - // Send 200 invoke response. - await SendInvokeResponseAsync(turnContext, cancellationToken: cancellationToken).ConfigureAwait(false); - return false; - } - - return true; - } - - private static async Task SendInvokeResponseAsync(ITurnContext turnContext, object? body = null, HttpStatusCode httpStatusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) - { - await turnContext.SendActivityAsync( - new Activity - { - Type = ActivityTypesEx.InvokeResponse, - Value = new InvokeResponse - { - Status = (int)httpStatusCode, - Body = body, - }, - }, cancellationToken).ConfigureAwait(false); - } - - private async Task ExchangedTokenAsync(ITurnContext turnContext, CancellationToken cancellationToken) - { - TokenResponse? tokenExchangeResponse = null; - var tokenExchangeRequest = ((JObject)turnContext.Activity.Value)?.ToObject(); - - try - { - var userTokenClient = turnContext.TurnState.Get(); - if (userTokenClient != null) - { - tokenExchangeResponse = await userTokenClient.ExchangeTokenAsync( - turnContext.Activity.From.Id, - _oAuthConnectionName, - turnContext.Activity.ChannelId, - new TokenExchangeRequest { Token = tokenExchangeRequest!.Token }, - cancellationToken).ConfigureAwait(false); - } - else - { - throw new NotSupportedException("Token Exchange is not supported by the current adapter."); - } - } -#pragma warning disable CA1031 // Do not catch general exception types (matching BF SDK behavior) - catch -#pragma warning restore CA1031 - { - // Ignore Exceptions - // If token exchange failed for any reason, tokenExchangeResponse above stays null, - // and hence we send back a failure invoke response to the caller. - } - - if (string.IsNullOrEmpty(tokenExchangeResponse?.Token)) - { - // The token could not be exchanged (which could be due to a consent requirement) - // Notify the sender that PreconditionFailed so they can respond accordingly. - - var invokeResponse = new TokenExchangeInvokeResponse - { - Id = tokenExchangeRequest!.Id, - ConnectionName = _oAuthConnectionName, - FailureDetail = "The bot is unable to exchange token. Proceed with regular login.", - }; - - await SendInvokeResponseAsync(turnContext, invokeResponse, HttpStatusCode.PreconditionFailed, cancellationToken).ConfigureAwait(false); - - return false; - } - - return true; - } - - private class TokenStoreItem : IStoreItem - { - public string? ETag { get; set; } - - public static string GetStorageKey(ITurnContext turnContext) - { - var activity = turnContext.Activity; - var channelId = activity.ChannelId ?? throw new InvalidOperationException("invalid activity-missing channelId"); - var conversationId = activity.Conversation?.Id ?? throw new InvalidOperationException("invalid activity-missing Conversation.Id"); - - var value = activity.Value as JObject; - if (value == null || !value.ContainsKey("id")) - { - throw new InvalidOperationException("Invalid signin/tokenExchange. Missing activity.Value.Id."); - } - - return $"{channelId}/{conversationId}/{value.Value("id")}"; - } - } -} From 56979298ba72ac1a8e15168d7cabc6ecd9d5a94f Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Wed, 22 Apr 2026 18:02:08 -0700 Subject: [PATCH 19/22] Add DI-based OAuth flow registration for TeamsBotApp Introduce TeamsBotApplicationOptions for DI configuration of OAuth flows, enabling registration of connection name, card text, and button text at startup. Overload AddTeamsBotApplication to accept configuration delegates. Update TeamsBotApplication to auto-register flows from DI options and provide GetOAuthFlow for callback attachment. Enhance OAuthFlow to support default OAuthOptions per flow. Update docs and samples to demonstrate the new pattern. Improve status messages, JSON formatting, and add validation. --- core/docs/sso/OAuthFlow-Design.md | 87 +++++++++++++------ core/samples/OAuthFlowBot/Program.cs | 31 +++++-- core/samples/SsoBot/Program.cs | 21 +++-- .../Auth/OAuthFlow.cs | 6 +- .../Auth/OAuthFlowExtensions.cs | 18 +++- .../Auth/OAuthOptions.cs | 2 + .../TeamsBotApplication.HostingExtensions.cs | 30 +++++++ .../TeamsBotApplication.cs | 37 +++++++- .../TeamsBotApplicationOptions.cs | 33 +++++++ 9 files changed, 218 insertions(+), 47 deletions(-) create mode 100644 core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationOptions.cs diff --git a/core/docs/sso/OAuthFlow-Design.md b/core/docs/sso/OAuthFlow-Design.md index 52028d30..ecb2ca62 100644 --- a/core/docs/sso/OAuthFlow-Design.md +++ b/core/docs/sso/OAuthFlow-Design.md @@ -45,7 +45,7 @@ Developers can use **either** the context-level API (simple, matches Teams SDK v | Scenario | Context API (simple) | OAuthFlow API (advanced) | |---|---|---| -| Sign in | `context.SignIn(new OAuthOptions { ConnectionName = "gh" })` | `githubAuth.SignInAsync(context)` | +| Sign in | `context.SignIn(new OAuthOptions { ConnectionName = "gh" })` | `githubAuth.SignInAsync(context)` (uses options from `AddOAuthFlow`) | | Sign out | `context.SignOut("gh")` | `githubAuth.SignOutAsync(context)` | | Check status | `context.IsSignedInAsync("gh")` | `githubAuth.IsSignedInAsync(context)` | | All connections | `context.GetConnectionStatusAsync()` | `graphAuth.GetConnectionStatusAsync(context)` | @@ -83,10 +83,14 @@ await context.SignIn(); // uses context.ConnectionName ("graph") bot.AddOAuthFlow("graph"); // single flow → becomes the default await context.SignIn(); // works (single flow auto-resolves) -// New -- multiple flows, must specify connection -bot.AddOAuthFlow("graph"); -bot.AddOAuthFlow("gh"); -await context.SignIn(new OAuthOptions { ConnectionName = "gh" }); +// New -- multiple flows with options configured at registration +var ghAuth = bot.AddOAuthFlow(new OAuthOptions +{ + ConnectionName = "gh", + OAuthCardText = "Sign in to GitHub", + SignInButtonText = "Sign In" +}); +await ghAuth.SignInAsync(context); // uses options from registration ``` **Migration**: Replace reads of `context.ConnectionName` with the explicit connection name in `OAuthOptions` or `SignOut(connectionName)`. @@ -337,17 +341,41 @@ Each failure is logged with the user ID, conversation ID, failure code, and mess ## API Surface -### Registration +### Registration (DI pattern — recommended) + +```csharp +// Configure OAuth flows during service registration +services.AddTeamsBotApplication(options => +{ + options.AddOAuthFlow("GraphConnection", o => + { + o.OAuthCardText = "Sign in to your Microsoft account"; + o.SignInButtonText = "Sign In to Graph"; + }); + options.AddOAuthFlow("GitHubConnection"); // uses defaults +}); + +// Flows are auto-registered when the bot is constructed. +// Access them for callbacks: +TeamsBotApplication bot = app.UseTeamsBotApplication(); +bot.GetOAuthFlow("GraphConnection").OnSignInComplete(async (ctx, token, ct) => { ... }); +``` + +### Registration (imperative — on the bot instance) ```csharp public static class OAuthFlowExtensions { - /// Register an OAuthFlow with an explicit connection name. + /// Register an OAuthFlow with an explicit connection name (uses default OAuthCard text). public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connectionName); + + /// Register an OAuthFlow with OAuthOptions that configure the connection name + /// and default OAuthCard text. Per-call options override these defaults. + public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, OAuthOptions options); } ``` -`AddOAuthFlow` registers three routes on the app's `Router`: +Both approaches register three routes on the app's `Router`: | Route name | Activity type | Purpose | |---|---|---| @@ -550,19 +578,33 @@ Azure Bot resource has two OAuth connection settings: | `GraphConnection` | Azure AD v2 | `User.Read Calendars.Read` | | `GitHubConnection` | GitHub | `repo read:user` | -### Registration (using context API) +### Registration (DI pattern — recommended) ```csharp var builder = WebApplication.CreateSlimBuilder(args); -builder.Services.AddTeamsBotApplication(); + +// Configure OAuth flows at the DI level +builder.Services.AddTeamsBotApplication(options => +{ + options.AddOAuthFlow("GraphConnection", o => + { + o.OAuthCardText = "Sign in to your Microsoft account"; + o.SignInButtonText = "Sign In to Graph"; + }); + options.AddOAuthFlow("GitHubConnection", o => + { + o.OAuthCardText = "Sign in to your GitHub account"; + o.SignInButtonText = "Sign In to GitHub"; + }); +}); + var app = builder.Build(); TeamsBotApplication bot = app.UseTeamsBotApplication(); -// Register two OAuthFlow instances -OAuthFlow graphAuth = bot.AddOAuthFlow("GraphConnection"); -OAuthFlow githubAuth = bot.AddOAuthFlow("GitHubConnection"); +// Get pre-registered flows and attach callbacks +OAuthFlow graphAuth = bot.GetOAuthFlow("GraphConnection"); +OAuthFlow githubAuth = bot.GetOAuthFlow("GitHubConnection"); -// Sign-in complete callbacks graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => { await context.SendActivityAsync($"Connected to Graph ({tokenResponse.ConnectionName})!", ct); @@ -573,15 +615,10 @@ githubAuth.OnSignInComplete(async (context, tokenResponse, ct) => await context.SendActivityAsync($"Connected to GitHub ({tokenResponse.ConnectionName})!", ct); }); -// Context-based API -- connection specified per-call +// SignInAsync uses the OAuthCardText/SignInButtonText configured at registration bot.OnMessage(@"(?i)^login graph$", async (context, ct) => { - string? token = await context.SignIn(new OAuthOptions - { - ConnectionName = "GraphConnection", - OAuthCardText = "Sign in to your Microsoft account", - SignInButtonText = "Sign In to Graph" - }, ct); + string? token = await graphAuth.SignInAsync(context, ct); if (token is not null) await context.SendActivityAsync("Already signed in to Graph.", ct); @@ -589,12 +626,7 @@ bot.OnMessage(@"(?i)^login graph$", async (context, ct) => bot.OnMessage(@"(?i)^login github$", async (context, ct) => { - string? token = await context.SignIn(new OAuthOptions - { - ConnectionName = "GitHubConnection", - OAuthCardText = "Sign in to your GitHub account", - SignInButtonText = "Sign In to GitHub" - }, ct); + string? token = await githubAuth.SignInAsync(context, ct); if (token is not null) await context.SendActivityAsync("Already signed in to GitHub.", ct); @@ -632,6 +664,7 @@ When multiple `OAuthFlow` instances are registered, invoke routes are registered | File | Location | |---|---| +| `TeamsBotApplicationOptions.cs` | `Microsoft.Teams.Bot.Apps/TeamsBotApplicationOptions.cs` | | `OAuthFlow.cs` | `Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs` | | `OAuthFlowExtensions.cs` | `Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs` | | `OAuthOptions.cs` | `Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs` | diff --git a/core/samples/OAuthFlowBot/Program.cs b/core/samples/OAuthFlowBot/Program.cs index 552cc3c7..5cae285c 100644 --- a/core/samples/OAuthFlowBot/Program.cs +++ b/core/samples/OAuthFlowBot/Program.cs @@ -15,29 +15,42 @@ using Microsoft.Teams.Bot.Apps.Auth; using Microsoft.Teams.Bot.Apps.Handlers; using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core.Schema; WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); -webAppBuilder.Services.AddTeamsBotApplication(); + +// Configure OAuth flows at the DI level -- card text is set once here +webAppBuilder.Services.AddTeamsBotApplication(options => +{ + options.AddOAuthFlow("teamsgraph", o => + { + o.OAuthCardText = "Sign in to your Microsoft account"; + o.SignInButtonText = "Sign In to Graph"; + }); + options.AddOAuthFlow("gh", o => + { + o.OAuthCardText = "Sign in to your GitHub account"; + o.SignInButtonText = "Sign In to GitHub"; + }); +}); + WebApplication webApp = webAppBuilder.Build(); TeamsBotApplication bot = webApp.UseTeamsBotApplication(); // ==================== OAUTH FLOW SETUP ==================== -// Register two OAuthFlow instances, one per connections -OAuthFlow graphAuth = bot.AddOAuthFlow("teamsgraph"); -OAuthFlow githubAuth = bot.AddOAuthFlow("gh"); +// Get the pre-registered flows and attach callbacks +OAuthFlow graphAuth = bot.GetOAuthFlow("teamsgraph"); +OAuthFlow githubAuth = bot.GetOAuthFlow("gh"); -// Sign-in complete callbacks graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => { - await context.SendActivityAsync($"Connected to Microsoft Graph ({tokenResponse.ConnectionName})!", ct); + await context.SendActivityAsync($"User {context.Activity.From?.Name} connected to Microsoft Graph ({tokenResponse.ConnectionName})!", ct); }); githubAuth.OnSignInComplete(async (context, tokenResponse, ct) => { - await context.SendActivityAsync($"Connected to GitHub ({tokenResponse.ConnectionName})!", ct); + await context.SendActivityAsync($"User {context.Activity.From?.Name} connected to GitHub ({tokenResponse.ConnectionName})!", ct); }); // ==================== MESSAGE HANDLERS ==================== @@ -88,7 +101,7 @@ await context.SendActivityAsync( var statuses = await graphAuth.GetConnectionStatusAsync(context, ct); var lines = statuses.Select(s => $"- **{s.ConnectionName}** ({s.ServiceProviderDisplayName}): " + - $"{(s.HasToken == true ? "connected" : "not connected")}"); + $"{(s.HasToken == true ? "✅ connected" : "❌ not connected")}"); await context.SendActivityAsync( new MessageActivity($"OAuth connections for {context.Activity.From?.Name} :\n" + string.Join("\n", lines)) diff --git a/core/samples/SsoBot/Program.cs b/core/samples/SsoBot/Program.cs index b7db280b..58ccd766 100644 --- a/core/samples/SsoBot/Program.cs +++ b/core/samples/SsoBot/Program.cs @@ -10,20 +10,27 @@ // |-------------------|-------------|--------------------------| // | GraphConnection | Azure AD v2 | User.Read Calendars.Read | +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Teams.Bot.Apps; using Microsoft.Teams.Bot.Apps.Auth; using Microsoft.Teams.Bot.Apps.Handlers; using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core.Schema; WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); -webAppBuilder.Services.AddTeamsBotApplication(); + +// Configure the single OAuth flow at the DI level +webAppBuilder.Services.AddTeamsBotApplication(options => +{ + options.AddOAuthFlow("sso"); +}); + WebApplication webApp = webAppBuilder.Build(); TeamsBotApplication bot = webApp.UseTeamsBotApplication(); -// Register a single OAuthFlow -- this becomes the default for all context.SignIn/SignOut calls -OAuthFlow auth = bot.AddOAuthFlow("sso"); +// Get the pre-registered flow and attach callbacks +OAuthFlow auth = bot.GetOAuthFlow("sso"); auth.OnSignInComplete(async (context, tokenResponse, ct) => { @@ -63,7 +70,8 @@ try { string json = await http.GetStringAsync("https://graph.microsoft.com/v1.0/me", ct); - await context.SendActivityAsync($"```json\n{json}\n```", ct); + string indentedJson = JsonSerializer.Serialize(JsonSerializer.Deserialize(json), new JsonSerializerOptions { WriteIndented = true }); + await context.SendActivityAsync(new MessageActivity($" ## Graph Me \n ```json\n{indentedJson}\n```") { TextFormat = TextFormats.Markdown }, ct); } catch (HttpRequestException ex) { @@ -83,7 +91,8 @@ { string json = await http.GetStringAsync( "https://graph.microsoft.com/v1.0/me/events?$top=3&$select=subject,start,end&$orderby=start/dateTime", ct); - await context.SendActivityAsync($"```json\n{json}\n```", ct); + string indentedJson = JsonSerializer.Serialize(JsonSerializer.Deserialize(json), new JsonSerializerOptions { WriteIndented = true }); + await context.SendActivityAsync(new MessageActivity($" ## Graph Calendar \n ```json\n{indentedJson}\n```") { TextFormat = TextFormats.Markdown }, ct); } catch (HttpRequestException ex) { diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs index ebb617af..d2aa3c81 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs @@ -37,6 +37,7 @@ public class OAuthFlow private readonly TeamsBotApplication _app; private readonly ILogger _logger; private readonly string _connectionName; + private readonly OAuthOptions _defaultOptions; private SignInCompleteHandler? _onSignInComplete; private SignInFailureHandler? _onSignInFailure; @@ -44,10 +45,11 @@ public class OAuthFlow // Teams may send duplicates from multiple endpoints (mobile, desktop, web). private readonly ConcurrentDictionary _processedExchanges = new(); - internal OAuthFlow(TeamsBotApplication app, string connectionName, ILogger logger) + internal OAuthFlow(TeamsBotApplication app, string connectionName, OAuthOptions options, ILogger logger) { _app = app; _connectionName = connectionName; + _defaultOptions = options; _logger = logger; } @@ -116,7 +118,7 @@ public OAuthFlow OnSignInFailure(SignInFailureHandler handler) public async Task SignInAsync(Context context, OAuthOptions? options, CancellationToken cancellationToken = default) where TActivity : TeamsActivity { ArgumentNullException.ThrowIfNull(context); - options ??= new OAuthOptions(); + options ??= _defaultOptions; string userId = GetUserId(context); string channelId = GetChannelId(context); diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs index 9c234a0f..cc1ae75f 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs @@ -22,14 +22,28 @@ public static class OAuthFlowExtensions /// The OAuth connection name configured on the bot. /// The instance for configuring callbacks. public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connectionName) + => AddOAuthFlow(app, new OAuthOptions { ConnectionName = connectionName }); + + /// + /// Register an with that configure both the + /// connection name and the default OAuthCard text shown during sign-in. + /// Per-call options passed to + /// override these defaults. + /// + /// The Teams bot application. + /// OAuth options. is required. + /// The instance for configuring callbacks. + public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, OAuthOptions options) { ArgumentNullException.ThrowIfNull(app); - ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.ConnectionName, nameof(options.ConnectionName)); + string connectionName = options.ConnectionName; OAuthFlowRegistry registry = GetOrCreateRegistry(app); ILogger logger = GetLogger(app); - OAuthFlow flow = new(app, connectionName, logger); + OAuthFlow flow = new(app, connectionName, options, logger); registry.Register(connectionName, flow); return flow; diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs index f1edff42..d295dc10 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthOptions.cs @@ -10,6 +10,8 @@ public class OAuthOptions { /// /// The OAuth connection name to use. If null, uses the default registered connection. + /// When passed to , + /// this is required and identifies the connection. /// public string? ConnectionName { get; set; } diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs index b38b0ff0..e1168bf3 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs @@ -36,6 +36,18 @@ public static IServiceCollection AddTeamsBotApplication(this IServiceCollection return AddTeamsBotApplication(services, sectionName); } + /// + /// Adds the default TeamsBotApplication with configuration options. + /// + /// The service collection. + /// A delegate to configure . + /// The configuration section name for AzureAd settings. Default is "AzureAd". + /// The service collection for chaining. + public static IServiceCollection AddTeamsBotApplication(this IServiceCollection services, Action configure, string sectionName = "AzureAd") + { + return AddTeamsBotApplication(services, configure, sectionName); + } + /// /// Adds a custom TeamsBotApplication /// @@ -43,11 +55,29 @@ public static IServiceCollection AddTeamsBotApplication(this IServiceCollection /// The configuration section name for AzureAd settings. Default is "AzureAd". /// The updated WebApplicationBuilder instance. public static IServiceCollection AddTeamsBotApplication(this IServiceCollection services, string sectionName = "AzureAd") where TApp : TeamsBotApplication + { + return AddTeamsBotApplication(services, configure: null, sectionName); + } + + /// + /// Adds a custom TeamsBotApplication with configuration options. + /// + /// The custom TeamsBotApplication type. + /// The service collection. + /// A delegate to configure . Can be null. + /// The configuration section name for AzureAd settings. Default is "AzureAd". + /// The service collection for chaining. + public static IServiceCollection AddTeamsBotApplication(this IServiceCollection services, Action? configure, string sectionName = "AzureAd") where TApp : TeamsBotApplication { BotConfig botConfig = BotConfig.Resolve(services, sectionName); services.AddBotClient(nameof(ApiClient), botConfig); + // Register TeamsBotApplicationOptions + TeamsBotApplicationOptions teamsOptions = new(); + configure?.Invoke(teamsOptions); + services.AddSingleton(teamsOptions); + services.AddBotApplication(botConfig); return services; } diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs index c41938d2..f0ce38ee 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs @@ -30,6 +30,30 @@ public class TeamsBotApplication : BotApplication ///
internal OAuthFlowRegistry? OAuthRegistry { get; set; } + /// + /// Gets a registered by connection name. + /// Use this to attach callbacks (, ) + /// to flows that were configured via . + /// + /// The OAuth connection name. + /// The instance. + /// No flow is registered for the given connection name. + public OAuthFlow GetOAuthFlow(string connectionName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + + OAuthFlow? flow = OAuthRegistry?.Resolve(connectionName); + if (flow is null) + { + IEnumerable registered = OAuthRegistry?.GetRegisteredConnectionNames() ?? []; + throw new InvalidOperationException( + $"No OAuthFlow registered for connection '{connectionName}'. " + + $"Registered connections: [{string.Join(", ", registered)}]."); + } + + return flow; + } + /// /// Gets the client used to interact with the Teams API service. /// @@ -56,18 +80,29 @@ public class TeamsBotApplication : BotApplication /// /// /// Options containing the application (client) ID, used for logging and diagnostics. Defaults to an empty instance if not provided. + /// Teams-specific options including OAuth flow configuration. Defaults to an empty instance if not provided. public TeamsBotApplication( ConversationClient conversationClient, UserTokenClient userTokenClient, ApiClient teamsApiClient, IHttpContextAccessor httpContextAccessor, ILogger logger, - BotApplicationOptions? options = null) + BotApplicationOptions? options = null, + TeamsBotApplicationOptions? teamsOptions = null) : base(conversationClient, userTokenClient, logger, options) { _teamsApiClient = teamsApiClient; Api = teamsApiClient; Router = new Router(logger); + + // Auto-register OAuth flows from DI options + if (teamsOptions is not null) + { + foreach (var descriptor in teamsOptions.OAuthFlows) + { + this.AddOAuthFlow(descriptor.Options); + } + } OnActivity = async (activity, cancellationToken) => { logger.LogInformation("OnActivity invoked for activity: Id={Id}", activity.Id); diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationOptions.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationOptions.cs new file mode 100644 index 00000000..1fbcd52e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Auth; + +namespace Microsoft.Teams.Bot.Apps; + +/// +/// Options for configuring a . +/// +public sealed class TeamsBotApplicationOptions +{ + internal List OAuthFlows { get; } = []; + + /// + /// Register an OAuth flow with the given connection name and optional configuration. + /// + /// The OAuth connection name configured on the bot. + /// Optional delegate to configure the (card text, button text). + /// This instance for chaining. + public TeamsBotApplicationOptions AddOAuthFlow(string connectionName, Action? configure = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + + OAuthOptions options = new() { ConnectionName = connectionName }; + configure?.Invoke(options); + + OAuthFlows.Add(new OAuthFlowDescriptor(connectionName, options)); + return this; + } + + internal sealed record OAuthFlowDescriptor(string ConnectionName, OAuthOptions Options); +} From 06df7980d75f09d2e7f226622ce099442ba4cd31 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 23 Apr 2026 16:04:07 -0700 Subject: [PATCH 20/22] Track pending sign-ins per user in OAuthFlow Add per-user pending sign-in tracking to OAuthFlow for accurate scoping of signin/failure notifications. Update sign-in, token exchange, and verify state logic to manage pending state. Refactor cleanup logic and enhance signin/failure handling for multi-instance support. Add unit tests for all scenarios and include Moq for test mocking. --- core/samples/OAuthFlowBot/Program.cs | 14 +- .../Auth/OAuthFlow.cs | 48 +- .../Auth/OAuthFlowExtensions.cs | 16 +- .../Microsoft.Teams.Bot.Apps.UnitTests.csproj | 1 + .../OAuthFlowTests.cs | 513 ++++++++++++++++++ 5 files changed, 584 insertions(+), 8 deletions(-) create mode 100644 core/test/Microsoft.Teams.Bot.Apps.UnitTests/OAuthFlowTests.cs diff --git a/core/samples/OAuthFlowBot/Program.cs b/core/samples/OAuthFlowBot/Program.cs index 5cae285c..95aa1e1a 100644 --- a/core/samples/OAuthFlowBot/Program.cs +++ b/core/samples/OAuthFlowBot/Program.cs @@ -21,7 +21,7 @@ // Configure OAuth flows at the DI level -- card text is set once here webAppBuilder.Services.AddTeamsBotApplication(options => { - options.AddOAuthFlow("teamsgraph", o => + options.AddOAuthFlow("sso-bad", o => { o.OAuthCardText = "Sign in to your Microsoft account"; o.SignInButtonText = "Sign In to Graph"; @@ -40,7 +40,7 @@ // ==================== OAUTH FLOW SETUP ==================== // Get the pre-registered flows and attach callbacks -OAuthFlow graphAuth = bot.GetOAuthFlow("teamsgraph"); +OAuthFlow graphAuth = bot.GetOAuthFlow("sso-bad"); OAuthFlow githubAuth = bot.GetOAuthFlow("gh"); graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => @@ -48,11 +48,21 @@ await context.SendActivityAsync($"User {context.Activity.From?.Name} connected to Microsoft Graph ({tokenResponse.ConnectionName})!", ct); }); +graphAuth.OnSignInFailure(async (context, failure, ct) => +{ + await context.SendActivityAsync($"User {context.Activity.From?.Name} failed to connect to Microsoft Graph. {failure?.Message}", ct); +}); + githubAuth.OnSignInComplete(async (context, tokenResponse, ct) => { await context.SendActivityAsync($"User {context.Activity.From?.Name} connected to GitHub ({tokenResponse.ConnectionName})!", ct); }); +githubAuth.OnSignInFailure(async (context, failure, ct) => +{ + await context.SendActivityAsync($"User {context.Activity.From?.Name} failed to connect to GitHub. {failure?.Message}", ct); +}); + // ==================== MESSAGE HANDLERS ==================== bot.OnMessage("(?i)^help$", async (context, ct) => diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs index d2aa3c81..17323925 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs @@ -45,6 +45,10 @@ public class OAuthFlow // Teams may send duplicates from multiple endpoints (mobile, desktop, web). private readonly ConcurrentDictionary _processedExchanges = new(); + // Tracks users with a pending sign-in (OAuthCard sent, waiting for tokenExchange/verifyState/failure). + // Used to scope signin/failure notifications to flows that actually initiated a sign-in. + private readonly ConcurrentDictionary _pendingSignIns = new(); + internal OAuthFlow(TeamsBotApplication app, string connectionName, OAuthOptions options, ILogger logger) { _app = app; @@ -181,6 +185,10 @@ public OAuthFlow OnSignInFailure(SignInFailureHandler handler) .Build(); await context.SendActivityAsync(oauthActivity, cancellationToken).ConfigureAwait(false); + + // Track that this user has a pending sign-in for this flow + _pendingSignIns[userId] = DateTimeOffset.UtcNow; + return null; } @@ -245,7 +253,7 @@ internal async Task HandleTokenExchangeAsync(Context HandleTokenExchangeAsync(Context HandleTokenExchangeAsync(Context HandleVerifyStateAsync(Context HandleVerifyStateAsync(Context HandleVerifyStateAsync(Context HandleVerifyStateAsync(Context + /// Whether this flow has a pending sign-in for the given user. + /// Used to scope signin/failure notifications to flows that initiated a sign-in. + ///
+ /// + /// Best-effort: in multi-instance deployments the OAuthCard may have been sent by a different instance, + /// so this check may return false even when a sign-in is active. Callers should fall back + /// to notifying all flows when no flow reports a pending sign-in. + /// + internal bool HasPendingSignIn(string userId) + { + return _pendingSignIns.ContainsKey(userId); + } + /// /// Handles the signin/failure invoke activity sent by the Teams client when SSO fails client-side. /// internal async Task HandleSignInFailureAsync(Context context, SignInFailureValue failureValue, CancellationToken cancellationToken) { + string? userId = context.Activity.From?.Id; + if (userId is not null) + { + _pendingSignIns.TryRemove(userId, out _); + } + _logger.LogWarning( "Sign-in failed for user '{UserId}' in conversation '{ConversationId}': {FailureCode} — {FailureMessage}.{Guidance}", - context.Activity.From?.Id, + userId, context.Activity.Conversation?.Id, failureValue.Code, failureValue.Message, @@ -409,7 +444,7 @@ internal async Task HandleSignInFailureAsync(Context kvp in _processedExchanges) @@ -419,6 +454,13 @@ private void CleanupExpiredExchanges() _processedExchanges.TryRemove(kvp.Key, out _); } } + foreach (KeyValuePair kvp in _pendingSignIns) + { + if (kvp.Value < cutoff) + { + _pendingSignIns.TryRemove(kvp.Key, out _); + } + } } private static string GetUserId(Context context) where TActivity : TeamsActivity diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs index cc1ae75f..3d4eddcb 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlowExtensions.cs @@ -100,9 +100,19 @@ private static void RegisterRoutes(TeamsBotApplication app, OAuthFlowRegistry re { InvokeActivity typedActivity = new(ctx.Activity); SignInFailureValue failureValue = typedActivity.Value ?? new SignInFailureValue(); - - // signin/failure doesn't carry a connection name, so notify all registered flows - foreach (OAuthFlow flow in registry.GetAllFlows()) + string? userId = ctx.Activity.From?.Id; + + // signin/failure doesn't carry a connection name. + // Scope to flows that have an active sign-in for this user; + // fall back to all flows if none report a pending sign-in + // (e.g., multi-instance deployment where the OAuthCard was sent by another node). + IEnumerable allFlows = registry.GetAllFlows(); + List activeFlows = userId is not null + ? allFlows.Where(f => f.HasPendingSignIn(userId)).ToList() + : []; + IEnumerable targetFlows = activeFlows.Count > 0 ? activeFlows : allFlows; + + foreach (OAuthFlow flow in targetFlows) { await flow.HandleSignInFailureAsync(ctx, failureValue, cancellationToken).ConfigureAwait(false); } diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/Microsoft.Teams.Bot.Apps.UnitTests.csproj b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/Microsoft.Teams.Bot.Apps.UnitTests.csproj index e0b982ab..435231d7 100644 --- a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/Microsoft.Teams.Bot.Apps.UnitTests.csproj +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/Microsoft.Teams.Bot.Apps.UnitTests.csproj @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + all diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/OAuthFlowTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/OAuthFlowTests.cs new file mode 100644 index 00000000..8b8f7152 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/OAuthFlowTests.cs @@ -0,0 +1,513 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Apps.Api.Clients; +using Microsoft.Teams.Bot.Apps.Auth; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; +using Moq; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class OAuthFlowTests +{ + private const string GraphConnection = "graph"; + private const string GitHubConnection = "github"; + private const string TestUserId = "user-1"; + private const string TestChannelId = "msteams"; + + // ==================== signin/failure scoping ==================== + + [Fact] + public async Task SignInFailure_OnlyNotifiesFlowWithPendingSignIn() + { + // Arrange + TestHarness harness = CreateHarness(GraphConnection, GitHubConnection); + bool graphFailureFired = false; + bool githubFailureFired = false; + + harness.GraphFlow!.OnSignInFailure((_, _, _) => { graphFailureFired = true; return Task.CompletedTask; }); + harness.GitHubFlow!.OnSignInFailure((_, _, _) => { githubFailureFired = true; return Task.CompletedTask; }); + + // Initiate sign-in only for Graph (sends OAuthCard -> marks pending) + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow.SignInAsync(ctx); + + // Act - simulate signin/failure invoke for the same user + Context failureCtx = CreateInvokeContext(harness, TestUserId); + SignInFailureValue failureValue = new() { Code = "tokenmissing", Message = "Token acquisition failed." }; + + // The route handler filters by HasPendingSignIn, so verify the flags + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + Assert.False(harness.GitHubFlow.HasPendingSignIn(TestUserId)); + + await harness.GraphFlow.HandleSignInFailureAsync(failureCtx, failureValue, CancellationToken.None); + + // Assert - only Graph callback fired + Assert.True(graphFailureFired); + Assert.False(githubFailureFired); + } + + [Fact] + public async Task SignInFailure_ClearsPendingSignIn() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow!.SignInAsync(ctx); + + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + + // Act + Context failureCtx = CreateInvokeContext(harness, TestUserId); + await harness.GraphFlow.HandleSignInFailureAsync(failureCtx, new SignInFailureValue { Code = "invokeerror" }, CancellationToken.None); + + // Assert + Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + [Fact] + public async Task TokenExchange_Success_ClearsPendingSignIn() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow!.SignInAsync(ctx); + + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + + // Arrange exchange + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny())) + .ReturnsAsync(new GetTokenResult { Token = "access-token", ConnectionName = GraphConnection }); + + SignInTokenExchangeValue exchangeValue = new() { Id = "exchange-1", ConnectionName = GraphConnection, Token = "sso-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + // Act + InvokeResponse response = await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + + // Assert + Assert.Equal(200, response.Status); + Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + [Fact] + public async Task TokenExchange_Failure_ClearsPendingSignIn() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow!.SignInAsync(ctx); + + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + + // Arrange exchange failure + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "bad-token", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Unauthorized", null, System.Net.HttpStatusCode.Unauthorized)); + + SignInTokenExchangeValue exchangeValue = new() { Id = "exchange-2", ConnectionName = GraphConnection, Token = "bad-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + // Act + InvokeResponse response = await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + + // Assert - 401 passed through (unexpected code) + Assert.Equal(401, response.Status); + Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + [Fact] + public async Task VerifyState_Success_ClearsPendingSignIn() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow!.SignInAsync(ctx); + + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + + // Arrange verify state + harness.MockUserTokenClient + .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "123456", It.IsAny())) + .ReturnsAsync(new GetTokenResult { Token = "access-token", ConnectionName = GraphConnection }); + + SignInVerifyStateValue verifyValue = new() { State = "123456" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + // Act + InvokeResponse response = await harness.GraphFlow.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + + // Assert + Assert.Equal(200, response.Status); + Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + // ==================== No pending sign-in for unrelated user ==================== + + [Fact] + public async Task HasPendingSignIn_FalseForDifferentUser() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow!.SignInAsync(ctx); + + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + Assert.False(harness.GraphFlow.HasPendingSignIn("other-user")); + } + + // ==================== Token exchange error code mapping ==================== + + [Fact] + public async Task TokenExchange_ExpectedError_Returns412WithBody() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Not found", null, System.Net.HttpStatusCode.NotFound)); + + SignInTokenExchangeValue exchangeValue = new() { Id = "ex-1", ConnectionName = GraphConnection, Token = "sso-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + + Assert.Equal(412, response.Status); + Assert.NotNull(response.Body); + } + + [Fact] + public async Task TokenExchange_UnexpectedError_ReturnsOriginalStatusCode() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Forbidden", null, System.Net.HttpStatusCode.Forbidden)); + + SignInTokenExchangeValue exchangeValue = new() { Id = "ex-2", ConnectionName = GraphConnection, Token = "sso-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + + Assert.Equal(403, response.Status); + } + + // ==================== Token exchange deduplication ==================== + + [Fact] + public async Task TokenExchange_Duplicate_Returns200NoOp() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny())) + .ReturnsAsync(new GetTokenResult { Token = "access-token", ConnectionName = GraphConnection }); + + SignInTokenExchangeValue exchangeValue = new() { Id = "dup-1", ConnectionName = GraphConnection, Token = "sso-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + // First call + InvokeResponse first = await harness.GraphFlow!.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + Assert.Equal(200, first.Status); + + // Second call with same exchange ID + InvokeResponse second = await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + Assert.Equal(200, second.Status); + + // ExchangeTokenAsync only called once + harness.MockUserTokenClient.Verify( + c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny()), + Times.Once); + } + + // ==================== verifyState error codes ==================== + + [Fact] + public async Task VerifyState_NullState_Returns404() + { + TestHarness harness = CreateHarness(GraphConnection); + + SignInVerifyStateValue verifyValue = new() { State = null }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + + Assert.Equal(404, response.Status); + } + + [Fact] + public async Task VerifyState_NoToken_Returns412() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "badcode", It.IsAny())) + .ReturnsAsync((GetTokenResult?)null); + + SignInVerifyStateValue verifyValue = new() { State = "badcode" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + + Assert.Equal(412, response.Status); + } + + [Fact] + public async Task VerifyState_ExpectedError_Returns412() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "code", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Bad request", null, System.Net.HttpStatusCode.BadRequest)); + + SignInVerifyStateValue verifyValue = new() { State = "code" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + + Assert.Equal(412, response.Status); + } + + [Fact] + public async Task VerifyState_UnexpectedError_ReturnsOriginalStatusCode() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "code", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Forbidden", null, System.Net.HttpStatusCode.Forbidden)); + + SignInVerifyStateValue verifyValue = new() { State = "code" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + + Assert.Equal(403, response.Status); + } + + // ==================== signin/failure callback receives failure details ==================== + + [Fact] + public async Task SignInFailure_CallbackReceivesFailureDetails() + { + TestHarness harness = CreateHarness(GraphConnection); + SignInFailureValue? receivedFailure = null; + + harness.GraphFlow!.OnSignInFailure((_, failure, _) => { receivedFailure = failure; return Task.CompletedTask; }); + + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + SignInFailureValue failureValue = new() { Code = "resourcematchfailed", Message = "URI mismatch" }; + + await harness.GraphFlow.HandleSignInFailureAsync(invokeCtx, failureValue, CancellationToken.None); + + Assert.NotNull(receivedFailure); + Assert.Equal("resourcematchfailed", receivedFailure.Code); + Assert.Equal("URI mismatch", receivedFailure.Message); + } + + [Fact] + public async Task TokenExchange_FailureCallback_ReceivesNullFailureValue() + { + TestHarness harness = CreateHarness(GraphConnection); + SignInFailureValue? receivedFailure = new() { Code = "sentinel" }; + bool callbackFired = false; + + harness.GraphFlow!.OnSignInFailure((_, failure, _) => + { + callbackFired = true; + receivedFailure = failure; + return Task.CompletedTask; + }); + + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Bad request", null, System.Net.HttpStatusCode.BadRequest)); + + SignInTokenExchangeValue exchangeValue = new() { Id = "ex-fail", ConnectionName = GraphConnection, Token = "sso-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + + Assert.True(callbackFired); + Assert.Null(receivedFailure); + } + + // ==================== SignInAsync returns token when cached ==================== + + [Fact] + public async Task SignInAsync_WithCachedToken_ReturnsToken() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, null, It.IsAny())) + .ReturnsAsync(new GetTokenResult { Token = "cached-token", ConnectionName = GraphConnection }); + + Context ctx = CreateMessageContext(harness, TestUserId); + string? token = await harness.GraphFlow!.SignInAsync(ctx); + + Assert.Equal("cached-token", token); + Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + [Fact] + public async Task SignInAsync_NoToken_SendsOAuthCardAndReturnsNull() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + string? token = await harness.GraphFlow!.SignInAsync(ctx); + + Assert.Null(token); + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + // ==================== Helpers ==================== + + private sealed class TestHarness + { + public required TeamsBotApplication App { get; init; } + public required Mock MockUserTokenClient { get; init; } + public required Mock MockConversationClient { get; init; } + public OAuthFlow? GraphFlow { get; init; } + public OAuthFlow? GitHubFlow { get; init; } + } + + private static TestHarness CreateHarness(params string[] connectionNames) + { + Mock mockUserTokenClient = CreateMockUserTokenClient(); + Mock mockConversationClient = new(new HttpClient(), NullLogger.Instance); + + ApiClient apiClient = new( + new HttpClient(), + mockConversationClient.Object, + mockUserTokenClient.Object); + + TeamsBotApplication app = new( + mockConversationClient.Object, + mockUserTokenClient.Object, + apiClient, + new HttpContextAccessor(), + NullLogger.Instance, + new BotApplicationOptions { AppId = "test-app-id" }); + + OAuthFlow? graphFlow = null; + OAuthFlow? githubFlow = null; + + foreach (string name in connectionNames) + { + OAuthFlow flow = app.AddOAuthFlow(name); + if (name == GraphConnection) graphFlow = flow; + else if (name == GitHubConnection) githubFlow = flow; + } + + return new TestHarness + { + App = app, + MockUserTokenClient = mockUserTokenClient, + MockConversationClient = mockConversationClient, + GraphFlow = graphFlow, + GitHubFlow = githubFlow + }; + } + + private static Mock CreateMockUserTokenClient() + { + Mock mockConfig = new(); + return new Mock( + new HttpClient(), + mockConfig.Object, + NullLogger.Instance); + } + + private static Context CreateMessageContext(TestHarness harness, string userId) + { + MessageActivity activity = new("hello") + { + ChannelId = TestChannelId, + From = new TeamsConversationAccount { Id = userId }, + Recipient = new TeamsConversationAccount { Id = "bot-id" }, + Conversation = new TeamsConversation { Id = "conv-1" }, + ServiceUrl = new Uri("https://smba.trafficmanager.net/test/"), + }; + + return new Context(harness.App, activity); + } + + private static Context CreateInvokeContext(TestHarness harness, string userId) + { + InvokeActivity activity = new() + { + ChannelId = TestChannelId, + From = new TeamsConversationAccount { Id = userId }, + Recipient = new TeamsConversationAccount { Id = "bot-id" }, + Conversation = new TeamsConversation { Id = "conv-1" }, + ServiceUrl = new Uri("https://smba.trafficmanager.net/test/"), + }; + + return new Context(harness.App, activity); + } + + private static void SetupSilentTokenReturnsNull(Mock mock, string connectionName) + { + mock.Setup(c => c.GetTokenAsync(TestUserId, connectionName, TestChannelId, null, It.IsAny())) + .ReturnsAsync((GetTokenResult?)null); + } + + private static void SetupGetSignInResource(Mock mock) + { + mock.Setup(c => c.GetSignInResourceAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(new GetSignInResourceResult + { + SignInLink = "https://login.microsoftonline.com/test", + TokenExchangeResource = new TokenExchangeResource { Id = "tex-1", Uri = new Uri("api://test") }, + TokenPostResource = new TokenPostResource { SasUrl = new Uri("https://token.botframework.com/test") } + }); + } + + private static void SetupSendActivity(TestHarness harness) + { + harness.MockConversationClient + .Setup(c => c.SendActivityAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new SendActivityResponse { Id = "activity-1" }); + } +} From 06699af5d5db532261c77614abd77e79697a43f0 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 23 Apr 2026 17:33:02 -0700 Subject: [PATCH 21/22] Update OAuth flow logic, add 'login' command, update deps - Renamed OAuth flow "sso-bad" to "sso" for consistency - Added "login" command to sign in to both Graph and GitHub - Updated help text to document new command - Changed OAuthFlow to not fire failure callback on no token - Updated related unit test for new failure handling logic - Updated and cleaned up package references in csproj files --- core/samples/OAuthFlowBot/Program.cs | 29 +++++++++++++++---- .../Auth/OAuthFlow.cs | 13 +++------ .../Microsoft.Teams.Bot.Core.csproj | 8 ++--- .../OAuthFlowTests.cs | 8 +++-- 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/core/samples/OAuthFlowBot/Program.cs b/core/samples/OAuthFlowBot/Program.cs index 95aa1e1a..987b38f6 100644 --- a/core/samples/OAuthFlowBot/Program.cs +++ b/core/samples/OAuthFlowBot/Program.cs @@ -21,7 +21,7 @@ // Configure OAuth flows at the DI level -- card text is set once here webAppBuilder.Services.AddTeamsBotApplication(options => { - options.AddOAuthFlow("sso-bad", o => + options.AddOAuthFlow("sso", o => { o.OAuthCardText = "Sign in to your Microsoft account"; o.SignInButtonText = "Sign In to Graph"; @@ -40,7 +40,7 @@ // ==================== OAUTH FLOW SETUP ==================== // Get the pre-registered flows and attach callbacks -OAuthFlow graphAuth = bot.GetOAuthFlow("sso-bad"); +OAuthFlow graphAuth = bot.GetOAuthFlow("sso"); OAuthFlow githubAuth = bot.GetOAuthFlow("gh"); graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => @@ -71,6 +71,7 @@ **OAuthFlow Bot** - Multi-connection OAuth sample Commands: + - `login` - Sign in to all connections - `login graph` - Sign in to Microsoft Graph - `login github` - Sign in to GitHub - `status` - Show OAuth connection status @@ -86,10 +87,26 @@ await context.SendActivityAsync( new MessageActivity(helpText) { TextFormat = TextFormats.Markdown }, ct); }); +bot.OnMessage("(?i)^login$", async (context, ct) => +{ + string? tokenGitHub = await githubAuth.SignInAsync(context, ct); + string? tokenGraph = await graphAuth.SignInAsync(context, ct); + if (tokenGraph is not null) + { + await context.SendActivityAsync("Already signed in to Graph.", ct); + } + + if (tokenGitHub is not null) + { + await context.SendActivityAsync("Already signed in to GitHub.", ct); + } + +}); + bot.OnMessage("(?i)^login graph$", async (context, ct) => { - string? token = await graphAuth.SignInAsync(context, ct); - if (token is not null) + string? tokenGraph = await graphAuth.SignInAsync(context, ct); + if (tokenGraph is not null) { await context.SendActivityAsync("Already signed in to Graph.", ct); } @@ -98,8 +115,8 @@ await context.SendActivityAsync( bot.OnMessage("(?i)^login github$", async (context, ct) => { - string? token = await githubAuth.SignInAsync(context, ct); - if (token is not null) + string? tokenGitHub = await githubAuth.SignInAsync(context, ct); + if (tokenGitHub is not null) { await context.SendActivityAsync("Already signed in to GitHub.", ct); } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs index 17323925..a91ce6de 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs @@ -388,15 +388,10 @@ internal async Task HandleVerifyStateAsync(Context baseContext = new(context.TeamsBotApplication, context.Activity); - await _onSignInFailure(baseContext, null, cancellationToken).ConfigureAwait(false); - } - - // 412 tells Teams to fall back to the sign-in card + // No token returned — the code likely belongs to a different connection. + // Do NOT fire OnSignInFailure or clear pending state; the verifyState loop + // in OAuthFlowExtensions will try the next registered flow. + _logger.LogDebug("Verify state: no token for connection '{ConnectionName}', user '{UserId}'. Code may belong to another connection.", connectionName, userId); return new InvokeResponse(412); } diff --git a/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj b/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj index 356a5500..cc475bd3 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj +++ b/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj @@ -15,17 +15,15 @@ - - - - - + + + diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/OAuthFlowTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/OAuthFlowTests.cs index 8b8f7152..e19d8d6e 100644 --- a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/OAuthFlowTests.cs +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/OAuthFlowTests.cs @@ -270,9 +270,11 @@ public async Task VerifyState_NullState_Returns404() } [Fact] - public async Task VerifyState_NoToken_Returns412() + public async Task VerifyState_NoToken_Returns412_WithoutFiringFailureCallback() { TestHarness harness = CreateHarness(GraphConnection); + bool failureFired = false; + harness.GraphFlow!.OnSignInFailure((_, _, _) => { failureFired = true; return Task.CompletedTask; }); harness.MockUserTokenClient .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "badcode", It.IsAny())) @@ -281,9 +283,11 @@ public async Task VerifyState_NoToken_Returns412() SignInVerifyStateValue verifyValue = new() { State = "badcode" }; Context invokeCtx = CreateInvokeContext(harness, TestUserId); - InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + InvokeResponse response = await harness.GraphFlow.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); Assert.Equal(412, response.Status); + // No token means the code belongs to another connection — NOT a failure + Assert.False(failureFired); } [Fact] From 2208a5fb2049cf182ac74dd29f25dffbfd7cab0f Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 28 Apr 2026 11:14:47 -0700 Subject: [PATCH 22/22] Refactor csproj: add Compat, update DataProtection scope Reformatted csproj for consistent indentation, added Microsoft.Teams.Bot.Compat to InternalsVisibleTo, and moved Microsoft.AspNetCore.DataProtection to a general ItemGroup for all target frameworks. No changes to package set; only grouping and formatting improved. --- .../Microsoft.Teams.Bot.Core.csproj | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj b/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj index 559d8f64..345bb23b 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj +++ b/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj @@ -1,33 +1,31 @@  - - net8.0;net10.0 - enable - enable - True - + + net8.0;net10.0 + enable + enable + True + - - - + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + +