diff --git a/.gitignore b/.gitignore index 26d20f91..e89a149a 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/CompatBot/EchoBot.cs b/core/samples/CompatBot/EchoBot.cs index 6b8da45e..4f59a5de 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 { await context.SendActivityAsync("Hi there! 👋 You said hello!", cancellationToken); + + 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) @@ -144,7 +162,7 @@ [Visit Microsoft](https://www.microsoft.com) await context.SendActivityAsync(reply, cancellationToken); return new CoreInvokeResponse(200) - { + { Type = "application/vnd.microsoft.activity.message", Body = "Invokes are great !!" }; 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 00000000..2df92c3e --- /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 00000000..f2492418 --- /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 00000000..27906ec8 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Api/ConversationsApi.cs @@ -0,0 +1,46 @@ +// 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) +/// - Reaction operations (add, 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); + Reactions = new ReactionsApi(conversationClient); + } + + /// + /// Gets the activities API for sending, updating, and deleting activities. + /// + public ActivitiesApi Activities { get; } + + /// + /// Gets the members API for managing conversation members. + /// + public MembersApi Members { get; } + + /// + /// Gets the reactions API for adding and removing reactions on activities. + /// + public ReactionsApi Reactions { get; } +} 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 00000000..d75a5bfe --- /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 00000000..d341a00b --- /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/README.md b/core/src/Microsoft.Teams.Bot.Apps/Api/README.md new file mode 100644 index 00000000..7aa8404b --- /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. 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 00000000..672c4734 --- /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/Api/TeamsApi.cs b/core/src/Microsoft.Teams.Bot.Apps/Api/TeamsApi.cs new file mode 100644 index 00000000..77aead90 --- /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 00000000..f05ea625 --- /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 00000000..ee41d245 --- /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 00000000..5ab3d212 --- /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/Schema/MessageActivities/MessageReactionActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/MessageActivities/MessageReactionActivity.cs index 70a0bdd6..c66e5830 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 99285853..6b8e0527 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.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs index 60a8de90..0314dfaf 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/src/Microsoft.Teams.Bot.Core/ConversationClient.cs b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs index c74886ba..a901386b 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); @@ -430,6 +464,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() { diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs index 1bd1a5b6..daca5df1 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 d05e635e..624cc038 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.Tests/Microsoft.Teams.Bot.Core.Tests.csproj b/core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj index b7aad5b2..9d24827f 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 00000000..3fba18dd --- /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!)); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs index 212d4927..85c4c3c6 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 c3f62fd0..0e9cfba7 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] @@ -480,4 +475,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 36835f3b..5a0d30c7 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] @@ -346,4 +344,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 + } }