From 7a92aaf76c8e88f4fd580b07c792be999e5f7a32 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 17 Feb 2026 10:47:38 -0800 Subject: [PATCH 1/7] Add hierarchical Teams API facade to TeamsBotApplication Introduced a structured API facade (`TeamsBotApplication.Api`) for Teams operations, grouping functionality into Conversations, Users, Teams, Meetings, and Batch sub-APIs. Added new classes for each sub-API, with extensive XML documentation. Updated `TeamsBotApplication` to expose the new facade. Added integration tests for the API hierarchy and error handling. Cleaned up `.csproj` by removing unused RunSettings property. This refactor improves discoverability, organization, and developer experience for Teams API usage. --- .../Api/ActivitiesApi.cs | 186 ++++++ .../Microsoft.Teams.Bot.Apps/Api/BatchApi.cs | 338 +++++++++++ .../Api/ConversationsApi.cs | 39 ++ .../Api/MeetingsApi.cs | 159 +++++ .../Api/MembersApi.cs | 246 ++++++++ .../Microsoft.Teams.Bot.Apps/Api/TeamsApi.cs | 65 ++ .../Api/TeamsOperationsApi.cs | 106 ++++ .../Api/UserTokenApi.cs | 254 ++++++++ .../Microsoft.Teams.Bot.Apps/Api/UsersApi.cs | 32 + .../TeamsBotApplication.cs | 20 + .../Microsoft.Teams.Bot.Core.Tests.csproj | 3 - .../TeamsApiFacadeTests.cs | 574 ++++++++++++++++++ 12 files changed, 2019 insertions(+), 3 deletions(-) create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/ActivitiesApi.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/BatchApi.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/MeetingsApi.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/MembersApi.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/TeamsApi.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/TeamsOperationsApi.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/UserTokenApi.cs create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/UsersApi.cs create mode 100644 core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiFacadeTests.cs diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/ActivitiesApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/ActivitiesApi.cs new file mode 100644 index 000000000..2df92c3ed --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/ActivitiesApi.cs @@ -0,0 +1,186 @@ +// 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; + } + + /// + /// 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. + 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); + + /// + /// 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); + + /// + /// Deletes an existing activity from a conversation using Teams activity context. + /// + /// The Teams 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( + TeamsActivity 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); + 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 members for the activity. + 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); + 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 new file mode 100644 index 000000000..f24924184 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/BatchApi.cs @@ -0,0 +1,338 @@ +// 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); + return _client.SendMessageToListOfUsersAsync( + activity, + teamsMembers, + contextActivity.ChannelData?.Tenant?.Id ?? throw new InvalidOperationException("Tenant ID not available in activity"), + 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); + return _client.SendMessageToAllUsersInTenantAsync( + activity, + contextActivity.ChannelData?.Tenant?.Id ?? throw new InvalidOperationException("Tenant ID not available in activity"), + 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); + return _client.SendMessageToAllUsersInTeamAsync( + activity, + teamId, + contextActivity.ChannelData?.Tenant?.Id ?? throw new InvalidOperationException("Tenant ID not available in activity"), + 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); + return _client.SendMessageToListOfChannelsAsync( + activity, + channelMembers, + contextActivity.ChannelData?.Tenant?.Id ?? throw new InvalidOperationException("Tenant ID not available in activity"), + 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); + 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); + 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); + return _client.CancelOperationAsync( + operationId, + activity.ServiceUrl!, + activity.From.GetAgenticIdentity(), + customHeaders, + cancellationToken); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs new file mode 100644 index 000000000..17edd3221 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Teams.Bot.Apps.Api; + +/// +/// 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) +/// +/// +public class ConversationsApi +{ + /// + /// Initializes a new instance of the class. + /// + /// The conversation client for conversation operations. + internal ConversationsApi(ConversationClient conversationClient) + { + Activities = new ActivitiesApi(conversationClient); + Members = new MembersApi(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; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/MeetingsApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/MeetingsApi.cs new file mode 100644 index 000000000..d75a5bfed --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/MeetingsApi.cs @@ -0,0 +1,159 @@ +// 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); + 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); + return _client.FetchParticipantAsync( + meetingId, + participantId, + activity.ChannelData?.Tenant?.Id ?? throw new InvalidOperationException("Tenant ID not available in activity"), + 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); + 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 new file mode 100644 index 000000000..d341a00b2 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/MembersApi.cs @@ -0,0 +1,246 @@ +// 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); + 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); + 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); + 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); + 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); + return _client.DeleteConversationMemberAsync( + activity.Conversation.Id!, + memberId, + activity.ServiceUrl!, + activity.From.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 new file mode 100644 index 000000000..77aead90f --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/TeamsApi.cs @@ -0,0 +1,65 @@ +// 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 new file mode 100644 index 000000000..f05ea625f --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/TeamsOperationsApi.cs @@ -0,0 +1,106 @@ +// 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. + /// + /// The ID of the team. + /// 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 team details. + public Task GetByIdAsync( + string teamId, + TeamsActivity activity, + CustomHeaders? customHeaders = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + 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 ID of the team. + /// 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( + string teamId, + TeamsActivity activity, + CustomHeaders? customHeaders = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + return _client.FetchChannelListAsync( + teamId, + 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 new file mode 100644 index 000000000..ee41d245f --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/UserTokenApi.cs @@ -0,0 +1,254 @@ +// 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); + 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); + 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); + 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); + 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); + 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); + 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 new file mode 100644 index 000000000..5ab3d2122 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/UsersApi.cs @@ -0,0 +1,32 @@ +// 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/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs index 60a8de904..0314dfafb 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs @@ -5,6 +5,7 @@ using Microsoft.Teams.Bot.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Apps.Api; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Apps.Routing; using Microsoft.Teams.Bot.Apps.Handlers; @@ -18,6 +19,7 @@ public class TeamsBotApplication : BotApplication { private readonly TeamsApiClient _teamsApiClient; private static TeamsBotApplicationBuilder? _botApplicationBuilder; + private TeamsApi? _api; /// /// Gets the router for dispatching Teams activities to registered routes. @@ -29,6 +31,24 @@ public class TeamsBotApplication : BotApplication /// public TeamsApiClient TeamsApiClient => _teamsApiClient; + /// + /// Gets the hierarchical API facade for Teams operations. + /// + /// + /// This property provides a structured API for accessing Teams operations through a hierarchy: + /// + /// Api.Conversations.Activities - Activity operations (send, update, delete) + /// Api.Conversations.Members - Member operations (get, delete) + /// Api.Users.Token - User token operations (OAuth SSO, sign-in resources) + /// Api.Teams - Team operations (get details, channels) + /// Api.Meetings - Meeting operations (get info, participant, notifications) + /// Api.Batch - Batch messaging operations + /// + /// + public TeamsApi Api => _api ??= new TeamsApi( + ConversationClient, + UserTokenClient, + _teamsApiClient); /// /// 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 index b7aad5b2d..9d24827fa 100644 --- 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 @@ -6,9 +6,6 @@ enable false - - ../.runsettings - diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiFacadeTests.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiFacadeTests.cs new file mode 100644 index 000000000..3fba18dda --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiFacadeTests.cs @@ -0,0 +1,574 @@ +// 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.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Api; +using Microsoft.Teams.Bot.Apps.Schema; + +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; + + public TeamsApiFacadeTests() + { + IConfigurationBuilder builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables(); + + IConfiguration configuration = builder.Build(); + + ServiceCollection services = new(); + services.AddLogging(); + 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/"); + } + + [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, + 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, + 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 = new TeamsConversationAccount { Id = "test-user" } + }; + + TeamDetails result = await _teamsBotApplication.Api.Teams.GetByIdAsync( + teamId, + 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() + { + Id = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set") + } + }; + + 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_UpdateAsync() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + // First send an activity + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Original message via Api at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() { Id = conversationId } + }; + + 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, + }; + + 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() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + // 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() { Id = conversationId } + }; + + 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, + cancellationToken: CancellationToken.None); + + Console.WriteLine($"Deleted activity via Api.Conversations.Activities.DeleteAsync: {sendResponse.Id}"); + } + + [Fact] + public async Task Api_Conversations_Activities_GetMembersAsync() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + // First send an activity + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message for GetMembersAsync test at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() { Id = conversationId } + }; + + 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, + 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() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + IList members = await _teamsBotApplication.Api.Conversations.Members.GetAllAsync( + conversationId, + _serviceUrl, + 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() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + TeamsActivity activity = new() + { + ServiceUrl = _serviceUrl, + Conversation = new TeamsConversation { Id = conversationId }, + From = new TeamsConversationAccount { Id = "test-user" } + }; + + 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 conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + 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, + 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() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + PagedMembersResult result = await _teamsBotApplication.Api.Conversations.Members.GetPagedAsync( + conversationId, + _serviceUrl, + 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] + 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, + 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, + 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)); + } + + [Fact] + public async Task Api_Teams_GetByIdAsync_ThrowsOnNullActivity() + { + await Assert.ThrowsAsync(() + => _teamsBotApplication.Api.Teams.GetByIdAsync("team-id", (TeamsActivity)null!)); + } + + [Fact] + public async Task Api_Teams_GetChannelsAsync_ThrowsOnNullActivity() + { + await Assert.ThrowsAsync(() + => _teamsBotApplication.Api.Teams.GetChannelsAsync("team-id", (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!)); + } +} From ce1a868719061857c2703f20c0e904969ea10437 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 17 Feb 2026 10:58:02 -0800 Subject: [PATCH 2/7] Add TeamsApi REST endpoint mapping to README Added detailed tables to README.md mapping TeamsApi facade methods to their underlying REST endpoints. Documentation covers Conversations, Users (Token), Teams, Meetings, and Batch operations, including HTTP methods, endpoint paths, and relevant base URLs. Added notes on service URL usage and path parameter encoding. --- .../Microsoft.Teams.Bot.Apps/Api/README.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/README.md diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/README.md b/core/src/Microsoft.Teams.Bot.Apps/Api/README.md new file mode 100644 index 000000000..7aa8404b0 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/README.md @@ -0,0 +1,77 @@ +# 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. From 0ce027b23c9ea27a65c5762a3fe8245a153e71d3 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 17 Feb 2026 19:41:19 -0800 Subject: [PATCH 3/7] Add API for adding/removing reactions to messages Introduces ReactionsApi for programmatic message reactions in Teams bots, with AddAsync and DeleteAsync methods. ConversationClient now supports AddReactionAsync and DeleteReactionAsync. Expanded ReactionTypes with more documented types. Demonstrated usage in Program.cs by adding a "cake" reaction to "hello" messages. --- core/samples/TeamsBot/Program.cs | 2 + .../Api/ConversationsApi.cs | 7 + .../Api/ReactionsApi.cs | 129 ++++++++++++++++++ .../MessageReactionActivity.cs | 37 +++-- .../TeamsBotApplication.HostingExtensions.cs | 2 +- .../ConversationClient.cs | 62 +++++++++ 6 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 core/src/Microsoft.Teams.Bot.Apps/Api/ReactionsApi.cs diff --git a/core/samples/TeamsBot/Program.cs b/core/samples/TeamsBot/Program.cs index 3edd52837..e8e6d4763 100644 --- a/core/samples/TeamsBot/Program.cs +++ b/core/samples/TeamsBot/Program.cs @@ -18,6 +18,8 @@ teamsApp.OnMessage("(?i)hello", async (context, cancellationToken) => { await context.SendActivityAsync("Hi there! 👋 You said hello!", cancellationToken); + + await teamsApp.Api.Conversations.Reactions.AddAsync(context.Activity, "cake", cancellationToken: cancellationToken); }); // Markdown handler: matches "markdown" (case-insensitive) diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs index 17edd3221..27906ec80 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs @@ -13,6 +13,7 @@ namespace Microsoft.Teams.Bot.Apps.Api; /// /// - Activity operations (send, update, delete, history) /// - Member operations (get, delete) +/// - Reaction operations (add, delete) /// /// public class ConversationsApi @@ -25,6 +26,7 @@ internal ConversationsApi(ConversationClient conversationClient) { Activities = new ActivitiesApi(conversationClient); Members = new MembersApi(conversationClient); + Reactions = new ReactionsApi(conversationClient); } /// @@ -36,4 +38,9 @@ internal ConversationsApi(ConversationClient conversationClient) /// 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; } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/Api/ReactionsApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/ReactionsApi.cs new file mode 100644 index 000000000..672c4734e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/ReactionsApi.cs @@ -0,0 +1,129 @@ +// 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 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 in a conversation. + /// + /// The ID of the conversation. + /// The ID of the activity to react to. + /// The type of reaction to add (e.g., "like", "heart", "laugh"). + /// 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 AddAsync( + string conversationId, + string activityId, + string reactionType, + Uri serviceUrl, + AgenticIdentity? agenticIdentity = null, + CustomHeaders? customHeaders = null, + CancellationToken cancellationToken = default) + => _client.AddReactionAsync(conversationId, activityId, reactionType, serviceUrl, agenticIdentity, customHeaders, cancellationToken); + + /// + /// Adds a reaction to an activity using activity context. + /// + /// The activity to react to. Must contain valid Id, Conversation.Id, and ServiceUrl. + /// 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 reactionType, + CustomHeaders? customHeaders = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Id); + ArgumentNullException.ThrowIfNull(activity.Conversation); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + + return _client.AddReactionAsync( + activity.Conversation.Id, + activity.Id, + reactionType, + activity.ServiceUrl, + activity.Recipient.GetAgenticIdentity(), + customHeaders, + cancellationToken); + } + + /// + /// Removes a reaction from an activity in a conversation. + /// + /// The ID of the conversation. + /// The ID of the activity to remove the reaction from. + /// The type of reaction to remove (e.g., "like", "heart", "laugh"). + /// 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, + string reactionType, + Uri serviceUrl, + AgenticIdentity? agenticIdentity = null, + CustomHeaders? customHeaders = null, + CancellationToken cancellationToken = default) + => _client.DeleteReactionAsync(conversationId, activityId, reactionType, serviceUrl, agenticIdentity, 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 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 reactionType, + CustomHeaders? customHeaders = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Id); + ArgumentNullException.ThrowIfNull(activity.Conversation); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + + return _client.DeleteReactionAsync( + activity.Conversation.Id, + activity.Id, + reactionType, + activity.ServiceUrl, + activity.Recipient.GetAgenticIdentity(), + customHeaders, + cancellationToken); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivities/MessageReactionActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivities/MessageReactionActivity.cs index 70a0bdd64..c66e58306 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivities/MessageReactionActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivities/MessageReactionActivity.cs @@ -109,39 +109,54 @@ public class MessageReaction public static class ReactionTypes { /// - /// Like reaction. + /// Like reaction (👍). /// public const string Like = "like"; /// - /// Heart reaction. + /// Heart reaction (❤️). /// public const string Heart = "heart"; /// - /// Laugh reaction. + /// Checkmark reaction (✅). + /// + public const string Checkmark = "checkmark"; + + /// + /// Hourglass reaction (⏳). + /// + public const string Hourglass = "hourglass"; + + /// + /// Pushpin reaction (📌). + /// + public const string Pushpin = "pushpin"; + + /// + /// Exclamation reaction (❗). + /// + public const string Exclamation = "exclamation"; + + /// + /// Laugh reaction (😆). /// public const string Laugh = "laugh"; /// - /// Surprise reaction. + /// Surprise reaction (😮). /// public const string Surprise = "surprise"; /// - /// Sad reaction. + /// Sad reaction (🙁). /// public const string Sad = "sad"; /// - /// Angry reaction. + /// Angry reaction (😠). /// public const string Angry = "angry"; - - /// - /// Plus one reaction. - /// - public const string PlusOne = "plusOne"; } /* diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs index 99285853b..6b8e0527a 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs @@ -47,7 +47,7 @@ public static IServiceCollection AddTeamsBotApplication(this IServiceCollection sp.GetService>()); }); - services.AddSingleton(); + //services.AddSingleton(); services.AddBotApplication(); return services; } diff --git a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs index c74886baf..aa86f985d 100644 --- a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs +++ b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs @@ -430,6 +430,68 @@ public async Task UploadAttachmentAsync(string convers cancellationToken).ConfigureAwait(false))!; } + /// + /// Adds a reaction to an activity in a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to react to. Cannot be null or whitespace. + /// The type of reaction to add (e.g., "like", "heart", "laugh"). Cannot be null or whitespace. + /// The service URL for the conversation. 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 reaction could not be added successfully. + public async Task AddReactionAsync(string conversationId, string activityId, string reactionType, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentException.ThrowIfNullOrWhiteSpace(reactionType); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}/reactions/{reactionType}"; + + logger.LogTrace("Adding reaction at {Url}", url); + + await _botHttpClient.SendAsync( + HttpMethod.Put, + url, + body: null, + CreateRequestOptions(agenticIdentity, "adding reaction", customHeaders), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Removes a reaction from an activity in a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to remove the reaction from. Cannot be null or whitespace. + /// The type of reaction to remove (e.g., "like", "heart", "laugh"). Cannot be null or whitespace. + /// The service URL for the conversation. 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 reaction could not be removed successfully. + public async Task DeleteReactionAsync(string conversationId, string activityId, string reactionType, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentException.ThrowIfNullOrWhiteSpace(reactionType); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}/reactions/{reactionType}"; + + logger.LogTrace("Deleting reaction at {Url}", url); + + await _botHttpClient.SendAsync( + HttpMethod.Delete, + url, + body: null, + CreateRequestOptions(agenticIdentity, "deleting reaction", customHeaders), + cancellationToken).ConfigureAwait(false); + } + private BotRequestOptions CreateRequestOptions(AgenticIdentity? agenticIdentity, string operationDescription, CustomHeaders? customHeaders) => new() { From 05225a2818489de175a4c02dc6f745b7381a0d09 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 17 Feb 2026 22:01:23 -0800 Subject: [PATCH 4/7] Add support for targeted (private) activities in SDK Introduces IsTargeted property to CoreActivity for private messages, updates ConversationClient to append isTargetedActivity query string for send/update/delete, and extends CoreActivityBuilder for targeted recipient support. Adds a "tm" command to send private messages to all members. Updates and adds tests for IsTargeted logic and ensures it is not serialized. Also updates .gitignore for .claude/. --- .gitignore | 2 + core/samples/TeamsBot/Program.cs | 16 ++ .../ConversationClient.cs | 36 +++- .../Schema/CoreActivity.cs | 7 + .../Schema/CoreActivityBuilder.cs | 14 +- .../ConversationClientTests.cs | 174 ++++++++++++++++++ .../CoreActivityBuilderTests.cs | 63 +++++++ .../Schema/CoreActivityTests.cs | 49 +++++ 8 files changed, 359 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 26d20f91e..e89a149a3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ # local app settings files appsettings.Local.json +.claude/ + # User-specific files *.rsuser *.suo diff --git a/core/samples/TeamsBot/Program.cs b/core/samples/TeamsBot/Program.cs index e8e6d4763..2eada81b6 100644 --- a/core/samples/TeamsBot/Program.cs +++ b/core/samples/TeamsBot/Program.cs @@ -22,6 +22,22 @@ await teamsApp.Api.Conversations.Reactions.AddAsync(context.Activity, "cake", cancellationToken: cancellationToken); }); +teamsApp.OnMessage("(?i)tm", async (context, cancellationToken) => +{ + var members = await teamsApp.Api.Conversations.Members.GetAllAsync(context.Activity, cancellationToken: cancellationToken); + foreach (var member in members) + { + await context.SendActivityAsync( + TeamsActivity.CreateBuilder() + .WithText($"Hello {member.Name}!") + .WithRecipient(member, true) + .Build(), cancellationToken) + ; + } + await context.SendActivityAsync($"Sent a private message to {members.Count} member(s) of the conversation!", cancellationToken); + +}); + // Markdown handler: matches "markdown" (case-insensitive) teamsApp.OnMessage("(?i)markdown", async (context, cancellationToken) => { diff --git a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs index aa86f985d..a901386bd 100644 --- a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs +++ b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs @@ -51,6 +51,11 @@ public async Task SendActivityAsync(CoreActivity activity, url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{convId}/activities"; } + if (activity.IsTargeted) + { + url += url.Contains('?', StringComparison.Ordinal) ? "&isTargetedActivity=true" : "?isTargetedActivity=true"; + } + logger?.LogInformation("Sending activity to {Url}", url); string body = activity.ToJson(); @@ -83,6 +88,12 @@ public async Task UpdateActivityAsync(string conversatio ArgumentNullException.ThrowIfNull(activity.ServiceUrl); string url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}"; + + if (activity.IsTargeted) + { + url += "?isTargetedActivity=true"; + } + string body = activity.ToJson(); logger.LogTrace("Updating activity at {Url}: {Activity}", url, body); @@ -107,7 +118,24 @@ public async Task UpdateActivityAsync(string conversatio /// A cancellation token that can be used to cancel the delete operation. /// A task that represents the asynchronous operation. /// Thrown if the activity could not be deleted successfully. - public async Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + public Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + return DeleteActivityAsync(conversationId, activityId, serviceUrl, isTargeted: false, agenticIdentity, customHeaders, cancellationToken); + } + + /// + /// Deletes an existing activity from a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to delete. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// If true, deletes a targeted activity. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the delete operation. + /// A task that represents the asynchronous operation. + /// Thrown if the activity could not be deleted successfully. + public async Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, bool isTargeted, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); ArgumentException.ThrowIfNullOrWhiteSpace(activityId); @@ -115,6 +143,11 @@ public async Task DeleteActivityAsync(string conversationId, string activityId, string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}"; + if (isTargeted) + { + url += "?isTargetedActivity=true"; + } + logger.LogTrace("Deleting activity at {Url}", url); await _botHttpClient.SendAsync( @@ -145,6 +178,7 @@ await DeleteActivityAsync( activity.Conversation.Id, activity.Id, activity.ServiceUrl, + activity.IsTargeted, activity.From.GetAgenticIdentity(), customHeaders, cancellationToken).ConfigureAwait(false); diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs index 1bd1a5b6d..daca5df13 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs @@ -57,6 +57,12 @@ public class CoreActivity /// Gets or sets the account that should receive this activity. /// [JsonPropertyName("recipient")] public ConversationAccount Recipient { get; set; } = new(); + + /// + /// Indicates if this is a targeted message visible only to a specific recipient. + /// Used internally by the SDK for routing - not serialized to the service. + /// + [JsonIgnore] public bool IsTargeted { get; set; } /// /// Gets or sets the conversation in which this activity is taking place. /// @@ -150,6 +156,7 @@ protected CoreActivity(CoreActivity activity) Attachments = activity.Attachments; Properties = activity.Properties; Value = activity.Value; + IsTargeted = activity.IsTargeted; } /// diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs index d05e635ea..624cc0381 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs @@ -47,7 +47,7 @@ public TBuilder WithConversationReference(TActivity activity) WithChannelId(activity.ChannelId); SetConversation(activity.Conversation); SetFrom(activity.Recipient); - SetRecipient(activity.From); + //SetRecipient(activity.From); return (TBuilder)this; } @@ -140,8 +140,20 @@ public TBuilder WithFrom(ConversationAccount from) /// The recipient account. /// The builder instance for chaining. public TBuilder WithRecipient(ConversationAccount recipient) + { + return WithRecipient(recipient, false); + } + + /// + /// Sets the recipient account information and optionally marks this as a targeted message. + /// + /// The recipient account. + /// If true, marks this as a targeted message visible only to the specified recipient. + /// The builder instance for chaining. + public TBuilder WithRecipient(ConversationAccount recipient, bool isTargeted) { SetRecipient(recipient); + _activity.IsTargeted = isTargeted; return (TBuilder)this; } diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs index 212d4927b..85c4c3c61 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Bot.Core.Schema; using Moq; using Moq.Protected; @@ -168,4 +170,176 @@ public async Task SendActivityAsync_ConstructsCorrectUrl() Assert.Equal("https://test.service.url/v3/conversations/conv123/activities/", capturedRequest.RequestUri?.ToString()); Assert.Equal(HttpMethod.Post, capturedRequest.Method); } + + [Fact] + public async Task SendActivityAsync_WithIsTargeted_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/"), + IsTargeted = true + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task SendActivityAsync_WithIsTargetedFalse_DoesNotAppendQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/"), + IsTargeted = false + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.DoesNotContain("isTargetedActivity", capturedRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task UpdateActivityAsync_WithIsTargeted_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/"), + IsTargeted = true + }; + + await conversationClient.UpdateActivityAsync("conv123", "activity123", activity); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Put, capturedRequest.Method); + } + + [Fact] + public async Task DeleteActivityAsync_WithIsTargeted_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + await conversationClient.DeleteActivityAsync( + "conv123", + "activity123", + new Uri("https://test.service.url/"), + isTargeted: true); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Delete, capturedRequest.Method); + } + + [Fact] + public async Task DeleteActivityAsync_WithActivity_UsesIsTargetedProperty() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + CoreActivity activity = new() + { + Id = "activity123", + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/"), + IsTargeted = true + }; + + await conversationClient.DeleteActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Delete, capturedRequest.Method); + } } diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs index c3f62fd0b..33cfc1d2a 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs @@ -480,4 +480,67 @@ public void IntegrationTest_CreateComplexActivity() Assert.Equal("conv-001", activity.Conversation.Id); Assert.NotNull(activity.ChannelData); } + + [Fact] + public void WithRecipient_DefaultsToNotTargeted() + { + CoreActivity activity = new CoreActivityBuilder() + .WithRecipient(new ConversationAccount { Id = "user-123" }) + .Build(); + + Assert.False(activity.IsTargeted); + Assert.NotNull(activity.Recipient); + Assert.Equal("user-123", activity.Recipient.Id); + } + + [Fact] + public void WithRecipient_WithIsTargetedTrue_SetsIsTargeted() + { + CoreActivity activity = new CoreActivityBuilder() + .WithRecipient(new ConversationAccount { Id = "user-123" }, true) + .Build(); + + Assert.True(activity.IsTargeted); + Assert.NotNull(activity.Recipient); + Assert.Equal("user-123", activity.Recipient.Id); + } + + [Fact] + public void WithRecipient_WithIsTargetedFalse_DoesNotSetIsTargeted() + { + CoreActivity activity = new CoreActivityBuilder() + .WithRecipient(new ConversationAccount { Id = "user-123" }, false) + .Build(); + + Assert.False(activity.IsTargeted); + Assert.NotNull(activity.Recipient); + Assert.Equal("user-123", activity.Recipient.Id); + } + + [Fact] + public void WithRecipient_Targeted_MaintainsFluentChaining() + { + CoreActivityBuilder builder = new(); + + CoreActivityBuilder result = builder.WithRecipient(new ConversationAccount { Id = "user-123" }, true); + + Assert.Same(builder, result); + } + + [Fact] + public void WithRecipient_Targeted_CanChainWithOtherMethods() + { + CoreActivity activity = new CoreActivityBuilder() + .WithType(ActivityType.Message) + .WithRecipient(new ConversationAccount { Id = "user-123", Name = "Test User" }, true) + .WithChannelId("msteams") + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.True(activity.IsTargeted); + Assert.NotNull(activity.Recipient); + Assert.Equal("user-123", activity.Recipient.Id); + Assert.Equal("Test User", activity.Recipient.Name); + Assert.Equal("msteams", activity.ChannelId); + } } diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs index 36835f3b2..240f5cf27 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs @@ -346,4 +346,53 @@ public async Task DeserializeInvokeWithValueAsync() Assert.Equal("value1", act.Value["key1"]?.GetValue()); Assert.Equal(2, act.Value["key2"]?.GetValue()); } + + [Fact] + public void IsTargeted_DefaultsToFalse() + { + CoreActivity activity = new(); + + Assert.False(activity.IsTargeted); + } + + [Fact] + public void IsTargeted_CanBeSetToTrue() + { + CoreActivity activity = new() + { + IsTargeted = true + }; + + Assert.True(activity.IsTargeted); + } + + [Fact] + public void IsTargeted_IsNotSerializedToJson() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + IsTargeted = true + }; + + string json = activity.ToJson(); + + Assert.DoesNotContain("isTargeted", json, StringComparison.OrdinalIgnoreCase); + Assert.True(activity.IsTargeted); // Property still holds value + } + + [Fact] + public void IsTargeted_IsNotDeserializedFromJson() + { + string json = """ + { + "type": "message", + "isTargeted": true + } + """; + + CoreActivity activity = CoreActivity.FromJsonString(json); + + Assert.False(activity.IsTargeted); // Should default to false since JsonIgnore + } } From 81d6a09b846186e51abbc75aa19a53e428264323 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 17 Feb 2026 22:09:00 -0800 Subject: [PATCH 5/7] Remove Recipient assertions from activity unit tests Removed checks for Recipient Id and Name in several tests in CoreActivityBuilderTests.cs and CoreActivityTests.cs to streamline test coverage. Other test logic remains unchanged. --- .../CoreActivityBuilderTests.cs | 5 ----- .../Schema/CoreActivityTests.cs | 2 -- 2 files changed, 7 deletions(-) diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs index 33cfc1d2a..0e9cfba73 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs @@ -338,8 +338,6 @@ public void WithConversationReference_AppliesConversationReference() Assert.Equal("conv-123", activity.Conversation.Id); Assert.Equal("bot-1", activity.From.Id); Assert.Equal("Bot", activity.From.Name); - Assert.Equal("user-1", activity.Recipient.Id); - Assert.Equal("User One", activity.Recipient.Name); } [Fact] @@ -360,8 +358,6 @@ public void WithConversationReference_SwapsFromAndRecipient() Assert.Equal("bot-id", replyActivity.From.Id); Assert.Equal("Bot", replyActivity.From.Name); - Assert.Equal("user-id", replyActivity.Recipient.Id); - Assert.Equal("User", replyActivity.Recipient.Name); } [Fact] @@ -424,7 +420,6 @@ public void WithConversationReference_ChainedWithOtherMethods_MaintainsFluentInt Assert.Equal(ActivityType.Message, activity.Type); Assert.Equal("bot-1", activity.From.Id); - Assert.Equal("user-1", activity.Recipient.Id); } [Fact] diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs index 240f5cf27..5a0d30c72 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs @@ -293,8 +293,6 @@ public void CreateReply() Assert.Equal("conversation1", reply.Conversation.Id); Assert.Equal("bot1", reply.From.Id); Assert.Equal("Bot One", reply.From.Name); - Assert.Equal("user1", reply.Recipient.Id); - Assert.Equal("User One", reply.Recipient.Name); } [Fact] From c9f923b9dc98c31c4fbb7a48291e471c256dcb3d Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Tue, 17 Feb 2026 22:59:24 -0800 Subject: [PATCH 6/7] Add proactive TeamsActivity message in EchoBot EchoBot now sends a "Hello TM !" message using TeamsBotApplication's ConversationClient after echoing the user's input. The TeamsActivity is built with correct conversation, recipient, sender, and service URL details. Minor formatting adjustment made in Program.cs with no logic changes. --- core/samples/CompatBot/EchoBot.cs | 13 ++++++++++++- core/samples/TeamsBot/Program.cs | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/core/samples/CompatBot/EchoBot.cs b/core/samples/CompatBot/EchoBot.cs index 6b8da45e4..4f59a5dee 100644 --- a/core/samples/CompatBot/EchoBot.cs +++ b/core/samples/CompatBot/EchoBot.cs @@ -9,9 +9,9 @@ using Microsoft.Teams.Bot.Core.Schema; using Microsoft.Bot.Schema; using Microsoft.Bot.Schema.Teams; +using Newtonsoft.Json.Linq; using Microsoft.Teams.Bot.Apps; using Microsoft.Teams.Bot.Apps.Schema; -using Newtonsoft.Json.Linq; namespace CompatBot; @@ -40,6 +40,17 @@ protected override async Task OnMessageActivityAsync(ITurnContext Date: Wed, 25 Feb 2026 19:25:48 +0000 Subject: [PATCH 7/7] Initial plan