diff --git a/.azdo/cd-core.yaml b/.azdo/cd-core.yaml index afadfdf6..0bd65534 100644 --- a/.azdo/cd-core.yaml +++ b/.azdo/cd-core.yaml @@ -2,10 +2,9 @@ pr: branches: include: - next/* - # Uncomment and edit the following lines to add path filters for PRs in the future - # paths: - # include: - # - core/** + paths: + include: + - core/** trigger: branches: @@ -55,7 +54,7 @@ stages: - task: NuGetCommand@2 displayName: 'Push NuGet Packages' - condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/next/core')) + condition: eq(variables['PushToADOFeed'], true) inputs: command: push packagesToPush: '$(Build.ArtifactStagingDirectory)/*.nupkg' diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4e99def3..9cc0b875 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,18 @@ "bicepVersion": "latest" }, "ghcr.io/dotnet/aspire-devcontainer-feature/dotnetaspire:1": { - "version": "9.0" + "version": "latest" + }, + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "10.0" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": true, + "azureDnsAutoDetection": true, + "installDockerBuildx": true, + "installDockerComposeSwitch": true, + "version": "latest", + "dockerDashComposeVersion": "v2" } } diff --git a/.github/workflows/core-ci.yaml b/.github/workflows/core-ci.yaml new file mode 100644 index 00000000..845f09f9 --- /dev/null +++ b/.github/workflows/core-ci.yaml @@ -0,0 +1,40 @@ +name: Core-CI +permissions: + contents: read + pull-requests: write + +on: + push: + branches: [ next/core ] + pull_request: + branches: [ next/core ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore + working-directory: core + + - name: Build Core + run: dotnet build --no-restore + working-directory: core + + - name: Test + run: dotnet test --no-build + working-directory: core + + - name: Build Core Tests + run: dotnet build + working-directory: core/test \ No newline at end of file diff --git a/.github/workflows/core-test.yaml b/.github/workflows/core-test.yaml new file mode 100644 index 00000000..9ba13ad0 --- /dev/null +++ b/.github/workflows/core-test.yaml @@ -0,0 +1,41 @@ +name: Core-Test +permissions: + contents: read + pull-requests: write + +on: + workflow_dispatch: + +jobs: + build-and-test: + runs-on: ubuntu-latest + environment: test_tenant + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore + working-directory: core + + - name: Build + run: dotnet build --no-restore + working-directory: core + + - name: Test + run: dotnet test --no-build test/Microsoft.Bot.Core.Tests/Microsoft.Bot.Core.Tests.csproj + working-directory: core + env: + AzureAd__Instance: 'https://login.microsoftonline.com/' + AzureAd__ClientId: 'aabdbd62-bc97-4afb-83ee-575594577de5' + AzureAd__TenantId: '56653e9d-2158-46ee-90d7-675c39642038' + AzureAd__Scope: 'https://api.botframework.com/.default' + AzureAd__ClientCredentials__0__SourceType: 'ClientSecret' + AzureAd__ClientCredentials__0__ClientSecret: ${{ secrets.CLIENT_SECRET}} \ No newline at end of file diff --git a/core/.editorconfig b/core/.editorconfig new file mode 100644 index 00000000..540657fa --- /dev/null +++ b/core/.editorconfig @@ -0,0 +1,41 @@ +root = true + +# All files +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +# C# files +[*.cs] +indent_style = space +indent_size = 4 +nullable = enable +#dotnet_diagnostic.CS1591.severity = none ## Suppress missing XML comment warnings +dotnet_diagnostic.CA1848.severity = warning + +#### Nullable Reference Types #### +# Make nullable warnings strict +dotnet_diagnostic.CS8600.severity = error +dotnet_diagnostic.CS8602.severity = error +dotnet_diagnostic.CS8603.severity = error +dotnet_diagnostic.CS8604.severity = error +dotnet_diagnostic.CS8618.severity = error # Non-nullable field uninitialized + +# Code quality rules +dotnet_code_quality_unused_parameters = all:suggestion +dotnet_diagnostic.IDE0079.severity = warning + +#### Coding conventions #### +dotnet_sort_system_directives_first = true +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true + +file_header_template = Copyright (c) Microsoft Corporation.\nLicensed under the MIT License. + +[samples/**/*.cs] +dotnet_diagnostic.CA1848.severity = none # Suppress Logger perfomance in samples + +# Test projects can be more lenient +[tests/**/*.cs] +dotnet_diagnostic.CS8602.severity = warning diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 00000000..1fe9a12f --- /dev/null +++ b/core/.gitignore @@ -0,0 +1,3 @@ +launchSettings.json +appsettings.Development.json +*.runsettings \ No newline at end of file diff --git a/core/README.md b/core/README.md new file mode 100644 index 00000000..d28509f4 --- /dev/null +++ b/core/README.md @@ -0,0 +1,137 @@ +# Microsoft.Teams.Bot.Core + +Bot Core implements the Activity Protocol, including schema, conversation client, user token client, and support for Bot and Agentic Identities. + +## Design Principles + +- Loose schema. `TeamsActivity` contains only the strictly required fields for Conversation Client, additional fields are captured as a Dictionary with JsonExtensionData attributes. +- Simple Serialization. `TeamsActivity` can be serialized/deserialized without any custom logic, and trying to avoid custom converters as much as possible. +- Extensible schema. Fields subject to extension, such as `ChannelData` must define their own `Properties` to allow serialization of unknown fields. Use of generics to allow additional types that are not defined in the Core Library. +- Auth based on MSAL. Token acquisition done on top of MSAL +- Respect ASP.NET DI. `TeamsBotApplication` dependencies are configured based on .NET ServiceCollection extensions, reusing the existing `HttpClient` +- Respect ILogger and IConfiguration. + +## Samples + +### Extensible Activity + +```cs +public class MyChannelData : ChannelData +{ + [JsonPropertyName("customField")] + public string? CustomField { get; set; } + + [JsonPropertyName("myChannelId")] + public string? MyChannelId { get; set; } +} + +public class MyCustomChannelDataActivity : TeamsActivity +{ + [JsonPropertyName("channelData")] + public new MyChannelData? ChannelData { get; set; } +} + +[Fact] +public void Deserialize_CustomChannelDataActivity() +{ + string json = """ + { + "type": "message", + "channelData": { + "customField": "customFieldValue", + "myChannelId": "12345" + } + } + """; + var deserializedActivity = TeamsActivity.FromJsonString(json); + Assert.NotNull(deserializedActivity); + Assert.NotNull(deserializedActivity.ChannelData); + Assert.Equal("customFieldValue", deserializedActivity.ChannelData.CustomField); + Assert.Equal("12345", deserializedActivity.ChannelData.MyChannelId); +} +``` + +> Note `FromJsonString` lives in `TeamsActivity`, and there is no need to override. + + +### Basic Bot Application Usage + +```cs +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Schema; + +var builder = TeamsBotApplication.CreateBuilder(); +var teamsApp = builder.Build(); + +teamsApp.OnMessage = async (messageArgs, context, cancellationToken) => +{ + await context.SendTypingActivityAsync(cancellationToken); + + string replyText = $"You sent: `{messageArgs.Text}` in activity of type `{context.Activity.Type}`."; + + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithText(replyText) + .Build(); + + await context.SendActivityAsync(reply, cancellationToken); +}; + +teamsApp.Run(); +``` + +## Testing in Teams + +Need to create a Teams Application, configure it in ABS and capture `TenantId`, `ClientId` and `ClientSecret`. Provide those values as + +```json +{ + "AzureAd" : { + "Instance" : "https://login.microsoftonline.com/", + "TenantId" : "", + "ClientId" : "", + "Scope" : "https://api.botframework.com/.default", + "ClientCredentials" : [ + { + "SourceType" : "ClientSecret", + "ClientSecret" : "" + } + ] + } +} +``` + +or as env vars, using the IConfiguration Environment Configuration Provider: + +```env + AzureAd__Instance=https://login.microsoftonline.com/ + AzureAd__TenantId= + AzureAd__ClientId= + AzureAd__Scope=https://api.botframework.com/.default + AzureAd__ClientCredentials__0__SourceType=ClientSecret + AzureAd__ClientCredentials__0__ClientSecret= +``` + + + +## Testing in localhost (anonymous) + +When not providing MSAL configuration all the communication will happen as anonymous REST calls, suitable for localhost testing. + +### Install Playground + +Linux +``` +curl -s https://raw.githubusercontent.com/OfficeDev/microsoft-365-agents-toolkit/dev/.github/scripts/install-agentsplayground-linux.sh | bash +``` + +Windows +``` +winget install m365agentsplayground +``` + + +### Run Scenarios + +``` +dotnet samples/scenarios/middleware.cs -- --urls "http://localhost:3978" +``` diff --git a/core/bot_icon.png b/core/bot_icon.png new file mode 100644 index 00000000..37c81be7 Binary files /dev/null and b/core/bot_icon.png differ diff --git a/core/core.slnx b/core/core.slnx new file mode 100644 index 00000000..f94b02cb --- /dev/null +++ b/core/core.slnx @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/samples/AFBot/AFBot.csproj b/core/samples/AFBot/AFBot.csproj new file mode 100644 index 00000000..cfd5f10d --- /dev/null +++ b/core/samples/AFBot/AFBot.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/core/samples/AFBot/DropTypingMiddleware.cs b/core/samples/AFBot/DropTypingMiddleware.cs new file mode 100644 index 00000000..3c997e77 --- /dev/null +++ b/core/samples/AFBot/DropTypingMiddleware.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + +namespace AFBot; + +internal class DropTypingMiddleware : ITurnMiddleWare +{ + public Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn nextTurn, CancellationToken cancellationToken = default) + { + if (activity.Type == ActivityType.Typing) return Task.CompletedTask; + return nextTurn(cancellationToken); + } +} diff --git a/core/samples/AFBot/Program.cs b/core/samples/AFBot/Program.cs new file mode 100644 index 00000000..39ceb5d5 --- /dev/null +++ b/core/samples/AFBot/Program.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel; +using AFBot; +using Azure.AI.OpenAI; +using Azure.Monitor.OpenTelemetry.AspNetCore; +using Microsoft.Agents.AI; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; +using OpenAI; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddOpenTelemetry().UseAzureMonitor(); +webAppBuilder.Services.AddBotApplication(); +WebApplication webApp = webAppBuilder.Build(); +BotApplication botApp = webApp.UseBotApplication(); + +AzureOpenAIClient azureClient = new( + new Uri("https://tsdkfoundry.openai.azure.com/"), + new ApiKeyCredential(Environment.GetEnvironmentVariable("AZURE_OpenAI_KEY")!)); + +ChatClientAgent agent = azureClient.GetChatClient("gpt-5-nano").CreateAIAgent( + instructions: "You are an expert acronym maker, made an acronym made up from the first three characters of the user's message. " + + "Some examples: OMW on my way, BTW by the way, TVM thanks very much, and so on." + + "Always respond with the three complete words only, and include a related emoji at the end.", + name: "AcronymMaker"); + +botApp.Use(new DropTypingMiddleware()); + +botApp.OnActivity = async (activity, cancellationToken) => +{ + ArgumentNullException.ThrowIfNull(activity); + + CancellationTokenSource timer = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, new CancellationTokenSource(TimeSpan.FromSeconds(15)).Token); + + CoreActivity typing = CoreActivity.CreateBuilder() + .WithType(ActivityType.Typing) + .WithConversationReference(activity) + .Build(); + await botApp.SendActivityAsync(typing, cancellationToken); + + AgentRunResponse agentResponse = await agent.RunAsync(activity.Properties["text"]?.ToString() ?? "OMW", cancellationToken: timer.Token); + + var m1 = agentResponse.Messages.FirstOrDefault(); + Console.WriteLine($"AI:: GOT {agentResponse.Messages.Count} msgs"); + CoreActivity replyActivity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithConversationReference(activity) + .WithProperty("text", m1!.Text) + .Build(); + + var res = await botApp.SendActivityAsync(replyActivity, cancellationToken); + + Console.WriteLine("SENT >>> => " + res?.Id); +}; + +webApp.Run(); diff --git a/core/samples/AFBot/appsettings.json b/core/samples/AFBot/appsettings.json new file mode 100644 index 00000000..1ff8c135 --- /dev/null +++ b/core/samples/AFBot/appsettings.json @@ -0,0 +1,10 @@ +{ + "APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=00000000-0000-0000-0000-000000000000;", + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Bot": "Trace" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/AllFeatures/AllFeatures.csproj b/core/samples/AllFeatures/AllFeatures.csproj new file mode 100644 index 00000000..515a66f8 --- /dev/null +++ b/core/samples/AllFeatures/AllFeatures.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/AllFeatures/AllFeatures.http b/core/samples/AllFeatures/AllFeatures.http new file mode 100644 index 00000000..e81f1fd6 --- /dev/null +++ b/core/samples/AllFeatures/AllFeatures.http @@ -0,0 +1,6 @@ +@AllFeatures_HostAddress = http://localhost:5290 + +GET {{AllFeatures_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/core/samples/AllFeatures/Program.cs b/core/samples/AllFeatures/Program.cs new file mode 100644 index 00000000..ff314f19 --- /dev/null +++ b/core/samples/AllFeatures/Program.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Apps.Schema.Entities; + +var builder = TeamsBotApplication.CreateBuilder(); +var teamsApp = builder.Build(); + +teamsApp.OnMessage = async (messageArgs, context, cancellationToken) => +{ + string replyText = $"You sent: `{messageArgs.Text}` in activity of type `{context.Activity.Type}`."; + + await context.SendTypingActivityAsync(cancellationToken); + + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithConversationReference(context.Activity) + .WithText(replyText) + .Build(); + + reply.AddMention(context.Activity.From!, "ridobotlocal", true); + + await context.TeamsBotApplication.SendActivityAsync(reply, cancellationToken); +}; + +teamsApp.Run(); diff --git a/core/samples/AllFeatures/appsettings.json b/core/samples/AllFeatures/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/core/samples/AllFeatures/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/CompatBot/Cards.cs b/core/samples/CompatBot/Cards.cs new file mode 100644 index 00000000..253ec28a --- /dev/null +++ b/core/samples/CompatBot/Cards.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CompatBot; + +internal class Cards +{ + + public static object ResponseCard(string? feedback) => new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new + { + type = "TextBlock", + text = "Form Submitted Successfully! ✓", + weight = "Bolder", + size = "Large", + color = "Good" + }, + new + { + type = "TextBlock", + text = $"You entered: **{feedback ?? "(empty)"}**", + wrap = true + } + } + }; + + public static readonly object FeedbackCardObj = new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new + { + type = "TextBlock", + text = "Please provide your feedback:", + weight = "Bolder", + size = "Medium" + }, + new + { + type = "Input.Text", + id = "feedback", + placeholder = "Enter your feedback here", + isMultiline = true + } + }, + actions = new object[] + { + new + { + type = "Action.Execute", + title = "Submit Feedback" + } + } + }; +} diff --git a/core/samples/CompatBot/CompatBot.csproj b/core/samples/CompatBot/CompatBot.csproj new file mode 100644 index 00000000..abacede3 --- /dev/null +++ b/core/samples/CompatBot/CompatBot.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/core/samples/CompatBot/EchoBot.cs b/core/samples/CompatBot/EchoBot.cs new file mode 100644 index 00000000..7c594e9a --- /dev/null +++ b/core/samples/CompatBot/EchoBot.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Connector; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Compat; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Schema; +using Newtonsoft.Json.Linq; + +namespace CompatBot; + +public class ConversationData +{ + public int MessageCount { get; set; } = 0; + +} + +internal class EchoBot(TeamsBotApplication teamsBotApp, ConversationState conversationState, ILogger logger) + : TeamsActivityHandler +{ + public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) + { + await base.OnTurnAsync(turnContext, cancellationToken); + + await conversationState.SaveChangesAsync(turnContext, false, cancellationToken); + } + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + logger.LogInformation("OnMessage"); + IStatePropertyAccessor conversationStateAccessors = conversationState.CreateProperty(nameof(ConversationData)); + ConversationData conversationData = await conversationStateAccessors.GetAsync(turnContext, () => new ConversationData(), cancellationToken); + + string replyText = $"Echo from BF Compat [{conversationData.MessageCount++}]: {turnContext.Activity.Text}"; + await turnContext.SendActivityAsync(MessageFactory.Text(replyText, replyText), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text($"Send a proactive message `/api/notify/{turnContext.Activity.Conversation.Id}`"), cancellationToken); + + // TeamsAPXClient provides Teams-specific operations like: + // - FetchTeamDetailsAsync, FetchChannelListAsync + // - FetchMeetingInfoAsync, FetchParticipantAsync, SendMeetingNotificationAsync + // - Batch messaging: SendMessageToListOfUsersAsync, SendMessageToAllUsersInTenantAsync, etc. + + await SendUpdateDeleteActivityAsync(turnContext, teamsBotApp.ConversationClient, cancellationToken); + + var attachment = new Attachment + { + ContentType = "application/vnd.microsoft.card.adaptive", + Content = Cards.FeedbackCardObj + }; + var attachmentReply = MessageFactory.Attachment(attachment); + await turnContext.SendActivityAsync(attachmentReply, cancellationToken); + + } + + + protected override async Task OnMessageReactionActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Message reaction received."), cancellationToken); + } + + protected override async Task OnInstallationUpdateActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Installation update received."), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text($"Send a proactive messages to `/api/notify/{turnContext.Activity.Conversation.Id}`"), cancellationToken); + } + + protected override async Task OnInstallationUpdateAddAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Installation update Add received."), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text($"Send a proactive messages to `/api/notify/{turnContext.Activity.Conversation.Id}`"), cancellationToken); + } + + protected override async Task OnInvokeActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + logger.LogInformation("Invoke Activity received: {Name}", turnContext.Activity.Name); + var actionValue = JObject.FromObject(turnContext.Activity.Value); + var action = actionValue["action"] as JObject; + var actionData = action?["data"] as JObject; + var userInput = actionData?["feedback"]?.ToString(); + //var userInput = actionValue["userInput"]?.ToString(); + + logger.LogInformation("Action: {Action}, User Input: {UserInput}", action, userInput); + + + + var attachment = new Attachment + { + ContentType = "application/vnd.microsoft.card.adaptive", + Content = Cards.ResponseCard(userInput) + }; + + var card = MessageFactory.Attachment(attachment); + await turnContext.SendActivityAsync(card, cancellationToken); + + return new InvokeResponse + { + Status = 200, + Body = "invokes from compat bot" + }; + } + + protected override async Task OnMembersAddedAsync(IList membersAdded, ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Welcome."), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text($"Send a proactive messages to `/api/notify/{turnContext.Activity.Conversation.Id}`"), cancellationToken); + } + + protected override Task OnMembersRemovedAsync(IList membersRemoved, ITurnContext turnContext, CancellationToken cancellationToken) + { + return turnContext.SendActivityAsync(MessageFactory.Text("Bye."), cancellationToken); + } + + protected override async Task OnTeamsMeetingStartAsync(MeetingStartEventDetails meeting, ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Welcome to meeting: "), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text($"{meeting.Title} {meeting.MeetingType}"), cancellationToken); + } + + private static async Task SendUpdateDeleteActivityAsync(ITurnContext turnContext, ConversationClient conversationClient, CancellationToken cancellationToken) + { + var cr = turnContext.Activity.GetConversationReference(); + Activity reply = (Activity)Activity.CreateMessageActivity(); + reply.ApplyConversationReference(cr, isIncoming: false); + reply.Text = "This is a proactive message sent using the Conversations API."; + + TeamsActivity ta = reply.FromCompatActivity(); + + var res = await conversationClient.SendActivityAsync(ta, null, cancellationToken); + + await Task.Delay(2000, cancellationToken); + + await conversationClient.UpdateActivityAsync( + cr.Conversation.Id, + res.Id!, + TeamsActivity.CreateBuilder() + .WithId(res.Id ?? "") + .WithServiceUrl(new Uri(turnContext.Activity.ServiceUrl)) + .WithType(ActivityType.Message) + .WithText("This message has been updated.") + .WithFrom(ta.From) + .Build(), + null, + cancellationToken); + + await Task.Delay(2000, cancellationToken); + + await conversationClient.DeleteActivityAsync(cr.Conversation.Id, res.Id!, new Uri(turnContext.Activity.ServiceUrl), AgenticIdentity.FromProperties(ta.From.Properties), null, cancellationToken); + + await turnContext.SendActivityAsync(MessageFactory.Text("Proactive message sent and deleted."), cancellationToken); + } + +} diff --git a/core/samples/CompatBot/MyCompatMiddleware.cs b/core/samples/CompatBot/MyCompatMiddleware.cs new file mode 100644 index 00000000..084384b6 --- /dev/null +++ b/core/samples/CompatBot/MyCompatMiddleware.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; + +namespace CompatBot +{ + public class MyCompatMiddleware : Microsoft.Bot.Builder.IMiddleware + { + public Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default) + { + Console.WriteLine("MyCompatMiddleware: OnTurnAsync"); + Console.WriteLine(turnContext.Activity.Text); + return next(cancellationToken); + } + } +} diff --git a/core/samples/CompatBot/Program.cs b/core/samples/CompatBot/Program.cs new file mode 100644 index 00000000..81f04a1d --- /dev/null +++ b/core/samples/CompatBot/Program.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Monitor.OpenTelemetry.AspNetCore; +using CompatBot; + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Compat; +using Microsoft.Bot.Schema; + +// using Microsoft.Bot.Connector.Authentication; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddOpenTelemetry().UseAzureMonitor(); +builder.AddCompatAdapter(); + +//builder.Services.AddSingleton(); +//builder.Services.AddSingleton(provider => +// new CloudAdapter( +// provider.GetRequiredService(), +// provider.GetRequiredService>())); + + +MemoryStorage storage = new(); +ConversationState conversationState = new(storage); +builder.Services.AddSingleton(conversationState); +builder.Services.AddTransient(); + +WebApplication app = builder.Build(); + +CompatAdapter compatAdapter = (CompatAdapter)app.Services.GetRequiredService(); +compatAdapter.Use(new MyCompatMiddleware()); + +app.MapPost("/api/messages", async (IBotFrameworkHttpAdapter adapter, IBot bot, HttpRequest request, HttpResponse response, CancellationToken ct) => + await adapter.ProcessAsync(request, response, bot, ct)); + +app.MapGet("/api/notify/{cid}", async (IBotFrameworkHttpAdapter adapter, string cid, CancellationToken ct) => +{ + Activity proactive = new() + { + Conversation = new() { Id = cid }, + ServiceUrl = "https://smba.trafficmanager.net/teams" + }; + await ((CompatAdapter)adapter).ContinueConversationAsync( + string.Empty, + proactive.GetConversationReference(), + async (turnContext, ct) => + { + await turnContext.SendActivityAsync( + MessageFactory.Text($"Proactive.
SDK `{BotApplication.Version}` at {DateTime.Now:T}"), ct); + }, + ct); +}); + +app.Run(); diff --git a/core/samples/CompatBot/appsettings.json b/core/samples/CompatBot/appsettings.json new file mode 100644 index 00000000..1ff8c135 --- /dev/null +++ b/core/samples/CompatBot/appsettings.json @@ -0,0 +1,10 @@ +{ + "APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=00000000-0000-0000-0000-000000000000;", + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Bot": "Trace" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/CoreBot/CoreBot.csproj b/core/samples/CoreBot/CoreBot.csproj new file mode 100644 index 00000000..48aeee8f --- /dev/null +++ b/core/samples/CoreBot/CoreBot.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/core/samples/CoreBot/Program.cs b/core/samples/CoreBot/Program.cs new file mode 100644 index 00000000..529a3ffb --- /dev/null +++ b/core/samples/CoreBot/Program.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Monitor.OpenTelemetry.AspNetCore; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddOpenTelemetry().UseAzureMonitor(); +webAppBuilder.Services.AddBotApplication(); +WebApplication webApp = webAppBuilder.Build(); +BotApplication botApp = webApp.UseBotApplication(); + +webApp.MapGet("/", () => "CoreBot is running."); + +botApp.OnActivity = async (activity, cancellationToken) => +{ + string replyText = $"CoreBot running on SDK {BotApplication.Version}."; + + replyText += $"
Received Activity `{activity.Type}`."; + + //activity.Properties.Where(kvp => kvp.Key.StartsWith("text")).ToList().ForEach(kvp => + //{ + // replyText += $"
{kvp.Key}:`{kvp.Value}` "; + //}); + + + string? conversationType = "unknown conversation type"; + if (activity.Conversation.Properties.TryGetValue("conversationType", out object? ctProp)) + { + conversationType = ctProp?.ToString(); + } + + replyText += $"
To conv type: `{conversationType}` conv id: `{activity.Conversation.Id}`"; + + CoreActivity replyActivity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithConversationReference(activity) + .WithProperty("text", replyText) + .Build(); + + await botApp.SendActivityAsync(replyActivity, cancellationToken); +}; + +webApp.Run(); diff --git a/core/samples/CoreBot/appsettings.json b/core/samples/CoreBot/appsettings.json new file mode 100644 index 00000000..1ff8c135 --- /dev/null +++ b/core/samples/CoreBot/appsettings.json @@ -0,0 +1,10 @@ +{ + "APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=00000000-0000-0000-0000-000000000000;", + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Bot": "Trace" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/Proactive/Proactive.csproj b/core/samples/Proactive/Proactive.csproj new file mode 100644 index 00000000..8cb14294 --- /dev/null +++ b/core/samples/Proactive/Proactive.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + diff --git a/core/samples/Proactive/Program.cs b/core/samples/Proactive/Program.cs new file mode 100644 index 00000000..85ccefa7 --- /dev/null +++ b/core/samples/Proactive/Program.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Hosting; + +using Proactive; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Services.AddConversationClient(); +builder.Services.AddHostedService(); + +IHost host = builder.Build(); +host.Run(); diff --git a/core/samples/Proactive/Worker.cs b/core/samples/Proactive/Worker.cs new file mode 100644 index 00000000..aef0308e --- /dev/null +++ b/core/samples/Proactive/Worker.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Proactive; + +public class Worker(ConversationClient conversationClient, ILogger logger) : BackgroundService +{ + private const string ConversationId = "a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT"; + private const string FromId = "28:56653e9d-2158-46ee-90d7-675c39642038"; + private const string ServiceUrl = "https://smba.trafficmanager.net/teams/"; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (logger.IsEnabled(LogLevel.Information)) + { + CoreActivity proactiveMessage = new() + { + ServiceUrl = new Uri(ServiceUrl), + From = new() { Id = FromId }, + Conversation = new() { Id = ConversationId } + }; + proactiveMessage.Properties["text"] = $"Proactive hello at {DateTimeOffset.Now}"; + var aid = await conversationClient.SendActivityAsync(proactiveMessage, cancellationToken: stoppingToken); + logger.LogInformation("Activity {Aid} sent", aid.Id); + } + await Task.Delay(1000, stoppingToken); + } + } +} diff --git a/core/samples/Proactive/appsettings.json b/core/samples/Proactive/appsettings.json new file mode 100644 index 00000000..e258d268 --- /dev/null +++ b/core/samples/Proactive/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Bot.Core": "Information" + } + } +} diff --git a/core/samples/TeamsBot/Cards.cs b/core/samples/TeamsBot/Cards.cs new file mode 100644 index 00000000..afcb611f --- /dev/null +++ b/core/samples/TeamsBot/Cards.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace TeamsBot; + +internal class Cards +{ + public static object ResponseCard(string? feedback) => new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new + { + type = "TextBlock", + text = "Form Submitted Successfully! ✓", + weight = "Bolder", + size = "Large", + color = "Good" + }, + new + { + type = "TextBlock", + text = $"You entered: **{feedback ?? "(empty)"}**", + wrap = true + } + } + }; + + public static object ReactionsCard(string? reactionsAdded, string? reactionsRemoved) => new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new + { + type = "TextBlock", + text = "Reaction Received", + weight = "Bolder", + size = "Medium" + }, + new + { + type = "TextBlock", + text = $"Reactions Added: {reactionsAdded ?? "(empty)"}", + wrap = true + }, + new + { + type = "TextBlock", + text = $"Reactions Removed: {reactionsRemoved ?? "(empty)"}", + wrap = true + } + } + }; + + public static readonly object FeedbackCardObj = new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new + { + type = "TextBlock", + text = "Please provide your feedback:", + weight = "Bolder", + size = "Medium" + }, + new + { + type = "Input.Text", + id = "feedback", + placeholder = "Enter your feedback here", + isMultiline = true + } + }, + actions = new object[] + { + new + { + type = "Action.Execute", + title = "Submit Feedback" + } + } + }; +} diff --git a/core/samples/TeamsBot/Program.cs b/core/samples/TeamsBot/Program.cs new file mode 100644 index 00000000..e97fd388 --- /dev/null +++ b/core/samples/TeamsBot/Program.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Apps.Schema.Entities; +using TeamsBot; + +var builder = TeamsBotApplication.CreateBuilder(); +var teamsApp = builder.Build(); + + + + +teamsApp.OnMessage = async (messageArgs, context, cancellationToken) => +{ + await context.SendTypingActivityAsync(cancellationToken); + + string replyText = $"You sent: `{messageArgs.Text}` in activity of type `{context.Activity.Type}`."; + + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithText(replyText) + .Build(); + + reply.AddMention(context.Activity.From!); + + await context.SendActivityAsync(reply, cancellationToken); + + TeamsActivity feedbackCard = TeamsActivity.CreateBuilder() + .WithAttachment(TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.FeedbackCardObj) + .Build()) + .Build(); + await context.SendActivityAsync(feedbackCard, cancellationToken); +}; + +teamsApp.OnMessageReaction = async (args, context, cancellationToken) => +{ + string reactionsAdded = string.Join(", ", args.ReactionsAdded?.Select(r => r.Type) ?? []); + string reactionsRemoved = string.Join(", ", args.ReactionsRemoved?.Select(r => r.Type) ?? []); + + var reply = TeamsActivity.CreateBuilder() + .WithAttachment(TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.ReactionsCard(reactionsAdded, reactionsRemoved)) + .Build() + ) + .Build(); + + await context.SendActivityAsync(reply, cancellationToken); +}; + +teamsApp.OnInvoke = async (context, cancellationToken) => +{ + var valueNode = context.Activity.Value; + string? feedbackValue = valueNode?["action"]?["data"]?["feedback"]?.GetValue(); + + var reply = TeamsActivity.CreateBuilder() + .WithAttachment(TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.ResponseCard(feedbackValue)) + .Build() + ) + .Build(); + + await context.SendActivityAsync(reply, cancellationToken); + + return new CoreInvokeResponse(200) + { + Type = "application/vnd.microsoft.activity.message", + Body = "Invokes are great !!" + }; +}; + +//teamsApp.OnActivity = async (activity, ct) => +//{ +// var reply = CoreActivity.CreateBuilder() +// .WithConversationReference(activity) +// .WithProperty("text", "yo") +// .Build(); +// await teamsApp.SendActivityAsync(reply, ct); +//}; + + +teamsApp.Run(); diff --git a/core/samples/TeamsBot/TeamsBot.csproj b/core/samples/TeamsBot/TeamsBot.csproj new file mode 100644 index 00000000..f30bcbe3 --- /dev/null +++ b/core/samples/TeamsBot/TeamsBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/TeamsBot/appsettings.json b/core/samples/TeamsBot/appsettings.json new file mode 100644 index 00000000..f88090b1 --- /dev/null +++ b/core/samples/TeamsBot/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Bot": "Information", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/scenarios/Properties/launchSettings.example.json b/core/samples/scenarios/Properties/launchSettings.example.json new file mode 100644 index 00000000..5ef4cff2 --- /dev/null +++ b/core/samples/scenarios/Properties/launchSettings.example.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "ridobotlocal": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development", + "AzureAd__Instance": "https://login.microsoftonline.com/", + "AzureAd__ClientId": "", + "AzureAd__TenantId": "", + "AzureAd__ClientCredentials__0__SourceType": "ClientSecret", + "AzureAd__ClientCredentials__0__ClientSecret": "", + "Logging__LogLevel__Default": "Warning", + "Logging__LogLevel__Microsoft.Bot": "Information", + } + } + } +} diff --git a/core/samples/scenarios/hello-assistant.cs b/core/samples/scenarios/hello-assistant.cs new file mode 100644 index 00000000..eac282ce --- /dev/null +++ b/core/samples/scenarios/hello-assistant.cs @@ -0,0 +1,34 @@ +#!/usr/bin/dotnet run + +#:sdk Microsoft.NET.Sdk.Web + +#:project ../../src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj +#:project ../../src/Microsoft.Teams.Bot.Apps/Microsoft.Teams.Bot.Apps.csproj + + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Apps.Schema.Entities; + +var builder = TeamsBotApplication.CreateBuilder(); +var teamsApp = builder.Build(); + +teamsApp.OnMessage = async (messageArgs, context, cancellationToken) => +{ + string replyText = $"You sent: `{messageArgs.Text}` in activity of type `{context.Activity.Type}`."; + + // await context.SendTypingActivityAsync(cancellationToken); + + // TeamsActivity reply = TeamsActivity.CreateBuilder() + // .WithType(TeamsActivityType.Message) + // .WithConversationReference(context.Activity) + // .WithText(replyText) + // .Build(); + + + // reply.AddMention(context.Activity.From!, "ridobotlocal", true); + + await context.SendActivityAsync(replyText, cancellationToken); +}; + +teamsApp.Run(); \ No newline at end of file diff --git a/core/samples/scenarios/middleware.cs b/core/samples/scenarios/middleware.cs new file mode 100755 index 00000000..5a771997 --- /dev/null +++ b/core/samples/scenarios/middleware.cs @@ -0,0 +1,39 @@ +#!/usr/bin/dotnet run + +#:sdk Microsoft.NET.Sdk.Web + +#:project ../../src/Microsoft.Bot.Core/Microsoft.Bot.Core.csproj + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Core.Hosting; + + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddBotApplication(); +WebApplication webApp = webAppBuilder.Build(); +var botApp = webApp.UseBotApplication(); + +botApp.Use(new MyTurnMiddleWare()); + +botApp.OnActivity = async (activity, cancellationToken) => +{ + string? text = activity.Properties.TryGetValue("text", out object? value) ? value?.ToString() : null; + var replyActivity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithConversationReference(activity) + .WithProperty("text", "You said " + text) + .Build(); + await botApp.SendActivityAsync(replyActivity, cancellationToken); +}; + +webApp.Run(); + +public class MyTurnMiddleWare : ITurnMiddleWare +{ + public Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn next, CancellationToken cancellationToken = default) + { + Console.WriteLine($"MIDDLEWARE: Processing activity {activity.Type} {activity.Id}"); + return next(cancellationToken); + } +} \ No newline at end of file diff --git a/core/samples/scenarios/proactive.cs b/core/samples/scenarios/proactive.cs new file mode 100644 index 00000000..9728133a --- /dev/null +++ b/core/samples/scenarios/proactive.cs @@ -0,0 +1,43 @@ +#!/usr/bin/dotnet run + +#:sdk Microsoft.NET.Sdk.Worker + +#:project ../../src/Microsoft.Bot.Core/Microsoft.Bot.Core.csproj + +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddBotApplicationClients(); +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); + +public class Worker(ConversationClient conversationClient, ILogger logger) : BackgroundService +{ + const string conversationId = "a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT"; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (logger.IsEnabled(LogLevel.Information)) + { + CoreActivity proactiveMessage = new() + { + Text = $"Proactive hello at {DateTimeOffset.Now}", + ServiceUrl = new Uri("https://smba.trafficmanager.net/amer/56653e9d-2158-46ee-90d7-675c39642038/"), + Conversation = new() + { + Id = conversationId + } + }; + var aid = await conversationClient.SendActivityAsync(proactiveMessage, stoppingToken); + logger.LogInformation($"Activity {aid} sent"); + } + await Task.Delay(1000, stoppingToken); + } + } +} \ No newline at end of file diff --git a/core/src/Directory.Build.props b/core/src/Directory.Build.props new file mode 100644 index 00000000..36f9ef7a --- /dev/null +++ b/core/src/Directory.Build.props @@ -0,0 +1,34 @@ + + + Microsoft Teams SDK + Microsoft + Microsoft + © Microsoft Corporation. All rights reserved. + https://github.com/microsoft/teams.net + git + false + bot_icon.png + README.md + MIT + true + true + true + snupkg + false + + + latest-all + true + true + + + + + + + + all + 3.9.50 + + + diff --git a/core/src/Directory.Build.targets b/core/src/Directory.Build.targets new file mode 100644 index 00000000..7d5f7f8e --- /dev/null +++ b/core/src/Directory.Build.targets @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/core/src/Microsoft.Teams.Bot.Apps/Context.cs b/core/src/Microsoft.Teams.Bot.Apps/Context.cs new file mode 100644 index 00000000..303ceaef --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Context.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps; + +// TODO: Make Context Generic over the TeamsActivity type. +// It should be able to work with any type of TeamsActivity. + + +/// +/// Context for a bot turn. +/// +/// +/// +public class Context(TeamsBotApplication botApplication, TeamsActivity activity) +{ + /// + /// Base bot application. + /// + public TeamsBotApplication TeamsBotApplication { get; } = botApplication; + + /// + /// Current activity. + /// + public TeamsActivity Activity { get; } = activity; + + /// + /// Sends a message activity as a reply. + /// + /// + /// + /// + public Task SendActivityAsync(string text, CancellationToken cancellationToken = default) + => TeamsBotApplication.SendActivityAsync( + new TeamsActivityBuilder() + .WithConversationReference(Activity) + .WithText(text) + .Build(), cancellationToken); + + /// + /// Sends Activity + /// + /// + /// + /// + public Task SendActivityAsync(TeamsActivity activity, CancellationToken cancellationToken = default) + => TeamsBotApplication.SendActivityAsync( + new TeamsActivityBuilder(activity) + .WithConversationReference(Activity) + .Build(), cancellationToken); + + + /// + /// Sends a typing activity to the conversation asynchronously. + /// + /// + /// + public Task SendTypingActivityAsync(CancellationToken cancellationToken = default) + => TeamsBotApplication.SendActivityAsync( + TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Typing) + .WithConversationReference(Activity) + .Build(), cancellationToken); +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/ConversationUpdateHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/ConversationUpdateHandler.cs new file mode 100644 index 00000000..e4aa68ae --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/ConversationUpdateHandler.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling conversation update activities. +/// +/// +/// +/// +/// +public delegate Task ConversationUpdateHandler(ConversationUpdateArgs conversationUpdateActivity, Context context, CancellationToken cancellationToken = default); + +/// +/// Conversation update activity arguments. +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227: Collection Properties should be read only", Justification = "")] +public class ConversationUpdateArgs(TeamsActivity act) +{ + /// + /// Activity for the conversation update. + /// + public TeamsActivity Activity { get; set; } = act; + + /// + /// Members added to the conversation. + /// + public IList? MembersAdded { get; set; } = + act.Properties.TryGetValue("membersAdded", out object? value) + && value is JsonElement je + && je.ValueKind == JsonValueKind.Array + ? JsonSerializer.Deserialize>(je.GetRawText()) + : null; + + /// + /// Members removed from the conversation. + /// + public IList? MembersRemoved { get; set; } = + act.Properties.TryGetValue("membersRemoved", out object? value2) + && value2 is JsonElement je2 + && je2.ValueKind == JsonValueKind.Array + ? JsonSerializer.Deserialize>(je2.GetRawText()) + : null; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/InstallationUpdateHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/InstallationUpdateHandler.cs new file mode 100644 index 00000000..fc139713 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/InstallationUpdateHandler.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling installation update activities. +/// +/// +/// +/// +/// +public delegate Task InstallationUpdateHandler(InstallationUpdateArgs installationUpdateActivity, Context context, CancellationToken cancellationToken = default); + + +/// +/// Installation update activity arguments. +/// +/// +public class InstallationUpdateArgs(TeamsActivity act) +{ + /// + /// Activity for the installation update. + /// + public TeamsActivity Activity { get; set; } = act; + + /// + /// Installation action: "add" or "remove". + /// + public string? Action { get; set; } = act.Properties.TryGetValue("action", out object? value) && value is string s ? s : null; + + /// + /// Gets or sets the identifier of the currently selected channel. + /// + public string? SelectedChannelId { get; set; } = act.ChannelData?.Settings?.SelectedChannel?.Id; + + /// + /// Gets a value indicating whether the current action is an add operation. + /// + public bool IsAdd => Action == "add"; + + /// + /// Gets a value indicating whether the current action is a remove operation. + /// + public bool IsRemove => Action == "remove"; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/InvokeHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/InvokeHandler.cs new file mode 100644 index 00000000..de737771 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/InvokeHandler.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Represents a method that handles an invocation request and returns a response asynchronously. +/// +/// The context for the invocation, containing request data and metadata required to process the operation. Cannot be +/// null. +/// A cancellation token that can be used to cancel the operation. The default value is . +/// A task that represents the asynchronous operation. The task result contains the response to the invocation. +public delegate Task InvokeHandler(Context context, CancellationToken cancellationToken = default); + + + +/// +/// Represents the response returned from an invocation handler. +/// +/// +/// Creates a new instance of the class with the specified status code and optional body. +/// +/// +/// +public class CoreInvokeResponse(int status, object? body = null) +{ + /// + /// Status code of the response. + /// + [JsonPropertyName("status")] + public int Status { get; set; } = status; + + // TODO: This is strange - Should this be Value or Body? + /// + /// Gets or sets the message body content. + /// + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Body { get; set; } = body; + + // TODO: Get confirmation that this should be "Type" + // This particular type should be for AC responses + /// + /// Gets or Sets the Type + /// + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Type { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageHandler.cs new file mode 100644 index 00000000..f8066a9e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageHandler.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +// TODO: Handlers should just have context instead of args + context. + +/// +/// Delegate for handling message activities. +/// +/// +/// +/// +/// +public delegate Task MessageHandler(MessageArgs messageArgs, Context context, CancellationToken cancellationToken = default); + + +/// +/// Message activity arguments. +/// +/// +public class MessageArgs(TeamsActivity act) +{ + /// + /// Activity for the message. + /// + public TeamsActivity Activity { get; set; } = act; + + /// + /// Gets or sets the text content of the message. + /// + public string? Text { get; set; } = + act.Properties.TryGetValue("text", out object? value) + && value is JsonElement je + && je.ValueKind == JsonValueKind.String + ? je.GetString() + : act.Properties.TryGetValue("text", out object? value2) + ? value2?.ToString() + : null; + + /// + /// Gets or sets the text format of the message (e.g., "plain", "markdown", "xml"). + /// + public string? TextFormat { get; set; } = + act.Properties.TryGetValue("textFormat", out object? value) + && value is JsonElement je + && je.ValueKind == JsonValueKind.String + ? je.GetString() + : act.Properties.TryGetValue("textFormat", out object? value2) + ? value2?.ToString() + : null; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageReactionHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageReactionHandler.cs new file mode 100644 index 00000000..2e55d26e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageReactionHandler.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling message reaction activities. +/// +/// +/// +/// +/// +public delegate Task MessageReactionHandler(MessageReactionArgs reactionActivity, Context context, CancellationToken cancellationToken = default); + + +/// +/// Message reaction activity arguments. +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227: Collection Properties should be read only", Justification = "")] +public class MessageReactionArgs(TeamsActivity act) +{ + /// + /// Activity for the message reaction. + /// + public TeamsActivity Activity { get; set; } = act; + + /// + /// Reactions added to the message. + /// + public IList? ReactionsAdded { get; set; } = + act.Properties.TryGetValue("reactionsAdded", out object? value) + && value is JsonElement je + && je.ValueKind == JsonValueKind.Array + ? JsonSerializer.Deserialize>(je.GetRawText()) + : null; + + /// + /// Reactions removed from the message. + /// + public IList? ReactionsRemoved { get; set; } = + act.Properties.TryGetValue("reactionsRemoved", out object? value2) + && value2 is JsonElement je2 + && je2.ValueKind == JsonValueKind.Array + ? JsonSerializer.Deserialize>(je2.GetRawText()) + : null; +} + +/// +/// Message reaction schema. +/// +public class MessageReaction +{ + /// + /// Type of the reaction (e.g., "like", "heart"). + /// + [JsonPropertyName("type")] public string? Type { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Microsoft.Teams.Bot.Apps.csproj b/core/src/Microsoft.Teams.Bot.Apps/Microsoft.Teams.Bot.Apps.csproj new file mode 100644 index 00000000..a25e40ad --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Microsoft.Teams.Bot.Apps.csproj @@ -0,0 +1,13 @@ + + + + net8.0;net10.0 + enable + enable + + + + + + + diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ClientInfoEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ClientInfoEntity.cs new file mode 100644 index 00000000..8e76775d --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ClientInfoEntity.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema.Entities; + + +/// +/// Extension methods for Activity to handle client info. +/// +public static class ActivityClientInfoExtensions +{ + /// + /// Adds a client info to the activity. + /// + /// + /// + /// + /// + /// + /// + public static ClientInfoEntity AddClientInfo(this TeamsActivity activity, string platform, string country, string timeZone, string locale) + { + ArgumentNullException.ThrowIfNull(activity); + + ClientInfoEntity clientInfo = new(platform, country, timeZone, locale); + activity.Entities ??= []; + activity.Entities.Add(clientInfo); + activity.Rebase(); + return clientInfo; + } + + /// + /// Gets the client info from the activity's entities. + /// + /// + /// + public static ClientInfoEntity? GetClientInfo(this TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + if (activity.Entities == null) + { + return null; + } + ClientInfoEntity? clientInfo = activity.Entities.FirstOrDefault(e => e is ClientInfoEntity) as ClientInfoEntity; + + return clientInfo; + } +} + +/// +/// Client info entity. +/// +public class ClientInfoEntity : Entity +{ + /// + /// Creates a new instance of . + /// + public ClientInfoEntity() : base("clientInfo") + { + ToProperties(); + } + + + /// + /// Initializes a new instance of the class with specified parameters. + /// + /// + /// + /// + /// + public ClientInfoEntity(string platform, string country, string timezone, string locale) : base("clientInfo") + { + Locale = locale; + Country = country; + Platform = platform; + Timezone = timezone; + ToProperties(); + } + /// + /// Gets or sets the locale information. + /// + [JsonPropertyName("locale")] public string? Locale { get; set; } + + /// + /// Gets or sets the country information. + /// + [JsonPropertyName("country")] public string? Country { get; set; } + + /// + /// Gets or sets the platform information. + /// + [JsonPropertyName("platform")] public string? Platform { get; set; } + + /// + /// Gets or sets the timezone information. + /// + [JsonPropertyName("timezone")] public string? Timezone { get; set; } + + /// + /// Adds custom fields as properties. + /// + public override void ToProperties() + { + base.Properties.Add("locale", Locale); + base.Properties.Add("country", Country); + base.Properties.Add("platform", Platform); + base.Properties.Add("timezone", Timezone); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/Entity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/Entity.cs new file mode 100644 index 00000000..d82dd335 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/Entity.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema.Entities; + + +/// +/// List of Entity objects. +/// +[JsonConverter(typeof(EntityListJsonConverter))] +public class EntityList : List +{ + /// + /// Converts the Entities collection to a JsonArray. + /// + /// + public JsonArray? ToJsonArray() + { + JsonArray jsonArray = []; + foreach (Entity entity in this) + { + JsonObject jsonObject = new() + { + ["type"] = entity.Type + }; + foreach (KeyValuePair property in entity.Properties) + { + jsonObject[property.Key] = property.Value as JsonNode ?? JsonValue.Create(property.Value); + } + jsonArray.Add(jsonObject); + } + return jsonArray; + } + + /// + /// Parses a JsonArray into an Entities collection. + /// + /// + /// + /// + public static EntityList FromJsonArray(JsonArray? jsonArray, JsonSerializerOptions? options = null) + { + if (jsonArray == null) + { + return []; + } + EntityList entities = []; + foreach (JsonNode? item in jsonArray) + { + if (item is JsonObject jsonObject + && jsonObject.TryGetPropertyValue("type", out JsonNode? typeNode) + && typeNode is JsonValue typeValue + && typeValue.GetValue() is string typeString) + { + + // TODO: Investigate if there is any way for Parent to avoid + // Knowing the children. + // Maybe a registry pattern, or Converters? + Entity? entity = typeString switch + { + "clientInfo" => item.Deserialize(options), + "mention" => item.Deserialize(options), + //"message" or "https://schema.org/Message" => (Entity?)item.Deserialize(options), + "ProductInfo" => item.Deserialize(options), + "streaminfo" => item.Deserialize(options), + _ => null + }; + if (entity != null) + entities.Add(entity); + } + } + return entities; + } +} + +/// +/// Entity base class. +/// +/// +/// Initializes a new instance of the Entity class with the specified type. +/// +/// The type of the entity. Cannot be null. +public class Entity(string type) +{ + /// + /// Gets or sets the type identifier for the object represented by this instance. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = type; + + /// + /// Gets or sets the OData type identifier for the object represented by this instance. + /// + [JsonPropertyName("@type")] public string? OType { get; set; } + + /// + /// Gets or sets the OData context for the object represented by this instance. + /// + [JsonPropertyName("@context")] public string? OContext { get; set; } + /// + /// Extended properties dictionary. + /// +#pragma warning disable CA2227 // Collection properties should be read only + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; +#pragma warning restore CA2227 // Collection properties should be read only + + /// + /// Adds properties to the Properties dictionary. + /// + public virtual void ToProperties() + { + throw new NotImplementedException(); + } + +} + +/// +/// JSON converter for EntityList. +/// +public class EntityListJsonConverter : JsonConverter +{ + /// + /// Reads and converts the JSON to EntityList. + /// + public override EntityList? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + JsonArray? jsonArray = JsonSerializer.Deserialize(ref reader, options); + return EntityList.FromJsonArray(jsonArray, options); + } + + /// + /// Writes the EntityList as JSON. + /// + public override void Write(Utf8JsonWriter writer, EntityList value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(value); + JsonArray? jsonArray = value.ToJsonArray(); + JsonSerializer.Serialize(writer, jsonArray, options); + } +} + diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/MentionEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/MentionEntity.cs new file mode 100644 index 00000000..3227cd43 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/MentionEntity.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema.Entities; + +/// +/// Extension methods for Activity to handle mentions. +/// +public static class ActivityMentionExtensions +{ + /// + /// Gets the MentionEntity from the activity's entities. + /// + /// The activity to extract the mention from. + /// The MentionEntity if found; otherwise, null. + public static IEnumerable GetMentions(this TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + if (activity.Entities == null) + { + return []; + } + return activity.Entities.Where(e => e is MentionEntity).Cast(); + } + + /// + /// Adds a mention to the activity. + /// + /// + /// + /// + /// + /// + public static MentionEntity AddMention(this TeamsActivity activity, ConversationAccount account, string? text = null, bool addText = true) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(account); + string? mentionText = text ?? account.Name; + if (addText) + { + string? currentText = activity.Properties.TryGetValue("text", out object? value) ? value?.ToString() : null; + activity.Properties["text"] = $"{mentionText} {currentText}"; + } + activity.Entities ??= []; + MentionEntity mentionEntity = new(account, $"{mentionText}"); + activity.Entities.Add(mentionEntity); + activity.Rebase(); + return mentionEntity; + } +} + +/// +/// Mention entity. +/// +public class MentionEntity : Entity +{ + /// + /// Creates a new instance of . + /// + public MentionEntity() : base("mention") { } + + /// + /// Creates a new instance of with the specified mentioned account and text. + /// + /// + /// + public MentionEntity(ConversationAccount mentioned, string? text) : base("mention") + { + Mentioned = mentioned; + Text = text; + ToProperties(); + } + + /// + /// Mentioned conversation account. + /// + [JsonPropertyName("mentioned")] public ConversationAccount? Mentioned { get; set; } + + /// + /// Text of the mention. + /// + [JsonPropertyName("text")] public string? Text { get; set; } + + /// + /// Creates a new instance of the MentionEntity class from the specified JSON node. + /// + /// A JsonNode containing the data to deserialize. Must include a 'mentioned' property representing a + /// ConversationAccount. + /// A MentionEntity object populated with values from the provided JSON node. + /// Thrown if jsonNode is null or does not contain the required 'mentioned' property. + public static MentionEntity FromJsonElement(JsonNode? jsonNode) + { + MentionEntity res = new() + { + // TODO: Verify if throwing exceptions is okay here + Mentioned = jsonNode?["mentioned"] != null + ? JsonSerializer.Deserialize(jsonNode["mentioned"]!.ToJsonString())! + : throw new ArgumentNullException(nameof(jsonNode), "mentioned property is required"), + Text = jsonNode?["text"]?.GetValue() + }; + res.ToProperties(); + return res; + } + + /// + /// Adds custom fields as properties. + /// + public override void ToProperties() + { + base.Properties.Add("mentioned", Mentioned); + base.Properties.Add("text", Text); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/OMessageEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/OMessageEntity.cs new file mode 100644 index 00000000..4edbafc9 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/OMessageEntity.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema.Entities +{ + /// + /// OMessage entity. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227: Collection Properties should be read only", Justification = "")] + public class OMessageEntity : Entity + { + + /// + /// Creates a new instance of . + /// + public OMessageEntity() : base("https://schema.org/Message") + { + OType = "Message"; + OContext = "https://schema.org"; + } + /// + /// Gets or sets the additional type. + /// + [JsonPropertyName("additionalType")] public IList? AdditionalType { get; set; } + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ProductInfoEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ProductInfoEntity.cs new file mode 100644 index 00000000..a56a4442 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ProductInfoEntity.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema.Entities; + + + + +/// +/// Product info entity. +/// +public class ProductInfoEntity : Entity +{ + /// + /// Creates a new instance of . + /// + public ProductInfoEntity() : base("ProductInfo") { } + /// + /// Ids the product id. + /// + [JsonPropertyName("id")] public string? Id { get; set; } + + /// + /// Adds custom fields as properties. + /// + public override void ToProperties() + { + + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/SensitiveUsageEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/SensitiveUsageEntity.cs new file mode 100644 index 00000000..6e6444c1 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/SensitiveUsageEntity.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema.Entities; + +/// +/// Represents an entity that describes the usage of sensitive content, including its name, description, and associated +/// pattern. +/// +public class SensitiveUsageEntity : OMessageEntity +{ + /// + /// Creates a new instance of . + /// + public SensitiveUsageEntity() : base() => OType = "CreativeWork"; + + /// + /// Gets or sets the name of the sensitive usage. + /// + [JsonPropertyName("name")] public required string Name { get; set; } + + /// + /// Gets or sets the description of the sensitive usage. + /// + [JsonPropertyName("description")] public string? Description { get; set; } + + /// + /// Gets or sets the pattern associated with the sensitive usage. + /// + [JsonPropertyName("pattern")] public DefinedTerm? Pattern { get; set; } +} + +/// +/// Defined term. +/// +public class DefinedTerm +{ + /// + /// Type of the defined term. + /// + [JsonPropertyName("@type")] public string Type { get; set; } = "DefinedTerm"; + + /// + /// OData type of the defined term. + /// + [JsonPropertyName("inDefinedTermSet")] public required string InDefinedTermSet { get; set; } + + /// + /// Gets or sets the name associated with the object. + /// + [JsonPropertyName("name")] public required string Name { get; set; } + + /// + /// Gets or sets the code that identifies the academic term. + /// + [JsonPropertyName("termCode")] public required string TermCode { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/StreamInfoEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/StreamInfoEntity.cs new file mode 100644 index 00000000..718bf7ab --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/StreamInfoEntity.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema.Entities; + +/// +/// Stream info entity. +/// +public class StreamInfoEntity : Entity +{ + /// + /// Creates a new instance of . + /// + public StreamInfoEntity() : base("streaminfo") { } + + /// + /// Gets or sets the stream id. + /// + [JsonPropertyName("streamId")] public string? StreamId { get; set; } + + /// + /// Gets or sets the stream type. See for possible values. + /// + [JsonPropertyName("streamType")] public string? StreamType { get; set; } + + /// + /// Gets or sets the stream sequence. + /// + [JsonPropertyName("streamSequence")] public int? StreamSequence { get; set; } +} + +/// +/// Represents the types of streams. +/// +public static class StreamType +{ + /// + /// Informative stream type. + /// + public const string Informative = "informative"; + /// + /// Streaming stream type. + /// + public const string Streaming = "streaming"; + /// + /// Represents the string literal "final". + /// + public const string Final = "final"; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Team.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Team.cs new file mode 100644 index 00000000..3aa75215 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Team.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema +{ + /// + /// Represents a team, including its identity, group association, and membership details. + /// + public class Team + { + /// + /// Represents the unique identifier of the team. + /// + [JsonPropertyName("id")] public string? Id { get; set; } + + /// + /// Azure Active Directory (AAD) Group ID associated with the team. + /// + [JsonPropertyName("aadGroupId")] public string? AadGroupId { get; set; } + + /// + /// Gets or sets the unique identifier of the tenant associated with this entity. + /// + [JsonPropertyName("tenantId")] public string? TenantId { get; set; } + + /// + /// Gets or sets the type identifier for the object represented by this instance. + /// + [JsonPropertyName("type")] public string? Type { get; set; } + + /// + /// Gets or sets the name associated with the object. + /// + [JsonPropertyName("name")] public string? Name { get; set; } + + /// + /// Number of channels in the team. + /// + [JsonPropertyName("channelCount")] public int? ChannelCount { get; set; } + + /// + /// Number of members in the team. + /// + [JsonPropertyName("memberCount")] public int? MemberCount { get; set; } + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivity.cs new file mode 100644 index 00000000..aea2a97c --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivity.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps.Schema.Entities; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Teams Activity schema. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227: Collection Properties should be read only", Justification = "")] +public class TeamsActivity : CoreActivity +{ + /// + /// Creates a new instance of the TeamsActivity class from the specified Activity object. + /// + /// The Activity instance to convert. Cannot be null. + /// A TeamsActivity object that represents the specified Activity. + public static TeamsActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new(activity); + } + + /// + /// Creates a new instance of the TeamsActivity class from the specified Activity object. + /// + /// + /// + public static new TeamsActivity FromJsonString(string json) => + FromJsonString(json, TeamsActivityJsonContext.Default.TeamsActivity) + .Rebase(); + + /// + /// Overrides the ToJson method to serialize the TeamsActivity object to a JSON string. + /// + /// + public new string ToJson() + => ToJson(TeamsActivityJsonContext.Default.TeamsActivity); + + /// + /// Default constructor. + /// + [JsonConstructor] + public TeamsActivity() + { + From = new TeamsConversationAccount(); + Recipient = new TeamsConversationAccount(); + Conversation = new TeamsConversation(); + } + + private static TeamsActivity FromJsonString(string json, JsonTypeInfo options) + => JsonSerializer.Deserialize(json, options)!; + + private TeamsActivity(CoreActivity activity) : base(activity) + { + // Convert base types to Teams-specific types + if (activity.ChannelData is not null) + { + ChannelData = new TeamsChannelData(activity.ChannelData); + } + From = new TeamsConversationAccount(activity.From); + Recipient = new TeamsConversationAccount(activity.Recipient); + Conversation = new TeamsConversation(activity.Conversation); + Attachments = TeamsAttachment.FromJArray(activity.Attachments); + Entities = EntityList.FromJsonArray(activity.Entities); + + Rebase(); + } + + /// + /// Resets shadow properties in base class + /// + /// + internal TeamsActivity Rebase() + { + base.Attachments = this.Attachments?.ToJsonArray(); + base.Entities = this.Entities?.ToJsonArray(); + base.ChannelData = new TeamsChannelData(this.ChannelData); + base.From = this.From; + base.Recipient = this.Recipient; + base.Conversation = this.Conversation; + + return this; + } + + /// + /// Gets or sets the account information for the sender of the Teams conversation. + /// + [JsonPropertyName("from")] public new TeamsConversationAccount From { get; set; } + + /// + /// Gets or sets the account information for the recipient of the Teams conversation. + /// + [JsonPropertyName("recipient")] public new TeamsConversationAccount Recipient { get; set; } + + /// + /// Gets or sets the conversation information for the Teams conversation. + /// + [JsonPropertyName("conversation")] public new TeamsConversation Conversation { get; set; } + + /// + /// Gets or sets the Teams-specific channel data associated with this activity. + /// + [JsonPropertyName("channelData")] public new TeamsChannelData? ChannelData { get; set; } + + /// + /// Gets or sets the entities specific to Teams. + /// + [JsonPropertyName("entities")] public new EntityList? Entities { get; set; } + + /// + /// Attachments specific to Teams. + /// + [JsonPropertyName("attachments")] public new IList? Attachments { get; set; } + + /// + /// Adds an entity to the activity's Entities collection. + /// + /// + /// + public TeamsActivity AddEntity(Entity entity) + { + // TODO: Pick up nuances about entities. + // For eg, there can only be 1 single MessageEntity + Entities ??= []; + Entities.Add(entity); + return this; + } + + /// + /// Creates a new TeamsActivityBuilder instance for building a TeamsActivity with a fluent API. + /// + /// A new TeamsActivityBuilder instance. + public static new TeamsActivityBuilder CreateBuilder() => new(); + + /// + /// Creates a new TeamsActivityBuilder instance initialized with the specified TeamsActivity. + /// + /// + /// + public static TeamsActivityBuilder CreateBuilder(TeamsActivity activity) => new(activity); + +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityBuilder.cs new file mode 100644 index 00000000..aed75764 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityBuilder.cs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps.Schema.Entities; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Provides a fluent API for building TeamsActivity instances. +/// +public class TeamsActivityBuilder : CoreActivityBuilder +{ + /// + /// Initializes a new instance of the TeamsActivityBuilder class. + /// + internal TeamsActivityBuilder() : base(TeamsActivity.FromActivity(new CoreActivity())) + { + } + + /// + /// Initializes a new instance of the TeamsActivityBuilder class with an existing activity. + /// + /// The activity to build upon. + internal TeamsActivityBuilder(TeamsActivity activity) : base(activity) + { + } + + /// + /// Sets the conversation (override for Teams-specific type). + /// + protected override void SetConversation(Conversation conversation) + { + _activity.Conversation = conversation is TeamsConversation teamsConv + ? teamsConv + : new TeamsConversation(conversation); + } + + /// + /// Sets the From account (override for Teams-specific type). + /// + protected override void SetFrom(ConversationAccount from) + { + _activity.From = from is TeamsConversationAccount teamsAccount + ? teamsAccount + : new TeamsConversationAccount(from); + } + + /// + /// Sets the Recipient account (override for Teams-specific type). + /// + protected override void SetRecipient(ConversationAccount recipient) + { + _activity.Recipient = recipient is TeamsConversationAccount teamsAccount + ? teamsAccount + : new TeamsConversationAccount(recipient); + } + + /// + /// Sets the Teams-specific channel data. + /// + /// The channel data. + /// The builder instance for chaining. + public TeamsActivityBuilder WithChannelData(TeamsChannelData? channelData) + { + _activity.ChannelData = channelData; + return this; + } + + /// + /// Sets the entities collection. + /// + /// The entities collection. + /// The builder instance for chaining. + public TeamsActivityBuilder WithEntities(EntityList entities) + { + _activity.Entities = entities; + return this; + } + + /// + /// Sets the attachments collection. + /// + /// The attachments collection. + /// The builder instance for chaining. + public TeamsActivityBuilder WithAttachments(IList attachments) + { + _activity.Attachments = attachments; + return this; + } + + // TODO: Builders should only have "With" methods, not "Add" methods. + /// + /// Replaces the attachments collection with a single attachment. + /// + /// The attachment to set. Passing null clears the attachments. + /// The builder instance for chaining. + public TeamsActivityBuilder WithAttachment(TeamsAttachment? attachment) + { + _activity.Attachments = attachment is null + ? null + : [attachment]; + + return this; + } + + /// + /// Adds an entity to the activity's Entities collection. + /// + /// The entity to add. + /// The builder instance for chaining. + public TeamsActivityBuilder AddEntity(Entity entity) + { + _activity.Entities ??= []; + _activity.Entities.Add(entity); + return this; + } + + /// + /// Adds an attachment to the activity's Attachments collection. + /// + /// The attachment to add. + /// The builder instance for chaining. + public TeamsActivityBuilder AddAttachment(TeamsAttachment attachment) + { + _activity.Attachments ??= []; + _activity.Attachments.Add(attachment); + return this; + } + + /// + /// Adds an Adaptive Card attachment to the activity. + /// + /// The Adaptive Card payload. + /// Optional callback to further configure the attachment before it is added. + /// The builder instance for chaining. + public TeamsActivityBuilder AddAdaptiveCardAttachment(object adaptiveCard, Action? configure = null) + { + TeamsAttachment attachment = BuildAdaptiveCardAttachment(adaptiveCard, configure); + return AddAttachment(attachment); + } + + /// + /// Sets the activity attachments collection to a single Adaptive Card attachment. + /// + /// The Adaptive Card payload. + /// Optional callback to further configure the attachment. + /// The builder instance for chaining. + public TeamsActivityBuilder WithAdaptiveCardAttachment(object adaptiveCard, Action? configure = null) + { + TeamsAttachment attachment = BuildAdaptiveCardAttachment(adaptiveCard, configure); + return WithAttachment(attachment); + } + + /// + /// Adds or sets the text content of the activity. + /// + /// + /// + /// + public TeamsActivityBuilder WithText(string text, string textFormat = "plain") + { + WithProperty("text", text); + WithProperty("textFormat", textFormat); + return this; + } + + /// + /// Adds a mention to the activity. + /// + /// The account to mention. + /// Optional custom text for the mention. If null, uses the account name. + /// Whether to prepend the mention text to the activity's text content. + /// The builder instance for chaining. + public TeamsActivityBuilder AddMention(ConversationAccount account, string? text = null, bool addText = true) + { + ArgumentNullException.ThrowIfNull(account); + string? mentionText = text ?? account.Name; + + if (addText) + { + string? currentText = _activity.Properties.TryGetValue("text", out object? value) ? value?.ToString() : null; + WithProperty("text", $"{mentionText} {currentText}"); + } + + _activity.Entities ??= []; + _activity.Entities.Add(new MentionEntity(account, $"{mentionText}")); + + CoreActivity baseActivity = _activity; + baseActivity.Entities = _activity.Entities.ToJsonArray(); + + return this; + } + + /// + /// Builds and returns the configured TeamsActivity instance. + /// + /// The configured TeamsActivity. + public override TeamsActivity Build() + { + _activity.Rebase(); + return _activity; + } + + private static TeamsAttachment BuildAdaptiveCardAttachment(object adaptiveCard, Action? configure) + { + ArgumentNullException.ThrowIfNull(adaptiveCard); + + TeamsAttachmentBuilder attachmentBuilder = TeamsAttachment + .CreateBuilder() + .WithAdaptiveCard(adaptiveCard); + + configure?.Invoke(attachmentBuilder); + + return attachmentBuilder.Build(); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityJsonContext.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityJsonContext.cs new file mode 100644 index 00000000..229a59eb --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityJsonContext.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps.Schema.Entities; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Json source generator context for Teams activity types. +/// +[JsonSourceGenerationOptions( + WriteIndented = true, + IncludeFields = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CoreActivity))] +[JsonSerializable(typeof(TeamsActivity))] +[JsonSerializable(typeof(Entity))] +[JsonSerializable(typeof(EntityList))] +[JsonSerializable(typeof(MentionEntity))] +[JsonSerializable(typeof(ClientInfoEntity))] +[JsonSerializable(typeof(TeamsChannelData))] +[JsonSerializable(typeof(ConversationAccount))] +[JsonSerializable(typeof(TeamsConversationAccount))] +[JsonSerializable(typeof(TeamsConversation))] +[JsonSerializable(typeof(ExtendedPropertiesDictionary))] +[JsonSerializable(typeof(System.Text.Json.JsonElement))] +[JsonSerializable(typeof(System.Int32))] +[JsonSerializable(typeof(System.Boolean))] +[JsonSerializable(typeof(System.Int64))] +[JsonSerializable(typeof(System.Double))] +public partial class TeamsActivityJsonContext : JsonSerializerContext +{ +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityType.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityType.cs new file mode 100644 index 00000000..edc8f6a5 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityType.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Provides constant values for activity types used in Microsoft Teams bot interactions. +/// +/// These activity type constants are used to identify the type of activity received or sent in a Teams +/// bot context. Use these values when handling or generating activities to ensure compatibility with the Teams +/// platform. +public static class TeamsActivityType +{ + + /// + /// Represents the default message string used for communication or display purposes. + /// + public const string Message = ActivityType.Message; + /// + /// Represents a typing indicator activity. + /// + public const string Typing = ActivityType.Typing; + + /// + /// Represents an invoke activity. + /// + public const string Invoke = "invoke"; + + /// + /// Conversation update activity type. + /// + public static readonly string ConversationUpdate = "conversationUpdate"; + /// + /// Installation update activity type. + /// + public static readonly string InstallationUpdate = "installationUpdate"; + /// + /// Message reaction activity type. + /// + public static readonly string MessageReaction = "messageReaction"; + +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachment.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachment.cs new file mode 100644 index 00000000..1746378c --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachment.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + + +/// +/// Extension methods for TeamsAttachment. +/// +public static class TeamsAttachmentExtensions +{ + static internal JsonArray ToJsonArray(this IList attachments) + { + JsonArray jsonArray = []; + foreach (TeamsAttachment attachment in attachments) + { + JsonNode jsonNode = JsonSerializer.SerializeToNode(attachment)!; + jsonArray.Add(jsonNode); + } + return jsonArray; + } +} + +/// +/// Teams attachment model. +/// +public class TeamsAttachment +{ + static internal IList FromJArray(JsonArray? jsonArray) + { + if (jsonArray is null) + { + return []; + } + List attachments = []; + foreach (JsonNode? item in jsonArray) + { + attachments.Add(JsonSerializer.Deserialize(item)!); + } + return attachments; + } + + /// + /// Content of the attachment. + /// + [JsonPropertyName("contentType")] public string ContentType { get; set; } = string.Empty; + + /// + /// Content URL of the attachment. + /// + [JsonPropertyName("contentUrl")] public Uri? ContentUrl { get; set; } + + /// + /// Content for the Attachment + /// + [JsonPropertyName("content")] public object? Content { get; set; } + + /// + /// Gets or sets the name of the attachment. + /// + [JsonPropertyName("name")] public string? Name { get; set; } + + /// + /// Gets or sets the thumbnail URL of the attachment. + /// + [JsonPropertyName("thumbnailUrl")] public Uri? ThumbnailUrl { get; set; } + + /// + /// Extension data for additional properties not explicitly defined by the type. + /// +#pragma warning disable CA2227 // Collection properties should be read only + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; +#pragma warning restore CA2227 // Collection properties should be read only + + /// + /// Creates a builder for constructing a instance. + /// + public static TeamsAttachmentBuilder CreateBuilder() => new(); + + /// + /// Creates a builder initialized with an existing instance. + /// + /// The attachment to wrap. + public static TeamsAttachmentBuilder CreateBuilder(TeamsAttachment attachment) => new(attachment); +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachmentBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachmentBuilder.cs new file mode 100644 index 00000000..19cbec22 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachmentBuilder.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Provides a fluent API for creating instances. +/// +public class TeamsAttachmentBuilder +{ + private const string AdaptiveCardContentType = "application/vnd.microsoft.card.adaptive"; + + private readonly TeamsAttachment _attachment; + + internal TeamsAttachmentBuilder() : this(new TeamsAttachment()) + { + } + + internal TeamsAttachmentBuilder(TeamsAttachment attachment) + { + _attachment = attachment ?? throw new ArgumentNullException(nameof(attachment)); + } + + /// + /// Sets the content type for the attachment. + /// + public TeamsAttachmentBuilder WithContentType(string contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + throw new ArgumentException("Content type cannot be null or whitespace.", nameof(contentType)); + } + + _attachment.ContentType = contentType; + return this; + } + + /// + /// Sets the payload for the attachment. + /// + public TeamsAttachmentBuilder WithContent(object? content) + { + _attachment.Content = content; + return this; + } + + /// + /// Sets the content url for the attachment. + /// + public TeamsAttachmentBuilder WithContentUrl(Uri? contentUrl) + { + _attachment.ContentUrl = contentUrl; + return this; + } + + /// + /// Sets the friendly name for the attachment. + /// + public TeamsAttachmentBuilder WithName(string? name) + { + _attachment.Name = name; + return this; + } + + /// + /// Sets the thumbnail url for the attachment. + /// + public TeamsAttachmentBuilder WithThumbnailUrl(Uri? thumbnailUrl) + { + _attachment.ThumbnailUrl = thumbnailUrl; + return this; + } + + /// + /// Adds or updates an extension property on the attachment. + /// Passing a null value removes the property. + /// + public TeamsAttachmentBuilder WithProperty(string propertyName, object? value) + { + if (string.IsNullOrWhiteSpace(propertyName)) + { + throw new ArgumentException("Property name cannot be null or whitespace.", nameof(propertyName)); + } + + if (value is null) + { + _attachment.Properties.Remove(propertyName); + } + else + { + _attachment.Properties[propertyName] = value; + } + + return this; + } + + /// + /// Configures the attachment to contain an Adaptive Card payload. + /// + public TeamsAttachmentBuilder WithAdaptiveCard(object adaptiveCard) + { + ArgumentNullException.ThrowIfNull(adaptiveCard); + _attachment.ContentType = AdaptiveCardContentType; + _attachment.Content = adaptiveCard; + _attachment.ContentUrl = null; + return this; + } + + /// + /// Builds the attachment. + /// + public TeamsAttachment Build() => _attachment; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannel.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannel.cs new file mode 100644 index 00000000..2c85d9c0 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannel.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a Microsoft Teams channel, including its identifier, type, and display name. +/// +/// This class is typically used to serialize or deserialize channel information when interacting with +/// Microsoft Teams APIs or webhooks. All properties are optional and may be null if the corresponding data is not +/// available. +public class TeamsChannel +{ + /// + /// Represents the unique identifier of the channel. + /// + [JsonPropertyName("id")] public string? Id { get; set; } + + /// + /// Azure Active Directory (AAD) Object ID associated with the channel. + /// + [JsonPropertyName("aadObjectId")] public string? AadObjectId { get; set; } + + /// + /// Type identifier for the channel. + /// + [JsonPropertyName("type")] public string? Type { get; set; } + + /// + /// Gets or sets the name associated with the object. + /// + [JsonPropertyName("name")] public string? Name { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelData.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelData.cs new file mode 100644 index 00000000..d17f8adf --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelData.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents Teams-specific channel data. +/// +public class TeamsChannelData : ChannelData +{ + /// + /// Creates a new instance of the class. + /// + public TeamsChannelData() + { + } + + /// + /// Creates a new instance of the class from the specified object. + /// + /// + public TeamsChannelData(ChannelData? cd) + { + if (cd is not null) + { + if (cd.Properties.TryGetValue("teamsChannelId", out object? channelIdObj) + && channelIdObj is JsonElement jeChannelId + && jeChannelId.ValueKind == JsonValueKind.String) + { + TeamsChannelId = jeChannelId.GetString(); + } + + if (cd.Properties.TryGetValue("channel", out object? channelObj) + && channelObj is JsonElement channelObjJE + && channelObjJE.ValueKind == JsonValueKind.Object) + { + Channel = JsonSerializer.Deserialize(channelObjJE.GetRawText()); + } + + if (cd.Properties.TryGetValue("tenant", out object? tenantObj) + && tenantObj is JsonElement je + && je.ValueKind == JsonValueKind.Object) + { + Tenant = JsonSerializer.Deserialize(je.GetRawText()); + } + } + } + + + /// + /// Settings for the Teams channel. + /// + [JsonPropertyName("settings")] public TeamsChannelDataSettings? Settings { get; set; } + + /// + /// Gets or sets the unique identifier of the Microsoft Teams channel associated with this entity. + /// + [JsonPropertyName("teamsChannelId")] public string? TeamsChannelId { get; set; } + + /// + /// Teams Team Id. + /// + [JsonPropertyName("teamsTeamId")] public string? TeamsTeamId { get; set; } + + /// + /// Gets or sets the channel information associated with this entity. + /// + [JsonPropertyName("channel")] public TeamsChannel? Channel { get; set; } + + /// + /// Team information. + /// + [JsonPropertyName("team")] public Team? Team { get; set; } + + /// + /// Tenant information. + /// + [JsonPropertyName("tenant")] public TeamsChannelDataTenant? Tenant { get; set; } + +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelDataSettings.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelDataSettings.cs new file mode 100644 index 00000000..0b6a5214 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelDataSettings.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Teams channel data settings. +/// +public class TeamsChannelDataSettings +{ + /// + /// Selected channel information. + /// + [JsonPropertyName("selectedChannel")] public required TeamsChannel SelectedChannel { get; set; } + + /// + /// Gets or sets the collection of additional properties not explicitly defined by the type. + /// + /// This property stores extra JSON fields encountered during deserialization that do not map to + /// known properties. It enables round-tripping of unknown or custom data without loss. The dictionary keys + /// correspond to the property names in the JSON payload. +#pragma warning disable CA2227 // Collection properties should be read only + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; +#pragma warning restore CA2227 // Collection properties should be read only +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelDataTenant.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelDataTenant.cs new file mode 100644 index 00000000..5f2d59d6 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelDataTenant.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Tenant information for Teams channel data. +/// +public class TeamsChannelDataTenant +{ + /// + /// Unique identifier of the tenant. + /// + [JsonPropertyName("id")] public string? Id { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversation.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversation.cs new file mode 100644 index 00000000..aa310b49 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversation.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Defines known conversation types for Teams. +/// +public static class ConversationType +{ + /// + /// One-to-one conversation between a user and a bot. + /// + public const string Personal = "personal"; + + /// + /// Group chat conversation. + /// + public const string GroupChat = "groupChat"; +} + +/// +/// Teams Conversation schema. +/// +public class TeamsConversation : Conversation +{ + /// + /// Initializes a new instance of the TeamsConversation class. + /// + [JsonConstructor] + public TeamsConversation() + { + Id = string.Empty; + } + + /// + /// Creates a new instance of the TeamsConversation class from the specified Conversation object. + /// + /// + public TeamsConversation(Conversation conversation) + { + ArgumentNullException.ThrowIfNull(conversation); + Id = conversation.Id ?? string.Empty; + if (conversation.Properties == null) + { + return; + } + if (conversation.Properties.TryGetValue("tenantId", out object? tenantObj) && tenantObj is JsonElement je && je.ValueKind == JsonValueKind.String) + { + TenantId = je.GetString(); + } + if (conversation.Properties.TryGetValue("conversationType", out object? convTypeObj) && convTypeObj is JsonElement je2 && je2.ValueKind == JsonValueKind.String) + { + ConversationType = je2.GetString(); + } + } + + /// + /// Tenant Id. + /// + [JsonPropertyName("tenantId")] public string? TenantId { get; set; } + + /// + /// Conversation Type. See for known values. + /// + [JsonPropertyName("conversationType")] public string? ConversationType { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversationAccount .cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversationAccount .cs new file mode 100644 index 00000000..da0b576d --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversationAccount .cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a Microsoft Teams-specific conversation account, including Azure Active Directory (AAD) object +/// information. +/// +/// This class extends the base ConversationAccount to provide additional properties relevant to +/// Microsoft Teams, such as the Azure Active Directory object ID. It is typically used when working with Teams +/// conversations to access Teams-specific metadata. +public class TeamsConversationAccount : ConversationAccount +{ + /// + /// Conversation account. + /// + public ConversationAccount ConversationAccount { get; set; } + + /// + /// Initializes a new instance of the TeamsConversationAccount class. + /// + [JsonConstructor] + public TeamsConversationAccount() + { + ConversationAccount = new ConversationAccount(); + Id = string.Empty; + Name = string.Empty; + } + + /// + /// Initializes a new instance of the TeamsConversationAccount class using the specified conversation account. + /// + /// If the provided ConversationAccount contains an 'aadObjectId' property as a string, it is + /// used to set the AadObjectId property of the TeamsConversationAccount. + /// The ConversationAccount instance containing the conversation's identifier, name, and properties. Cannot be null. + public TeamsConversationAccount(ConversationAccount conversationAccount) + { + ArgumentNullException.ThrowIfNull(conversationAccount); + ConversationAccount = conversationAccount; + Properties = conversationAccount.Properties; + Id = conversationAccount.Id ?? string.Empty; + Name = conversationAccount.Name ?? string.Empty; + if (conversationAccount is not null + && conversationAccount.Properties.TryGetValue("aadObjectId", out object? aadObj) + && aadObj is JsonElement je + && je.ValueKind == JsonValueKind.String) + { + AadObjectId = je.GetString(); + } + } + /// + /// Gets or sets the Azure Active Directory (AAD) Object ID associated with the conversation account. + /// + [JsonPropertyName("aadObjectId")] public string? AadObjectId { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.Models.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.Models.cs new file mode 100644 index 00000000..837a3f69 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.Models.cs @@ -0,0 +1,488 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps; + +/// +/// Represents a list of channels in a team. +/// +public class ChannelList +{ + /// + /// Gets or sets the list of channel conversations. + /// + [JsonPropertyName("conversations")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList? Channels { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only +} + +/// +/// Represents detailed information about a team. +/// +public class TeamDetails +{ + /// + /// Gets or sets the unique identifier of the team. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the name of the team. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the Azure Active Directory group ID associated with the team. + /// + [JsonPropertyName("aadGroupId")] + public string? AadGroupId { get; set; } + + /// + /// Gets or sets the number of channels in the team. + /// + [JsonPropertyName("channelCount")] + public int? ChannelCount { get; set; } + + /// + /// Gets or sets the number of members in the team. + /// + [JsonPropertyName("memberCount")] + public int? MemberCount { get; set; } + + /// + /// Gets or sets the type of the team. Valid values are standard, sharedChannel and privateChannel. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } +} + +/// +/// Represents information about a meeting. +/// +public class MeetingInfo +{ + ///// + ///// Gets or sets the unique identifier of the meeting. + ///// + //[JsonPropertyName("id")] + //public string? Id { get; set; } + + /// + /// Gets or sets the details of the meeting. + /// + [JsonPropertyName("details")] + public MeetingDetails? Details { get; set; } + + /// + /// Gets or sets the conversation associated with the meeting. + /// + [JsonPropertyName("conversation")] + public ConversationAccount? Conversation { get; set; } + + /// + /// Gets or sets the organizer of the meeting. + /// + [JsonPropertyName("organizer")] + public ConversationAccount? Organizer { get; set; } +} + +/// +/// Represents detailed information about a meeting. +/// +public class MeetingDetails +{ + /// + /// Gets or sets the unique identifier of the meeting. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the Microsoft Graph resource ID of the meeting. + /// + [JsonPropertyName("msGraphResourceId")] + public string? MsGraphResourceId { get; set; } + + /// + /// Gets or sets the scheduled start time of the meeting. + /// + [JsonPropertyName("scheduledStartTime")] + public DateTimeOffset? ScheduledStartTime { get; set; } + + /// + /// Gets or sets the scheduled end time of the meeting. + /// + [JsonPropertyName("scheduledEndTime")] + public DateTimeOffset? ScheduledEndTime { get; set; } + + /// + /// Gets or sets the join URL of the meeting. + /// + [JsonPropertyName("joinUrl")] + public Uri? JoinUrl { get; set; } + + /// + /// Gets or sets the title of the meeting. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the type of the meeting. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } +} + +/// +/// Represents a meeting participant with their details. +/// +public class MeetingParticipant +{ + /// + /// Gets or sets the user information. + /// + [JsonPropertyName("user")] + public ConversationAccount? User { get; set; } + + /// + /// Gets or sets the meeting information. + /// + [JsonPropertyName("meeting")] + public MeetingParticipantInfo? Meeting { get; set; } + + /// + /// Gets or sets the conversation information. + /// + [JsonPropertyName("conversation")] + public ConversationAccount? Conversation { get; set; } +} + +/// +/// Represents meeting-specific participant information. +/// +public class MeetingParticipantInfo +{ + /// + /// Gets or sets the role of the participant in the meeting. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// Gets or sets a value indicating whether the participant is in the meeting. + /// + [JsonPropertyName("inMeeting")] + public bool? InMeeting { get; set; } +} + +/// +/// Base class for meeting notifications. +/// +public abstract class MeetingNotificationBase +{ + /// + /// Gets or sets the type of the notification. + /// + [JsonPropertyName("type")] + public abstract string Type { get; } +} + +/// +/// Represents a targeted meeting notification. +/// +public class TargetedMeetingNotification : MeetingNotificationBase +{ + /// + [JsonPropertyName("type")] + public override string Type => "targetedMeetingNotification"; + + /// + /// Gets or sets the value of the notification. + /// + [JsonPropertyName("value")] + public TargetedMeetingNotificationValue? Value { get; set; } +} + +/// +/// Represents the value of a targeted meeting notification. +/// +public class TargetedMeetingNotificationValue +{ + /// + /// Gets or sets the list of recipients for the notification. + /// + [JsonPropertyName("recipients")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList? Recipients { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only + + /// + /// Gets or sets the surface configurations for the notification. + /// + [JsonPropertyName("surfaces")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList? Surfaces { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only +} + +/// +/// Represents a surface for meeting notifications. +/// +public class MeetingNotificationSurface +{ + /// + /// Gets or sets the surface type (e.g., "meetingStage"). + /// + [JsonPropertyName("surface")] + public string? Surface { get; set; } + + /// + /// Gets or sets the content type of the notification. + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// Gets or sets the content of the notification. + /// + [JsonPropertyName("content")] + public object? Content { get; set; } +} + +/// +/// Response from sending a meeting notification. +/// +public class MeetingNotificationResponse +{ + /// + /// Gets or sets the list of recipients for whom the notification failed. + /// + [JsonPropertyName("recipientsFailureInfo")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList? RecipientsFailureInfo { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only +} + +/// +/// Information about a failed notification recipient. +/// +public class MeetingNotificationRecipientFailureInfo +{ + /// + /// Gets or sets the recipient ID. + /// + [JsonPropertyName("recipientMri")] + public string? RecipientMri { get; set; } + + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("errorCode")] + public string? ErrorCode { get; set; } + + /// + /// Gets or sets the failure reason. + /// + [JsonPropertyName("failureReason")] + public string? FailureReason { get; set; } +} + +/// +/// Represents a team member for batch operations. +/// +public class TeamMember +{ + /// + /// Creates a new instance of the class. + /// + public TeamMember() + { + } + + /// + /// Creates a new instance of the class with the specified ID. + /// + /// The member ID. + public TeamMember(string id) + { + Id = id; + } + + /// + /// Gets or sets the member ID. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Represents the state of a batch operation. +/// +public class BatchOperationState +{ + /// + /// Gets or sets the state of the operation. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + + /// + /// Gets or sets the status map containing the count of different statuses. + /// + [JsonPropertyName("statusMap")] + public BatchOperationStatusMap? StatusMap { get; set; } + + /// + /// Gets or sets the retry after date time. + /// + [JsonPropertyName("retryAfter")] + public DateTimeOffset? RetryAfter { get; set; } + + /// + /// Gets or sets the total entries count. + /// + [JsonPropertyName("totalEntriesCount")] + public int? TotalEntriesCount { get; set; } +} + +/// +/// Represents the status map for a batch operation. +/// +public class BatchOperationStatusMap +{ + /// + /// Gets or sets the count of successful entries. + /// + [JsonPropertyName("success")] + public int? Success { get; set; } + + /// + /// Gets or sets the count of failed entries. + /// + [JsonPropertyName("failed")] + public int? Failed { get; set; } + + /// + /// Gets or sets the count of throttled entries. + /// + [JsonPropertyName("throttled")] + public int? Throttled { get; set; } + + /// + /// Gets or sets the count of pending entries. + /// + [JsonPropertyName("pending")] + public int? Pending { get; set; } +} + +/// +/// Response containing failed entries from a batch operation. +/// +public class BatchFailedEntriesResponse +{ + /// + /// Gets or sets the continuation token for paging. + /// + [JsonPropertyName("continuationToken")] + public string? ContinuationToken { get; set; } + + /// + /// Gets or sets the list of failed entries. + /// + [JsonPropertyName("failedEntries")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList? FailedEntries { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only +} + +/// +/// Represents a failed entry in a batch operation. +/// +public class BatchFailedEntry +{ + /// + /// Gets or sets the ID of the failed entry. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +/// +/// Request body for sending a message to a list of users. +/// +internal sealed class SendMessageToUsersRequest +{ + /// + /// Gets or sets the list of members. + /// + [JsonPropertyName("members")] + public IList? Members { get; set; } + + /// + /// Gets or sets the activity to send. + /// + [JsonPropertyName("activity")] + public object? Activity { get; set; } + + /// + /// Gets or sets the tenant ID. + /// + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } +} + +/// +/// Request body for sending a message to all users in a tenant. +/// +internal sealed class SendMessageToTenantRequest +{ + /// + /// Gets or sets the activity to send. + /// + [JsonPropertyName("activity")] + public object? Activity { get; set; } + + /// + /// Gets or sets the tenant ID. + /// + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } +} + +/// +/// Request body for sending a message to all users in a team. +/// +internal sealed class SendMessageToTeamRequest +{ + /// + /// Gets or sets the activity to send. + /// + [JsonPropertyName("activity")] + public object? Activity { get; set; } + + /// + /// Gets or sets the team ID. + /// + [JsonPropertyName("teamId")] + public string? TeamId { get; set; } + + /// + /// Gets or sets the tenant ID. + /// + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.cs new file mode 100644 index 00000000..608654e5 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.cs @@ -0,0 +1,444 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Http; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Teams.Bot.Apps; + +using CustomHeaders = Dictionary; + +/// +/// Provides methods for interacting with Teams-specific APIs. +/// +/// The HTTP client instance used to send requests to the Teams service. Must not be null. +/// The logger instance used for logging. Optional. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1848:Use the LoggerMessage delegates", Justification = "")] +public class TeamsApiClient(HttpClient httpClient, ILogger logger = default!) +{ + private readonly BotHttpClient _botHttpClient = new(httpClient, logger); + internal const string TeamsHttpClientName = "TeamsAPXClient"; + + /// + /// Gets the default custom headers that will be included in all requests. + /// + public CustomHeaders DefaultCustomHeaders { get; } = []; + + #region Team Operations + + /// + /// Fetches the list of channels for a given team. + /// + /// The ID of the team. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the list of channels. + /// Thrown if the channel list could not be retrieved successfully. + public async Task FetchChannelListAsync(string teamId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(teamId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/teams/{Uri.EscapeDataString(teamId)}/conversations"; + + logger?.LogTrace("Fetching channel list from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "fetching channel list", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Fetches details related to a team. + /// + /// The ID of the team. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the team details. + /// Thrown if the team details could not be retrieved successfully. + public async Task FetchTeamDetailsAsync(string teamId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(teamId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/teams/{Uri.EscapeDataString(teamId)}"; + + logger?.LogTrace("Fetching team details from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "fetching team details", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + #endregion + + #region Meeting Operations + + /// + /// Fetches information about a meeting. + /// + /// The ID of the meeting, encoded as a BASE64 string. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the meeting information. + /// Thrown if the meeting info could not be retrieved successfully. + public async Task FetchMeetingInfoAsync(string meetingId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(meetingId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}"; + + logger?.LogTrace("Fetching meeting info from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "fetching meeting info", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Fetches details for a meeting participant. + /// + /// The ID of the meeting. Cannot be null or whitespace. + /// The ID of the participant. Cannot be null or whitespace. + /// The ID of the tenant. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the participant details. + /// Thrown if the participant details could not be retrieved successfully. + public async Task FetchParticipantAsync(string meetingId, string participantId, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(meetingId); + ArgumentException.ThrowIfNullOrWhiteSpace(participantId); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}/participants/{Uri.EscapeDataString(participantId)}?tenantId={Uri.EscapeDataString(tenantId)}"; + + logger?.LogTrace("Fetching meeting participant from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "fetching meeting participant", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a notification to meeting participants. + /// + /// The ID of the meeting. Cannot be null or whitespace. + /// The notification to send. Cannot be null. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains information about failed recipients. + /// Thrown if the notification could not be sent successfully. + public async Task SendMeetingNotificationAsync(string meetingId, MeetingNotificationBase notification, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(meetingId); + ArgumentNullException.ThrowIfNull(notification); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}/notification"; + string body = JsonSerializer.Serialize(notification); + + logger?.LogTrace("Sending meeting notification to {Url}: {Notification}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending meeting notification", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + #endregion + + #region Batch Message Operations + + /// + /// Sends a message to a list of Teams users. + /// + /// The activity to send. Cannot be null. + /// The list of team members to send the message to. Cannot be null or empty. + /// The ID of the tenant. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the operation ID. + /// Thrown if the message could not be sent successfully. + public async Task SendMessageToListOfUsersAsync(CoreActivity activity, IList teamsMembers, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(teamsMembers); + if (teamsMembers.Count == 0) + { + throw new ArgumentException("teamsMembers cannot be empty", nameof(teamsMembers)); + } + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/users/"; + SendMessageToUsersRequest request = new() + { + Members = teamsMembers, + Activity = activity, + TenantId = tenantId + }; + string body = JsonSerializer.Serialize(request); + + logger?.LogTrace("Sending message to list of users at {Url}: {Request}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to list of users", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a message to all users in a tenant. + /// + /// The activity to send. Cannot be null. + /// The ID of the tenant. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the operation ID. + /// Thrown if the message could not be sent successfully. + public async Task SendMessageToAllUsersInTenantAsync(CoreActivity activity, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/tenant/"; + SendMessageToTenantRequest request = new() + { + Activity = activity, + TenantId = tenantId + }; + string body = JsonSerializer.Serialize(request); + + logger?.LogTrace("Sending message to all users in tenant at {Url}: {Request}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to all users in tenant", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a message to all users in a team. + /// + /// The activity to send. Cannot be null. + /// The ID of the team. Cannot be null or whitespace. + /// The ID of the tenant. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the operation ID. + /// Thrown if the message could not be sent successfully. + public async Task SendMessageToAllUsersInTeamAsync(CoreActivity activity, string teamId, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(teamId); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/team/"; + SendMessageToTeamRequest request = new() + { + Activity = activity, + TeamId = teamId, + TenantId = tenantId + }; + string body = JsonSerializer.Serialize(request); + + logger?.LogTrace("Sending message to all users in team at {Url}: {Request}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to all users in team", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a message to a list of Teams channels. + /// + /// The activity to send. Cannot be null. + /// The list of channels to send the message to. Cannot be null or empty. + /// The ID of the tenant. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the operation ID. + /// Thrown if the message could not be sent successfully. + public async Task SendMessageToListOfChannelsAsync(CoreActivity activity, IList channelMembers, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(channelMembers); + if (channelMembers.Count == 0) + { + throw new ArgumentException("channelMembers cannot be empty", nameof(channelMembers)); + } + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/channels/"; + SendMessageToUsersRequest request = new() + { + Members = channelMembers, + Activity = activity, + TenantId = tenantId + }; + string body = JsonSerializer.Serialize(request); + + logger?.LogTrace("Sending message to list of channels at {Url}: {Request}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to list of channels", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + #endregion + + #region Batch Operation Management + + /// + /// Gets the state of a batch operation. + /// + /// The ID of the operation. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the operation state. + /// Thrown if the operation state could not be retrieved successfully. + public async Task GetOperationStateAsync(string operationId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/{Uri.EscapeDataString(operationId)}"; + + logger?.LogTrace("Getting operation state from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting operation state", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Gets the failed entries of a batch operation with error code and message. + /// + /// The ID of the operation. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional continuation token for pagination. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the failed entries. + /// Thrown if the failed entries could not be retrieved successfully. + public async Task GetPagedFailedEntriesAsync(string operationId, Uri serviceUrl, string? continuationToken = null, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/failedentries/{Uri.EscapeDataString(operationId)}"; + + if (!string.IsNullOrWhiteSpace(continuationToken)) + { + url += $"?continuationToken={Uri.EscapeDataString(continuationToken)}"; + } + + logger?.LogTrace("Getting paged failed entries from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting paged failed entries", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Cancels a batch operation by its ID. + /// + /// The ID of the operation to cancel. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + /// Thrown if the operation could not be cancelled successfully. + public async Task CancelOperationAsync(string operationId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/{Uri.EscapeDataString(operationId)}"; + + logger?.LogTrace("Cancelling operation at {Url}", url); + + await _botHttpClient.SendAsync( + HttpMethod.Delete, + url, + body: null, + CreateRequestOptions(agenticIdentity, "cancelling operation", customHeaders), + cancellationToken).ConfigureAwait(false); + } + + #endregion + + #region Private Methods + + private BotRequestOptions CreateRequestOptions(AgenticIdentity? agenticIdentity, string operationDescription, CustomHeaders? customHeaders) => + new() + { + AgenticIdentity = agenticIdentity, + OperationDescription = operationDescription, + DefaultHeaders = DefaultCustomHeaders, + CustomHeaders = customHeaders + }; + + #endregion +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs new file mode 100644 index 00000000..f11b2cf2 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; + +namespace Microsoft.Teams.Bot.Apps; + +/// +/// Extension methods for . +/// +public static class TeamsBotApplicationHostingExtensions +{ + /// + /// Adds TeamsBotApplication to the service collection. + /// + /// The WebApplicationBuilder instance. + /// The configuration section name for AzureAd settings. Default is "AzureAd". + /// The updated WebApplicationBuilder instance. + public static IServiceCollection AddTeamsBotApplication(this IServiceCollection services, string sectionName = "AzureAd") + { + ServiceProvider sp = services.BuildServiceProvider(); + IConfiguration configuration = sp.GetRequiredService(); + + string scope = "https://api.botframework.com/.default"; + if (!string.IsNullOrEmpty(configuration[$"{sectionName}:Scope"])) + scope = configuration[$"{sectionName}:Scope"]!; + if (!string.IsNullOrEmpty(configuration["Scope"])) + scope = configuration["Scope"]!; + + services.AddHttpClient(TeamsApiClient.TeamsHttpClientName) + .AddHttpMessageHandler(sp => + new BotAuthenticationHandler( + sp.GetRequiredService(), + sp.GetRequiredService>(), + scope, + sp.GetService>())); + + services.AddBotApplication(); + return services; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs new file mode 100644 index 00000000..6ce733cf --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Teams.Bot.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps; + +/// +/// Teams specific Bot Application +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1848:Use the LoggerMessage delegates", Justification = "")] +public class TeamsBotApplication : BotApplication +{ + private readonly TeamsApiClient _teamsAPXClient; + private static TeamsBotApplicationBuilder? _botApplicationBuilder; + + /// + /// Handler for message activities. + /// + public MessageHandler? OnMessage { get; set; } + + /// + /// Handler for message reaction activities. + /// + public MessageReactionHandler? OnMessageReaction { get; set; } + + /// + /// Handler for installation update activities. + /// + public InstallationUpdateHandler? OnInstallationUpdate { get; set; } + + /// + /// Handler for invoke activities. + /// + public InvokeHandler? OnInvoke { get; set; } + + /// + /// Gets the client used to interact with the TeamsAPX service. + /// + public TeamsApiClient TeamsAPXClient => _teamsAPXClient; + + /// + /// Handler for conversation update activities. + /// + public ConversationUpdateHandler? OnConversationUpdate { get; set; } + + /// + /// + /// + /// + /// + /// + /// + public TeamsBotApplication( + ConversationClient conversationClient, + UserTokenClient userTokenClient, + TeamsApiClient teamsAPXClient, + IConfiguration config, + IHttpContextAccessor httpContextAccessor, + ILogger logger, + string sectionName = "AzureAd") + : base(conversationClient, userTokenClient, config, logger, sectionName) + { + _teamsAPXClient = teamsAPXClient; + OnActivity = async (activity, cancellationToken) => + { + logger.LogInformation("New {Type} activity received.", activity.Type); + TeamsActivity teamsActivity = TeamsActivity.FromActivity(activity); + Context context = new(this, teamsActivity); + if (teamsActivity.Type == TeamsActivityType.Message && OnMessage is not null) + { + await OnMessage.Invoke(new MessageArgs(teamsActivity), context, cancellationToken).ConfigureAwait(false); + } + if (teamsActivity.Type == TeamsActivityType.InstallationUpdate && OnInstallationUpdate is not null) + { + await OnInstallationUpdate.Invoke(new InstallationUpdateArgs(teamsActivity), context, cancellationToken).ConfigureAwait(false); + + } + if (teamsActivity.Type == TeamsActivityType.MessageReaction && OnMessageReaction is not null) + { + await OnMessageReaction.Invoke(new MessageReactionArgs(teamsActivity), context, cancellationToken).ConfigureAwait(false); + } + if (teamsActivity.Type == TeamsActivityType.ConversationUpdate && OnConversationUpdate is not null) + { + await OnConversationUpdate.Invoke(new ConversationUpdateArgs(teamsActivity), context, cancellationToken).ConfigureAwait(false); + } + if (teamsActivity.Type == TeamsActivityType.Invoke && OnInvoke is not null) + { + CoreInvokeResponse invokeResponse = await OnInvoke.Invoke(context, cancellationToken).ConfigureAwait(false); + HttpContext? httpContext = httpContextAccessor.HttpContext; + if (httpContext is not null) + { + httpContext.Response.StatusCode = invokeResponse.Status; + await httpContext.Response.WriteAsJsonAsync(invokeResponse, cancellationToken).ConfigureAwait(false); + } + } + }; + } + + /// + /// Creates a new instance of the TeamsBotApplicationBuilder to configure and build a Teams bot application. + /// + /// + public static TeamsBotApplicationBuilder CreateBuilder() + { + _botApplicationBuilder = new TeamsBotApplicationBuilder(); + return _botApplicationBuilder; + } + + /// + /// Runs the web application configured by the bot application builder. + /// + /// Call CreateBuilder() before invoking this method to ensure the bot application builder is + /// initialized. This method blocks the calling thread until the web application shuts down. +#pragma warning disable CA1822 // Mark members as static + public void Run() +#pragma warning restore CA1822 // Mark members as static + { + ArgumentNullException.ThrowIfNull(_botApplicationBuilder, "BotApplicationBuilder not initialized. Call CreateBuilder() first."); + + _botApplicationBuilder.WebApplication.Run(); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs new file mode 100644 index 00000000..5b9d6966 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Teams.Bot.Apps; + +/// +/// Teams Bot Application Builder to configure and build a Teams bot application. +/// +public class TeamsBotApplicationBuilder +{ + private readonly WebApplicationBuilder _webAppBuilder; + private WebApplication? _webApp; + private string _routePath = "/api/messages"; + internal WebApplication WebApplication => _webApp ?? throw new InvalidOperationException("Call Build"); + /// + /// Accessor for the service collection used to configure application services. + /// + public IServiceCollection Services => _webAppBuilder.Services; + /// + /// Accessor for the application configuration used to configure services and settings. + /// + public IConfiguration Configuration => _webAppBuilder.Configuration; + /// + /// Accessor for the web hosting environment information. + /// + public IWebHostEnvironment Environment => _webAppBuilder.Environment; + /// + /// Accessor for configuring the host settings and services. + /// + public ConfigureHostBuilder Host => _webAppBuilder.Host; + /// + /// Accessor for configuring logging services and settings. + /// + public ILoggingBuilder Logging => _webAppBuilder.Logging; + /// + /// Creates a new instance of the BotApplicationBuilder with default configuration and registered bot services. + /// + public TeamsBotApplicationBuilder() + { + _webAppBuilder = WebApplication.CreateSlimBuilder(); + _webAppBuilder.Services.AddHttpContextAccessor(); + _webAppBuilder.Services.AddTeamsBotApplication(); + } + + /// + /// Builds and configures the bot application pipeline, returning a fully initialized instance of the bot + /// application. + /// + /// A configured instance representing the bot application pipeline. + public TeamsBotApplication Build() + { + _webApp = _webAppBuilder.Build(); + TeamsBotApplication botApp = _webApp.Services.GetService() ?? throw new InvalidOperationException("Application not registered"); + _webApp.UseBotApplication(_routePath); + return botApp; + } + + /// + /// Sets the route path used to handle incoming bot requests. Defaults to "/api/messages". + /// + /// The route path to use for bot endpoints. Cannot be null or empty. + /// The current instance of for method chaining. + public TeamsBotApplicationBuilder WithRoutePath(string routePath) + { + _routePath = routePath; + return this; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs new file mode 100644 index 00000000..fbee125d --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; + +using Microsoft.Bot.Builder.Integration.AspNet.Core.Handlers; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Bot.Schema; +using Microsoft.Teams.Bot.Apps.Schema; +using Newtonsoft.Json; + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// Extension methods for converting between Bot Framework Activity and CoreActivity/TeamsActivity. +/// +public static class CompatActivity +{ + /// + /// Converts a CoreActivity to a Bot Framework Activity. + /// + /// + /// + public static Activity ToCompatActivity(this CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + using JsonTextReader reader = new(new StringReader(activity.ToJson())); + return BotMessageHandlerBase.BotMessageSerializer.Deserialize(reader)!; + } + + /// + /// Converts a Bot Framework Activity to a TeamsActivity. + /// + /// + /// + public static TeamsActivity FromCompatActivity(this Activity activity) + { + StringBuilder sb = new(); + using StringWriter stringWriter = new(sb); + using JsonTextWriter json = new(stringWriter); + BotMessageHandlerBase.BotMessageSerializer.Serialize(json, activity); + string jsonString = sb.ToString(); + CoreActivity coreActivity = CoreActivity.FromJsonString(jsonString); + return TeamsActivity.FromActivity(coreActivity); + } + + + /// + /// Converts a ConversationAccount to a ChannelAccount. + /// + /// + /// + public static Microsoft.Bot.Schema.ChannelAccount ToCompatChannelAccount(this Microsoft.Teams.Bot.Core.Schema.ConversationAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + Microsoft.Bot.Schema.ChannelAccount channelAccount; + if (account is TeamsConversationAccount tae) + { + channelAccount = new() + { + Id = account.Id, + Name = account.Name, + AadObjectId = tae.AadObjectId + }; + } + else + { + channelAccount = new() + { + Id = account.Id, + Name = account.Name + }; + } + + if (account.Properties.TryGetValue("aadObjectId", out object? aadObjectId)) + { + channelAccount.AadObjectId = aadObjectId?.ToString(); + } + + if (account.Properties.TryGetValue("userRole", out object? userRole)) + { + channelAccount.Role = userRole?.ToString(); + } + + if (account.Properties.TryGetValue("userPrincipalName", out object? userPrincipalName)) + { + channelAccount.Properties.Add("userPrincipalName", userPrincipalName?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("givenName", out object? givenName)) + { + channelAccount.Properties.Add("givenName", givenName?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("surname", out object? surname)) + { + channelAccount.Properties.Add("surname", surname?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("email", out object? email)) + { + channelAccount.Properties.Add("email", email?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("tenantId", out object? tenantId)) + { + channelAccount.Properties.Add("tenantId", tenantId?.ToString() ?? string.Empty); + } + + return channelAccount; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs new file mode 100644 index 00000000..5c93d33c --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Bot.Schema; +using Microsoft.Teams.Bot.Apps; + + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// Provides a compatibility adapter for processing bot activities and HTTP requests using legacy middleware and bot +/// framework interfaces. +/// +/// Use this adapter to bridge between legacy bot framework middleware and newer bot application models. +/// The adapter allows registration of middleware and error handling delegates, and supports processing HTTP requests +/// and continuing conversations. Thread safety is not guaranteed; instances should not be shared across concurrent +/// requests. +/// The bot application instance that handles activity processing and manages user token operations. +/// The underlying bot adapter used to interact with the bot framework and create turn contexts. +public class CompatAdapter(TeamsBotApplication botApplication, CompatBotAdapter compatBotAdapter) : IBotFrameworkHttpAdapter +{ + /// + /// Gets the collection of middleware components configured for the application. + /// + /// Use this property to access or inspect the set of middleware that will be invoked during + /// request processing. The returned collection is read-only and reflects the current middleware pipeline. + public MiddlewareSet MiddlewareSet { get; } = new MiddlewareSet(); + + /// + /// Gets or sets the error handling callback to be invoked when an exception occurs during a turn. + /// + /// Assign a delegate to customize how errors are handled within the bot's turn processing. The + /// callback receives the current turn context and the exception that was thrown. If not set, unhandled exceptions + /// may propagate and result in default error behavior. This property is typically used to log errors, send + /// user-friendly messages, or perform cleanup actions. + public Func? OnTurnError { get; set; } + + /// + /// Adds the specified middleware to the adapter's processing pipeline. + /// + /// The middleware component to be invoked during request processing. Cannot be null. + /// The current instance, enabling method chaining. + public CompatAdapter Use(Microsoft.Bot.Builder.IMiddleware middleware) + { + MiddlewareSet.Use(middleware); + return this; + } + + /// + /// Processes an incoming HTTP request and generates an appropriate HTTP response using the provided bot instance. + /// + /// + /// + /// + /// + /// + public async Task ProcessAsync(HttpRequest httpRequest, HttpResponse httpResponse, IBot bot, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(httpRequest); + ArgumentNullException.ThrowIfNull(httpResponse); + ArgumentNullException.ThrowIfNull(bot); + CoreActivity? coreActivity = null; + botApplication.OnActivity = async (activity, cancellationToken1) => + { + coreActivity = activity; + TurnContext turnContext = new(compatBotAdapter, activity.ToCompatActivity()); + turnContext.TurnState.Add(new CompatUserTokenClient(botApplication.UserTokenClient)); + CompatConnectorClient connectionClient = new(new CompatConversations(botApplication.ConversationClient) { ServiceUrl = activity.ServiceUrl?.ToString() }); + turnContext.TurnState.Add(connectionClient); + await bot.OnTurnAsync(turnContext, cancellationToken1).ConfigureAwait(false); + }; + + try + { + foreach (Microsoft.Bot.Builder.IMiddleware? middleware in MiddlewareSet) + { + botApplication.Use(new CompatAdapterMiddleware(middleware)); + } + + await botApplication.ProcessAsync(httpRequest.HttpContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (OnTurnError != null) + { + if (ex is BotHandlerException aex) + { + coreActivity = aex.Activity; + using TurnContext turnContext = new(compatBotAdapter, coreActivity!.ToCompatActivity()); + await OnTurnError(turnContext, ex).ConfigureAwait(false); + } + else + { + throw; + } + } + else + { + throw; + } + } + } + + /// + /// Continues an existing bot conversation by invoking the specified callback with the provided conversation + /// reference. + /// + /// Use this method to resume a conversation at a specific point, such as in response to an event + /// or proactive message. The callback is executed within the context of the continued conversation. + /// The unique identifier of the bot participating in the conversation. + /// A reference to the conversation to continue. Must not be null. + /// A delegate that handles the bot logic for the continued conversation. The callback receives a turn context and + /// cancellation token. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + public async Task ContinueConversationAsync(string botId, ConversationReference reference, BotCallbackHandler callback, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(callback); + + using TurnContext turnContext = new(compatBotAdapter, reference.GetContinuationActivity()); + turnContext.TurnState.Add(new CompatConnectorClient(new CompatConversations(botApplication.ConversationClient) { ServiceUrl = reference.ServiceUrl })); + await callback(turnContext, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatAdapterMiddleware.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatAdapterMiddleware.cs new file mode 100644 index 00000000..f999302a --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatAdapterMiddleware.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps; + +namespace Microsoft.Teams.Bot.Compat; + +internal sealed class CompatAdapterMiddleware(IMiddleware bfMiddleWare) : ITurnMiddleWare +{ + public Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn nextTurn, CancellationToken cancellationToken = default) + { + + if (botApplication is TeamsBotApplication tba) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + TurnContext turnContext = new(new CompatBotAdapter(tba), activity.ToCompatActivity()); +#pragma warning restore CA2000 // Dispose objects before losing scope + + turnContext.TurnState.Add( + new CompatUserTokenClient(botApplication.UserTokenClient) + ); + + turnContext.TurnState.Add( + new CompatConnectorClient( + new CompatConversations(botApplication.ConversationClient) + { + ServiceUrl = activity.ServiceUrl?.ToString() + } + ) + ); + + return bfMiddleWare.OnTurnAsync(turnContext, (activity) + => nextTurn(cancellationToken), cancellationToken); + } + return Task.CompletedTask; + } + +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatBotAdapter.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatBotAdapter.cs new file mode 100644 index 00000000..b829ae62 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatBotAdapter.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Apps; +using Newtonsoft.Json; + + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// Provides a Bot Framework adapter that enables compatibility between the Bot Framework SDK and a custom bot +/// application implementation. +/// +/// Use this adapter to bridge Bot Framework turn contexts and activities with a custom bot application. +/// This class is intended for scenarios where integration with non-standard bot runtimes or legacy systems is +/// required. +/// The bot application instance used to process and send activities within the adapter. +/// The HTTP context accessor used to retrieve the current HTTP context. +/// The +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1848:Use the LoggerMessage delegates", Justification = "")] +public class CompatBotAdapter(TeamsBotApplication botApplication, IHttpContextAccessor httpContextAccessor = default!, ILogger logger = default!) : BotAdapter +{ + /// + /// Deletes an activity from the conversation. + /// + /// + /// + /// + public override async Task DeleteActivityAsync(ITurnContext turnContext, ConversationReference reference, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(turnContext); + await botApplication.ConversationClient.DeleteActivityAsync(turnContext.Activity.FromCompatActivity(), cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends a set of activities to the conversation. + /// + /// + /// + /// + /// + public override async Task SendActivitiesAsync(ITurnContext turnContext, Activity[] activities, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(activities); + ArgumentNullException.ThrowIfNull(turnContext); + + ResourceResponse[] responses = new Microsoft.Bot.Schema.ResourceResponse[activities.Length]; + + for (int i = 0; i < activities.Length; i++) + { + Activity activity = activities[i]; + + if (activity.Type == ActivityTypes.Trace) + { + return [new ResourceResponse() { Id = null }]; + } + + if (activity.Type == "invokeResponse") + { + WriteInvokeResponseToHttpResponse(activity.Value as InvokeResponse); + return [new ResourceResponse() { Id = null }]; + } + + SendActivityResponse? resp = await botApplication.SendActivityAsync(activity.FromCompatActivity(), cancellationToken).ConfigureAwait(false); + + logger.LogInformation("Resp from SendActivitiesAsync: {RespId}", resp?.Id); + + responses[i] = new Microsoft.Bot.Schema.ResourceResponse() { Id = resp?.Id }; + } + return responses; + } + + /// + /// Updates an existing activity in the conversation. + /// + /// + /// + /// + /// ResourceResponse + public override async Task UpdateActivityAsync(ITurnContext turnContext, Activity activity, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(activity); + UpdateActivityResponse res = await botApplication.ConversationClient.UpdateActivityAsync( + activity.Conversation.Id, + activity.Id, + activity.FromCompatActivity(), + cancellationToken: cancellationToken).ConfigureAwait(false); + return new ResourceResponse() { Id = res.Id }; + } + + private void WriteInvokeResponseToHttpResponse(InvokeResponse? invokeResponse) + { + ArgumentNullException.ThrowIfNull(invokeResponse); + HttpResponse? response = httpContextAccessor?.HttpContext?.Response; + if (response is not null && !response.HasStarted) + { + using StreamWriter httpResponseStreamWriter = new(response.BodyWriter.AsStream()); + using JsonTextWriter httpResponseJsonWriter = new(httpResponseStreamWriter); + Microsoft.Bot.Builder.Integration.AspNet.Core.HttpHelper.BotMessageSerializer.Serialize(httpResponseJsonWriter, invokeResponse); + } + else + { + logger.LogWarning("HTTP response is null or has started. Cannot write invoke response. ResponseStarted: {ResponseStarted}", response?.HasStarted); + } + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatConnectorClient.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatConnectorClient.cs new file mode 100644 index 00000000..4e8fb883 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatConnectorClient.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Connector; +using Microsoft.Rest; +using Newtonsoft.Json; + +namespace Microsoft.Teams.Bot.Compat +{ + internal sealed class CompatConnectorClient(CompatConversations conversations) : IConnectorClient + { + public IConversations Conversations => conversations; + + public Uri BaseUri { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public JsonSerializerSettings SerializationSettings => throw new NotImplementedException(); + + public JsonSerializerSettings DeserializationSettings => throw new NotImplementedException(); + + public ServiceClientCredentials Credentials => throw new NotImplementedException(); + + public IAttachments Attachments => throw new NotImplementedException(); + + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + GC.SuppressFinalize(this); + } + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatConversations.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatConversations.cs new file mode 100644 index 00000000..3ad938d9 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatConversations.cs @@ -0,0 +1,355 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Connector; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Bot.Schema; +using Microsoft.Rest; + +// TODO: Figure out what to do with Agentic Identities. They're all "nulls" here right now. +// The identity is dependent on the incoming payload or supplied in for proactive scenarios. +namespace Microsoft.Teams.Bot.Compat +{ + internal sealed class CompatConversations(ConversationClient client) : IConversations + { + private readonly ConversationClient _client = client; + internal string? ServiceUrl { get; set; } + + public async Task> CreateConversationWithHttpMessagesAsync( + Microsoft.Bot.Schema.ConversationParameters parameters, + Dictionary>? customHeaders = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Microsoft.Teams.Bot.Core.ConversationParameters convoParams = new() + { + Activity = parameters.Activity.FromCompatActivity() + }; + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CreateConversationResponse res = await _client.CreateConversationAsync( + convoParams, + new Uri(ServiceUrl), + AgenticIdentity.FromProperties(convoParams.Activity?.From.Properties), + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + ConversationResourceResponse response = new() + { + ActivityId = res.ActivityId, + Id = res.Id, + ServiceUrl = res.ServiceUrl?.ToString(), + }; + + return new HttpOperationResponse + { + Body = response, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + + public async Task DeleteActivityWithHttpMessagesAsync(string conversationId, string activityId, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + await _client.DeleteActivityAsync( + conversationId, + activityId, + new Uri(ServiceUrl), + null!, + ConvertHeaders(customHeaders), + cancellationToken).ConfigureAwait(false); + return new HttpOperationResponse + { + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task DeleteConversationMemberWithHttpMessagesAsync(string conversationId, string memberId, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + await _client.DeleteConversationMemberAsync( + conversationId, + memberId, + new Uri(ServiceUrl), + null!, + ConvertHeaders(customHeaders), + cancellationToken).ConfigureAwait(false); + return new HttpOperationResponse { Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) }; + } + + public async Task>> GetActivityMembersWithHttpMessagesAsync(string conversationId, string activityId, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + IList members = await _client.GetActivityMembersAsync( + conversationId, + activityId, + new Uri(ServiceUrl!), + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + List channelAccounts = [.. members.Select(m => m.ToCompatChannelAccount())]; + + return new HttpOperationResponse> + { + Body = channelAccounts, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task>> GetConversationMembersWithHttpMessagesAsync(string conversationId, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + IList members = await _client.GetConversationMembersAsync( + conversationId, + new Uri(ServiceUrl), + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + List channelAccounts = [.. members.Select(m => m.ToCompatChannelAccount())]; + + return new HttpOperationResponse> + { + Body = channelAccounts, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> GetConversationPagedMembersWithHttpMessagesAsync(string conversationId, int? pageSize = null, string? continuationToken = null, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + Microsoft.Teams.Bot.Core.PagedMembersResult pagedMembers = await _client.GetConversationPagedMembersAsync( + conversationId, + new Uri(ServiceUrl), + pageSize, + continuationToken, + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + Microsoft.Bot.Schema.PagedMembersResult result = new() + { + ContinuationToken = pagedMembers.ContinuationToken, + Members = pagedMembers.Members?.Select(m => m.ToCompatChannelAccount()).ToList() + }; + + return new HttpOperationResponse + { + Body = result, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> GetConversationsWithHttpMessagesAsync(string? continuationToken = null, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + GetConversationsResponse conversations = await _client.GetConversationsAsync( + new Uri(ServiceUrl!), + continuationToken, + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + ConversationsResult result = new() + { + ContinuationToken = conversations.ContinuationToken, + Conversations = conversations.Conversations?.Select(c => new Microsoft.Bot.Schema.ConversationMembers + { + Id = c.Id, + Members = c.Members?.Select(m => m.ToCompatChannelAccount()).ToList() + }).ToList() + }; + + return new HttpOperationResponse + { + Body = result, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> ReplyToActivityWithHttpMessagesAsync(string conversationId, string activityId, Activity activity, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CoreActivity coreActivity = activity.FromCompatActivity(); + + // ReplyToActivity is not available in ConversationClient, use SendActivityAsync with replyToId in Properties + coreActivity.Properties["replyToId"] = activityId; + if (coreActivity.Conversation == null) + { + coreActivity.Conversation = new Microsoft.Teams.Bot.Core.Schema.Conversation { Id = conversationId }; + } + else + { + coreActivity.Conversation.Id = conversationId; + } + + SendActivityResponse response = await _client.SendActivityAsync(coreActivity, convertedHeaders, cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response.Id + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> SendConversationHistoryWithHttpMessagesAsync(string conversationId, Microsoft.Bot.Schema.Transcript transcript, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + Microsoft.Teams.Bot.Core.Transcript coreTranscript = new() + { + Activities = transcript.Activities?.Select(a => a.FromCompatActivity() as CoreActivity).ToList() + }; + + SendConversationHistoryResponse response = await _client.SendConversationHistoryAsync( + conversationId, + coreTranscript, + new Uri(ServiceUrl), + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response.Id + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> SendToConversationWithHttpMessagesAsync(string conversationId, Activity activity, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CoreActivity coreActivity = activity.FromCompatActivity(); + + // Ensure conversation ID is set + coreActivity.Conversation ??= new Microsoft.Teams.Bot.Core.Schema.Conversation { Id = conversationId }; + + SendActivityResponse response = await _client.SendActivityAsync(coreActivity, convertedHeaders, cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response.Id + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> UpdateActivityWithHttpMessagesAsync(string conversationId, string activityId, Activity activity, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CoreActivity coreActivity = activity.FromCompatActivity(); + + UpdateActivityResponse response = await _client.UpdateActivityAsync(conversationId, activityId, coreActivity, convertedHeaders, cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response.Id + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> UploadAttachmentWithHttpMessagesAsync(string conversationId, Microsoft.Bot.Schema.AttachmentData attachmentUpload, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + Microsoft.Teams.Bot.Core.AttachmentData coreAttachmentData = new() + { + Type = attachmentUpload.Type, + Name = attachmentUpload.Name, + OriginalBase64 = attachmentUpload.OriginalBase64, + ThumbnailBase64 = attachmentUpload.ThumbnailBase64 + }; + + UploadAttachmentResponse response = await _client.UploadAttachmentAsync( + conversationId, + coreAttachmentData, + new Uri(ServiceUrl), + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response.Id + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + private static Dictionary? ConvertHeaders(Dictionary>? customHeaders) + { + if (customHeaders == null) + { + return null; + } + + Dictionary convertedHeaders = []; + foreach (KeyValuePair> kvp in customHeaders) + { + convertedHeaders[kvp.Key] = string.Join(",", kvp.Value); + } + + return convertedHeaders; + } + + public async Task> GetConversationMemberWithHttpMessagesAsync(string userId, string conversationId, Dictionary> customHeaders = null!, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + Microsoft.Teams.Bot.Apps.Schema.TeamsConversationAccount response = await _client.GetConversationMemberAsync( + conversationId, userId, new Uri(ServiceUrl), null!, convertedHeaders, cancellationToken).ConfigureAwait(false); + + return new HttpOperationResponse + { + Body = response.ToCompatChannelAccount(), + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + + } + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatHostingExtensions.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatHostingExtensions.cs new file mode 100644 index 00000000..b2b669fc --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatHostingExtensions.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Teams.Bot.Apps; + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// Provides extension methods for registering compatibility adapters and related services to support legacy bot hosting +/// scenarios. +/// +/// These extension methods simplify the integration of compatibility adapters into modern hosting +/// environments by adding required services to the dependency injection container. Use these methods to enable legacy +/// bot functionality within applications built on the current hosting model. +public static class CompatHostingExtensions +{ + /// + /// Adds compatibility adapter services to the application's dependency injection container. + /// + /// This method registers services required for compatibility scenarios. It can be called + /// multiple times without adverse effects. + /// The host application builder to which the compatibility adapter services will be added. Cannot be null. + /// The same instance, enabling method chaining. + public static IHostApplicationBuilder AddCompatAdapter(this IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.AddCompatAdapter(); + return builder; + } + + /// + /// Registers the compatibility bot adapter and related services required for Bot Framework HTTP integration with + /// the application's dependency injection container. + /// + /// Call this method during application startup to enable Bot Framework HTTP endpoint support + /// using the compatibility adapter. This method should be invoked before building the service provider. + /// The service collection to which the compatibility adapter and related services will be added. Must not be null. + /// The same instance provided in , with the + /// compatibility adapter and related services registered. + public static IServiceCollection AddCompatAdapter(this IServiceCollection services) + { + services.AddTeamsBotApplication(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatUserTokenClient.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatUserTokenClient.cs new file mode 100644 index 00000000..f7218156 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatUserTokenClient.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Schema; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Teams.Bot.Compat; + +internal sealed class CompatUserTokenClient(UserTokenClient utc) : Microsoft.Bot.Connector.Authentication.UserTokenClient +{ + public async override Task GetTokenStatusAsync(string userId, string channelId, string includeFilter, CancellationToken cancellationToken) + { + GetTokenStatusResult[] res = await utc.GetTokenStatusAsync(userId, channelId, includeFilter, cancellationToken).ConfigureAwait(false); + return res.Select(t => new TokenStatus + { + ChannelId = channelId, + ConnectionName = t.ConnectionName, + HasToken = t.HasToken, + ServiceProviderDisplayName = t.ServiceProviderDisplayName, + }).ToArray(); + } + + public async override Task GetUserTokenAsync(string userId, string connectionName, string channelId, string magicCode, CancellationToken cancellationToken) + { + GetTokenResult? res = await utc.GetTokenAsync(userId, connectionName, channelId, magicCode, cancellationToken).ConfigureAwait(false); + if (res == null) + { + return null; + } + + return new TokenResponse + { + ChannelId = channelId, + ConnectionName = res.ConnectionName, + Token = res.Token + }; + } + + public async override Task GetSignInResourceAsync(string connectionName, Activity activity, string finalRedirect, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(activity); + GetSignInResourceResult res = await utc.GetSignInResource(activity.From.Id, connectionName, activity.ChannelId, finalRedirect, cancellationToken).ConfigureAwait(false); + SignInResource signInResource = new() + { + SignInLink = res!.SignInLink + }; + + if (res.TokenExchangeResource != null) + { + signInResource.TokenExchangeResource = new Microsoft.Bot.Schema.TokenExchangeResource + { + Id = res.TokenExchangeResource.Id, + Uri = res.TokenExchangeResource.Uri?.ToString(), + ProviderId = res.TokenExchangeResource.ProviderId + }; + } + + if (res.TokenPostResource != null) + { + signInResource.TokenPostResource = new Microsoft.Bot.Schema.TokenPostResource + { + SasUrl = res.TokenPostResource.SasUrl?.ToString() + }; + } + + return signInResource; + } + + public async override Task ExchangeTokenAsync(string userId, string connectionName, string channelId, + TokenExchangeRequest exchangeRequest, CancellationToken cancellationToken) + { + GetTokenResult resp = await utc.ExchangeTokenAsync(userId, connectionName, channelId, exchangeRequest.Token, + cancellationToken).ConfigureAwait(false); + return new TokenResponse + { + ChannelId = channelId, + ConnectionName = resp.ConnectionName, + Token = resp.Token + }; + } + + public async override Task SignOutUserAsync(string userId, string connectionName, string channelId, CancellationToken cancellationToken) + { + await utc.SignOutUserAsync(userId, connectionName, channelId, cancellationToken).ConfigureAwait(false); + } + + public async override Task> GetAadTokensAsync(string userId, string connectionName, string[] resourceUrls, string channelId, CancellationToken cancellationToken) + { + IDictionary res = await utc.GetAadTokensAsync(userId, connectionName, channelId, resourceUrls, cancellationToken).ConfigureAwait(false); + return res?.ToDictionary(kvp => kvp.Key, kvp => new TokenResponse + { + ChannelId = channelId, + ConnectionName = kvp.Value.ConnectionName, + Token = kvp.Value.Token + }) ?? new Dictionary(); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/InternalsVisibleTo.cs b/core/src/Microsoft.Teams.Bot.Compat/InternalsVisibleTo.cs new file mode 100644 index 00000000..c1d99fd1 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/InternalsVisibleTo.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Teams.Bot.Core.Tests")] diff --git a/core/src/Microsoft.Teams.Bot.Compat/Microsoft.Teams.Bot.Compat.csproj b/core/src/Microsoft.Teams.Bot.Compat/Microsoft.Teams.Bot.Compat.csproj new file mode 100644 index 00000000..6b50258b --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/Microsoft.Teams.Bot.Compat.csproj @@ -0,0 +1,15 @@ + + + + net8.0;net10.0 + enable + enable + + + + + + + + + diff --git a/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs new file mode 100644 index 00000000..fbf7ca81 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Represents a bot application. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1848:Use the LoggerMessage delegates", Justification = "")] +public class BotApplication +{ + private readonly ILogger _logger; + private readonly ConversationClient? _conversationClient; + private readonly UserTokenClient? _userTokenClient; + private readonly string _serviceKey; + internal TurnMiddleware MiddleWare { get; } + + /// + /// Initializes a new instance of the BotApplication class with the specified conversation client, configuration, + /// logger, and optional service key. + /// + /// This constructor sets up the bot application and starts the bot listener using the provided + /// configuration and service key. The service key is used to locate authentication credentials in the + /// configuration. + /// The client used to manage and interact with conversations for the bot. + /// The client used to manage user tokens for authentication. + /// The application configuration settings used to retrieve environment variables and service credentials. + /// The logger used to record operational and diagnostic information for the bot application. + /// The configuration key identifying the authentication service. Defaults to "AzureAd" if not specified. + public BotApplication(ConversationClient conversationClient, UserTokenClient userTokenClient, IConfiguration config, ILogger logger, string sectionName = "AzureAd") + { + ArgumentNullException.ThrowIfNull(config); + _logger = logger; + _serviceKey = sectionName; + MiddleWare = new TurnMiddleware(); + _conversationClient = conversationClient; + _userTokenClient = userTokenClient; + string appId = config["MicrosoftAppId"] ?? config["CLIENT_ID"] ?? config[$"{sectionName}:ClientId"] ?? "Unknown AppID"; + logger.LogInformation("Started bot listener \n on {Port} \n for AppID:{AppId} \n with SDK version {SdkVersion}", config?["ASPNETCORE_URLS"], appId, Version); + + } + + + /// + /// Gets the client used to manage and interact with conversations. + /// + /// Accessing this property before the client is initialized will result in an exception. Ensure + /// that the client is properly configured before use. + public ConversationClient ConversationClient => _conversationClient ?? throw new InvalidOperationException("ConversationClient not initialized"); + + /// + /// Gets the client used to manage user tokens for authentication. + /// + /// Accessing this property before the client is initialized will result in an exception. Ensure + /// that the client is properly configured before use. + public UserTokenClient UserTokenClient => _userTokenClient ?? throw new InvalidOperationException("UserTokenClient not registered"); + + /// + /// Gets or sets the delegate that is invoked to handle an incoming activity asynchronously. + /// + /// Assign a delegate to process activities as they are received. The delegate should accept an + /// and a , and return a representing the + /// asynchronous operation. If , incoming activities will not be handled. + public Func? OnActivity { get; set; } + + /// + /// Processes an incoming HTTP request containing a bot activity. + /// + /// + /// + /// + /// + /// + public async Task ProcessAsync(HttpContext httpContext, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(_conversationClient); + + _logger.LogDebug("Start processing HTTP request for activity"); + + CoreActivity activity = await CoreActivity.FromJsonStreamAsync(httpContext.Request.Body, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Invalid Activity"); + + _logger.LogInformation("Processing activity {Type} {Id}", activity.Type, activity.Id); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Received activity: {Activity}", activity.ToJson()); + } + + using (_logger.BeginScope("Processing activity {Type} {Id}", activity.Type, activity.Id)) + { + try + { + await MiddleWare.RunPipelineAsync(this, activity, this.OnActivity, 0, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing activity {Type} {Id}", activity.Type, activity.Id); + throw new BotHandlerException("Error processing activity", ex, activity); + } + finally + { + _logger.LogInformation("Finished processing activity {Type} {Id}", activity.Type, activity.Id); + } + } + } + + /// + /// Adds the specified turn middleware to the middleware pipeline. + /// + /// The middleware component to add to the pipeline. Cannot be null. + /// An ITurnMiddleWare instance representing the updated middleware pipeline. + public ITurnMiddleWare Use(ITurnMiddleWare middleware) + { + MiddleWare.Use(middleware); + return MiddleWare; + } + + /// + /// Sends the specified activity to the conversation asynchronously. + /// + /// The activity to send to the conversation. Cannot be null. + /// A cancellation token that can be used to cancel the send operation. + /// A task that represents the asynchronous operation. The task result contains the identifier of the sent activity. + /// Thrown if the conversation client has not been initialized. + public async Task SendActivityAsync(CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(_conversationClient, "ConversationClient not initialized"); + + return await _conversationClient.SendActivityAsync(activity, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the version of the SDK. + /// + public static string Version => ThisAssembly.NuGetPackageVersion; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/BotHandlerException.cs b/core/src/Microsoft.Teams.Bot.Core/BotHandlerException.cs new file mode 100644 index 00000000..6301925b --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/BotHandlerException.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Represents errors that occur during bot activity processing and provides context about the associated activity. +/// +/// Use this exception to capture and propagate errors that occur during bot activity handling, along +/// with contextual information about the activity involved. This can aid in debugging and error reporting +/// scenarios. +public class BotHandlerException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public BotHandlerException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that describes the reason for the exception. + public BotHandlerException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and inner exception. + /// + /// The error message that describes the reason for the exception. + /// The underlying exception that caused this exception, or null if no inner exception is specified. + public BotHandlerException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class with a specified error message, inner exception, and activity. + /// + /// The error message that describes the reason for the exception. + /// The underlying exception that caused this exception, or null if no inner exception is specified. + /// The bot activity associated with the error. Cannot be null. + public BotHandlerException(string message, Exception innerException, CoreActivity activity) : base(message, innerException) + { + Activity = activity; + } + + /// + /// Accesses the bot activity associated with the exception. + /// + public CoreActivity? Activity { get; } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.Models.cs b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.Models.cs new file mode 100644 index 00000000..72508267 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.Models.cs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Response from sending an activity. +/// +public class SendActivityResponse +{ + /// + /// Id of the activity + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Response from updating an activity. +/// +public class UpdateActivityResponse +{ + /// + /// Id of the activity + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Response from deleting an activity. +/// +public class DeleteActivityResponse +{ + /// + /// Id of the activity + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Response from getting conversations. +/// +public class GetConversationsResponse +{ + /// + /// Gets or sets the continuation token that can be used to get paged results. + /// + [JsonPropertyName("continuationToken")] + public string? ContinuationToken { get; set; } + + /// + /// Gets or sets the list of conversations. + /// + [JsonPropertyName("conversations")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList? Conversations { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only +} + +/// +/// Represents a conversation and its members. +/// +public class ConversationMembers +{ + /// + /// Gets or sets the conversation ID. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the list of members in this conversation. + /// + [JsonPropertyName("members")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList? Members { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only +} + +/// +/// Parameters for creating a new conversation. +/// +public class ConversationParameters +{ + /// + /// Gets or sets a value indicating whether the conversation is a group conversation. + /// + [JsonPropertyName("isGroup")] + public bool? IsGroup { get; set; } + + /// + /// Gets or sets the bot's account for this conversation. + /// + [JsonPropertyName("bot")] + public ConversationAccount? Bot { get; set; } + + /// + /// Gets or sets the list of members to add to the conversation. + /// + [JsonPropertyName("members")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList? Members { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only + + /// + /// Gets or sets the topic name for the conversation (if supported by the channel). + /// + [JsonPropertyName("topicName")] + public string? TopicName { get; set; } + + /// + /// Gets or sets the initial activity to send when creating the conversation. + /// + [JsonPropertyName("activity")] + public CoreActivity? Activity { get; set; } + + /// + /// Gets or sets channel-specific payload for creating the conversation. + /// + [JsonPropertyName("channelData")] + public object? ChannelData { get; set; } + + /// + /// Gets or sets the tenant ID where the conversation should be created. + /// + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } +} + +/// +/// Response from creating a conversation. +/// +public class CreateConversationResponse +{ + /// + /// Gets or sets the ID of the activity (if sent). + /// + [JsonPropertyName("activityId")] + public string? ActivityId { get; set; } + + /// + /// Gets or sets the service endpoint where operations concerning the conversation may be performed. + /// + [JsonPropertyName("serviceUrl")] + public Uri? ServiceUrl { get; set; } + + /// + /// Gets or sets the identifier of the conversation resource. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Result from getting paged members of a conversation. +/// +public class PagedMembersResult +{ + /// + /// Gets or sets the continuation token that can be used to get paged results. + /// + [JsonPropertyName("continuationToken")] + public string? ContinuationToken { get; set; } + + /// + /// Gets or sets the list of members in this page. + /// + [JsonPropertyName("members")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList? Members { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only +} + +/// +/// A collection of activities that represents a conversation transcript. +/// +public class Transcript +{ + /// + /// Gets or sets the collection of activities that conforms to the Transcript schema. + /// + [JsonPropertyName("activities")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList? Activities { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only +} + +/// +/// Response from sending conversation history. +/// +public class SendConversationHistoryResponse +{ + /// + /// Gets or sets the ID of the resource. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Represents attachment data for uploading. +/// +public class AttachmentData +{ + /// + /// Gets or sets the Content-Type of the attachment. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Gets or sets the name of the attachment. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the attachment content as a byte array. + /// + [JsonPropertyName("originalBase64")] +#pragma warning disable CA1819 // Properties should not return arrays + public byte[]? OriginalBase64 { get; set; } +#pragma warning restore CA1819 // Properties should not return arrays + + /// + /// Gets or sets the attachment thumbnail as a byte array. + /// + [JsonPropertyName("thumbnailBase64")] +#pragma warning disable CA1819 // Properties should not return arrays + public byte[]? ThumbnailBase64 { get; set; } +#pragma warning restore CA1819 // Properties should not return arrays +} + +/// +/// Response from uploading an attachment. +/// +public class UploadAttachmentResponse +{ + /// + /// Gets or sets the ID of the uploaded attachment. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs new file mode 100644 index 00000000..d4e3d217 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs @@ -0,0 +1,435 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Bot.Core.Http; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Teams.Bot.Core; + +using CustomHeaders = Dictionary; + +/// +/// Provides methods for sending activities to a conversation endpoint using HTTP requests. +/// +/// The HTTP client instance used to send requests to the conversation service. Must not be null. +/// The logger instance used for logging. Optional. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1848:Use the LoggerMessage delegates", Justification = "")] +public class ConversationClient(HttpClient httpClient, ILogger logger = default!) +{ + private readonly BotHttpClient _botHttpClient = new(httpClient, logger); + internal const string ConversationHttpClientName = "BotConversationClient"; + + /// + /// Gets the default custom headers that will be included in all requests. + /// + public CustomHeaders DefaultCustomHeaders { get; } = []; + + /// + /// Sends the specified activity to the conversation endpoint asynchronously. + /// + /// The activity to send. Cannot be null. The activity 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 send operation. + /// A task that represents the asynchronous operation. The task result contains the response with the ID of the sent activity. + /// Thrown if the activity could not be sent successfully. The exception message includes the HTTP status code and + /// response content. + public async Task SendActivityAsync(CoreActivity activity, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(activity.Conversation); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + + string url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{activity.Conversation.Id}/activities/"; + + if (activity.ChannelId == "agents") + { + logger.LogInformation("Truncating conversation ID for 'agents' channel to comply with length restrictions."); + string conversationId = activity.Conversation.Id; + string convId = conversationId.Length > 325 ? conversationId[..325] : conversationId; + url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{convId}/activities"; + } + + string body = activity.ToJson(); + + logger?.LogTrace("Sending activity to {Url}: {Activity}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(activity.From.GetAgenticIdentity(), "sending activity", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Updates an existing activity in a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to update. Cannot be null or whitespace. + /// The updated activity data. Cannot be null. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the update operation. + /// A task that represents the asynchronous operation. The task result contains the response with the ID of the updated activity. + /// Thrown if the activity could not be updated successfully. + public async Task UpdateActivityAsync(string conversationId, string activityId, CoreActivity activity, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + + string url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}"; + string body = activity.ToJson(); + + logger.LogTrace("Updating activity at {Url}: {Activity}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Put, + url, + body, + CreateRequestOptions(activity.From.GetAgenticIdentity(), "updating activity", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + + /// + /// 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. + /// 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, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}"; + + logger.LogTrace("Deleting activity at {Url}", url); + + await _botHttpClient.SendAsync( + HttpMethod.Delete, + url, + body: null, + CreateRequestOptions(agenticIdentity, "deleting activity", customHeaders), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes an existing activity from a conversation using activity context. + /// + /// The activity to delete. Must contain valid Id, Conversation.Id, and ServiceUrl. Cannot be null. + /// 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(CoreActivity activity, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Id); + ArgumentNullException.ThrowIfNull(activity.Conversation); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + + await DeleteActivityAsync( + activity.Conversation.Id, + activity.Id, + activity.ServiceUrl, + activity.From.GetAgenticIdentity(), + customHeaders, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the members of a conversation. + /// + /// The ID of the conversation. 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. The task result contains a list of conversation members. + /// Thrown if the members could not be retrieved successfully. + public async Task> GetConversationMembersAsync(string conversationId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/members"; + + logger.LogTrace("Getting conversation members from {Url}", url); + + return (await _botHttpClient.SendAsync>( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting conversation members", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + + /// + /// Gets a specific member of a conversation. + /// + /// + /// + /// + /// + /// + /// + /// + public async Task GetConversationMemberAsync(string conversationId, string userId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) where T : ConversationAccount + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/members/{userId}"; + + logger.LogTrace("Getting conversation members from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting conversation member", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Gets the conversations in which the bot has participated. + /// + /// The service URL for the bot. Cannot be null. + /// Optional continuation token for pagination. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the conversations and an optional continuation token. + /// Thrown if the conversations could not be retrieved successfully. + public async Task GetConversationsAsync(Uri serviceUrl, string? continuationToken = null, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations"; + if (!string.IsNullOrWhiteSpace(continuationToken)) + { + url += $"?continuationToken={Uri.EscapeDataString(continuationToken)}"; + } + + logger.LogTrace("Getting conversations from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting conversations", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Gets the members of a specific activity. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity. 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. The task result contains a list of members for the activity. + /// Thrown if the activity members could not be retrieved successfully. + public async Task> GetActivityMembersAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}/members"; + + logger.LogTrace("Getting activity members from {Url}", url); + + return (await _botHttpClient.SendAsync>( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting activity members", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Creates a new conversation. + /// + /// The parameters for creating the conversation. Cannot be null. + /// The service URL for the bot. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the conversation resource response with the conversation ID. + /// Thrown if the conversation could not be created successfully. + public async Task CreateConversationAsync(ConversationParameters parameters, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(parameters); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations"; + + logger.LogTrace("Creating conversation at {Url} with parameters: {Parameters}", url, JsonSerializer.Serialize(parameters)); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + JsonSerializer.Serialize(parameters), + CreateRequestOptions(agenticIdentity, "creating conversation", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Gets the members of a conversation one page at a time. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// 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. + /// Thrown if the conversation members could not be retrieved successfully. + public async Task GetConversationPagedMembersAsync(string conversationId, Uri serviceUrl, int? pageSize = null, string? continuationToken = null, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/pagedmembers"; + + List queryParams = []; + if (pageSize.HasValue) + { + queryParams.Add($"pageSize={pageSize.Value}"); + } + if (!string.IsNullOrWhiteSpace(continuationToken)) + { + queryParams.Add($"continuationToken={Uri.EscapeDataString(continuationToken)}"); + } + if (queryParams.Count > 0) + { + url += $"?{string.Join("&", queryParams)}"; + } + + logger.LogTrace("Getting paged conversation members from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting paged conversation members", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Deletes a member from a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the member to delete. 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 member could not be deleted successfully. + /// If the deleted member was the last member of the conversation, the conversation is also deleted. + public async Task DeleteConversationMemberAsync(string conversationId, string memberId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(memberId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/members/{memberId}"; + + logger.LogTrace("Deleting conversation member at {Url}", url); + + await _botHttpClient.SendAsync( + HttpMethod.Delete, + url, + body: null, + CreateRequestOptions(agenticIdentity, "deleting conversation member", customHeaders), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Uploads and sends historic activities to the conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The transcript containing the historic activities. Cannot be null. + /// 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. The task result contains the response with a resource ID. + /// Thrown if the history could not be sent successfully. + /// Activities in the transcript must have unique IDs and appropriate timestamps for proper rendering. + public async Task SendConversationHistoryAsync(string conversationId, Transcript transcript, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(transcript); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/history"; + + logger.LogTrace("Sending conversation history to {Url}: {Transcript}", url, JsonSerializer.Serialize(transcript)); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + JsonSerializer.Serialize(transcript), + CreateRequestOptions(agenticIdentity, "sending conversation history", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Uploads an attachment to the channel's blob storage. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The attachment data to upload. Cannot be null. + /// 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. The task result contains the response with an attachment ID. + /// Thrown if the attachment could not be uploaded successfully. + /// This is useful for storing data in a compliant store when dealing with enterprises. + public async Task UploadAttachmentAsync(string conversationId, AttachmentData attachmentData, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(attachmentData); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/attachments"; + + logger.LogTrace("Uploading attachment to {Url}: {AttachmentData}", url, JsonSerializer.Serialize(attachmentData)); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + JsonSerializer.Serialize(attachmentData), + CreateRequestOptions(agenticIdentity, "uploading attachment", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + private BotRequestOptions CreateRequestOptions(AgenticIdentity? agenticIdentity, string operationDescription, CustomHeaders? customHeaders) => + new() + { + AgenticIdentity = agenticIdentity, + OperationDescription = operationDescription, + DefaultHeaders = DefaultCustomHeaders, + CustomHeaders = customHeaders + }; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs new file mode 100644 index 00000000..41c15ace --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.TokenCacheProviders.InMemory; + +namespace Microsoft.Teams.Bot.Core.Hosting; + +/// +/// Provides extension methods for registering bot application clients and related authentication services with the +/// dependency injection container. +/// +/// This class is intended to be used during application startup to configure HTTP clients, token +/// acquisition, and agent identity services required for bot-to-bot communication. The configuration section specified +/// by the Azure Active Directory (AAD) configuration name is used to bind authentication options. Typically, these +/// methods are called in the application's service configuration pipeline. +public static class AddBotApplicationExtensions +{ + internal const string MsalConfigKey = "AzureAd"; + + /// + /// Configures the application to handle bot messages at the specified route and returns the registered bot + /// application instance. + /// + /// This method adds authentication and authorization middleware to the request pipeline and maps + /// a POST endpoint for bot messages. The endpoint requires authorization. Ensure that the bot application is + /// registered in the service container before calling this method. + /// The type of the bot application to use. Must inherit from BotApplication. + /// The application builder used to configure the request pipeline. + /// The route path at which to listen for incoming bot messages. Defaults to "api/messages". + /// The registered bot application instance of type TApp. + /// Thrown if the bot application of type TApp is not registered in the application's service container. + public static TApp UseBotApplication( + this IApplicationBuilder builder, + string routePath = "api/messages") + where TApp : BotApplication + { + ArgumentNullException.ThrowIfNull(builder); + TApp app = builder.ApplicationServices.GetService() ?? throw new InvalidOperationException("Application not registered"); + WebApplication? webApp = builder as WebApplication; + ArgumentNullException.ThrowIfNull(webApp); + webApp.MapPost(routePath, (HttpContext httpContext, CancellationToken cancellationToken) + => app.ProcessAsync(httpContext, cancellationToken) + ).RequireAuthorization(); + + return app; + } + + /// + /// Adds a bot application to the service collection. + /// + /// + /// + /// + /// + public static IServiceCollection AddBotApplication(this IServiceCollection services, string sectionName = "AzureAd") where TApp : BotApplication + { + ILogger logger = services.BuildServiceProvider().GetRequiredService>(); + services.AddAuthorization(logger, sectionName); + services.AddConversationClient(sectionName); + services.AddUserTokenClient(sectionName); + services.AddSingleton(); + return services; + } + + /// + /// Adds conversation client to the service collection. + /// + /// service collection + /// Configuration Section name, defaults to AzureAD + /// + public static IServiceCollection AddConversationClient(this IServiceCollection services, string sectionName = "AzureAd") => + services.AddBotClient(ConversationClient.ConversationHttpClientName, sectionName); + + /// + /// Adds user token client to the service collection. + /// + /// service collection + /// Configuration Section name, defaults to AzureAD + /// + public static IServiceCollection AddUserTokenClient(this IServiceCollection services, string sectionName = "AzureAd") => + services.AddBotClient(UserTokenClient.UserTokenHttpClientName, sectionName); + + private static IServiceCollection AddBotClient( + this IServiceCollection services, + string httpClientName, + string sectionName) where TClient : class + { + ServiceProvider sp = services.BuildServiceProvider(); + IConfiguration configuration = sp.GetRequiredService(); + ILogger logger = sp.GetRequiredService().CreateLogger(typeof(AddBotApplicationExtensions)); + ArgumentNullException.ThrowIfNull(configuration); + + string scope = "https://api.botframework.com/.default"; + if (!string.IsNullOrEmpty(configuration[$"{sectionName}:Scope"])) + scope = configuration[$"{sectionName}:Scope"]!; + if (!string.IsNullOrEmpty(configuration["Scope"])) + scope = configuration["Scope"]!; + + services + .AddHttpClient() + .AddTokenAcquisition(true) + .AddInMemoryTokenCaches() + .AddAgentIdentities(); + + if (services.ConfigureMSAL(configuration, sectionName)) + { + services.AddHttpClient(httpClientName) + .AddHttpMessageHandler(sp => + new BotAuthenticationHandler( + sp.GetRequiredService(), + sp.GetRequiredService>(), + scope, + sp.GetService>())); + } + else + { + _logAuthConfigNotFound(logger, null); + services.AddHttpClient(httpClientName); + } + + return services; + } + + private static bool ConfigureMSAL(this IServiceCollection services, IConfiguration configuration, string sectionName) + { + ArgumentNullException.ThrowIfNull(configuration); + ILogger logger = services.BuildServiceProvider().GetRequiredService().CreateLogger(typeof(AddBotApplicationExtensions)); + + if (configuration["MicrosoftAppId"] is not null) + { + _logUsingBFConfig(logger, null); + BotConfig botConfig = BotConfig.FromBFConfig(configuration); + services.ConfigureMSALFromBotConfig(botConfig, logger); + } + else if (configuration["CLIENT_ID"] is not null) + { + _logUsingCoreConfig(logger, null); + BotConfig botConfig = BotConfig.FromCoreConfig(configuration); + services.ConfigureMSALFromBotConfig(botConfig, logger); + } + else + { + _logUsingSectionConfig(logger, sectionName, null); + services.ConfigureMSALFromConfig(configuration.GetSection(sectionName)); + } + return true; + } + + private static IServiceCollection ConfigureMSALFromConfig(this IServiceCollection services, IConfigurationSection msalConfigSection) + { + ArgumentNullException.ThrowIfNull(msalConfigSection); + services.Configure(MsalConfigKey, msalConfigSection); + return services; + } + + private static IServiceCollection ConfigureMSALWithSecret(this IServiceCollection services, string tenantId, string clientId, string clientSecret) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(clientId); + ArgumentException.ThrowIfNullOrWhiteSpace(clientSecret); + + services.Configure(MsalConfigKey, options => + { + // TODO: Make Instance configurable + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = tenantId; + options.ClientId = clientId; + options.ClientCredentials = [ + new CredentialDescription() + { + SourceType = CredentialSource.ClientSecret, + ClientSecret = clientSecret + } + ]; + }); + return services; + } + + private static IServiceCollection ConfigureMSALWithFIC(this IServiceCollection services, string tenantId, string clientId, string? ficClientId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(clientId); + + CredentialDescription ficCredential = new() + { + SourceType = CredentialSource.SignedAssertionFromManagedIdentity, + }; + if (!string.IsNullOrEmpty(ficClientId) && !IsSystemAssignedManagedIdentity(ficClientId)) + { + ficCredential.ManagedIdentityClientId = ficClientId; + } + + services.Configure(MsalConfigKey, options => + { + // TODO: Make Instance configurable + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = tenantId; + options.ClientId = clientId; + options.ClientCredentials = [ + ficCredential + ]; + }); + return services; + } + + private static IServiceCollection ConfigureMSALWithUMI(this IServiceCollection services, string tenantId, string clientId, string? managedIdentityClientId = null) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNullOrWhiteSpace(clientId); + + // Register ManagedIdentityOptions for BotAuthenticationHandler to use + bool isSystemAssigned = IsSystemAssignedManagedIdentity(managedIdentityClientId); + string? umiClientId = isSystemAssigned ? null : (managedIdentityClientId ?? clientId); + + services.Configure(options => + { + options.UserAssignedClientId = umiClientId; + }); + + services.Configure(MsalConfigKey, options => + { + // TODO: Make Instance configurable + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = tenantId; + options.ClientId = clientId; + }); + return services; + } + + private static IServiceCollection ConfigureMSALFromBotConfig(this IServiceCollection services, BotConfig botConfig, ILogger logger) + { + ArgumentNullException.ThrowIfNull(botConfig); + if (!string.IsNullOrEmpty(botConfig.ClientSecret)) + { + _logUsingClientSecret(logger, null); + services.ConfigureMSALWithSecret(botConfig.TenantId, botConfig.ClientId, botConfig.ClientSecret); + } + else if (string.IsNullOrEmpty(botConfig.FicClientId) || botConfig.FicClientId == botConfig.ClientId) + { + _logUsingUMI(logger, null); + services.ConfigureMSALWithUMI(botConfig.TenantId, botConfig.ClientId, botConfig.FicClientId); + } + else + { + bool isSystemAssigned = IsSystemAssignedManagedIdentity(botConfig.FicClientId); + _logUsingFIC(logger, isSystemAssigned ? "System-Assigned" : "User-Assigned", null); + services.ConfigureMSALWithFIC(botConfig.TenantId, botConfig.ClientId, botConfig.FicClientId); + } + return services; + } + + /// + /// Determines if the provided client ID represents a system-assigned managed identity. + /// + private static bool IsSystemAssignedManagedIdentity(string? clientId) + => string.Equals(clientId, BotConfig.SystemManagedIdentityIdentifier, StringComparison.OrdinalIgnoreCase); + + private static readonly Action _logUsingBFConfig = + LoggerMessage.Define(LogLevel.Debug, new(1), "Configuring MSAL from Bot Framework configuration"); + private static readonly Action _logUsingCoreConfig = + LoggerMessage.Define(LogLevel.Debug, new(2), "Configuring MSAL from Core bot configuration"); + private static readonly Action _logUsingSectionConfig = + LoggerMessage.Define(LogLevel.Debug, new(3), "Configuring MSAL from {SectionName} configuration section"); + private static readonly Action _logUsingClientSecret = + LoggerMessage.Define(LogLevel.Debug, new(4), "Configuring authentication with client secret"); + private static readonly Action _logUsingUMI = + LoggerMessage.Define(LogLevel.Debug, new(5), "Configuring authentication with User-Assigned Managed Identity"); + private static readonly Action _logUsingFIC = + LoggerMessage.Define(LogLevel.Debug, new(6), "Configuring authentication with Federated Identity Credential (Managed Identity) with {IdentityType} Managed Identity"); + private static readonly Action _logAuthConfigNotFound = + LoggerMessage.Define(LogLevel.Warning, new(7), "Authentication configuration not found. Running without Auth"); + + +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotAuthenticationHandler.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotAuthenticationHandler.cs new file mode 100644 index 00000000..5cb65517 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotAuthenticationHandler.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http.Headers; + +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; + +namespace Microsoft.Teams.Bot.Core.Hosting; + +/// +/// HTTP message handler that automatically acquires and attaches authentication tokens +/// for Bot Framework API calls. Supports both app-only and agentic (user-delegated) token acquisition. +/// +/// +/// Initializes a new instance of the class. +/// +/// The authorization header provider for acquiring tokens. +/// The logger instance. +/// The scope for the token request. +/// Optional managed identity options for user-assigned managed identity authentication. +internal sealed class BotAuthenticationHandler( + IAuthorizationHeaderProvider authorizationHeaderProvider, + ILogger logger, + string scope, + IOptions? managedIdentityOptions = null) : DelegatingHandler +{ + private readonly IAuthorizationHeaderProvider _authorizationHeaderProvider = authorizationHeaderProvider ?? throw new ArgumentNullException(nameof(authorizationHeaderProvider)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly string _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + private readonly IOptions? _managedIdentityOptions = managedIdentityOptions; + private static readonly Action _logAgenticToken = + LoggerMessage.Define(LogLevel.Debug, new(2), "Acquiring agentic token for app {AgenticAppId}"); + private static readonly Action _logAppOnlyToken = + LoggerMessage.Define(LogLevel.Debug, new(3), "Acquiring app-only token for scope: {Scope}"); + + /// + /// Key used to store the agentic identity in HttpRequestMessage options. + /// + public static readonly HttpRequestOptionsKey AgenticIdentityKey = new("AgenticIdentity"); + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Options.TryGetValue(AgenticIdentityKey, out AgenticIdentity? agenticIdentity); + + string token = await GetAuthorizationHeaderAsync(agenticIdentity, cancellationToken).ConfigureAwait(false); + + string tokenValue = token.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? token["Bearer ".Length..] + : token; + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenValue); + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets an authorization header for Bot Framework API calls. + /// Supports both app-only and agentic (user-delegated) token acquisition. + /// + /// Optional agentic identity for user-delegated token acquisition. If not provided, acquires an app-only token. + /// Cancellation token. + /// The authorization header value. + private async Task GetAuthorizationHeaderAsync(AgenticIdentity? agenticIdentity, CancellationToken cancellationToken) + { + AuthorizationHeaderProviderOptions options = new() + { + AcquireTokenOptions = new AcquireTokenOptions() + { + AuthenticationOptionsName = AddBotApplicationExtensions.MsalConfigKey, + } + }; + + // Conditionally apply ManagedIdentity configuration if registered + if (_managedIdentityOptions is not null) + { + ManagedIdentityOptions miOptions = _managedIdentityOptions.Value; + + if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId)) + { + options.AcquireTokenOptions.ManagedIdentity = miOptions; + } + } + + if (agenticIdentity is not null && + !string.IsNullOrEmpty(agenticIdentity.AgenticAppId) && + !string.IsNullOrEmpty(agenticIdentity.AgenticUserId)) + { + _logAgenticToken(_logger, agenticIdentity.AgenticAppId, null); + + options.WithAgentUserIdentity(agenticIdentity.AgenticAppId, Guid.Parse(agenticIdentity.AgenticUserId)); + string token = await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync([_scope], options, null, cancellationToken).ConfigureAwait(false); + return token; + } + + _logAppOnlyToken(_logger, _scope, null); + string appToken = await _authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(_scope, options, cancellationToken).ConfigureAwait(false); + return appToken; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs new file mode 100644 index 00000000..dddfe836 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Teams.Bot.Core.Hosting; + + +internal sealed class BotConfig +{ + public const string SystemManagedIdentityIdentifier = "system"; + + public string TenantId { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string? ClientSecret { get; set; } + public string? FicClientId { get; set; } + + public static BotConfig FromBFConfig(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return new() + { + TenantId = configuration["MicrosoftAppTenantId"] ?? string.Empty, + ClientId = configuration["MicrosoftAppId"] ?? string.Empty, + ClientSecret = configuration["MicrosoftAppPassword"], + }; + } + + public static BotConfig FromCoreConfig(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return new() + { + TenantId = configuration["TENANT_ID"] ?? string.Empty, + ClientId = configuration["CLIENT_ID"] ?? string.Empty, + ClientSecret = configuration["CLIENT_SECRET"], + FicClientId = configuration["MANAGED_IDENTITY_CLIENT_ID"], + }; + } + + public static BotConfig FromAadConfig(IConfiguration configuration, string sectionName = "AzureAd") + { + ArgumentNullException.ThrowIfNull(configuration); + IConfigurationSection section = configuration.GetSection(sectionName); + return new() + { + TenantId = section["TenantId"] ?? string.Empty, + ClientId = section["ClientId"] ?? string.Empty, + ClientSecret = section["ClientSecret"], + }; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs new file mode 100644 index 00000000..583f2db7 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; + +namespace Microsoft.Teams.Bot.Core.Hosting +{ + /// + /// Provides extension methods for configuring JWT authentication and authorization for bots and agents. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1848:Use the LoggerMessage delegates", Justification = "")] + public static class JwtExtensions + { + internal const string BotScheme = "BotScheme"; + internal const string AgentScheme = "AgentScheme"; + internal const string BotScope = "https://api.botframework.com/.default"; + internal const string AgentScope = "https://botapi.skype.com/.default"; + internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration"; + internal const string AgentOIDC = "https://login.microsoftonline.com/"; + + /// + /// Adds JWT authentication for bots and agents. + /// + /// The service collection to add authentication to. + /// The application configuration containing the settings. + /// Indicates whether to use agent authentication (true) or bot authentication (false). + /// The configuration section name for the settings. Defaults to "AzureAd". + /// The logger instance for logging. + /// An for further authentication configuration. + public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection services, IConfiguration configuration, bool useAgentAuth, ILogger logger, string aadSectionName = "AzureAd") + { + + // TODO: Task 5039187: Refactor use of BotConfig for MSAL and JWT + + AuthenticationBuilder builder = services.AddAuthentication(); + ArgumentNullException.ThrowIfNull(configuration); + string audience = configuration[$"{aadSectionName}:ClientId"] + ?? configuration["CLIENT_ID"] + ?? configuration["MicrosoftAppId"] + ?? throw new InvalidOperationException("ClientID not found in configuration, tried the 3 option"); + + if (!useAgentAuth) + { + string[] validIssuers = ["https://api.botframework.com"]; + builder.AddCustomJwtBearer(BotScheme, validIssuers, audience, logger); + } + else + { + string tenantId = configuration[$"{aadSectionName}:TenantId"] + ?? configuration["TENANT_ID"] + ?? configuration["MicrosoftAppTenantId"] + ?? "botframework.com"; // TODO: Task 5039198: Test JWT Validation for MultiTenant + + string[] validIssuers = [$"https://sts.windows.net/{tenantId}/", $"https://login.microsoftonline.com/{tenantId}/v2", "https://api.botframework.com"]; + builder.AddCustomJwtBearer(AgentScheme, validIssuers, audience, logger); + } + return builder; + } + + /// + /// Adds authorization policies to the service collection. + /// + /// The service collection to add authorization to. + /// The configuration section name for the settings. Defaults to "AzureAd". + /// The logger instance for logging. + /// An for further authorization configuration. + public static AuthorizationBuilder AddAuthorization(this IServiceCollection services, ILogger logger, string aadSectionName = "AzureAd") + { + IConfiguration configuration = services.BuildServiceProvider().GetRequiredService(); + string azureScope = configuration[$"Scope"]!; + bool useAgentAuth = false; + + if (string.Equals(azureScope, AgentScope, StringComparison.OrdinalIgnoreCase)) + { + useAgentAuth = true; + } + + services.AddBotAuthentication(configuration, useAgentAuth, logger, aadSectionName); + AuthorizationBuilder authorizationBuilder = services + .AddAuthorizationBuilder() + .AddDefaultPolicy("DefaultPolicy", policy => + { + if (!useAgentAuth) + { + policy.AuthenticationSchemes.Add(BotScheme); + } + else + { + policy.AuthenticationSchemes.Add(AgentScheme); + } + policy.RequireAuthenticatedUser(); + }); + return authorizationBuilder; + } + + private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuilder builder, string schemeName, string[] validIssuers, string audience, ILogger logger) + { + builder.AddJwtBearer(schemeName, jwtOptions => + { + jwtOptions.SaveToken = true; + jwtOptions.IncludeErrorDetails = true; + jwtOptions.Audience = audience; + jwtOptions.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + ValidateIssuer = true, + ValidateAudience = true, + ValidIssuers = validIssuers + }; + jwtOptions.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + jwtOptions.MapInboundClaims = true; + jwtOptions.Events = new JwtBearerEvents + { + OnMessageReceived = async context => + { + logger.LogDebug("OnMessageReceived invoked for scheme: {Scheme}", schemeName); + string authorizationHeader = context.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authorizationHeader)) + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= jwtOptions.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + logger.LogWarning("Authorization header is missing."); + return; + } + + string[] parts = authorizationHeader?.Split(' ')!; + if (parts.Length != 2 || parts[0] != "Bearer") + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= jwtOptions.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + logger.LogWarning("Invalid authorization header format."); + return; + } + + JwtSecurityToken token = new(parts[1]); + string issuer = token.Claims.FirstOrDefault(claim => claim.Type == "iss")?.Value!; + string tid = token.Claims.FirstOrDefault(claim => claim.Type == "tid")?.Value!; + + string oidcAuthority = issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase) + ? BotOIDC : $"{AgentOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration"; + + logger.LogDebug("Using OIDC Authority: {OidcAuthority} for issuer: {Issuer}", oidcAuthority, issuer); + + jwtOptions.ConfigurationManager = new ConfigurationManager( + oidcAuthority, + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever + { + RequireHttps = jwtOptions.RequireHttpsMetadata + }); + + + await Task.CompletedTask.ConfigureAwait(false); + }, + OnTokenValidated = context => + { + logger.LogInformation("Token validated successfully for scheme: {Scheme}", schemeName); + return Task.CompletedTask; + }, + OnForbidden = context => + { + logger.LogWarning("Forbidden response for scheme: {Scheme}", schemeName); + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + logger.LogWarning("Authentication failed for scheme: {Scheme}. Exception: {Exception}", schemeName, context.Exception); + return Task.CompletedTask; + } + }; + jwtOptions.Validate(); + }); + return builder; + } + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs b/core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs new file mode 100644 index 00000000..03532835 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Net; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Teams.Bot.Core.Http; +/// +/// Provides shared HTTP request functionality for bot clients. +/// +/// The HTTP client instance used to send requests. +/// The logger instance used for logging. Optional. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1848:Use the LoggerMessage delegates", Justification = "")] +public class BotHttpClient(HttpClient httpClient, ILogger? logger = null) +{ + private static readonly JsonSerializerOptions DefaultJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Sends an HTTP request and deserializes the response. + /// + /// The type to deserialize the response to. + /// The HTTP method to use. + /// The full URL for the request. + /// The request body content. Optional. + /// The request options. Optional. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the deserialized response, or null if the response is empty or 404 (when ReturnNullOnNotFound is true). + /// Thrown if the request fails and the failure is not handled by options. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "String URLs are used for consistency with existing API patterns")] + public async Task SendAsync( + HttpMethod method, + string url, + string? body = null, + BotRequestOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new BotRequestOptions(); + + using HttpRequestMessage request = CreateRequest(method, url, body, options); + + logger?.LogTrace("Sending HTTP {Method} request to {Url} with body: {Body}", method, url, body); + + using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + return await HandleResponseAsync(response, method, url, options, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends an HTTP request with query parameters and deserializes the response. + /// + /// The type to deserialize the response to. + /// The HTTP method to use. + /// The base URL for the request. + /// The endpoint path to append to the base URL. + /// The query parameters to include in the request. Optional. + /// The request body content. Optional. + /// The request options. Optional. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the deserialized response, or null if the response is empty or 404 (when ReturnNullOnNotFound is true). + /// Thrown if the request fails and the failure is not handled by options. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "String URLs are used for consistency with existing API patterns")] + public async Task SendAsync( + HttpMethod method, + string baseUrl, + string endpoint, + Dictionary? queryParams = null, + string? body = null, + BotRequestOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseUrl); + ArgumentNullException.ThrowIfNull(endpoint); + + string fullPath = $"{baseUrl.TrimEnd('/')}/{endpoint.TrimStart('/')}"; + string url = queryParams?.Count > 0 + ? QueryHelpers.AddQueryString(fullPath, queryParams) + : fullPath; + + return await SendAsync(method, url, body, options, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends an HTTP request without expecting a response body. + /// + /// The HTTP method to use. + /// The full URL for the request. + /// The request body content. Optional. + /// The request options. Optional. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + /// Thrown if the request fails. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "String URLs are used for consistency with existing API patterns")] + public async Task SendAsync( + HttpMethod method, + string url, + string? body = null, + BotRequestOptions? options = null, + CancellationToken cancellationToken = default) + { + await SendAsync(method, url, body, options, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends an HTTP request with query parameters without expecting a response body. + /// + /// The HTTP method to use. + /// The base URL for the request. + /// The endpoint path to append to the base URL. + /// The query parameters to include in the request. Optional. + /// The request body content. Optional. + /// The request options. Optional. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + /// Thrown if the request fails. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "String URLs are used for consistency with existing API patterns")] + public async Task SendAsync( + HttpMethod method, + string baseUrl, + string endpoint, + Dictionary? queryParams = null, + string? body = null, + BotRequestOptions? options = null, + CancellationToken cancellationToken = default) + { + await SendAsync(method, baseUrl, endpoint, queryParams, body, options, cancellationToken).ConfigureAwait(false); + } + + private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string? body, BotRequestOptions options) + { + HttpRequestMessage request = new(method, url); + + if (body is not null) + { + request.Content = new StringContent(body, Encoding.UTF8, MediaTypeNames.Application.Json); + } + + if (options.AgenticIdentity is not null) + { + request.Options.Set(BotAuthenticationHandler.AgenticIdentityKey, options.AgenticIdentity); + } + + if (options.DefaultHeaders is not null) + { + foreach (KeyValuePair header in options.DefaultHeaders) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + if (options.CustomHeaders is not null) + { + foreach (KeyValuePair header in options.CustomHeaders) + { + request.Headers.Remove(header.Key); + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return request; + } + + private async Task HandleResponseAsync( + HttpResponseMessage response, + HttpMethod method, + string url, + BotRequestOptions options, + CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return await DeserializeResponseAsync(response, options, cancellationToken).ConfigureAwait(false); + } + + if (response.StatusCode == HttpStatusCode.NotFound && options.ReturnNullOnNotFound) + { + logger?.LogWarning("Resource not found: {Url}", url); + return default; + } + + string errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + string responseHeaders = FormatResponseHeaders(response); + + logger?.LogWarning( + "HTTP request error {Method} {Url}\nStatus Code: {StatusCode}\nResponse Headers: {ResponseHeaders}\nResponse Body: {ResponseBody}", + method, url, response.StatusCode, responseHeaders, errorContent); + + string operationDescription = options.OperationDescription ?? "request"; + throw new HttpRequestException( + $"Error {operationDescription} {response.StatusCode}. {errorContent}", + inner: null, + statusCode: response.StatusCode); + } + + private static async Task DeserializeResponseAsync( + HttpResponseMessage response, + BotRequestOptions options, + CancellationToken cancellationToken) + { + string responseString = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(responseString) || responseString.Length <= 2) + { + return default; + } + + if (typeof(T) == typeof(string)) + { + try + { + T? result = JsonSerializer.Deserialize(responseString, DefaultJsonOptions); + return result ?? (T)(object)responseString; + } + catch (JsonException) + { + return (T)(object)responseString; + } + } + + T? deserializedResult = JsonSerializer.Deserialize(responseString, DefaultJsonOptions); + + if (deserializedResult is null) + { + string operationDescription = options.OperationDescription ?? "request"; + throw new InvalidOperationException($"Failed to deserialize response for {operationDescription}"); + } + + return deserializedResult; + } + + private static string FormatResponseHeaders(HttpResponseMessage response) + { + StringBuilder sb = new(); + + foreach (KeyValuePair> header in response.Headers) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"Response header: {header.Key} : {string.Join(",", header.Value)}"); + } + + foreach (KeyValuePair> header in response.TrailingHeaders) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"Response trailing header: {header.Key} : {string.Join(",", header.Value)}"); + } + + return sb.ToString(); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Http/BotRequestOptions.cs b/core/src/Microsoft.Teams.Bot.Core/Http/BotRequestOptions.cs new file mode 100644 index 00000000..cbeccb52 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Http/BotRequestOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.Http; + +using CustomHeaders = Dictionary; + +/// +/// Options for configuring a bot HTTP request. +/// +public record BotRequestOptions +{ + /// + /// Gets the agentic identity for authentication. + /// + public AgenticIdentity? AgenticIdentity { get; init; } + + /// + /// Gets the custom headers to include in the request. + /// These headers override default headers if the same key exists. + /// + public CustomHeaders? CustomHeaders { get; init; } + + /// + /// Gets the default custom headers that will be included in all requests. + /// + public CustomHeaders? DefaultHeaders { get; init; } + + /// + /// Gets a value indicating whether to return null instead of throwing on 404 responses. + /// + public bool ReturnNullOnNotFound { get; init; } + + /// + /// Gets a description of the operation for logging and error messages. + /// + public string? OperationDescription { get; init; } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/ITurnMiddleWare.cs b/core/src/Microsoft.Teams.Bot.Core/ITurnMiddleWare.cs new file mode 100644 index 00000000..cb55c6ab --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/ITurnMiddleWare.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Represents a delegate that invokes the next middleware component in the pipeline asynchronously. +/// +/// This delegate is typically used in middleware scenarios to advance the request processing pipeline. +/// The cancellation token should be observed to support cooperative cancellation. +/// A cancellation token that can be used to cancel the asynchronous operation. +/// A task that represents the completion of the middleware invocation. +public delegate Task NextTurn(CancellationToken cancellationToken); + +/// +/// Defines a middleware component that can process or modify activities during a bot turn. +/// +/// Implement this interface to add custom logic before or after the bot processes an activity. +/// Middleware can perform tasks such as logging, authentication, or altering activities. Multiple middleware components +/// can be chained together; each should call the nextTurn delegate to continue the pipeline. +public interface ITurnMiddleWare +{ + /// + /// Triggers the middleware to process an activity during a bot turn. + /// + /// + /// + /// + /// + /// + Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn nextTurn, CancellationToken cancellationToken = default); +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj b/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj new file mode 100644 index 00000000..b7c88f86 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj @@ -0,0 +1,29 @@ + + + + net8.0;net10.0 + enable + enable + True + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/ActivityType.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/ActivityType.cs new file mode 100644 index 00000000..ea381801 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/ActivityType.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Provides constant values that represent activity types used in messaging workflows. +/// +/// Use the fields of this class to specify or compare activity types in message-based systems. This +/// class is typically used to avoid hardcoding string literals for activity type identifiers. +public static class ActivityType +{ + /// + /// Represents the default message string used for communication or display purposes. + /// + public const string Message = "message"; + /// + /// Represents a typing indicator activity. + /// + public const string Typing = "typing"; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/AgenticIdentity.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/AgenticIdentity.cs new file mode 100644 index 00000000..51c54329 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/AgenticIdentity.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Represents an agentic identity for user-delegated token acquisition. +/// +public sealed class AgenticIdentity +{ + /// + /// Agentic application ID. + /// + public string? AgenticAppId { get; set; } + /// + /// Agentic user ID. + /// + public string? AgenticUserId { get; set; } + + /// + /// Agentic application blueprint ID. + /// + public string? AgenticAppBlueprintId { get; set; } + + /// + /// Creates an instance from the provided properties dictionary. + /// + /// + /// + public static AgenticIdentity? FromProperties(ExtendedPropertiesDictionary? properties) + { + if (properties is null) + { + return null; + } + + properties.TryGetValue("agenticAppId", out object? appIdObj); + properties.TryGetValue("agenticUserId", out object? userIdObj); + properties.TryGetValue("agenticAppBlueprintId", out object? bluePrintObj); + return new AgenticIdentity + { + AgenticAppId = appIdObj?.ToString(), + AgenticUserId = userIdObj?.ToString(), + AgenticAppBlueprintId = bluePrintObj?.ToString() + }; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/ChannelData.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/ChannelData.cs new file mode 100644 index 00000000..680480bd --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/ChannelData.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Represents channel-specific data associated with an activity. +/// +/// +/// This class serves as a container for custom properties that are specific to a particular +/// messaging channel. The properties dictionary allows channels to include additional metadata +/// that is not part of the standard activity schema. +/// +public class ChannelData +{ + /// + /// Gets the extension data dictionary for storing channel-specific properties. + /// + [JsonExtensionData] +#pragma warning disable CA2227 // Collection properties should be read only + public ExtendedPropertiesDictionary Properties { get; set; } = []; +#pragma warning restore CA2227 // Collection properties should be read only +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/Conversation.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/Conversation.cs new file mode 100644 index 00000000..59ef6d4f --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/Conversation.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Represents a conversation, including its unique identifier and associated extended properties. +/// +public class Conversation() +{ + /// + /// Gets or sets the unique identifier for the object. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Gets the extension data dictionary for storing additional properties not defined in the schema. + /// + [JsonExtensionData] +#pragma warning disable CA2227 // Collection properties should be read only + public ExtendedPropertiesDictionary Properties { get; set; } = []; +#pragma warning restore CA2227 // Collection properties should be read only +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/ConversationAccount.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/ConversationAccount.cs new file mode 100644 index 00000000..232cc3a6 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/ConversationAccount.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Represents a conversation account, including its unique identifier, display name, and any additional properties +/// associated with the conversation. +/// +/// This class is typically used to model the account information for a conversation in messaging or chat +/// applications. The additional properties dictionary allows for extensibility to support custom metadata or +/// protocol-specific fields. +public class ConversationAccount() +{ + /// + /// Gets or sets the unique identifier for the object. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the display name of the conversation account. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets the extension data dictionary for storing additional properties not defined in the schema. + /// + [JsonExtensionData] +#pragma warning disable CA2227 // Collection properties should be read only + public ExtendedPropertiesDictionary Properties { get; set; } = []; +#pragma warning restore CA2227 // Collection properties should be read only + + /// + /// Gets the agentic identity from the account properties. + /// + /// An AgenticIdentity instance if properties contain agentic identity information; otherwise, null. + internal AgenticIdentity? GetAgenticIdentity() + { + Properties.TryGetValue("agenticAppId", out object? appIdObj); + Properties.TryGetValue("agenticUserId", out object? userIdObj); + Properties.TryGetValue("agenticAppBlueprintId", out object? bluePrintObj); + + if (appIdObj is null && userIdObj is null && bluePrintObj is null) + { + return null; + } + + return new AgenticIdentity + { + AgenticAppId = appIdObj?.ToString(), + AgenticUserId = userIdObj?.ToString(), + AgenticAppBlueprintId = bluePrintObj?.ToString() + }; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs new file mode 100644 index 00000000..3fc0846b --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Represents a dictionary for storing extended properties as key-value pairs. +/// +public class ExtendedPropertiesDictionary : Dictionary { } + +/// +/// Represents a core activity object that encapsulates the data and metadata for a bot interaction. +/// +/// +/// This class provides the foundational structure for bot activities including message exchanges, +/// conversation updates, and other bot-related events. It supports serialization to and from JSON +/// and includes extension properties for channel-specific data. +/// Follows the Activity Protocol Specification: https://github.com/microsoft/Agents/blob/main/specs/activity/protocol-activity.md +/// +public class CoreActivity +{ + /// + /// Gets or sets the type of the activity. See for common values. + /// + /// + /// Common activity types include "message", "conversationUpdate", "contactRelationUpdate", etc. + /// + [JsonPropertyName("type")] public string Type { get; set; } + /// + /// Gets or sets the unique identifier for the channel on which this activity is occurring. + /// + [JsonPropertyName("channelId")] public string? ChannelId { get; set; } + /// + /// Gets or sets the unique identifier for the activity. + /// + [JsonPropertyName("id")] public string? Id { get; set; } + /// + /// Gets or sets the URL of the service endpoint for this activity. + /// + /// + /// This URL is used to send responses back to the channel. + /// + [JsonPropertyName("serviceUrl")] public Uri? ServiceUrl { get; set; } + /// + /// Gets or sets channel-specific data associated with this activity. + /// + [JsonPropertyName("channelData")] public ChannelData? ChannelData { get; set; } + /// + /// Gets or sets the account that sent this activity. + /// + [JsonPropertyName("from")] public ConversationAccount From { get; set; } = new(); + /// + /// Gets or sets the account that should receive this activity. + /// + [JsonPropertyName("recipient")] public ConversationAccount Recipient { get; set; } = new(); + /// + /// Gets or sets the conversation in which this activity is taking place. + /// + [JsonPropertyName("conversation")] public Conversation Conversation { get; set; } = new(); + + /// + /// Gets the collection of entities contained in this activity. + /// + /// + /// Entities are structured objects that represent mentions, places, or other data. + /// +#pragma warning disable CA2227 // Collection properties should be read only + [JsonPropertyName("entities")] public JsonArray? Entities { get; set; } + + /// + /// Gets the collection of attachments associated with this activity. + /// + [JsonPropertyName("attachments")] public JsonArray? Attachments { get; set; } + + // TODO: Can value need be a JSONObject? + /// + /// Gets or sets the value payload of the activity. + /// + [JsonPropertyName("value")] public JsonNode? Value { get; set; } + + /// + /// Gets the extension data dictionary for storing additional properties not defined in the schema. + /// + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; +#pragma warning restore CA2227 // Collection properties should be read only + + /// + /// Gets the default JSON serializer options used for serializing and deserializing activities. + /// + /// + /// Uses the source-generated JSON context for AOT-compatible serialization. + /// + public static readonly JsonSerializerOptions DefaultJsonOptions = CoreActivityJsonContext.Default.Options; + + /// + /// Gets the JSON serializer options used for reflection-based serialization of extended activity types. + /// + /// + /// Uses reflection-based serialization to support custom activity types that extend CoreActivity. + /// This is used when serializing/deserializing types not registered in the source-generated context. + /// + private static readonly JsonSerializerOptions ReflectionJsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Creates a new instance of the class with the specified activity type. + /// + /// + public CoreActivity(string type = ActivityType.Message) + { + Type = type; + } + + + /// + /// Creates a new instance of the class. As Message type by default. + /// + public CoreActivity() + { + Type = ActivityType.Message; + } + + /// + /// Creates a new instance of the class by copying properties from another activity. + /// + /// The source activity to copy from. + protected CoreActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + + Id = activity.Id; + ServiceUrl = activity.ServiceUrl; + ChannelId = activity.ChannelId; + Type = activity.Type; + // TODO: Figure out why this is needed... + // ReplyToId = activity.ReplyToId; + ChannelData = activity.ChannelData; + From = activity.From; + Recipient = activity.Recipient; + Conversation = activity.Conversation; + Entities = activity.Entities; + Attachments = activity.Attachments; + Properties = activity.Properties; + Value = activity.Value; + } + + /// + /// Serializes the current activity to a JSON string. + /// + /// A JSON string representation of the activity. + public string ToJson() + => JsonSerializer.Serialize(this, CoreActivityJsonContext.Default.CoreActivity); + + /// + /// Serializes the current activity to a JSON string using the specified JsonTypeInfo options. + /// + /// + /// + /// + public string ToJson(JsonTypeInfo ops) where T : CoreActivity + => JsonSerializer.Serialize(this, ops); + + /// + /// Serializes the specified activity instance to a JSON string using the default serialization options. + /// + /// The serialization uses the default JSON options defined by DefaultJsonOptions. The resulting + /// JSON reflects the public properties of the activity instance. + /// The type of the activity to serialize. Must inherit from CoreActivity. + /// The activity instance to serialize. Cannot be null. + /// A JSON string representation of the specified activity instance. + public static string ToJson(T instance) where T : CoreActivity + => JsonSerializer.Serialize(instance, ReflectionJsonOptions); + + /// + /// Deserializes a JSON string into a object. + /// + /// The JSON string to deserialize. + /// A instance. + public static CoreActivity FromJsonString(string json) + => JsonSerializer.Deserialize(json, CoreActivityJsonContext.Default.CoreActivity)!; + + /// + /// Asynchronously deserializes a JSON stream into a object. + /// + /// The stream containing JSON data to deserialize. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the deserialized instance, or null if deserialization fails. + public static ValueTask FromJsonStreamAsync(Stream stream, CancellationToken cancellationToken = default) + => JsonSerializer.DeserializeAsync(stream, CoreActivityJsonContext.Default.CoreActivity, cancellationToken); + + /// + /// Deserializes a JSON stream into an instance of type T using the specified JsonTypeInfo options. + /// + /// + /// + /// + /// + /// + public static ValueTask FromJsonStreamAsync(Stream stream, JsonTypeInfo ops, CancellationToken cancellationToken = default) where T : CoreActivity + => JsonSerializer.DeserializeAsync(stream, ops, cancellationToken); + + /// + /// Asynchronously deserializes a JSON value from the specified stream into an instance of type T. + /// + /// The caller is responsible for managing the lifetime of the provided stream. The method uses + /// default JSON serialization options. + /// The type of the object to deserialize. Must derive from CoreActivity. + /// The stream containing the JSON data to deserialize. The stream must be readable and positioned at the start of + /// the JSON content. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A ValueTask that represents the asynchronous operation. The result contains an instance of type T if + /// deserialization is successful; otherwise, null. + public static ValueTask FromJsonStreamAsync(Stream stream, CancellationToken cancellationToken = default) where T : CoreActivity + => JsonSerializer.DeserializeAsync(stream, ReflectionJsonOptions, cancellationToken); + + /// + /// Creates a new instance of the to construct activity instances. + /// + /// + public static CoreActivityBuilder CreateBuilder() => new(); + +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs new file mode 100644 index 00000000..d05e635e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Provides a fluent API for building CoreActivity instances. +/// +/// The type of activity being built. +/// The type of the builder (for fluent method chaining). +public abstract class CoreActivityBuilder + where TActivity : CoreActivity + where TBuilder : CoreActivityBuilder +{ + /// + /// The activity being built. + /// +#pragma warning disable CA1051 // Do not declare visible instance fields + protected readonly TActivity _activity; +#pragma warning restore CA1051 // Do not declare visible instance fields + + /// + /// Initializes a new instance of the CoreActivityBuilder class. + /// + /// The activity to build upon. + protected CoreActivityBuilder(TActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + _activity = activity; + } + + /// + /// Apply Conversation Reference + /// + /// The source activity to copy conversation reference from. + /// The builder instance for chaining. + public TBuilder WithConversationReference(TActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(activity.ChannelId); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + ArgumentNullException.ThrowIfNull(activity.Conversation); + ArgumentNullException.ThrowIfNull(activity.From); + ArgumentNullException.ThrowIfNull(activity.Recipient); + + WithServiceUrl(activity.ServiceUrl); + WithChannelId(activity.ChannelId); + SetConversation(activity.Conversation); + SetFrom(activity.Recipient); + SetRecipient(activity.From); + + return (TBuilder)this; + } + + /// + /// Sets the conversation (to be overridden by derived classes for type-specific behavior). + /// + protected abstract void SetConversation(Conversation conversation); + + /// + /// Sets the From account (to be overridden by derived classes for type-specific behavior). + /// + protected abstract void SetFrom(ConversationAccount from); + + /// + /// Sets the Recipient account (to be overridden by derived classes for type-specific behavior). + /// + protected abstract void SetRecipient(ConversationAccount recipient); + + /// + /// Sets the activity ID. + /// + /// The activity ID. + /// The builder instance for chaining. + public TBuilder WithId(string id) + { + _activity.Id = id; + return (TBuilder)this; + } + + /// + /// Sets the service URL. + /// + /// The service URL. + /// The builder instance for chaining. + public TBuilder WithServiceUrl(Uri serviceUrl) + { + _activity.ServiceUrl = serviceUrl; + return (TBuilder)this; + } + + /// + /// Sets the channel ID. + /// + /// The channel ID. + /// The builder instance for chaining. + public TBuilder WithChannelId(string channelId) + { + _activity.ChannelId = channelId; + return (TBuilder)this; + } + + /// + /// Sets the activity type. + /// + /// The activity type. + /// The builder instance for chaining. + public TBuilder WithType(string type) + { + _activity.Type = type; + return (TBuilder)this; + } + + /// + /// Adds or updates a property in the activity's Properties dictionary. + /// + /// Name of the property. + /// Value of the property. + /// The builder instance for chaining. + public TBuilder WithProperty(string name, T? value) + { + _activity.Properties[name] = value; + return (TBuilder)this; + } + + /// + /// Sets the sender account information. + /// + /// The sender account. + /// The builder instance for chaining. + public TBuilder WithFrom(ConversationAccount from) + { + SetFrom(from); + return (TBuilder)this; + } + + /// + /// Sets the recipient account information. + /// + /// The recipient account. + /// The builder instance for chaining. + public TBuilder WithRecipient(ConversationAccount recipient) + { + SetRecipient(recipient); + return (TBuilder)this; + } + + /// + /// Sets the conversation information. + /// + /// The conversation information. + /// The builder instance for chaining. + public TBuilder WithConversation(Conversation conversation) + { + SetConversation(conversation); + return (TBuilder)this; + } + + /// + /// Sets the channel-specific data (to be overridden by derived classes for type-specific behavior). + /// + /// The channel data. + /// The builder instance for chaining. + public virtual TBuilder WithChannelData(ChannelData? channelData) + { + _activity.ChannelData = channelData; + return (TBuilder)this; + } + + /// + /// Builds and returns the configured activity instance. + /// + /// The configured activity. + public abstract TActivity Build(); +} + +/// +/// Provides a fluent API for building CoreActivity instances. +/// +public class CoreActivityBuilder : CoreActivityBuilder +{ + /// + /// Initializes a new instance of the CoreActivityBuilder class. + /// + internal CoreActivityBuilder() : base(new CoreActivity()) + { + } + + /// + /// Initializes a new instance of the CoreActivityBuilder class with an existing activity. + /// + /// The activity to build upon. + internal CoreActivityBuilder(CoreActivity activity) : base(activity) + { + } + + /// + /// Sets the conversation. + /// + protected override void SetConversation(Conversation conversation) + { + _activity.Conversation = conversation; + } + + /// + /// Sets the From account. + /// + protected override void SetFrom(ConversationAccount from) + { + _activity.From = from; + } + + /// + /// Sets the Recipient account. + /// + protected override void SetRecipient(ConversationAccount recipient) + { + _activity.Recipient = recipient; + } + + /// + /// Builds and returns the configured CoreActivity instance. + /// + /// The configured CoreActivity. + public override CoreActivity Build() + { + return _activity; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityJsonContext.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityJsonContext.cs new file mode 100644 index 00000000..e8360273 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityJsonContext.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// JSON source generator context for Core activity types. +/// This enables AOT-compatible and reflection-free JSON serialization. +/// +[JsonSourceGenerationOptions( + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CoreActivity))] +[JsonSerializable(typeof(ChannelData))] +[JsonSerializable(typeof(Conversation))] +[JsonSerializable(typeof(ConversationAccount))] +[JsonSerializable(typeof(ExtendedPropertiesDictionary))] +[JsonSerializable(typeof(System.Text.Json.JsonElement))] +[JsonSerializable(typeof(System.Int32))] +[JsonSerializable(typeof(System.Boolean))] +[JsonSerializable(typeof(System.Int64))] +[JsonSerializable(typeof(System.Double))] +public partial class CoreActivityJsonContext : JsonSerializerContext +{ +} diff --git a/core/src/Microsoft.Teams.Bot.Core/TurnMiddleware.cs b/core/src/Microsoft.Teams.Bot.Core/TurnMiddleware.cs new file mode 100644 index 00000000..e0101a07 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/TurnMiddleware.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +internal sealed class TurnMiddleware : ITurnMiddleWare, IEnumerable +{ + private readonly IList _middlewares = []; + internal TurnMiddleware Use(ITurnMiddleWare middleware) + { + _middlewares.Add(middleware); + return this; + } + public async Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn next, CancellationToken cancellationToken = default) + { + await RunPipelineAsync(botApplication, activity, null!, 0, cancellationToken).ConfigureAwait(false); + await next(cancellationToken).ConfigureAwait(false); + } + + public Task RunPipelineAsync(BotApplication botApplication, CoreActivity activity, Func? callback, int nextMiddlewareIndex, CancellationToken cancellationToken) + { + if (nextMiddlewareIndex == _middlewares.Count) + { + return callback is not null ? callback!(activity, cancellationToken) ?? Task.CompletedTask : Task.CompletedTask; + } + ITurnMiddleWare nextMiddleware = _middlewares[nextMiddlewareIndex]; + return nextMiddleware.OnTurnAsync( + botApplication, + activity, + (ct) => RunPipelineAsync(botApplication, activity, callback, nextMiddlewareIndex + 1, ct), + cancellationToken); + } + + public IEnumerator GetEnumerator() + { + return _middlewares.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.Models.cs b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.Models.cs new file mode 100644 index 00000000..258aebc1 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.Models.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core; + + +/// +/// Result object for GetTokenStatus API call. +/// +public class GetTokenStatusResult +{ + /// + /// The connection name associated with the token. + /// + public string? ConnectionName { get; set; } + /// + /// Indicates whether a token is available. + /// + public bool? HasToken { get; set; } + /// + /// The display name of the service provider. + /// + public string? ServiceProviderDisplayName { get; set; } +} + +/// +/// Result object for GetToken API call. +/// +public class GetTokenResult +{ + /// + /// The connection name associated with the token. + /// + public string? ConnectionName { get; set; } + /// + /// The token string. + /// + public string? Token { get; set; } +} + +/// +/// SignIn resource object. +/// +public class GetSignInResourceResult +{ + /// + /// The link for signing in. + /// + public string? SignInLink { get; set; } + /// + /// The resource for token post. + /// + public TokenPostResource? TokenPostResource { get; set; } + + /// + /// The token exchange resources. + /// + public TokenExchangeResource? TokenExchangeResource { get; set; } +} +/// +/// Token post resource object. +/// +public class TokenPostResource +{ + /// + /// The URL to which the token should be posted. + /// + public Uri? SasUrl { get; set; } +} + +/// +/// Token exchange resource object. +/// +public class TokenExchangeResource +{ + /// + /// ID of the token exchange resource. + /// + public string? Id { get; set; } + /// + /// Provider ID of the token exchange resource. + /// + public string? ProviderId { get; set; } + /// + /// URI of the token exchange resource. + /// + public Uri? Uri { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs new file mode 100644 index 00000000..c3d5c85e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Microsoft.Teams.Bot.Core.Http; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Client for managing user tokens via HTTP requests. +/// +/// +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1848:Use the LoggerMessage delegates", Justification = "")] +public class UserTokenClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) +{ + internal const string UserTokenHttpClientName = "BotUserTokenClient"; + private readonly ILogger _logger = logger; + private readonly BotHttpClient _botHttpClient = new(httpClient, logger); + private readonly string _apiEndpoint = configuration["UserTokenApiEndpoint"] ?? "https://token.botframework.com"; + private readonly JsonSerializerOptions _defaultOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + internal AgenticIdentity? AgenticIdentity { get; set; } + + /// + /// Gets the token status for each connection for the given user. + /// + /// The user ID. + /// The channel ID. + /// The optional include parameter. + /// The cancellation token. + /// + public async Task GetTokenStatusAsync(string userId, string channelId, string? include = null, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() + { + { "userid", userId }, + { "channelId", channelId } + }; + + if (!string.IsNullOrEmpty(include)) + { + queryParams.Add("include", include); + } + + _logger.LogInformation("Calling API endpoint: {Endpoint}", "api/usertoken/GetTokenStatus"); + IList? result = await _botHttpClient.SendAsync>( + HttpMethod.Get, + _apiEndpoint, + "api/usertoken/GetTokenStatus", + queryParams, + body: null, + CreateRequestOptions("getting token status"), + cancellationToken).ConfigureAwait(false); + + if (result == null || result.Count == 0) + { + return [new GetTokenStatusResult { HasToken = false }]; + } + return [.. result]; + + } + + /// + /// Gets the user token for a particular connection. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The optional code. + /// The cancellation token. + /// + public async Task GetTokenAsync(string userId, string connectionName, string channelId, string? code = null, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() + { + { "userid", userId }, + { "connectionName", connectionName }, + { "channelId", channelId } + }; + + if (!string.IsNullOrEmpty(code)) + { + queryParams.Add("code", code); + } + + _logger.LogInformation("Calling API endpoint: {Endpoint}", "api/usertoken/GetToken"); + return await _botHttpClient.SendAsync( + HttpMethod.Get, + _apiEndpoint, + "api/usertoken/GetToken", + queryParams, + body: null, + CreateRequestOptions("getting token", returnNullOnNotFound: true), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Get the token or raw signin link to be sent to the user for signin for a connection. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The optional final redirect URL. + /// The cancellation token. + /// + public async Task GetSignInResource(string userId, string connectionName, string channelId, string? finalRedirect = null, CancellationToken cancellationToken = default) + { + var tokenExchangeState = new + { + ConnectionName = connectionName, + Conversation = new + { + User = new ConversationAccount { Id = userId }, + } + }; + string tokenExchangeStateJson = JsonSerializer.Serialize(tokenExchangeState, _defaultOptions); + string state = Convert.ToBase64String(Encoding.UTF8.GetBytes(tokenExchangeStateJson)); + + Dictionary queryParams = new() + { + { "state", state } + }; + + if (!string.IsNullOrEmpty(finalRedirect)) + { + queryParams.Add("finalRedirect", finalRedirect); + } + + _logger.LogInformation("Calling API endpoint: {Endpoint}", "api/botsignin/GetSignInResource"); + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + _apiEndpoint, + "api/botsignin/GetSignInResource", + queryParams, + body: null, + CreateRequestOptions("getting sign-in resource"), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Exchanges a token for another token. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The token to exchange. + /// The cancellation token. + public async Task ExchangeTokenAsync(string userId, string connectionName, string channelId, string? exchangeToken, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() + { + { "userid", userId }, + { "connectionName", connectionName }, + { "channelId", channelId } + }; + + var tokenExchangeRequest = new + { + token = exchangeToken + }; + + _logger.LogInformation("Calling API endpoint: {Endpoint}", "api/usertoken/exchange"); + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + _apiEndpoint, + "api/usertoken/exchange", + queryParams, + JsonSerializer.Serialize(tokenExchangeRequest), + CreateRequestOptions("exchanging token"), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Signs the user out of a connection. + /// The user ID. + /// The connection name. + /// The channel ID. + /// The cancellation token. + /// + public async Task SignOutUserAsync(string userId, string? connectionName = null, string? channelId = null, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() + { + { "userid", userId } + }; + + if (!string.IsNullOrEmpty(connectionName)) + { + queryParams.Add("connectionName", connectionName); + } + + if (!string.IsNullOrEmpty(channelId)) + { + queryParams.Add("channelId", channelId); + } + + _logger.LogInformation("Calling API endpoint: {Endpoint}", "api/usertoken/SignOut"); + await _botHttpClient.SendAsync( + HttpMethod.Delete, + _apiEndpoint, + "api/usertoken/SignOut", + queryParams, + body: null, + CreateRequestOptions("signing out user"), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets AAD tokens for a user. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The resource URLs. + /// The cancellation token. + /// + public async Task> GetAadTokensAsync(string userId, string connectionName, string channelId, string[]? resourceUrls = null, CancellationToken cancellationToken = default) + { + var body = new + { + channelId, + connectionName, + userId, + resourceUrls = resourceUrls ?? [] + }; + + _logger.LogInformation("Calling API endpoint with POST: {Endpoint}", "api/usertoken/GetAadTokens"); + return (await _botHttpClient.SendAsync>( + HttpMethod.Post, + _apiEndpoint, + "api/usertoken/GetAadTokens", + queryParams: null, + JsonSerializer.Serialize(body), + CreateRequestOptions("getting AAD tokens"), + cancellationToken).ConfigureAwait(false))!; + } + + private BotRequestOptions CreateRequestOptions(string operationDescription, bool returnNullOnNotFound = false) => + new() + { + AgenticIdentity = AgenticIdentity, + OperationDescription = operationDescription, + ReturnNullOnNotFound = returnNullOnNotFound + }; +} diff --git a/core/test/ABSTokenServiceClient/ABSTokenServiceClient.csproj b/core/test/ABSTokenServiceClient/ABSTokenServiceClient.csproj new file mode 100644 index 00000000..f0e2c1c8 --- /dev/null +++ b/core/test/ABSTokenServiceClient/ABSTokenServiceClient.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/core/test/ABSTokenServiceClient/Program.cs b/core/test/ABSTokenServiceClient/Program.cs new file mode 100644 index 00000000..2cac7461 --- /dev/null +++ b/core/test/ABSTokenServiceClient/Program.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using ABSTokenServiceClient; +using Microsoft.AspNetCore.Builder; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Extensions.DependencyInjection; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddUserTokenClient(); +builder.Services.AddHostedService(); +WebApplication host = builder.Build(); +host.Run(); diff --git a/core/test/ABSTokenServiceClient/UserTokenCLIService.cs b/core/test/ABSTokenServiceClient/UserTokenCLIService.cs new file mode 100644 index 00000000..2ee9e2c1 --- /dev/null +++ b/core/test/ABSTokenServiceClient/UserTokenCLIService.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using Microsoft.Teams.Bot.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ABSTokenServiceClient +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1848:Use the LoggerMessage delegates", Justification = "")] + internal class UserTokenCLIService(UserTokenClient userTokenClient, ILogger logger) : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) + { + return ExecuteAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + protected async Task ExecuteAsync(CancellationToken cancellationToken) + { + const string userId = "your-user-id"; + const string connectionName = "graph"; + const string channelId = "msteams"; + + logger.LogInformation("Application started"); + + try + { + logger.LogInformation("=== Testing GetTokenStatus ==="); + GetTokenStatusResult[] tokenStatus = await userTokenClient.GetTokenStatusAsync(userId, channelId, null, cancellationToken); + logger.LogInformation("GetTokenStatus result: {Result}", JsonSerializer.Serialize(tokenStatus, new JsonSerializerOptions { WriteIndented = true })); + + if (tokenStatus[0].HasToken == true) + { + GetTokenResult? tokenResponse = await userTokenClient.GetTokenAsync(userId, connectionName, channelId, null, cancellationToken); + logger.LogInformation("GetToken result: {Result}", JsonSerializer.Serialize(tokenResponse, new JsonSerializerOptions { WriteIndented = true })); + } + else + { + GetSignInResourceResult req = await userTokenClient.GetSignInResource(userId, connectionName, channelId, null, cancellationToken); + logger.LogInformation("GetSignInResource result: {Result}", JsonSerializer.Serialize(req, new JsonSerializerOptions { WriteIndented = true })); + + Console.WriteLine("Code?"); + string code = Console.ReadLine()!; + + GetTokenResult? tokenResponse2 = await userTokenClient.GetTokenAsync(userId, connectionName, channelId, code, cancellationToken); + logger.LogInformation("GetToken With Code result: {Result}", JsonSerializer.Serialize(tokenResponse2, new JsonSerializerOptions { WriteIndented = true })); + } + + Console.WriteLine("Want to signout? y/n"); + string yn = Console.ReadLine()!; + if ("y".Equals(yn, StringComparison.OrdinalIgnoreCase)) + { + try + { + await userTokenClient.SignOutUserAsync(userId, connectionName, channelId, cancellationToken); + logger.LogInformation("SignOutUser completed successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during SignOutUser"); + } + } + } + catch (Exception ex) + { + + logger.LogError(ex, "Error during API testing"); + } + + logger.LogInformation("Application completed successfully"); + } + } +} diff --git a/core/test/ABSTokenServiceClient/appsettings.json b/core/test/ABSTokenServiceClient/appsettings.json new file mode 100644 index 00000000..3c9252dc --- /dev/null +++ b/core/test/ABSTokenServiceClient/appsettings.json @@ -0,0 +1,28 @@ + +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Program": "Information", + "ABSTokenServiceClient.UserTokenCLIService": "Information" + } + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "HH:mm:ss:ms " + } + }, + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "your-tenant-id-here", + "ClientId": "your-client-id-here", + "ClientCredentials": [ + { + "SourceType": "ClientSecret", + "ClientSecret": "your-client-secret-here" + } + ] + } +} diff --git a/core/test/IntegrationTests.slnx b/core/test/IntegrationTests.slnx new file mode 100644 index 00000000..d3811a8d --- /dev/null +++ b/core/test/IntegrationTests.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/ConversationUpdateActivityTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/ConversationUpdateActivityTests.cs new file mode 100644 index 00000000..293e4198 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/ConversationUpdateActivityTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class ConversationUpdateActivityTests +{ + [Fact] + public void AsConversationUpdate_MembersAdded() + { + string json = """ + { + "type": "conversationUpdate", + "conversation": { + "id": "19" + }, + "membersAdded": [ + { + "id": "user1", + "name": "User One" + }, + { + "id": "bot1", + "name": "Bot One" + } + ] + } + """; + TeamsActivity act = TeamsActivity.FromJsonString(json); + Assert.NotNull(act); + Assert.Equal("conversationUpdate", act.Type); + + ConversationUpdateArgs? cua = new(act); + + Assert.NotNull(cua); + Assert.NotNull(cua.MembersAdded); + Assert.Equal(2, cua.MembersAdded!.Count); + Assert.Equal("user1", cua.MembersAdded[0].Id); + Assert.Equal("User One", cua.MembersAdded[0].Name); + Assert.Equal("bot1", cua.MembersAdded[1].Id); + Assert.Equal("Bot One", cua.MembersAdded[1].Name); + } + + [Fact] + public void AsConversationUpdate_MembersRemoved() + { + string json = """ + { + "type": "conversationUpdate", + "conversation": { + "id": "19" + }, + "membersRemoved": [ + { + "id": "user2", + "name": "User Two" + } + ] + } + """; + TeamsActivity act = TeamsActivity.FromJsonString(json); + Assert.NotNull(act); + Assert.Equal("conversationUpdate", act.Type); + + ConversationUpdateArgs? cua = new(act); + + Assert.NotNull(cua); + Assert.NotNull(cua.MembersRemoved); + Assert.Single(cua.MembersRemoved!); + Assert.Equal("user2", cua.MembersRemoved[0].Id); + Assert.Equal("User Two", cua.MembersRemoved[0].Name); + } + + [Fact] + public void AsConversationUpdate_BothMembersAddedAndRemoved() + { + string json = """ + { + "type": "conversationUpdate", + "conversation": { + "id": "19" + }, + "membersAdded": [ + { + "id": "newuser", + "name": "New User" + } + ], + "membersRemoved": [ + { + "id": "olduser", + "name": "Old User" + } + ] + } + """; + TeamsActivity act = TeamsActivity.FromJsonString(json); + Assert.NotNull(act); + Assert.Equal("conversationUpdate", act.Type); + + ConversationUpdateArgs? cua = new(act); + + Assert.NotNull(cua); + Assert.NotNull(cua.MembersAdded); + Assert.NotNull(cua.MembersRemoved); + Assert.Single(cua.MembersAdded!); + Assert.Single(cua.MembersRemoved!); + Assert.Equal("newuser", cua.MembersAdded[0].Id); + Assert.Equal("olduser", cua.MembersRemoved[0].Id); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/MessageReactionActivityTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/MessageReactionActivityTests.cs new file mode 100644 index 00000000..56f66f1d --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/MessageReactionActivityTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class MessageReactionActivityTests +{ + [Fact] + public void AsMessageReaction() + { + string json = """ + { + "type": "messageReaction", + "conversation": { + "id": "19" + }, + "reactionsAdded": [ + { + "type": "like" + }, + { + "type": "heart" + } + ] + } + """; + TeamsActivity act = TeamsActivity.FromJsonString(json); + Assert.NotNull(act); + Assert.Equal("messageReaction", act.Type); + + // MessageReactionActivity? mra = MessageReactionActivity.FromActivity(act); + MessageReactionArgs? mra = new(act); + + Assert.NotNull(mra); + Assert.NotNull(mra!.ReactionsAdded); + Assert.Equal(2, mra!.ReactionsAdded!.Count); + Assert.Equal("like", mra!.ReactionsAdded[0].Type); + Assert.Equal("heart", mra!.ReactionsAdded[1].Type); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/Microsoft.Teams.Bot.Apps.UnitTests.csproj b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/Microsoft.Teams.Bot.Apps.UnitTests.csproj new file mode 100644 index 00000000..3c769976 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/Microsoft.Teams.Bot.Apps.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + net8.0;net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityBuilderTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityBuilderTests.cs new file mode 100644 index 00000000..1964ef5c --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityBuilderTests.cs @@ -0,0 +1,849 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Apps.Schema.Entities; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class TeamsActivityBuilderTests +{ + private readonly TeamsActivityBuilder builder; + public TeamsActivityBuilderTests() + { + builder = TeamsActivity.CreateBuilder(); + } + + [Fact] + public void Constructor_DefaultConstructor_CreatesNewActivity() + { + TeamsActivity activity = builder.Build(); + + Assert.NotNull(activity); + Assert.NotNull(activity.From); + Assert.NotNull(activity.Recipient); + Assert.NotNull(activity.Conversation); + } + + [Fact] + public void Constructor_WithExistingActivity_UsesProvidedActivity() + { + TeamsActivity existingActivity = new() + { + Id = "test-id" + }; + existingActivity.Properties["text"] = "existing text"; + + TeamsActivityBuilder taBuilder = TeamsActivity.CreateBuilder(existingActivity); + TeamsActivity activity = taBuilder.Build(); + + Assert.Equal("test-id", activity.Id); + Assert.Equal("existing text", activity.Properties["text"]); + } + + [Fact] + public void Constructor_WithNullActivity_ThrowsArgumentNullException() + { + Assert.Throws(() => TeamsActivity.CreateBuilder(null!)); + } + + [Fact] + public void WithId_SetsActivityId() + { + var activity = builder + .WithId("test-activity-id") + .Build(); + + Assert.Equal("test-activity-id", activity.Id); + } + + [Fact] + public void WithServiceUrl_SetsServiceUrl() + { + Uri serviceUrl = new("https://smba.trafficmanager.net/teams/"); + + var activity = builder + .WithServiceUrl(serviceUrl) + .Build(); + + Assert.Equal(serviceUrl, activity.ServiceUrl); + } + + [Fact] + public void WithChannelId_SetsChannelId() + { + var activity = builder + .WithChannelId("msteams") + .Build(); + + Assert.Equal("msteams", activity.ChannelId); + } + + [Fact] + public void WithType_SetsActivityType() + { + var activity = builder + .WithType(ActivityType.Message) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + } + + [Fact] + public void WithText_SetsTextContent() + { + var activity = builder + .WithText("Hello, World!") + .Build(); + + Assert.Equal("Hello, World!", activity.Properties["text"]); + } + + [Fact] + public void WithFrom_SetsSenderAccount() + { + TeamsConversationAccount fromAccount = new(new ConversationAccount + { + Id = "sender-id", + Name = "Sender Name" + }); + + var activity = builder + .WithFrom(fromAccount) + .Build(); + + Assert.Equal("sender-id", activity.From.Id); + Assert.Equal("Sender Name", activity.From.Name); + } + + [Fact] + public void WithRecipient_SetsRecipientAccount() + { + TeamsConversationAccount recipientAccount = new(new ConversationAccount + { + Id = "recipient-id", + Name = "Recipient Name" + }); + + var activity = builder + .WithRecipient(recipientAccount) + .Build(); + + Assert.Equal("recipient-id", activity.Recipient.Id); + Assert.Equal("Recipient Name", activity.Recipient.Name); + } + + [Fact] + public void WithConversation_SetsConversationInfo() + { + TeamsConversation conversation = new(new Conversation + { + Id = "conversation-id" + }) + { + TenantId = "tenant-123", + ConversationType = "channel" + }; + + var activity = builder + .WithConversation(conversation) + .Build(); + + Assert.Equal("conversation-id", activity.Conversation.Id); + Assert.Equal("tenant-123", activity.Conversation.TenantId); + Assert.Equal("channel", activity.Conversation.ConversationType); + } + + [Fact] + public void WithChannelData_SetsChannelData() + { + TeamsChannelData channelData = new() + { + TeamsChannelId = "19:channel-id@thread.tacv2", + TeamsTeamId = "19:team-id@thread.tacv2" + }; + + var activity = builder + .WithChannelData(channelData) + .Build(); + + Assert.NotNull(activity.ChannelData); + Assert.Equal("19:channel-id@thread.tacv2", activity.ChannelData.TeamsChannelId); + Assert.Equal("19:team-id@thread.tacv2", activity.ChannelData.TeamsTeamId); + } + + [Fact] + public void WithEntities_SetsEntitiesCollection() + { + EntityList entities = + [ + new ClientInfoEntity + { + Locale = "en-US", + Platform = "Web" + } + ]; + + var activity = builder + .WithEntities(entities) + .Build(); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + } + + [Fact] + public void WithAttachments_SetsAttachmentsCollection() + { + List attachments = + [ + new() { + ContentType = "application/json", + Name = "test-attachment" + } + ]; + + var activity = builder + .WithAttachments(attachments) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("application/json", activity.Attachments[0].ContentType); + Assert.Equal("test-attachment", activity.Attachments[0].Name); + } + + [Fact] + public void WithAttachment_SetsSingleAttachment() + { + TeamsAttachment attachment = new() + { + ContentType = "application/json", + Name = "single" + }; + + var activity = builder + .WithAttachment(attachment) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("single", activity.Attachments[0].Name); + } + + [Fact] + public void AddEntity_AddsEntityToCollection() + { + ClientInfoEntity entity = new() + { + Locale = "en-US", + Country = "US" + }; + + var activity = builder + .AddEntity(entity) + .Build(); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + } + + [Fact] + public void AddEntity_MultipleEntities_AddsAllToCollection() + { + var activity = builder + .AddEntity(new ClientInfoEntity { Locale = "en-US" }) + .AddEntity(new ProductInfoEntity { Id = "product-123" }) + .Build(); + + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities.Count); + } + + [Fact] + public void AddAttachment_AddsAttachmentToCollection() + { + TeamsAttachment attachment = new() + { + ContentType = "text/html", + Name = "test.html" + }; + + var activity = builder + .AddAttachment(attachment) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("text/html", activity.Attachments[0].ContentType); + } + + [Fact] + public void AddAttachment_MultipleAttachments_AddsAllToCollection() + { + var activity = builder + .AddAttachment(new TeamsAttachment { ContentType = "text/html" }) + .AddAttachment(new TeamsAttachment { ContentType = "application/json" }) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Equal(2, activity.Attachments.Count); + } + + [Fact] + public void AddAdaptiveCardAttachment_AddsAdaptiveCard() + { + var adaptiveCard = new { type = "AdaptiveCard", version = "1.2" }; + + var activity = builder + .AddAdaptiveCardAttachment(adaptiveCard) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("application/vnd.microsoft.card.adaptive", activity.Attachments[0].ContentType); + Assert.Same(adaptiveCard, activity.Attachments[0].Content); + } + + [Fact] + public void WithAdaptiveCardAttachment_ConfigureActionAppliesChanges() + { + var adaptiveCard = new { type = "AdaptiveCard" }; + + var activity = builder + .WithAdaptiveCardAttachment(adaptiveCard, b => b.WithName("feedback")) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("feedback", activity.Attachments[0].Name); + } + + [Fact] + public void AddAdaptiveCardAttachment_WithNullPayload_Throws() + { + Assert.Throws(() => builder.AddAdaptiveCardAttachment(null!)); + } + + [Fact] + public void AddMention_WithNullAccount_ThrowsArgumentNullException() + { + Assert.Throws(() => builder.AddMention(null!)); + } + + [Fact] + public void AddMention_WithAccountAndDefaultText_AddsMentionAndUpdatesText() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "John Doe" + }; + + var activity = builder + .WithText("said hello") + .AddMention(account) + .Build(); + + Assert.Equal("John Doe said hello", activity.Properties["text"]); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + MentionEntity? mention = activity.Entities[0] as MentionEntity; + Assert.NotNull(mention); + Assert.Equal("user-123", mention.Mentioned?.Id); + Assert.Equal("John Doe", mention.Mentioned?.Name); + Assert.Equal("John Doe", mention.Text); + } + + [Fact] + public void AddMention_WithCustomText_UsesCustomText() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "John Doe" + }; + + var activity = builder + .WithText("replied") + .AddMention(account, "CustomName") + .Build(); + + Assert.Equal("CustomName replied", activity.Properties["text"]); + + MentionEntity? mention = activity.Entities![0] as MentionEntity; + Assert.NotNull(mention); + Assert.Equal("CustomName", mention.Text); + } + + [Fact] + public void AddMention_WithAddTextFalse_DoesNotUpdateText() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "John Doe" + }; + + TeamsActivity activity = builder + .WithText("original text") + .AddMention(account, addText: false) + .Build(); + + Assert.Equal("original text", activity.Properties["text"]); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + } + + [Fact] + public void AddMention_MultipleMentions_AddsAllMentions() + { + ConversationAccount account1 = new() { Id = "user-1", Name = "User One" }; + ConversationAccount account2 = new() { Id = "user-2", Name = "User Two" }; + + TeamsActivity activity = builder + .WithText("message") + .AddMention(account1) + .AddMention(account2) + .Build(); + + Assert.Equal("User Two User One message", activity.Properties["text"]); + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities.Count); + } + + [Fact] + public void FluentAPI_CompleteActivity_BuildsCorrectly() + { + TeamsActivity activity = builder + .WithType(ActivityType.Message) + .WithId("activity-123") + .WithChannelId("msteams") + .WithText("Test message") + .WithServiceUrl(new Uri("https://smba.trafficmanager.net/teams/")) + .WithFrom(new TeamsConversationAccount(new ConversationAccount + { + Id = "sender-id", + Name = "Sender" + })) + .WithRecipient(new TeamsConversationAccount(new ConversationAccount + { + Id = "recipient-id", + Name = "Recipient" + })) + .WithConversation(new TeamsConversation(new Conversation + { + Id = "conv-id" + })) + .AddEntity(new ClientInfoEntity { Locale = "en-US" }) + .AddAttachment(new TeamsAttachment { ContentType = "text/html" }) + .AddMention(new ConversationAccount { Id = "user-1", Name = "User" }) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.Equal("activity-123", activity.Id); + Assert.Equal("msteams", activity.ChannelId); + Assert.Equal("User Test message", activity.Properties["text"]); + Assert.Equal("sender-id", activity.From.Id); + Assert.Equal("recipient-id", activity.Recipient.Id); + Assert.Equal("conv-id", activity.Conversation.Id); + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities.Count); // ClientInfo + Mention + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + } + + [Fact] + public void FluentAPI_MethodChaining_ReturnsBuilderInstance() + { + + TeamsActivityBuilder result1 = builder.WithId("id"); + TeamsActivityBuilder result2 = builder.WithText("text"); + TeamsActivityBuilder result3 = builder.WithType(ActivityType.Message); + + Assert.Same(builder, result1); + Assert.Same(builder, result2); + Assert.Same(builder, result3); + } + + [Fact] + public void Build_CalledMultipleTimes_ReturnsSameInstance() + { + builder + .WithId("test-id"); + + TeamsActivity activity1 = builder.Build(); + TeamsActivity activity2 = builder.Build(); + + Assert.Same(activity1, activity2); + } + + [Fact] + public void Builder_ModifyingExistingActivity_PreservesOriginalData() + { + TeamsActivity original = new() + { + Id = "original-id", + Type = ActivityType.Message + }; + original.Properties["text"] = "original text"; + + TeamsActivity modified = TeamsActivity.CreateBuilder(original) + .WithText("modified text") + .Build(); + + Assert.Equal("original-id", modified.Id); + Assert.Equal("modified text", modified.Properties["text"]); + Assert.Equal(ActivityType.Message, modified.Type); + } + + [Fact] + public void AddMention_UpdatesBaseEntityCollection() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "Test User" + }; + + TeamsActivity activity = builder + .AddMention(account) + .Build(); + + CoreActivity baseActivity = activity; + Assert.NotNull(baseActivity.Entities); + Assert.NotEmpty(baseActivity.Entities); + } + + [Fact] + public void WithChannelData_NullValue_SetsToNull() + { + TeamsActivity activity = builder + .WithChannelData(null!) + .Build(); + + Assert.Null(activity.ChannelData); + } + + [Fact] + public void AddEntity_NullEntitiesCollection_InitializesCollection() + { + TeamsActivity activity = builder.Build(); + + Assert.NotNull(activity.Entities); + + ClientInfoEntity entity = new() { Locale = "en-US" }; + builder.AddEntity(entity); + + TeamsActivity result = builder.Build(); + Assert.NotNull(result.Entities); + Assert.Single(result.Entities); + } + + [Fact] + public void AddAttachment_NullAttachmentsCollection_InitializesCollection() + { + TeamsActivity activity = builder.Build(); + + Assert.NotNull(activity.Attachments); + + TeamsAttachment attachment = new() { ContentType = "text/html" }; + builder.AddAttachment(attachment); + + TeamsActivity result = builder.Build(); + Assert.NotNull(result.Attachments); + Assert.Single(result.Attachments); + } + + [Fact] + public void Builder_EmptyText_AddMention_PrependsMention() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "User" + }; + + TeamsActivity activity = builder + .AddMention(account) + .Build(); + + Assert.Equal("User ", activity.Properties["text"]); + } + + [Fact] + public void WithConversationReference_WithNullActivity_ThrowsArgumentNullException() + { + Assert.Throws(() => builder.WithConversationReference(null!)); + } + + [Fact] + public void WithConversationReference_WithNullChannelId_ThrowsArgumentNullException() + { + + TeamsActivity sourceActivity = new() + { + ChannelId = null, + ServiceUrl = new Uri("https://test.com"), + Conversation = new TeamsConversation(new Conversation()), + From = new TeamsConversationAccount(new ConversationAccount()), + Recipient = new TeamsConversationAccount(new ConversationAccount()) + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullServiceUrl_ThrowsArgumentNullException() + { + TeamsActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = null, + Conversation = new TeamsConversation(new Conversation()), + From = new TeamsConversationAccount(new ConversationAccount()), + Recipient = new TeamsConversationAccount(new ConversationAccount()) + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithEmptyConversationId_DoesNotThrow() + { + TeamsActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new TeamsConversation(new Conversation()), + From = new TeamsConversationAccount(new ConversationAccount { Id = "user-1" }), + Recipient = new TeamsConversationAccount(new ConversationAccount { Id = "bot-1" }) + }; + + TeamsActivity result = builder.WithConversationReference(sourceActivity).Build(); + + Assert.NotNull(result.Conversation); + } + + [Fact] + public void WithConversationReference_WithEmptyFromId_DoesNotThrow() + { + TeamsActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new TeamsConversation(new Conversation { Id = "conv-1" }), + From = new TeamsConversationAccount(new ConversationAccount()), + Recipient = new TeamsConversationAccount(new ConversationAccount { Id = "bot-1" }) + }; + + TeamsActivity result = builder.WithConversationReference(sourceActivity).Build(); + + Assert.NotNull(result.From); + } + + [Fact] + public void WithConversationReference_WithEmptyRecipientId_DoesNotThrow() + { + TeamsActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new TeamsConversation(new Conversation { Id = "conv-1" }), + From = new TeamsConversationAccount(new ConversationAccount { Id = "user-1" }), + Recipient = new TeamsConversationAccount(new ConversationAccount()) + }; + + TeamsActivity result = builder.WithConversationReference(sourceActivity).Build(); + + Assert.NotNull(result.Recipient); + } + + [Fact] + public void WithFrom_WithBaseConversationAccount_ConvertsToTeamsConversationAccount() + { + ConversationAccount baseAccount = new() + { + Id = "user-123", + Name = "User Name" + }; + + TeamsActivity activity = builder + .WithFrom(baseAccount) + .Build(); + + Assert.IsType(activity.From); + Assert.Equal("user-123", activity.From.Id); + Assert.Equal("User Name", activity.From.Name); + } + + [Fact] + public void WithRecipient_WithBaseConversationAccount_ConvertsToTeamsConversationAccount() + { + ConversationAccount baseAccount = new() + { + Id = "bot-123", + Name = "Bot Name" + }; + + TeamsActivity activity = builder + .WithRecipient(baseAccount) + .Build(); + + Assert.IsType(activity.Recipient); + Assert.Equal("bot-123", activity.Recipient.Id); + Assert.Equal("Bot Name", activity.Recipient.Name); + } + + [Fact] + public void WithConversation_WithBaseConversation_ConvertsToTeamsConversation() + { + Conversation baseConversation = new() + { + Id = "conv-123" + }; + + TeamsActivity activity = builder + .WithConversation(baseConversation) + .Build(); + + Assert.IsType(activity.Conversation); + Assert.Equal("conv-123", activity.Conversation.Id); + } + + [Fact] + public void WithEntities_WithNullValue_SetsToNull() + { + TeamsActivity activity = builder + .WithEntities([new ClientInfoEntity()]) + .WithEntities(null!) + .Build(); + + Assert.Null(activity.Entities); + } + + [Fact] + public void WithAttachments_WithNullValue_SetsToNull() + { + TeamsActivity activity = builder + .WithAttachments([new()]) + .WithAttachments(null!) + .Build(); + + Assert.Null(activity.Attachments); + } + + [Fact] + public void AddMention_WithAccountWithNullName_UsesNullText() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = null + }; + + TeamsActivity activity = builder + .WithText("message") + .AddMention(account) + .Build(); + + Assert.Equal(" message", activity.Properties["text"]); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + } + + [Fact] + public void Build_MultipleCalls_ReturnsRebasedActivity() + { + builder + .AddEntity(new ClientInfoEntity { Locale = "en-US" }); + + TeamsActivity activity1 = builder.Build(); + CoreActivity baseActivity1 = activity1; + Assert.NotNull(baseActivity1.Entities); + + builder.AddEntity(new ProductInfoEntity { Id = "prod-1" }); + TeamsActivity activity2 = builder.Build(); + CoreActivity baseActivity2 = activity2; + + Assert.Same(activity1, activity2); + Assert.NotNull(baseActivity2.Entities); + Assert.Equal(2, activity2.Entities!.Count); + } + + [Fact] + public void IntegrationTest_CreateComplexActivity() + { + Uri serviceUrl = new("https://smba.trafficmanager.net/amer/test/"); + TeamsChannelData channelData = new() + { + TeamsChannelId = "19:channel@thread.tacv2", + TeamsTeamId = "19:team@thread.tacv2" + }; + + TeamsActivity activity = builder + .WithType(ActivityType.Message) + .WithId("msg-001") + .WithServiceUrl(serviceUrl) + .WithChannelId("msteams") + .WithText("Please review this document") + .WithFrom(new TeamsConversationAccount(new ConversationAccount + { + Id = "bot-id", + Name = "Bot" + })) + .WithRecipient(new TeamsConversationAccount(new ConversationAccount + { + Id = "user-id", + Name = "User" + })) + .WithConversation(new TeamsConversation(new Conversation + { + Id = "conv-001" + }) + { + TenantId = "tenant-001", + ConversationType = "channel" + }) + .WithChannelData(channelData) + .AddEntity(new ClientInfoEntity + { + Locale = "en-US", + Country = "US", + Platform = "Web" + }) + .AddAttachment(new TeamsAttachment + { + ContentType = "application/vnd.microsoft.card.adaptive", + Name = "adaptive-card.json" + }) + .AddMention(new ConversationAccount + { + Id = "manager-id", + Name = "Manager" + }, "Manager") + .Build(); + + // Verify all properties + Assert.Equal(ActivityType.Message, activity.Type); + Assert.Equal("msg-001", activity.Id); + Assert.Equal(serviceUrl, activity.ServiceUrl); + Assert.Equal("msteams", activity.ChannelId); + Assert.Equal("Manager Please review this document", activity.Properties["text"]); + Assert.Equal("bot-id", activity.From.Id); + Assert.Equal("user-id", activity.Recipient.Id); + Assert.Equal("conv-001", activity.Conversation.Id); + Assert.Equal("tenant-001", activity.Conversation.TenantId); + Assert.Equal("channel", activity.Conversation.ConversationType); + Assert.NotNull(activity.ChannelData); + Assert.Equal("19:channel@thread.tacv2", activity.ChannelData.TeamsChannelId); + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities.Count); // ClientInfo + Mention + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityTests.cs new file mode 100644 index 00000000..f1eae204 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityTests.cs @@ -0,0 +1,373 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Apps.Schema.Entities; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class TeamsActivityTests +{ + + [Fact] + public void DeserializeActivityWithTeamsChannelData() + { + TeamsActivity activityWithTeamsChannelData = TeamsActivity.FromJsonString(json); + TeamsChannelData tcd = activityWithTeamsChannelData.ChannelData!; + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2", tcd.TeamsChannelId); + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2", tcd.Channel!.Id); + // Assert.Equal("b15a9416-0ad3-4172-9210-7beb711d3f70", activity.From.AadObjectId); + } + + [Fact] + public void DeserializeTeamsActivityWithTeamsChannelData() + { + TeamsActivity activity = TeamsActivity.FromJsonString(json); + TeamsChannelData tcd = activity.ChannelData!; + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2", tcd.TeamsChannelId); + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2", tcd.Channel!.Id); + Assert.Equal("b15a9416-0ad3-4172-9210-7beb711d3f70", activity.From.AadObjectId); + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", activity.Conversation.Id); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("text/html", activity.Attachments[0].ContentType); + + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities.Count); + + } + + [Fact] + public void DownCastTeamsActivity_To_CoreActivity() + { + CoreActivity activity = CoreActivity.FromJsonString(json); + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", activity.Conversation!.Id); + TeamsActivity teamsActivity = TeamsActivity.FromActivity(activity); + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", teamsActivity.Conversation!.Id); + + static void AssertCid(CoreActivity a) + { + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", a.Conversation!.Id); + } + AssertCid(teamsActivity); + + } + + [Fact] + public void DownCastTeamsActivity_To_CoreActivity_FromJsonString() + { + + TeamsActivity teamsActivity = TeamsActivity.FromJsonString(json); + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", teamsActivity.Conversation!.Id); + + static void AssertCid(CoreActivity a) + { + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", a.Conversation!.Id); + } + AssertCid(teamsActivity); + + } + + + [Fact] + public void AddMentionEntity_To_TeamsActivity() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + activity + .AddMention(new ConversationAccount + { + Id = "user-id-01", + Name = "rido" + }, "ridotest"); + + + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.Equal("mention", activity.Entities[0].Type); + MentionEntity? mention = activity.Entities[0] as MentionEntity; + Assert.NotNull(mention); + Assert.Equal("user-id-01", mention.Mentioned?.Id); + Assert.Equal("rido", mention.Mentioned?.Name); + Assert.Equal("ridotest", mention.Text); + + string jsonResult = activity.ToJson(); + Assert.Contains("user-id-01", jsonResult); + } + + [Fact] + public void AddMentionEntity_Serialize_From_CoreActivity() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + activity.AddMention(new ConversationAccount + { + Id = "user-id-01", + Name = "rido" + }, "ridotest"); + + + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.Equal("mention", activity.Entities[0].Type); + MentionEntity? mention = activity.Entities[0] as MentionEntity; + Assert.NotNull(mention); + Assert.Equal("user-id-01", mention.Mentioned?.Id); + Assert.Equal("rido", mention.Mentioned?.Name); + Assert.Equal("ridotest", mention.Text); + + static void SerializeAndAssert(CoreActivity a) + { + string json = a.ToJson(); + Assert.Contains("user-id-01", json); + } + + SerializeAndAssert(activity); + } + + + [Fact] + public void TeamsActivityBuilder_FluentAPI() + { + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithText("Hello World") + .WithChannelId("msteams") + .AddMention(new ConversationAccount + { + Id = "user-123", + Name = "TestUser" + }) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.Equal("TestUser Hello World", activity.Properties["text"]); + Assert.Equal("msteams", activity.ChannelId); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + MentionEntity? mention = activity.Entities[0] as MentionEntity; + Assert.NotNull(mention); + Assert.Equal("user-123", mention.Mentioned?.Id); + Assert.Equal("TestUser", mention.Mentioned?.Name); + } + + [Fact] + public void Deserialize_With_Entities() + { + TeamsActivity activity = TeamsActivity.FromJsonString(json); + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities.Count); + + List mentions = activity.Entities.Where(e => e is MentionEntity).ToList(); + Assert.Single(mentions); + MentionEntity? m1 = mentions[0] as MentionEntity; + Assert.NotNull(m1); + Assert.NotNull(m1.Mentioned); + Assert.Equal("28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", m1.Mentioned.Id); + Assert.Equal("ridotest", m1.Mentioned.Name); + Assert.Equal("ridotest", m1.Text); + + List clientInfos = [.. activity.Entities.Where(e => e is ClientInfoEntity)]; + Assert.Single(clientInfos); + ClientInfoEntity? c1 = clientInfos[0] as ClientInfoEntity; + Assert.NotNull(c1); + Assert.Equal("en-US", c1.Locale); + Assert.Equal("US", c1.Country); + Assert.Equal("Web", c1.Platform); + Assert.Equal("America/Los_Angeles", c1.Timezone); + + } + + + [Fact] + public void Deserialize_With_Entities_Extensions() + { + TeamsActivity activity = TeamsActivity.FromJsonString(json); + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities.Count); + + var mentions = activity.GetMentions(); + Assert.Single(mentions); + MentionEntity? m1 = mentions.FirstOrDefault(); + Assert.NotNull(m1); + Assert.NotNull(m1.Mentioned); + Assert.Equal("28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", m1.Mentioned.Id); + Assert.Equal("ridotest", m1.Mentioned.Name); + Assert.Equal("ridotest", m1.Text); + + var clientInfo = activity.GetClientInfo(); + Assert.NotNull(clientInfo); + Assert.Equal("en-US", clientInfo.Locale); + Assert.Equal("US", clientInfo.Country); + Assert.Equal("Web", clientInfo.Platform); + Assert.Equal("America/Los_Angeles", clientInfo.Timezone); + } + + [Fact] + public void Serialize_TeamsActivity_WithEntities() + { + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithText("Hello World") + .WithChannelId("msteams") + .Build(); + + activity.AddClientInfo("Web", "US", "America/Los_Angeles", "en-US"); + + string jsonResult = activity.ToJson(); + Assert.Contains("clientInfo", jsonResult); + Assert.Contains("Web", jsonResult); + Assert.Contains("Hello World", jsonResult); + } + + [Fact] + public void Deserialize_TeamsActivity_WithAttachments() + { + TeamsActivity activity = TeamsActivity.FromJsonString(json); + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + TeamsAttachment attachment = activity.Attachments[0] as TeamsAttachment; + Assert.NotNull(attachment); + Assert.Equal("text/html", attachment.ContentType); + Assert.Equal("

ridotest reply to thread

", attachment.Content?.ToString()); + } + + [Fact] + public void Deserialize_TeamsActivity_Invoke_WithValue() + { + //TeamsActivity activity = CoreActivity.FromJsonString(jsonInvoke); + TeamsActivity activity = TeamsActivity.FromActivity(CoreActivity.FromJsonString(jsonInvoke)); + Assert.NotNull(activity.Value); + string feedback = activity.Value?["action"]?["data"]?["feedback"]?.ToString()!; + Assert.Equal("test invokes", feedback); + } + + private const string jsonInvoke = """ + { + "type": "invoke", + "channelId": "msteams", + "id": "f:17b96347-e8b4-f340-10bc-eb52fc1a6ad4", + "serviceUrl": "https://smba.trafficmanager.net/amer/56653e9d-2158-46ee-90d7-675c39642038/", + "channelData": { + "tenant": { + "id": "56653e9d-2158-46ee-90d7-675c39642038" + }, + "source": { + "name": "message" + }, + "legacy": { + "replyToId": "1:12SWreU4430kJA9eZCb1kXDuo6A8KdDEGB6d9TkjuDYM" + } + }, + "from": { + "id": "29:1uMVvhoAyfTqdMsyvHL0qlJTTfQF9MOUSI8_cQts2kdSWEZVDyJO2jz-CsNOhQcdYq1Bw4cHT0__O6XDj4AZ-Jw", + "name": "Rido", + "aadObjectId": "c5e99701-2a32-49c1-a660-4629ceeb8c61" + }, + "recipient": { + "id": "28:aabdbd62-bc97-4afb-83ee-575594577de5", + "name": "ridobotlocal" + }, + "conversation": { + "id": "a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT", + "conversationType": "personal", + "tenantId": "56653e9d-2158-46ee-90d7-675c39642038" + }, + "entities": [ + { + "locale": "en-US", + "country": "US", + "platform": "Web", + "timezone": "America/Los_Angeles", + "type": "clientInfo" + } + ], + "value": { + "action": { + "type": "Action.Execute", + "title": "Submit Feedback", + "data": { + "feedback": "test invokes" + } + }, + "trigger": "manual" + }, + "name": "adaptiveCard/action", + "timestamp": "2026-01-07T06:04:59.89Z", + "localTimestamp": "2026-01-06T22:04:59.89-08:00", + "replyToId": "1767765488332", + "locale": "en-US", + "localTimezone": "America/Los_Angeles" + } + """; + + private const string json = """ + { + "type": "message", + "channelId": "msteams", + "text": "\u003Cat\u003Eridotest\u003C/at\u003E reply to thread", + "id": "1759944781430", + "serviceUrl": "https://smba.trafficmanager.net/amer/50612dbb-0237-4969-b378-8d42590f9c00/", + "channelData": { + "teamsChannelId": "19:6848757105754c8981c67612732d9aa7@thread.tacv2", + "teamsTeamId": "19:66P469zibfbsGI-_a0aN_toLTZpyzS6u7CT3TsXdgPw1@thread.tacv2", + "channel": { + "id": "19:6848757105754c8981c67612732d9aa7@thread.tacv2" + }, + "team": { + "id": "19:66P469zibfbsGI-_a0aN_toLTZpyzS6u7CT3TsXdgPw1@thread.tacv2" + }, + "tenant": { + "id": "50612dbb-0237-4969-b378-8d42590f9c00" + } + }, + "from": { + "id": "29:17bUvCasIPKfQIXHvNzcPjD86fwm6GkWc1PvCGP2-NSkNb7AyGYpjQ7Xw-XgTwaHW5JxZ4KMNDxn1kcL8fwX1Nw", + "name": "rido", + "aadObjectId": "b15a9416-0ad3-4172-9210-7beb711d3f70" + }, + "recipient": { + "id": "28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", + "name": "ridotest" + }, + "conversation": { + "id": "19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", + "isGroup": true, + "conversationType": "channel", + "tenantId": "50612dbb-0237-4969-b378-8d42590f9c00" + }, + "entities": [ + { + "mentioned": { + "id": "28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", + "name": "ridotest" + }, + "text": "\u003Cat\u003Eridotest\u003C/at\u003E", + "type": "mention" + }, + { + "locale": "en-US", + "country": "US", + "platform": "Web", + "timezone": "America/Los_Angeles", + "type": "clientInfo" + } + ], + "textFormat": "plain", + "attachments": [ + { + "contentType": "text/html", + "content": "\u003Cp\u003E\u003Cspan itemtype=\u0022http://schema.skype.com/Mention\u0022 itemscope=\u0022\u0022 itemid=\u00220\u0022\u003Eridotest\u003C/span\u003E\u0026nbsp;reply to thread\u003C/p\u003E" + } + ], + "timestamp": "2025-10-08T17:33:01.4953744Z", + "localTimestamp": "2025-10-08T10:33:01.4953744-07:00", + "locale": "en-US", + "localTimezone": "America/Los_Angeles" + } + """; +} diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatActivityTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatActivityTests.cs new file mode 100644 index 00000000..1ebf562e --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatActivityTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AdaptiveCards; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Compat.UnitTests +{ + public class CompatActivityTests + { + [Fact] + public void FromCompatActivity() + { + CoreActivity coreActivity = CoreActivity.FromJsonString(compatActivityJson); + Assert.NotNull(coreActivity); + Assert.NotNull(coreActivity.Attachments); + Assert.Single(coreActivity.Attachments); + TeamsActivity teamsActivity = TeamsActivity.FromActivity(coreActivity); + Assert.NotNull(teamsActivity); + Assert.NotNull(teamsActivity.Attachments); + Assert.Single(teamsActivity.Attachments); + var attachment = teamsActivity.Attachments[0]; + Assert.Equal("application/vnd.microsoft.card.adaptive", attachment.ContentType); + var content = attachment.Content; + var card = AdaptiveCard.FromJson(System.Text.Json.JsonSerializer.Serialize(content)).Card; + Assert.Equal(2, card.Body.Count); + var firstTextBlock = card.Body[0] as AdaptiveTextBlock; + Assert.NotNull(firstTextBlock); + Assert.Equal("Mention a user by User Principle Name: Hello Rido UPN", firstTextBlock.Text); + + } + + + string compatActivityJson = """ + { + "type": "message", + "serviceUrl": "https://smba.trafficmanager.net/amer/9a9b49fd-1dc5-4217-88b3-ecf855e91b0e/", + "channelId": "msteams", + "from": { + "id": "28:fa45fe59-200c-493c-aa4c-80c17ad6f307", + "name": "ridodev-local" + }, + "conversation": { + "conversationType": "personal", + "id": "a:188cfPEO2ZNiFxoCSq-2QwCkQTBywkMID0Y2704RpFR2QjMx8217cpDunnnI-rx95Qn_1ce11juGEelMnscuyEQvHTh_wRRRKR_WxbV8ZS4-1qFwb0l8T0Zrd9uiTCtLX", + "tenantId": "9a9b49fd-1dc5-4217-88b3-ecf855e91b0e" + }, + "recipient": { + "id": "29:1zIP3NcdoJbnv2Rp-x-7ukmDhrgy6JqXcDgYB4mFxGCtBRvVT7V0Iwu0obPlWlBd14M2qEa4p5qqJde0HTYy4cw", + "name": "Rido", + "aadObjectId": "16de8f24-f65d-4f6b-a837-3a7e638ab6e1" + }, + "attachmentLayout": "list", + "locale": "en-US", + "inputHint": "acceptingInput", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "speak": "This card mentions a user by User Principle Name: Hello Rido", + "body": [ + { + "type": "TextBlock", + "text": "Mention a user by User Principle Name: Hello Rido UPN" + }, + { + "type": "TextBlock", + "text": "Mention a user by AAD Object Id: Hello Rido AAD" + } + ], + "msteams": { + "entities": [ + { + "type": "mention", + "text": "Rido UPN", + "mentioned": { + "id": "rido@tsdk1.onmicrosoft.com", + "name": "Rido" + } + }, + { + "type": "mention", + "text": "Rido AAD", + "mentioned": { + "id": "16de8f24-f65d-4f6b-a837-3a7e638ab6e1", + "name": "Rido" + } + } + ] + } + } + } + ], + "entities": [], + "replyToId": "f:d1c5de53-9e8b-b5c3-c24d-07c2823079cf" + } + """; + } +} diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/Microsoft.Teams.Bot.Compat.UnitTests.csproj b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/Microsoft.Teams.Bot.Compat.UnitTests.csproj new file mode 100644 index 00000000..0eb1f233 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/Microsoft.Teams.Bot.Compat.UnitTests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/CompatConversationClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/CompatConversationClientTests.cs new file mode 100644 index 00000000..d78d74f3 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/CompatConversationClientTests.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Compat; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Bot.Core.Tests +{ + public class CompatConversationClientTests + { + string serviceUrl = "https://smba.trafficmanager.net/amer/"; + + string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + [Fact(Skip = "not implemented")] + public async Task GetMemberAsync() + { + + var compatAdapter = InitializeCompatAdapter(); + ConversationReference conversationReference = new ConversationReference + + { + ChannelId = "msteams", + ServiceUrl = serviceUrl, + Conversation = new ConversationAccount + { + Id = conversationId + } + }; + + await compatAdapter.ContinueConversationAsync( + string.Empty, conversationReference, + async (turnContext, cancellationToken) => + { + TeamsChannelAccount member = await TeamsInfo.GetMemberAsync(turnContext, userId, cancellationToken: cancellationToken); + Assert.NotNull(member); + Assert.Equal(userId, member.Id); + + }, CancellationToken.None); + } + + [Fact] + public async Task GetPagedMembersAsync() + { + + var compatAdapter = InitializeCompatAdapter(); + ConversationReference conversationReference = new ConversationReference + + { + ChannelId = "msteams", + ServiceUrl = serviceUrl, + Conversation = new ConversationAccount + { + Id = conversationId + } + }; + + await compatAdapter.ContinueConversationAsync( + string.Empty, conversationReference, + async (turnContext, cancellationToken) => + { + var result = await TeamsInfo.GetPagedMembersAsync(turnContext, cancellationToken: cancellationToken); + Assert.NotNull(result); + Assert.True(result.Members.Count > 0); + var m0 = result.Members[0]; + Assert.Equal(userId, m0.Id); + + }, CancellationToken.None); + } + + [Fact(Skip = "not implemented")] + public async Task GetMeetingInfo() + { + string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); + var compatAdapter = InitializeCompatAdapter(); + ConversationReference conversationReference = new ConversationReference + + { + ChannelId = "msteams", + ServiceUrl = serviceUrl, + Conversation = new ConversationAccount + { + Id = conversationId + } + }; + + await compatAdapter.ContinueConversationAsync( + string.Empty, conversationReference, + async (turnContext, cancellationToken) => + { + var result = await TeamsInfo.GetMeetingInfoAsync(turnContext, meetingId, cancellationToken); + Assert.NotNull(result); + + }, CancellationToken.None); + } + + + CompatAdapter InitializeCompatAdapter() + { + IConfigurationBuilder builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables(); + + IConfiguration configuration = builder.Build(); + + ServiceCollection services = new(); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton(configuration); + services.AddCompatAdapter(); + services.AddLogging(configure => configure.AddConsole()); + + var serviceProvider = services.BuildServiceProvider(); + CompatAdapter compatAdapter = (CompatAdapter)serviceProvider.GetRequiredService(); + return compatAdapter; + } + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/ConversationClientTest.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/ConversationClientTest.cs new file mode 100644 index 00000000..85a583a6 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/ConversationClientTest.cs @@ -0,0 +1,717 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Bot.Core.Tests; + +public class ConversationClientTest +{ + private readonly ServiceProvider _serviceProvider; + private readonly ConversationClient _conversationClient; + private readonly Uri _serviceUrl; + + public ConversationClientTest() + { + IConfigurationBuilder builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables(); + + IConfiguration configuration = builder.Build(); + + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(configuration); + services.AddBotApplication(); + _serviceProvider = services.BuildServiceProvider(); + _conversationClient = _serviceProvider.GetRequiredService(); + _serviceUrl = new Uri(Environment.GetEnvironmentVariable("TEST_SERVICEURL") ?? "https://smba.trafficmanager.net/teams/"); + } + + [Fact] + public async Task SendActivityDefault() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message from Automated tests, running in SDK `{BotApplication.Version}` at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set") + } + }; + SendActivityResponse res = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(res); + Assert.NotNull(res.Id); + } + + + [Fact] + public async Task SendActivityToChannel() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message from Automated tests, running in SDK `{BotApplication.Version}` at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = Environment.GetEnvironmentVariable("TEST_CHANNELID") ?? throw new InvalidOperationException("TEST_CHANNELID environment variable not set") + } + }; + SendActivityResponse res = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(res); + Assert.NotNull(res.Id); + } + + [Fact] + public async Task SendActivityToPersonalChat_FailsWithBad_ConversationId() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message from Automated tests, running in SDK `{BotApplication.Version}` at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = "a:1" + } + }; + + await Assert.ThrowsAsync(() + => _conversationClient.SendActivityAsync(activity)); + } + + [Fact] + public async Task UpdateActivity() + { + // First send an activity to get an ID + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Original message from Automated tests at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set") + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + // Now update the activity + CoreActivity updatedActivity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Updated message from Automated tests at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + }; + + UpdateActivityResponse updateResponse = await _conversationClient.UpdateActivityAsync( + activity.Conversation.Id, + sendResponse.Id, + updatedActivity, + cancellationToken: CancellationToken.None); + + Assert.NotNull(updateResponse); + Assert.NotNull(updateResponse.Id); + } + + [Fact] + public async Task DeleteActivity() + { + // First send an activity to get an ID + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message to delete from Automated tests at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set") + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + // Add a delay for 5 seconds + await Task.Delay(TimeSpan.FromSeconds(5)); + + // Now delete the activity + await _conversationClient.DeleteActivityAsync( + activity.Conversation.Id, + sendResponse.Id, + _serviceUrl, + cancellationToken: CancellationToken.None); + + // If no exception was thrown, the delete was successful + } + + [Fact] + public async Task GetConversationMembers() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + IList members = await _conversationClient.GetConversationMembersAsync( + conversationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(members); + Assert.NotEmpty(members); + + // Log members + Console.WriteLine($"Found {members.Count} members in conversation {conversationId}:"); + foreach (ConversationAccount member in members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + Assert.NotNull(member); + Assert.NotNull(member.Id); + } + } + + [Fact] + public async Task GetConversationMember() + { + string 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 _conversationClient.GetConversationMemberAsync( + conversationId, + userId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(member); + + // Log member + Console.WriteLine($"Found member in conversation {conversationId}:"); + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + Assert.NotNull(member); + Assert.NotNull(member.Id); + } + + + [Fact] + public async Task GetConversationMembersInChannel() + { + string channelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") ?? throw new InvalidOperationException("TEST_CHANNELID environment variable not set"); + + IList members = await _conversationClient.GetConversationMembersAsync( + channelId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(members); + Assert.NotEmpty(members); + + // Log members + Console.WriteLine($"Found {members.Count} members in channel {channelId}:"); + foreach (ConversationAccount member in members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + Assert.NotNull(member); + Assert.NotNull(member.Id); + } + } + + [Fact] + public async Task GetActivityMembers() + { + // First send an activity to get an activity ID + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message for GetActivityMembers test at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set") + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + // Now get the members of this activity + IList members = await _conversationClient.GetActivityMembersAsync( + activity.Conversation.Id, + sendResponse.Id, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(members); + Assert.NotEmpty(members); + + // Log activity members + Console.WriteLine($"Found {members.Count} members for activity {sendResponse.Id}:"); + foreach (ConversationAccount member in members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + Assert.NotNull(member); + Assert.NotNull(member.Id); + } + } + + // TODO: This doesn't work + [Fact(Skip = "Method not allowed by API")] + public async Task GetConversations() + { + GetConversationsResponse response = await _conversationClient.GetConversationsAsync( + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Conversations); + Assert.NotEmpty(response.Conversations); + + // Log conversations + Console.WriteLine($"Found {response.Conversations.Count} conversations:"); + foreach (ConversationMembers conversation in response.Conversations) + { + Console.WriteLine($" - Conversation Id: {conversation.Id}"); + Assert.NotNull(conversation); + Assert.NotNull(conversation.Id); + + if (conversation.Members != null && conversation.Members.Any()) + { + Console.WriteLine($" Members ({conversation.Members.Count}):"); + foreach (ConversationAccount member in conversation.Members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + } + } + } + } + + [Fact] + public async Task CreateConversation_WithMembers() + { + // Create a 1-on-1 conversation with a member + ConversationParameters parameters = new() + { + IsGroup = false, + Members = + [ + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), + } + ], + // TODO: This is required for some reason. Should it be required in the api? + TenantId = Environment.GetEnvironmentVariable("AzureAd__TenantId") ?? throw new InvalidOperationException("AzureAd__TenantId environment variable not set") + }; + + CreateConversationResponse response = await _conversationClient.CreateConversationAsync( + parameters, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + + Console.WriteLine($"Created conversation: {response.Id}"); + Console.WriteLine($" ActivityId: {response.ActivityId}"); + Console.WriteLine($" ServiceUrl: {response.ServiceUrl}"); + + // Send a message to the newly created conversation + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Test message to new conversation at {DateTime.UtcNow:s}" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = response.Id + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + Console.WriteLine($" Sent message with activity ID: {sendResponse.Id}"); + } + + // TODO: This doesn't work + [Fact(Skip = "Incorrect conversation creation parameters")] + public async Task CreateConversation_WithGroup() + { + // Create a group conversation + ConversationParameters parameters = new() + { + IsGroup = true, + Members = + [ + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), + }, + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID_2") ?? throw new InvalidOperationException("TEST_USER_ID_2 environment variable not set"), + } + ], + TenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set") + }; + + CreateConversationResponse response = await _conversationClient.CreateConversationAsync( + parameters, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + + Console.WriteLine($"Created group conversation: {response.Id}"); + + // Send a message to the newly created group conversation + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Test message to new group conversation at {DateTime.UtcNow:s}" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = response.Id + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + Console.WriteLine($" Sent message with activity ID: {sendResponse.Id}"); + } + + // TODO: This doesn't work + [Fact(Skip = "Incorrect conversation creation parameters")] + public async Task CreateConversation_WithTopicName() + { + // Create a conversation with a topic name + ConversationParameters parameters = new() + { + IsGroup = true, + TopicName = $"Test Conversation - {DateTime.UtcNow:s}", + Members = + [ + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), + } + ], + TenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set") + }; + + CreateConversationResponse response = await _conversationClient.CreateConversationAsync( + parameters, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + + Console.WriteLine($"Created conversation with topic '{parameters.TopicName}': {response.Id}"); + + // Send a message to the newly created conversation + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Test message to conversation with topic name at {DateTime.UtcNow:s}" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = response.Id + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + Console.WriteLine($" Sent message with activity ID: {sendResponse.Id}"); + } + + // TODO: This doesn't fail, but doesn't actually create the initial activity + [Fact] + public async Task CreateConversation_WithInitialActivity() + { + // Create a conversation with an initial message + ConversationParameters parameters = new() + { + IsGroup = false, + Members = + [ + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), + } + ], + Activity = new CoreActivity + { + Type = ActivityType.Message, + Properties = { { "text", $"Initial message sent at {DateTime.UtcNow:s}" } }, + }, + TenantId = Environment.GetEnvironmentVariable("AzureAd__TenantId") ?? throw new InvalidOperationException("AzureAd__TenantId environment variable not set") + }; + + CreateConversationResponse response = await _conversationClient.CreateConversationAsync( + parameters, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + // Assert.NotNull(response.ActivityId); // Should have an activity ID since we sent an initial message + + Console.WriteLine($"Created conversation with initial activity: {response.Id}"); + Console.WriteLine($" Initial activity ID: {response.ActivityId}"); + } + + [Fact] + public async Task CreateConversation_WithChannelData() + { + // Create a conversation with channel-specific data + ConversationParameters parameters = new() + { + IsGroup = false, + Members = + [ + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), + } + ], + ChannelData = new + { + teamsChannelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") + }, + TenantId = Environment.GetEnvironmentVariable("AzureAd__TenantId") ?? throw new InvalidOperationException("AzureAd__TenantId environment variable not set") + }; + + CreateConversationResponse response = await _conversationClient.CreateConversationAsync( + parameters, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + + Console.WriteLine($"Created conversation with channel data: {response.Id}"); + } + + [Fact] + public async Task GetConversationPagedMembers() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + PagedMembersResult result = await _conversationClient.GetConversationPagedMembersAsync( + conversationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Members); + Assert.NotEmpty(result.Members); + + Console.WriteLine($"Found {result.Members.Count} members in page:"); + foreach (ConversationAccount member in result.Members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + Assert.NotNull(member); + Assert.NotNull(member.Id); + } + + if (!string.IsNullOrWhiteSpace(result.ContinuationToken)) + { + Console.WriteLine($"Continuation token: {result.ContinuationToken}"); + } + } + + [Fact(Skip = "PageSize parameter not respected by API")] + public async Task GetConversationPagedMembers_WithPageSize() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + PagedMembersResult result = await _conversationClient.GetConversationPagedMembersAsync( + conversationId, + _serviceUrl, + pageSize: 1, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Members); + Assert.NotEmpty(result.Members); + Assert.Single(result.Members); + + Console.WriteLine($"Found {result.Members.Count} members with pageSize=1:"); + foreach (ConversationAccount member in result.Members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + } + + // If there's a continuation token, get the next page + if (!string.IsNullOrWhiteSpace(result.ContinuationToken)) + { + Console.WriteLine($"Getting next page with continuation token..."); + + PagedMembersResult nextPage = await _conversationClient.GetConversationPagedMembersAsync( + conversationId, + _serviceUrl, + pageSize: 1, + continuationToken: result.ContinuationToken, + cancellationToken: CancellationToken.None); + + Assert.NotNull(nextPage); + Assert.NotNull(nextPage.Members); + + Console.WriteLine($"Found {nextPage.Members.Count} members in next page:"); + foreach (ConversationAccount member in nextPage.Members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + } + } + } + + [Fact(Skip = "Method not allowed by API")] + public async Task DeleteConversationMember() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + // Get members before deletion + IList membersBefore = await _conversationClient.GetConversationMembersAsync( + conversationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(membersBefore); + Assert.NotEmpty(membersBefore); + + Console.WriteLine($"Members before deletion: {membersBefore.Count}"); + foreach (ConversationAccount member in membersBefore) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + } + + // Delete the test user + string memberToDelete = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + + // Verify the member is in the conversation before attempting to delete + Assert.Contains(membersBefore, m => m.Id == memberToDelete); + + await _conversationClient.DeleteConversationMemberAsync( + conversationId, + memberToDelete, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Console.WriteLine($"Deleted member: {memberToDelete}"); + + // Get members after deletion + IList membersAfter = await _conversationClient.GetConversationMembersAsync( + conversationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(membersAfter); + + Console.WriteLine($"Members after deletion: {membersAfter.Count}"); + foreach (ConversationAccount member in membersAfter) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + } + + // Verify the member was deleted + Assert.DoesNotContain(membersAfter, m => m.Id == memberToDelete); + } + + [Fact(Skip = "Unknown activity type error")] + public async Task SendConversationHistory() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + // Create a transcript with historic activities + Transcript transcript = new() + { + Activities = + [ + new() + { + Type = ActivityType.Message, + Id = Guid.NewGuid().ToString(), + Properties = { { "text", "Historic message 1" } }, + ServiceUrl = _serviceUrl, + Conversation = new() { Id = conversationId } + }, + new() + { + Type = ActivityType.Message, + Id = Guid.NewGuid().ToString(), + Properties = { { "text", "Historic message 2" } }, + ServiceUrl = _serviceUrl, + Conversation = new() { Id = conversationId } + }, + new() + { + Type = ActivityType.Message, + Id = Guid.NewGuid().ToString(), + Properties = { { "text", "Historic message 3" } }, + ServiceUrl = _serviceUrl, + Conversation = new() { Id = conversationId } + } + ] + }; + + SendConversationHistoryResponse response = await _conversationClient.SendConversationHistoryAsync( + conversationId, + transcript, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + + Console.WriteLine($"Sent conversation history with {transcript.Activities?.Count} activities"); + Console.WriteLine($"Response ID: {response.Id}"); + } + + [Fact(Skip = "Attachment upload endpoint not found")] + public async Task UploadAttachment() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + // Create a simple text file as an attachment + string fileContent = "This is a test attachment file created at " + DateTime.UtcNow.ToString("s"); + byte[] fileBytes = System.Text.Encoding.UTF8.GetBytes(fileContent); + + AttachmentData attachmentData = new() + { + Type = "text/plain", + Name = "test-attachment.txt", + OriginalBase64 = fileBytes + }; + + UploadAttachmentResponse response = await _conversationClient.UploadAttachmentAsync( + conversationId, + attachmentData, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + + Console.WriteLine($"Uploaded attachment: {attachmentData.Name}"); + Console.WriteLine($" Attachment ID: {response.Id}"); + Console.WriteLine($" Content-Type: {attachmentData.Type}"); + Console.WriteLine($" Size: {fileBytes.Length} bytes"); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj b/core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj new file mode 100644 index 00000000..b7aad5b2 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + ../.runsettings + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiClientTests.cs new file mode 100644 index 00000000..4c1663b1 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiClientTests.cs @@ -0,0 +1,562 @@ +// 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; + +namespace Microsoft.Bot.Core.Tests; + +public class TeamsApiClientTests +{ + private readonly ServiceProvider _serviceProvider; + private readonly TeamsApiClient _teamsClient; + private readonly Uri _serviceUrl; + + public TeamsApiClientTests() + { + IConfigurationBuilder builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables(); + + IConfiguration configuration = builder.Build(); + + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(configuration); + services.AddTeamsBotApplication(); + _serviceProvider = services.BuildServiceProvider(); + _teamsClient = _serviceProvider.GetRequiredService(); + _serviceUrl = new Uri(Environment.GetEnvironmentVariable("TEST_SERVICEURL") ?? "https://smba.trafficmanager.net/teams/"); + } + + #region Team Operations Tests + + [Fact] + public async Task FetchChannelList() + { + string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); + + ChannelList result = await _teamsClient.FetchChannelListAsync( + teamId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Channels); + Assert.NotEmpty(result.Channels); + + Console.WriteLine($"Found {result.Channels.Count} channels in team {teamId}:"); + foreach (var channel in result.Channels) + { + Console.WriteLine($" - Id: {channel.Id}, Name: {channel.Name}"); + Assert.NotNull(channel); + Assert.NotNull(channel.Id); + } + } + + [Fact] + public async Task FetchChannelList_FailsWithInvalidTeamId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchChannelListAsync("invalid-team-id", _serviceUrl)); + } + + [Fact] + public async Task FetchTeamDetails() + { + string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); + + TeamDetails result = await _teamsClient.FetchTeamDetailsAsync( + teamId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Id); + + Console.WriteLine($"Team details for {teamId}:"); + Console.WriteLine($" - Id: {result.Id}"); + Console.WriteLine($" - Name: {result.Name}"); + Console.WriteLine($" - AAD Group Id: {result.AadGroupId}"); + Console.WriteLine($" - Channel Count: {result.ChannelCount}"); + Console.WriteLine($" - Member Count: {result.MemberCount}"); + Console.WriteLine($" - Type: {result.Type}"); + } + + [Fact] + public async Task FetchTeamDetails_FailsWithInvalidTeamId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchTeamDetailsAsync("invalid-team-id", _serviceUrl)); + } + + #endregion + + #region Meeting Operations Tests + + [Fact] + public async Task FetchMeetingInfo() + { + string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); + + MeetingInfo result = await _teamsClient.FetchMeetingInfoAsync( + meetingId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + //Assert.NotNull(result.Id); + + Console.WriteLine($"Meeting info for {meetingId}:"); + + if (result.Details != null) + { + Console.WriteLine($" - Title: {result.Details.Title}"); + Console.WriteLine($" - Type: {result.Details.Type}"); + Console.WriteLine($" - Join URL: {result.Details.JoinUrl}"); + Console.WriteLine($" - Scheduled Start: {result.Details.ScheduledStartTime}"); + Console.WriteLine($" - Scheduled End: {result.Details.ScheduledEndTime}"); + } + if (result.Organizer != null) + { + Console.WriteLine($" - Organizer: {result.Organizer.Name} ({result.Organizer.Id})"); + } + } + + [Fact] + public async Task FetchMeetingInfo_FailsWithInvalidMeetingId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchMeetingInfoAsync("invalid-meeting-id", _serviceUrl)); + } + + [Fact(Skip = "Requires active meeting context")] + public async Task FetchParticipant() + { + string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); + string participantId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); + + MeetingParticipant result = await _teamsClient.FetchParticipantAsync( + meetingId, + participantId, + tenantId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + + Console.WriteLine($"Participant info for {participantId} in meeting {meetingId}:"); + if (result.User != null) + { + Console.WriteLine($" - User Id: {result.User.Id}"); + Console.WriteLine($" - User Name: {result.User.Name}"); + } + if (result.Meeting != null) + { + Console.WriteLine($" - Role: {result.Meeting.Role}"); + Console.WriteLine($" - In Meeting: {result.Meeting.InMeeting}"); + } + } + + [Fact(Skip = "Requires active meeting context")] + public async Task SendMeetingNotification() + { + string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); + string participantId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + + var notification = new TargetedMeetingNotification + { + Value = new TargetedMeetingNotificationValue + { + Recipients = [participantId], + Surfaces = + [ + new MeetingNotificationSurface + { + Surface = "meetingStage", + ContentType = "task", + Content = new { title = "Test Notification", url = "https://example.com" } + } + ] + } + }; + + MeetingNotificationResponse result = await _teamsClient.SendMeetingNotificationAsync( + meetingId, + notification, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + + Console.WriteLine($"Meeting notification sent to meeting {meetingId}"); + if (result.RecipientsFailureInfo != null && result.RecipientsFailureInfo.Count > 0) + { + Console.WriteLine($"Failed recipients:"); + foreach (var failure in result.RecipientsFailureInfo) + { + Console.WriteLine($" - {failure.RecipientMri}: {failure.ErrorCode} - {failure.FailureReason}"); + } + } + } + + #endregion + + #region Batch Message Operations Tests + + [Fact(Skip = "Batch operations require special permissions")] + public async Task SendMessageToListOfUsers() + { + string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); + string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Batch message from Automated tests at `{DateTime.UtcNow:s}`" } } + }; + + IList members = + [ + new TeamMember(userId) + ]; + + string operationId = await _teamsClient.SendMessageToListOfUsersAsync( + activity, + members, + tenantId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + + Console.WriteLine($"Batch message sent. Operation ID: {operationId}"); + } + + [Fact(Skip = "Batch operations require special permissions")] + public async Task SendMessageToAllUsersInTenant() + { + string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Tenant-wide message from Automated tests at `{DateTime.UtcNow:s}`" } } + }; + + string operationId = await _teamsClient.SendMessageToAllUsersInTenantAsync( + activity, + tenantId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + + Console.WriteLine($"Tenant-wide message sent. Operation ID: {operationId}"); + } + + [Fact(Skip = "Batch operations require special permissions")] + public async Task SendMessageToAllUsersInTeam() + { + string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); + string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Team-wide message from Automated tests at `{DateTime.UtcNow:s}`" } } + }; + + string operationId = await _teamsClient.SendMessageToAllUsersInTeamAsync( + activity, + teamId, + tenantId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + + Console.WriteLine($"Team-wide message sent. Operation ID: {operationId}"); + } + + [Fact(Skip = "Batch operations require special permissions")] + public async Task SendMessageToListOfChannels() + { + string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); + string channelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") ?? throw new InvalidOperationException("TEST_CHANNELID environment variable not set"); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Channel batch message from Automated tests at `{DateTime.UtcNow:s}`" } } + }; + + IList channels = + [ + new TeamMember(channelId) + ]; + + string operationId = await _teamsClient.SendMessageToListOfChannelsAsync( + activity, + channels, + tenantId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + + Console.WriteLine($"Channel batch message sent. Operation ID: {operationId}"); + } + + #endregion + + #region Batch Operation Management Tests + + [Fact(Skip = "Requires valid operation ID from batch operation")] + public async Task GetOperationState() + { + string operationId = Environment.GetEnvironmentVariable("TEST_OPERATION_ID") ?? throw new InvalidOperationException("TEST_OPERATION_ID environment variable not set"); + + BatchOperationState result = await _teamsClient.GetOperationStateAsync( + operationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.State); + + Console.WriteLine($"Operation state for {operationId}:"); + Console.WriteLine($" - State: {result.State}"); + Console.WriteLine($" - Total Entries: {result.TotalEntriesCount}"); + if (result.StatusMap != null) + { + Console.WriteLine($" - Success: {result.StatusMap.Success}"); + Console.WriteLine($" - Failed: {result.StatusMap.Failed}"); + Console.WriteLine($" - Throttled: {result.StatusMap.Throttled}"); + Console.WriteLine($" - Pending: {result.StatusMap.Pending}"); + } + if (result.RetryAfter != null) + { + Console.WriteLine($" - Retry After: {result.RetryAfter}"); + } + } + + [Fact] + public async Task GetOperationState_FailsWithInvalidOperationId() + { + await Assert.ThrowsAsync(() + => _teamsClient.GetOperationStateAsync("invalid-operation-id", _serviceUrl)); + } + + [Fact(Skip = "Requires valid operation ID from batch operation")] + public async Task GetPagedFailedEntries() + { + string operationId = Environment.GetEnvironmentVariable("TEST_OPERATION_ID") ?? throw new InvalidOperationException("TEST_OPERATION_ID environment variable not set"); + + BatchFailedEntriesResponse result = await _teamsClient.GetPagedFailedEntriesAsync( + operationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + + Console.WriteLine($"Failed entries for operation {operationId}:"); + if (result.FailedEntries != null && result.FailedEntries.Count > 0) + { + foreach (var entry in result.FailedEntries) + { + Console.WriteLine($" - Id: {entry.Id}, Error: {entry.Error}"); + } + } + else + { + Console.WriteLine(" No failed entries"); + } + + if (!string.IsNullOrWhiteSpace(result.ContinuationToken)) + { + Console.WriteLine($"Continuation token: {result.ContinuationToken}"); + } + } + + [Fact(Skip = "Requires valid operation ID from batch operation")] + public async Task CancelOperation() + { + string operationId = Environment.GetEnvironmentVariable("TEST_OPERATION_ID") ?? throw new InvalidOperationException("TEST_OPERATION_ID environment variable not set"); + + await _teamsClient.CancelOperationAsync( + operationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Console.WriteLine($"Operation {operationId} cancelled successfully"); + } + + #endregion + + #region Argument Validation Tests + + [Fact] + public async Task FetchChannelList_ThrowsOnNullTeamId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchChannelListAsync(null!, _serviceUrl)); + } + + [Fact] + public async Task FetchChannelList_ThrowsOnEmptyTeamId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchChannelListAsync("", _serviceUrl)); + } + + [Fact] + public async Task FetchChannelList_ThrowsOnNullServiceUrl() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchChannelListAsync("team-id", null!)); + } + + [Fact] + public async Task FetchTeamDetails_ThrowsOnNullTeamId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchTeamDetailsAsync(null!, _serviceUrl)); + } + + [Fact] + public async Task FetchMeetingInfo_ThrowsOnNullMeetingId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchMeetingInfoAsync(null!, _serviceUrl)); + } + + [Fact] + public async Task FetchParticipant_ThrowsOnNullMeetingId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchParticipantAsync(null!, "participant", "tenant", _serviceUrl)); + } + + [Fact] + public async Task FetchParticipant_ThrowsOnNullParticipantId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchParticipantAsync("meeting", null!, "tenant", _serviceUrl)); + } + + [Fact] + public async Task FetchParticipant_ThrowsOnNullTenantId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchParticipantAsync("meeting", "participant", null!, _serviceUrl)); + } + + [Fact] + public async Task SendMeetingNotification_ThrowsOnNullMeetingId() + { + var notification = new TargetedMeetingNotification(); + await Assert.ThrowsAsync(() + => _teamsClient.SendMeetingNotificationAsync(null!, notification, _serviceUrl)); + } + + [Fact] + public async Task SendMeetingNotification_ThrowsOnNullNotification() + { + await Assert.ThrowsAsync(() + => _teamsClient.SendMeetingNotificationAsync("meeting", null!, _serviceUrl)); + } + + [Fact] + public async Task SendMessageToListOfUsers_ThrowsOnNullActivity() + { + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToListOfUsersAsync(null!, [new TeamMember("id")], "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToListOfUsers_ThrowsOnNullMembers() + { + var activity = new CoreActivity { Type = ActivityType.Message }; + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToListOfUsersAsync(activity, null!, "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToListOfUsers_ThrowsOnEmptyMembers() + { + var activity = new CoreActivity { Type = ActivityType.Message }; + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToListOfUsersAsync(activity, [], "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToAllUsersInTenant_ThrowsOnNullActivity() + { + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToAllUsersInTenantAsync(null!, "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToAllUsersInTenant_ThrowsOnNullTenantId() + { + var activity = new CoreActivity { Type = ActivityType.Message }; + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToAllUsersInTenantAsync(activity, null!, _serviceUrl)); + } + + [Fact] + public async Task SendMessageToAllUsersInTeam_ThrowsOnNullActivity() + { + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToAllUsersInTeamAsync(null!, "team", "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToAllUsersInTeam_ThrowsOnNullTeamId() + { + var activity = new CoreActivity { Type = ActivityType.Message }; + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToAllUsersInTeamAsync(activity, null!, "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToListOfChannels_ThrowsOnEmptyChannels() + { + var activity = new CoreActivity { Type = ActivityType.Message }; + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToListOfChannelsAsync(activity, [], "tenant", _serviceUrl)); + } + + [Fact] + public async Task GetOperationState_ThrowsOnNullOperationId() + { + await Assert.ThrowsAsync(() + => _teamsClient.GetOperationStateAsync(null!, _serviceUrl)); + } + + [Fact] + public async Task GetPagedFailedEntries_ThrowsOnNullOperationId() + { + await Assert.ThrowsAsync(() + => _teamsClient.GetPagedFailedEntriesAsync(null!, _serviceUrl)); + } + + [Fact] + public async Task CancelOperation_ThrowsOnNullOperationId() + { + await Assert.ThrowsAsync(() + => _teamsClient.CancelOperationAsync(null!, _serviceUrl)); + } + + #endregion +} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/readme.md b/core/test/Microsoft.Teams.Bot.Core.Tests/readme.md new file mode 100644 index 00000000..125a5289 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/readme.md @@ -0,0 +1,21 @@ +# Microsoft.Bot.Core.Tests + +To run these tests we need to configure the environment variables using a `.runsettings` file, that should be localted in `core/` folder. + + +```xml + + + + + a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT + https://login.microsoftonline.com/ + + + https://api.botframework.com/.default + ClientSecret + + + + +``` \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/BotApplicationTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/BotApplicationTests.cs new file mode 100644 index 00000000..9f0dc316 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/BotApplicationTests.cs @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; + +namespace Microsoft.Teams.Bot.Core.UnitTests; + +public class BotApplicationTests +{ + [Fact] + public void Constructor_InitializesProperties() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + Assert.NotNull(botApp); + Assert.NotNull(botApp.ConversationClient); + } + + + + [Fact] + public async Task ProcessAsync_WithNullHttpContext_ThrowsArgumentNullException() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + await Assert.ThrowsAsync(() => + botApp.ProcessAsync(null!)); + } + + [Fact] + public async Task ProcessAsync_WithValidActivity_ProcessesSuccessfully() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + activity.Properties["text"] = "Test message"; + activity.Recipient.Properties["appId"] = "test-app-id"; + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + bool onActivityCalled = false; + botApp.OnActivity = (act, ct) => + { + onActivityCalled = true; + return Task.CompletedTask; + }; + + await botApp.ProcessAsync(httpContext); + + + Assert.True(onActivityCalled); + } + + [Fact] + public async Task ProcessAsync_WithMiddleware_ExecutesMiddleware() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + activity.Recipient.Properties["appId"] = "test-app-id"; + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + bool middlewareCalled = false; + Mock mockMiddleware = new(); + mockMiddleware + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(async (app, act, next, ct) => + { + middlewareCalled = true; + await next(ct); + }) + .Returns(Task.CompletedTask); + + botApp.Use(mockMiddleware.Object); + + bool onActivityCalled = false; + botApp.OnActivity = (act, ct) => + { + onActivityCalled = true; + return Task.CompletedTask; + }; + + await botApp.ProcessAsync(httpContext); + + Assert.True(middlewareCalled); + Assert.True(onActivityCalled); + } + + [Fact] + public async Task ProcessAsync_WithException_ThrowsBotHandlerException() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + activity.Recipient.Properties["appId"] = "test-app-id"; + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + botApp.OnActivity = (act, ct) => throw new InvalidOperationException("Test exception"); + + BotHandlerException exception = await Assert.ThrowsAsync(() => + botApp.ProcessAsync(httpContext)); + + Assert.Equal("Error processing activity", exception.Message); + Assert.IsType(exception.InnerException); + } + + [Fact] + public void Use_AddsMiddlewareToChain() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + Mock mockMiddleware = new(); + + ITurnMiddleWare result = botApp.Use(mockMiddleware.Object); + + Assert.NotNull(result); + } + + [Fact] + public async Task SendActivityAsync_WithValidActivity_SendsSuccessfully() + { + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + Mock mockConfig = new(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/") + }; + + var result = await botApp.SendActivityAsync(activity); + + Assert.NotNull(result); + Assert.Contains("activity123", result.Id); + } + + [Fact] + public async Task SendActivityAsync_WithNullActivity_ThrowsArgumentNullException() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + await Assert.ThrowsAsync(() => + botApp.SendActivityAsync(null!)); + } + + private static ConversationClient CreateMockConversationClient() + { + Mock mockHttpClient = new(); + return new ConversationClient(mockHttpClient.Object); + } + + private static UserTokenClient CreateMockUserTokenClient() + { + Mock mockHttpClient = new(); + NullLogger logger = NullLogger.Instance; + Mock mockConfiguration = new(); + return new UserTokenClient(mockHttpClient.Object, mockConfiguration.Object, logger); + } + + private static DefaultHttpContext CreateHttpContextWithActivity(CoreActivity activity) + { + DefaultHttpContext httpContext = new(); + string activityJson = activity.ToJson(); + byte[] bodyBytes = Encoding.UTF8.GetBytes(activityJson); + httpContext.Request.Body = new MemoryStream(bodyBytes); + httpContext.Request.ContentType = "application/json"; + return httpContext; + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs new file mode 100644 index 00000000..212d4927 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Teams.Bot.Core.Schema; +using Moq; +using Moq.Protected; + +namespace Microsoft.Teams.Bot.Core.UnitTests; + +public class ConversationClientTests +{ + [Fact] + public async Task SendActivityAsync_WithValidActivity_SendsSuccessfully() + { + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .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/") + }; + + var result = await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(result); + Assert.Contains("activity123", result.Id); + } + + [Fact] + public async Task SendActivityAsync_WithNullActivity_ThrowsArgumentNullException() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient); + + await Assert.ThrowsAsync(() => + conversationClient.SendActivityAsync(null!)); + } + + [Fact] + public async Task SendActivityAsync_WithNullConversation_ThrowsArgumentNullException() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/") + }; + + await Assert.ThrowsAsync(() => + conversationClient.SendActivityAsync(activity)); + } + + [Fact] + public async Task SendActivityAsync_WithNullConversationId_ThrowsArgumentNullException() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation() { Id = null! }, + ServiceUrl = new Uri("https://test.service.url/") + }; ; + + await Assert.ThrowsAsync(() => + conversationClient.SendActivityAsync(activity)); + } + + [Fact] + public async Task SendActivityAsync_WithNullServiceUrl_ThrowsArgumentNullException() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" } + }; + + await Assert.ThrowsAsync(() => + conversationClient.SendActivityAsync(activity)); + } + + [Fact] + public async Task SendActivityAsync_WithHttpError_ThrowsHttpRequestException() + { + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.BadRequest, + Content = new StringContent("Bad request error") + }); + + 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/") + }; + + HttpRequestException exception = await Assert.ThrowsAsync(() => + conversationClient.SendActivityAsync(activity)); + + Assert.Contains("Error sending activity", exception.Message); + Assert.Contains("BadRequest", exception.Message); + } + + [Fact] + public async Task SendActivityAsync_ConstructsCorrectUrl() + { + 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/") + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.Equal("https://test.service.url/v3/conversations/conv123/activities/", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, capturedRequest.Method); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs new file mode 100644 index 00000000..c3f62fd0 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs @@ -0,0 +1,483 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.UnitTests; + +public class CoreActivityBuilderTests +{ + [Fact] + public void Constructor_DefaultConstructor_CreatesNewActivity() + { + CoreActivityBuilder builder = new(); + CoreActivity activity = builder.Build(); + + Assert.NotNull(activity); + Assert.NotNull(activity.From); + Assert.NotNull(activity.Recipient); + Assert.NotNull(activity.Conversation); + } + + [Fact] + public void Constructor_WithExistingActivity_UsesProvidedActivity() + { + CoreActivity existingActivity = new() + { + Id = "test-id", + }; + + CoreActivityBuilder builder = new(existingActivity); + CoreActivity activity = builder.Build(); + + Assert.Equal("test-id", activity.Id); + } + + [Fact] + public void Constructor_WithNullActivity_ThrowsArgumentNullException() + { + Assert.Throws(() => new CoreActivityBuilder(null!)); + } + + [Fact] + public void WithId_SetsActivityId() + { + CoreActivity activity = new CoreActivityBuilder() + .WithId("test-activity-id") + .Build(); + + Assert.Equal("test-activity-id", activity.Id); + } + + [Fact] + public void WithServiceUrl_SetsServiceUrl() + { + Uri serviceUrl = new("https://smba.trafficmanager.net/teams/"); + + CoreActivity activity = new CoreActivityBuilder() + .WithServiceUrl(serviceUrl) + .Build(); + + Assert.Equal(serviceUrl, activity.ServiceUrl); + } + + [Fact] + public void WithChannelId_SetsChannelId() + { + CoreActivity activity = new CoreActivityBuilder() + .WithChannelId("msteams") + .Build(); + + Assert.Equal("msteams", activity.ChannelId); + } + + [Fact] + public void WithType_SetsActivityType() + { + CoreActivity activity = new CoreActivityBuilder() + .WithType(ActivityType.Message) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + } + + [Fact] + public void WithText_SetsTextContent_As_Property() + { + CoreActivity activity = new CoreActivityBuilder() + .WithProperty("text", "Hello, World!") + .Build(); + + Assert.Equal("Hello, World!", activity.Properties["text"]); + } + + [Fact] + public void WithFrom_SetsSenderAccount() + { + ConversationAccount fromAccount = new() + { + Id = "sender-id", + Name = "Sender Name" + }; + + CoreActivity activity = new CoreActivityBuilder() + .WithFrom(fromAccount) + .Build(); + + Assert.Equal("sender-id", activity.From.Id); + Assert.Equal("Sender Name", activity.From.Name); + } + + [Fact] + public void WithRecipient_SetsRecipientAccount() + { + ConversationAccount recipientAccount = new() + { + Id = "recipient-id", + Name = "Recipient Name" + }; + + CoreActivity activity = new CoreActivityBuilder() + .WithRecipient(recipientAccount) + .Build(); + + Assert.Equal("recipient-id", activity.Recipient.Id); + Assert.Equal("Recipient Name", activity.Recipient.Name); + } + + [Fact] + public void WithConversation_SetsConversationInfo() + { + Conversation conversation = new() + { + Id = "conversation-id" + }; + + CoreActivity activity = new CoreActivityBuilder() + .WithConversation(conversation) + .Build(); + + Assert.Equal("conversation-id", activity.Conversation.Id); + } + + [Fact] + public void WithChannelData_SetsChannelData() + { + ChannelData channelData = new(); + + CoreActivity activity = new CoreActivityBuilder() + .WithChannelData(channelData) + .Build(); + + Assert.NotNull(activity.ChannelData); + } + + [Fact] + public void FluentAPI_CompleteActivity_BuildsCorrectly() + { + CoreActivity activity = new CoreActivityBuilder() + .WithType(ActivityType.Message) + .WithId("activity-123") + .WithChannelId("msteams") + .WithProperty("text", "Test message") + .WithServiceUrl(new Uri("https://smba.trafficmanager.net/teams/")) + .WithFrom(new ConversationAccount + { + Id = "sender-id", + Name = "Sender" + }) + .WithRecipient(new ConversationAccount + { + Id = "recipient-id", + Name = "Recipient" + }) + .WithConversation(new Conversation + { + Id = "conv-id" + }) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.Equal("activity-123", activity.Id); + Assert.Equal("msteams", activity.ChannelId); + Assert.Equal("Test message", activity.Properties["text"]?.ToString()); + Assert.Equal("sender-id", activity.From.Id); + Assert.Equal("recipient-id", activity.Recipient.Id); + Assert.Equal("conv-id", activity.Conversation.Id); + } + + [Fact] + public void FluentAPI_MethodChaining_ReturnsBuilderInstance() + { + CoreActivityBuilder builder = new(); + + CoreActivityBuilder result1 = builder.WithId("id"); + CoreActivityBuilder result2 = builder.WithProperty("text", "text"); + CoreActivityBuilder result3 = builder.WithType(ActivityType.Message); + + Assert.Same(builder, result1); + Assert.Same(builder, result2); + Assert.Same(builder, result3); + } + + [Fact] + public void Build_CalledMultipleTimes_ReturnsSameInstance() + { + CoreActivityBuilder builder = new CoreActivityBuilder() + .WithId("test-id"); + + CoreActivity activity1 = builder.Build(); + CoreActivity activity2 = builder.Build(); + + Assert.Same(activity1, activity2); + } + + [Fact] + public void Builder_ModifyingExistingActivity_PreservesOriginalData() + { + CoreActivity original = new() + { + Id = "original-id", + Type = ActivityType.Message + }; + + CoreActivity modified = new CoreActivityBuilder(original) + .WithId("other-id") + .Build(); + + Assert.Equal("other-id", modified.Id); + Assert.Equal(ActivityType.Message, modified.Type); + } + + [Fact] + public void WithConversationReference_WithNullActivity_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + + Assert.Throws(() => builder.WithConversationReference(null!)); + } + + [Fact] + public void WithConversationReference_WithNullChannelId_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + CoreActivity sourceActivity = new() + { + ChannelId = null, + ServiceUrl = new Uri("https://test.com"), + Conversation = new Conversation(), + From = new ConversationAccount(), + Recipient = new ConversationAccount() + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullServiceUrl_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = null, + Conversation = new Conversation(), + From = new ConversationAccount(), + Recipient = new ConversationAccount() + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullConversation_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = null!, + From = new ConversationAccount(), + Recipient = new ConversationAccount() + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullFrom_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new Conversation(), + From = null!, + Recipient = new ConversationAccount() + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullRecipient_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new Conversation(), + From = new ConversationAccount(), + Recipient = null! + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_AppliesConversationReference() + { + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://smba.trafficmanager.net/teams/"), + Conversation = new Conversation { Id = "conv-123" }, + From = new ConversationAccount { Id = "user-1", Name = "User One" }, + Recipient = new ConversationAccount { Id = "bot-1", Name = "Bot" } + }; + + CoreActivity activity = new CoreActivityBuilder() + .WithConversationReference(sourceActivity) + .Build(); + + Assert.Equal("msteams", activity.ChannelId); + Assert.Equal(new Uri("https://smba.trafficmanager.net/teams/"), activity.ServiceUrl); + 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] + public void WithConversationReference_SwapsFromAndRecipient() + { + CoreActivity incomingActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new Conversation { Id = "conv-123" }, + From = new ConversationAccount { Id = "user-id", Name = "User" }, + Recipient = new ConversationAccount { Id = "bot-id", Name = "Bot" } + }; + + CoreActivity replyActivity = new CoreActivityBuilder() + .WithConversationReference(incomingActivity) + .Build(); + + 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] + public void WithChannelData_WithNullValue_SetsToNull() + { + CoreActivity activity = new CoreActivityBuilder() + .WithChannelData(new ChannelData()) + .WithChannelData(null) + .Build(); + + Assert.Null(activity.ChannelData); + } + + [Fact] + public void WithId_WithEmptyString_SetsEmptyId() + { + CoreActivity activity = new CoreActivityBuilder() + .WithId(string.Empty) + .Build(); + + Assert.Equal(string.Empty, activity.Id); + } + + [Fact] + public void WithChannelId_WithEmptyString_SetsEmptyChannelId() + { + CoreActivity activity = new CoreActivityBuilder() + .WithChannelId(string.Empty) + .Build(); + + Assert.Equal(string.Empty, activity.ChannelId); + } + + [Fact] + public void WithType_WithEmptyString_SetsEmptyType() + { + CoreActivity activity = new CoreActivityBuilder() + .WithType(string.Empty) + .Build(); + + Assert.Equal(string.Empty, activity.Type); + } + + [Fact] + public void WithConversationReference_ChainedWithOtherMethods_MaintainsFluentInterface() + { + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new Conversation { Id = "conv-123" }, + From = new ConversationAccount { Id = "user-1" }, + Recipient = new ConversationAccount { Id = "bot-1" } + }; + + CoreActivity activity = new CoreActivityBuilder() + .WithType(ActivityType.Message) + .WithConversationReference(sourceActivity) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.Equal("bot-1", activity.From.Id); + Assert.Equal("user-1", activity.Recipient.Id); + } + + [Fact] + public void Build_AfterModificationThenBuild_ReflectsChanges() + { + CoreActivityBuilder builder = new CoreActivityBuilder() + .WithId("id-1"); + + CoreActivity activity1 = builder.Build(); + Assert.Equal("id-1", activity1.Id); + + builder.WithId("id-2"); + CoreActivity activity2 = builder.Build(); + + Assert.Same(activity1, activity2); + Assert.Equal("id-2", activity2.Id); + } + + [Fact] + public void IntegrationTest_CreateComplexActivity() + { + Uri serviceUrl = new("https://smba.trafficmanager.net/amer/test/"); + ChannelData channelData = new(); + + CoreActivity activity = new CoreActivityBuilder() + .WithType(ActivityType.Message) + .WithId("msg-001") + .WithServiceUrl(serviceUrl) + .WithChannelId("msteams") + .WithFrom(new ConversationAccount + { + Id = "bot-id", + Name = "Bot" + }) + .WithRecipient(new ConversationAccount + { + Id = "user-id", + Name = "User" + }) + .WithConversation(new Conversation + { + Id = "conv-001" + }) + .WithChannelData(channelData) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.Equal("msg-001", activity.Id); + Assert.Equal(serviceUrl, activity.ServiceUrl); + Assert.Equal("msteams", activity.ChannelId); + Assert.Equal("bot-id", activity.From.Id); + Assert.Equal("user-id", activity.Recipient.Id); + Assert.Equal("conv-001", activity.Conversation.Id); + Assert.NotNull(activity.ChannelData); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs new file mode 100644 index 00000000..d13a72b8 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; + +namespace Microsoft.Teams.Bot.Core.UnitTests.Hosting; + +public class AddBotApplicationExtensionsTests +{ + private static ServiceProvider BuildServiceProvider(Dictionary configData, string? aadConfigSectionName = null) + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddLogging(); + + if (aadConfigSectionName is null) + { + services.AddConversationClient(); + } + else + { + services.AddConversationClient(aadConfigSectionName); + } + + return services.BuildServiceProvider(); + } + + private static void AssertMsalOptions(ServiceProvider serviceProvider, string expectedClientId, string expectedTenantId, string expectedInstance = "https://login.microsoftonline.com/") + { + var msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.Equal(expectedClientId, msalOptions.ClientId); + Assert.Equal(expectedTenantId, msalOptions.TenantId); + Assert.Equal(expectedInstance, msalOptions.Instance); + } + + [Fact] + public void AddConversationClient_WithBotFrameworkConfig_ConfiguresClientSecret() + { + // Arrange + var configData = new Dictionary + { + ["MicrosoftAppId"] = "test-app-id", + ["MicrosoftAppTenantId"] = "test-tenant-id", + ["MicrosoftAppPassword"] = "test-secret" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "test-app-id", "test-tenant-id"); + var msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.NotNull(msalOptions.ClientCredentials); + Assert.Single(msalOptions.ClientCredentials); + CredentialDescription credential = msalOptions.ClientCredentials.First(); + Assert.Equal(CredentialSource.ClientSecret, credential.SourceType); + Assert.Equal("test-secret", credential.ClientSecret); + } + + [Fact] + public void AddConversationClient_WithCoreConfigAndClientSecret_ConfiguresClientSecret() + { + // Arrange + Dictionary configData = new() + { + ["CLIENT_ID"] = "test-client-id", + ["TENANT_ID"] = "test-tenant-id", + ["CLIENT_SECRET"] = "test-client-secret" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "test-client-id", "test-tenant-id"); + var msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.NotNull(msalOptions.ClientCredentials); + Assert.Single(msalOptions.ClientCredentials); + CredentialDescription credential = msalOptions.ClientCredentials.First(); + Assert.Equal(CredentialSource.ClientSecret, credential.SourceType); + Assert.Equal("test-client-secret", credential.ClientSecret); + } + + [Fact] + public void AddConversationClient_WithCoreConfigAndSystemAssignedMI_ConfiguresSystemAssignedFIC() + { + // Arrange + Dictionary configData = new() + { + ["CLIENT_ID"] = "test-client-id", + ["TENANT_ID"] = "test-tenant-id", + ["MANAGED_IDENTITY_CLIENT_ID"] = "system" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "test-client-id", "test-tenant-id"); + var msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.NotNull(msalOptions.ClientCredentials); + Assert.Single(msalOptions.ClientCredentials); + CredentialDescription credential = msalOptions.ClientCredentials.First(); + Assert.Equal(CredentialSource.SignedAssertionFromManagedIdentity, credential.SourceType); + Assert.Null(credential.ManagedIdentityClientId); // System-assigned + + ManagedIdentityOptions managedIdentityOptions = serviceProvider.GetRequiredService>().Value; + Assert.Null(managedIdentityOptions.UserAssignedClientId); + } + + [Fact] + public void AddConversationClient_WithCoreConfigAndUserAssignedMI_ConfiguresUserAssignedFIC() + { + // Arrange + Dictionary configData = new() + { + ["CLIENT_ID"] = "test-client-id", + ["TENANT_ID"] = "test-tenant-id", + ["MANAGED_IDENTITY_CLIENT_ID"] = "umi-client-id" // Different from CLIENT_ID means FIC + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "test-client-id", "test-tenant-id"); + var msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.NotNull(msalOptions.ClientCredentials); + Assert.Single(msalOptions.ClientCredentials); + CredentialDescription credential = msalOptions.ClientCredentials.First(); + Assert.Equal(CredentialSource.SignedAssertionFromManagedIdentity, credential.SourceType); + Assert.Equal("umi-client-id", credential.ManagedIdentityClientId); + + ManagedIdentityOptions managedIdentityOptions = serviceProvider.GetRequiredService>().Value; + Assert.Null(managedIdentityOptions.UserAssignedClientId); + } + + [Fact] + public void AddConversationClient_WithCoreConfigAndNoManagedIdentity_ConfiguresUMIWithClientId() + { + // Arrange + Dictionary configData = new() + { + ["CLIENT_ID"] = "test-client-id", + ["TENANT_ID"] = "test-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "test-client-id", "test-tenant-id"); + var msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.Null(msalOptions.ClientCredentials); + + ManagedIdentityOptions managedIdentityOptions = serviceProvider.GetRequiredService>().Value; + Assert.Equal("test-client-id", managedIdentityOptions.UserAssignedClientId); + } + + [Fact] + public void AddConversationClient_WithDefaultSection_ConfiguresFromSection() + { + // AzureAd is the default Section Name + // Arrange + Dictionary configData = new() + { + ["AzureAd:ClientId"] = "azuread-client-id", + ["AzureAd:TenantId"] = "azuread-tenant-id", + ["AzureAd:Instance"] = "https://login.microsoftonline.com/" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "azuread-client-id", "azuread-tenant-id"); + } + + [Fact] + public void AddConversationClient_WithCustomSectionName_ConfiguresFromCustomSection() + { + // Arrange + Dictionary configData = new() + { + ["CustomAuth:ClientId"] = "custom-client-id", + ["CustomAuth:TenantId"] = "custom-tenant-id", + ["CustomAuth:Instance"] = "https://login.microsoftonline.com/" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData, "CustomAuth"); + + // Assert + AssertMsalOptions(serviceProvider, "custom-client-id", "custom-tenant-id"); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Microsoft.Teams.Bot.Core.UnitTests.csproj b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Microsoft.Teams.Bot.Core.UnitTests.csproj new file mode 100644 index 00000000..fbef6c2e --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Microsoft.Teams.Bot.Core.UnitTests.csproj @@ -0,0 +1,25 @@ + + + net8.0;net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/MiddlewareTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/MiddlewareTests.cs new file mode 100644 index 00000000..be9d1fd6 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/MiddlewareTests.cs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Teams.Bot.Core.UnitTests; + +public class MiddlewareTests +{ + [Fact] + public async Task BotApplication_Use_AddsMiddlewareToChain() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + Mock mockMiddleware = new(); + + ITurnMiddleWare result = botApp.Use(mockMiddleware.Object); + + Assert.NotNull(result); + } + + + [Fact] + public async Task Middleware_ExecutesInOrder() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + List executionOrder = []; + + Mock mockMiddleware1 = new(); + mockMiddleware1 + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(async (app, act, next, ct) => + { + executionOrder.Add(1); + await next(ct); + }) + .Returns(Task.CompletedTask); + + Mock mockMiddleware2 = new(); + mockMiddleware2 + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(async (app, act, next, ct) => + { + executionOrder.Add(2); + await next(ct); + }) + .Returns(Task.CompletedTask); + + botApp.Use(mockMiddleware1.Object); + botApp.Use(mockMiddleware2.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + activity.Recipient.Properties["appId"] = "test-app-id"; + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + botApp.OnActivity = (act, ct) => + { + executionOrder.Add(3); + return Task.CompletedTask; + }; + + await botApp.ProcessAsync(httpContext); + int[] expected = [1, 2, 3]; + Assert.Equal(expected, executionOrder); + } + + [Fact] + public async Task Middleware_CanShortCircuit() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + bool secondMiddlewareCalled = false; + bool onActivityCalled = false; + + Mock mockMiddleware1 = new(); + mockMiddleware1 + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); // Don't call next + + Mock mockMiddleware2 = new(); + mockMiddleware2 + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(() => secondMiddlewareCalled = true) + .Returns(Task.CompletedTask); + + botApp.Use(mockMiddleware1.Object); + botApp.Use(mockMiddleware2.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + activity.Recipient.Properties["appId"] = "test-app-id"; + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + botApp.OnActivity = (act, ct) => + { + onActivityCalled = true; + return Task.CompletedTask; + }; + + await botApp.ProcessAsync(httpContext); + + Assert.False(secondMiddlewareCalled); + Assert.False(onActivityCalled); + } + + [Fact] + public async Task Middleware_ReceivesCancellationToken() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + CancellationToken receivedToken = default; + + Mock mockMiddleware = new(); + mockMiddleware + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(async (app, act, next, ct) => + { + receivedToken = ct; + await next(ct); + }) + .Returns(Task.CompletedTask); + + botApp.Use(mockMiddleware.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + activity.Recipient.Properties["appId"] = "test-app-id"; + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + CancellationTokenSource cts = new(); + + await botApp.ProcessAsync(httpContext, cts.Token); + + Assert.Equal(cts.Token, receivedToken); + } + + [Fact] + public async Task Middleware_ReceivesActivity() + { + ConversationClient conversationClient = CreateMockConversationClient(); + + Mock mockConfig = new(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, mockConfig.Object, logger); + + CoreActivity? receivedActivity = null; + + Mock mockMiddleware = new(); + mockMiddleware + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(async (app, act, next, ct) => + { + receivedActivity = act; + await next(ct); + }) + .Returns(Task.CompletedTask); + + botApp.Use(mockMiddleware.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + activity.Recipient.Properties["appId"] = "test-app-id"; + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + await botApp.ProcessAsync(httpContext); + + Assert.NotNull(receivedActivity); + Assert.Equal(ActivityType.Message, receivedActivity.Type); + } + + private static ConversationClient CreateMockConversationClient() + { + Mock mockHttpClient = new(); + return new ConversationClient(mockHttpClient.Object); + } + + private static UserTokenClient CreateMockUserTokenClient() + { + Mock mockHttpClient = new(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + return new UserTokenClient(mockHttpClient.Object, mockConfig.Object, logger); + } + + private static DefaultHttpContext CreateHttpContextWithActivity(CoreActivity activity) + { + DefaultHttpContext httpContext = new(); + string activityJson = activity.ToJson(); + byte[] bodyBytes = Encoding.UTF8.GetBytes(activityJson); + httpContext.Request.Body = new MemoryStream(bodyBytes); + httpContext.Request.ContentType = "application/json"; + return httpContext; + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/ActivityExtensibilityTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/ActivityExtensibilityTests.cs new file mode 100644 index 00000000..d5d0f3e2 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/ActivityExtensibilityTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json.Serialization; + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.UnitTests.Schema; + +public class ActivityExtensibilityTests +{ + [Fact] + public void CustomActivity_ExtendedProperties_SerializedAndDeserialized() + { + MyCustomActivity customActivity = new() + { + CustomField = "CustomValue" + }; + string json = MyCustomActivity.ToJson(customActivity); + MyCustomActivity deserializedActivity = MyCustomActivity.FromActivity(CoreActivity.FromJsonString(json)); + Assert.NotNull(deserializedActivity); + Assert.Equal("CustomValue", deserializedActivity.CustomField); + } + + [Fact] + public async Task CustomActivity_ExtendedProperties_SerializedAndDeserialized_Async() + { + string json = """ + { + "type": "message", + "customField": "CustomValue" + } + """; + using MemoryStream stream = new(Encoding.UTF8.GetBytes(json)); + MyCustomActivity? deserializedActivity = await CoreActivity.FromJsonStreamAsync(stream); + Assert.NotNull(deserializedActivity); + Assert.Equal("CustomValue", deserializedActivity!.CustomField); + } + + + [Fact] + public void CustomChannelDataActivity_ExtendedProperties_SerializedAndDeserialized() + { + MyCustomChannelDataActivity customChannelDataActivity = new() + { + ChannelData = new MyChannelData + { + CustomField = "customFieldValue", + MyChannelId = "12345" + } + }; + string json = CoreActivity.ToJson(customChannelDataActivity); + MyCustomChannelDataActivity deserializedActivity = MyCustomChannelDataActivity.FromActivity(CoreActivity.FromJsonString(json)); + Assert.NotNull(deserializedActivity); + Assert.NotNull(deserializedActivity.ChannelData); + Assert.Equal(ActivityType.Message, deserializedActivity.Type); + Assert.Equal("customFieldValue", deserializedActivity.ChannelData.CustomField); + Assert.Equal("12345", deserializedActivity.ChannelData.MyChannelId); + } + + + [Fact] + public void Deserialize_CustomChannelDataActivity() + { + string json = """ + { + "type": "message", + "channelData": { + "customField": "customFieldValue", + "myChannelId": "12345" + } + } + """; + MyCustomChannelDataActivity deserializedActivity = MyCustomChannelDataActivity.FromActivity(CoreActivity.FromJsonString(json)); + Assert.NotNull(deserializedActivity); + Assert.NotNull(deserializedActivity.ChannelData); + Assert.Equal("customFieldValue", deserializedActivity.ChannelData.CustomField); + Assert.Equal("12345", deserializedActivity.ChannelData.MyChannelId); + } +} + +public class MyCustomActivity : CoreActivity +{ + internal static MyCustomActivity FromActivity(CoreActivity activity) + { + return new MyCustomActivity + { + Type = activity.Type, + ChannelId = activity.ChannelId, + Id = activity.Id, + ServiceUrl = activity.ServiceUrl, + ChannelData = activity.ChannelData, + From = activity.From, + Recipient = activity.Recipient, + Conversation = activity.Conversation, + Entities = activity.Entities, + Attachments = activity.Attachments, + Value = activity.Value, + Properties = activity.Properties, + CustomField = activity.Properties.TryGetValue("customField", out object? customFieldObj) + && customFieldObj is JsonElement jeCustomField + && jeCustomField.ValueKind == JsonValueKind.String + ? jeCustomField.GetString() + : null + }; + } + [JsonPropertyName("customField")] + public string? CustomField { get; set; } +} + + +public class MyChannelData : ChannelData +{ + public MyChannelData() + { + } + public MyChannelData(ChannelData cd) + { + if (cd is not null) + { + if (cd.Properties.TryGetValue("customField", out object? channelIdObj) + && channelIdObj is JsonElement jeChannelId + && jeChannelId.ValueKind == JsonValueKind.String) + { + CustomField = jeChannelId.GetString(); + } + + if (cd.Properties.TryGetValue("myChannelId", out object? mychannelIdObj) + && mychannelIdObj is JsonElement jemyChannelId + && jemyChannelId.ValueKind == JsonValueKind.String) + { + MyChannelId = jemyChannelId.GetString(); + } + } + } + + [JsonPropertyName("customField")] + public string? CustomField { get; set; } + + [JsonPropertyName("myChannelId")] + public string? MyChannelId { get; set; } +} + +public class MyCustomChannelDataActivity : CoreActivity +{ + [JsonPropertyName("channelData")] + public new MyChannelData? ChannelData { get; set; } + + internal static MyCustomChannelDataActivity FromActivity(CoreActivity coreActivity) + { + return new MyCustomChannelDataActivity + { + Type = coreActivity.Type, + ChannelId = coreActivity.ChannelId, + Id = coreActivity.Id, + ServiceUrl = coreActivity.ServiceUrl, + ChannelData = new MyChannelData(coreActivity.ChannelData ?? new Core.Schema.ChannelData()), + Recipient = coreActivity.Recipient, + Conversation = coreActivity.Conversation, + Entities = coreActivity.Entities, + Attachments = coreActivity.Attachments, + Value = coreActivity.Value, + Properties = coreActivity.Properties + }; + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs new file mode 100644 index 00000000..36835f3b --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs @@ -0,0 +1,349 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.UnitTests.Schema; + +public class CoreCoreActivityTests +{ + [Fact] + public void Ctor_And_Nulls() + { + CoreActivity a1 = new(); + Assert.NotNull(a1); + Assert.Equal(ActivityType.Message, a1.Type); + + CoreActivity a2 = new() + { + Type = "mytype" + }; + Assert.NotNull(a2); + Assert.Equal("mytype", a2.Type); + } + + [Fact] + public void Json_Nulls_Not_Deserialized() + { + string json = """ + { + "type": "message", + "text": null + } + """; + CoreActivity act = CoreActivity.FromJsonString(json); + Assert.NotNull(act); + Assert.Equal("message", act.Type); + + string json2 = """ + { + "type": "message" + } + """; + CoreActivity act2 = CoreActivity.FromJsonString(json2); + Assert.NotNull(act2); + Assert.Equal("message", act2.Type); + + } + + [Fact] + public void Accept_Unkown_Primitive_Fields() + { + string json = """ + { + "type": "message", + "text": "hello", + "unknownString": "some string", + "unknownInt": 123, + "unknownBool": true, + "unknownNull": null + } + """; + CoreActivity act = CoreActivity.FromJsonString(json); + Assert.NotNull(act); + Assert.Equal("message", act.Type); + Assert.True(act.Properties.ContainsKey("unknownString")); + Assert.True(act.Properties.ContainsKey("unknownInt")); + Assert.True(act.Properties.ContainsKey("unknownBool")); + Assert.True(act.Properties.ContainsKey("unknownNull")); + Assert.Equal("some string", act.Properties["unknownString"]?.ToString()); + Assert.Equal(123, ((JsonElement)act.Properties["unknownInt"]!).GetInt32()); + Assert.True(((JsonElement)act.Properties["unknownBool"]!).GetBoolean()); + Assert.Null(act.Properties["unknownNull"]); + } + + [Fact] + public void Serialize_Unkown_Primitive_Fields() + { + CoreActivity act = new() + { + Type = ActivityType.Message, + }; + act.Properties["unknownString"] = "some string"; + act.Properties["unknownInt"] = 123; + act.Properties["unknownBool"] = true; + act.Properties["unknownNull"] = null; + act.Properties["unknownLong"] = 1L; + act.Properties["unknownDouble"] = 1.0; + + string json = act.ToJson(); + Assert.Contains("\"type\": \"message\"", json); + Assert.Contains("\"unknownString\": \"some string\"", json); + Assert.Contains("\"unknownInt\": 123", json); + Assert.Contains("\"unknownBool\": true", json); + Assert.Contains("\"unknownNull\": null", json); + Assert.Contains("\"unknownLong\": 1", json); + Assert.Contains("\"unknownDouble\": 1", json); + } + + [Fact] + public void Deserialize_Unkown__Fields_In_KnownObjects() + { + string json = """ + { + "type": "message", + "text": "hello", + "from": { + "id": "1", + "name": "tester", + "aadObjectId": "123" + } + } + """; + CoreActivity act = CoreActivity.FromJsonString(json); + Assert.NotNull(act); + Assert.Equal("message", act.Type); + Assert.NotNull(act.From); + Assert.IsType(act.From); + Assert.Equal("1", act.From!.Id); + Assert.Equal("tester", act.From.Name); + Assert.True(act.From.Properties.ContainsKey("aadObjectId")); + Assert.Equal("123", act.From.Properties["aadObjectId"]?.ToString()); + } + + [Fact] + public void Deserialize_Serialize_Unkown__Fields_In_KnownObjects() + { + string json = """ + { + "type": "message", + "text": "hello", + "from": { + "id": "1", + "name": "tester", + "aadObjectId": "123" + } + } + """; + CoreActivity act = CoreActivity.FromJsonString(json); + string json2 = act.ToJson(); + Assert.Contains("\"type\": \"message\"", json2); + Assert.Contains("\"text\": \"hello\"", json2); + Assert.Contains("\"from\": {", json2); + Assert.Contains("\"id\": \"1\"", json2); + Assert.Contains("\"name\": \"tester\"", json2); + Assert.Contains("\"aadObjectId\": \"123\"", json2); + } + + [Fact] + public void Deserialize_Serialize_Entities() + { + string json = """ + { + "type": "message", + "text": "hello", + "entities": [ + { + "mentioned": { + "id": "28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", + "name": "ridotest" + }, + "text": "\u003Cat\u003Eridotest\u003C/at\u003E", + "type": "mention" + }, + { + "locale": "en-US", + "country": "US", + "platform": "Web", + "timezone": "America/Los_Angeles", + "type": "clientInfo" + } + ] + } + """; + CoreActivity act = CoreActivity.FromJsonString(json); + string json2 = act.ToJson(); + Assert.Contains("\"type\": \"message\"", json2); + Assert.NotNull(act.Entities); + Assert.Equal(2, act.Entities!.Count); + + } + + + [Fact] + public void Handling_Nulls_from_default_serializer() + { + string json = """ + { + "type": "message", + "text": null, + "unknownString": null + } + """; + CoreActivity? act = JsonSerializer.Deserialize(json); //without default options + Assert.NotNull(act); + Assert.Equal("message", act.Type); + Assert.Null(act.Properties["text"]); + Assert.Null(act.Properties["unknownString"]!); + + string json2 = JsonSerializer.Serialize(act); //without default options + Assert.Contains("\"type\":\"message\"", json2); + Assert.Contains("\"text\":null", json2); + Assert.Contains("\"unknownString\":null", json2); + } + + [Fact] + public void Serialize_With_Properties_Initialized() + { + CoreActivity act = new() + { + Type = ActivityType.Message, + Properties = + { + { "customField", "customValue" } + }, + ChannelData = new() + { + Properties = + { + { "channelCustomField", "channelCustomValue" } + } + }, + Conversation = new() + { + Properties = + { + { "conversationCustomField", "conversationCustomValue" } + } + }, + From = new() + { + Id = "user1", + Properties = + { + { "fromCustomField", "fromCustomValue" } + } + }, + Recipient = new() + { + Id = "bot1", + Properties = + { + { "recipientCustomField", "recipientCustomValue" } + } + + } + }; + string json = act.ToJson(); + Assert.Contains("\"type\": \"message\"", json); + Assert.Contains("\"customField\": \"customValue\"", json); + Assert.Contains("\"channelCustomField\": \"channelCustomValue\"", json); + Assert.Contains("\"conversationCustomField\": \"conversationCustomValue\"", json); + Assert.Contains("\"fromCustomField\": \"fromCustomValue\"", json); + Assert.Contains("\"recipientCustomField\": \"recipientCustomValue\"", json); + } + + + [Fact] + public void CreateReply() + { + CoreActivity act = new() + { + Type = "myActivityType", + Id = "CoreActivity1", + ChannelId = "channel1", + ServiceUrl = new Uri("http://service.url"), + From = new ConversationAccount() + { + Id = "user1", + Name = "User One" + }, + Recipient = new ConversationAccount() + { + Id = "bot1", + Name = "Bot One" + }, + Conversation = new Conversation() + { + Id = "conversation1" + } + }; + CoreActivity reply = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithConversationReference(act) + .WithProperty("text", "reply") + .Build(); + + Assert.NotNull(reply); + Assert.Equal(ActivityType.Message, reply.Type); + Assert.Equal("reply", reply.Properties["text"]); + Assert.Equal("channel1", reply.ChannelId); + Assert.NotNull(reply.ServiceUrl); + Assert.Equal("http://service.url/", reply.ServiceUrl.ToString()); + 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] + public async Task DeserializeAsync() + { + string json = """ + { + "type": "message", + "text": "hello", + "from": { + "id": "1", + "name": "tester", + "aadObjectId": "123" + } + } + """; + using MemoryStream ms = new(System.Text.Encoding.UTF8.GetBytes(json)); + CoreActivity? act = await CoreActivity.FromJsonStreamAsync(ms); + Assert.NotNull(act); + Assert.Equal("message", act.Type); + Assert.Equal("hello", act.Properties["text"]?.ToString()); + Assert.NotNull(act.From); + Assert.IsType(act.From); + Assert.Equal("1", act.From.Id); + Assert.Equal("tester", act.From.Name); + Assert.True(act.From.Properties.ContainsKey("aadObjectId")); + Assert.Equal("123", act.From.Properties["aadObjectId"]?.ToString()); + } + + + [Fact] + public async Task DeserializeInvokeWithValueAsync() + { + string json = """ + { + "type": "invoke", + "value": { + "key1": "value1", + "key2": 2 + } + } + """; + using MemoryStream ms = new(System.Text.Encoding.UTF8.GetBytes(json)); + CoreActivity? act = await CoreActivity.FromJsonStreamAsync(ms); + Assert.NotNull(act); + Assert.Equal("invoke", act.Type); + Assert.NotNull(act.Value); + Assert.NotNull(act.Value["key1"]); + Assert.Equal("value1", act.Value["key1"]?.GetValue()); + Assert.Equal(2, act.Value["key2"]?.GetValue()); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/EntitiesTest.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/EntitiesTest.cs new file mode 100644 index 00000000..480ec83a --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/EntitiesTest.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.UnitTests.Schema; + +public class EntitiesTest +{ + [Fact] + public void Test_Entity_Deserialization() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "mention", + "mentioned": { + "id": "user1", + "name": "User One" + }, + "text": "User One" + } + ] + } + """; + CoreActivity activity = CoreActivity.FromJsonString(json); + Assert.NotNull(activity); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + JsonNode? e1 = activity.Entities[0]; + Assert.NotNull(e1); + Assert.Equal("mention", e1["type"]?.ToString()); + Assert.NotNull(e1["mentioned"]); + Assert.True(e1["mentioned"]?.AsObject().ContainsKey("id")); + Assert.NotNull(e1["mentioned"]?["id"]); + Assert.Equal("user1", e1["mentioned"]?["id"]?.ToString()); + Assert.Equal("User One", e1["mentioned"]?["name"]?.ToString()); + Assert.Equal("User One", e1["text"]?.ToString()); + } + + [Fact] + public void Entitiy_Serialization() + { + JsonNodeOptions nops = new() + { + PropertyNameCaseInsensitive = false + }; + + CoreActivity activity = new(ActivityType.Message); + JsonObject mentionEntity = new() + { + ["type"] = "mention", + ["mentioned"] = new JsonObject + { + ["id"] = "user1", + ["name"] = "UserOne" + }, + ["text"] = "User One" + }; + activity.Entities = new JsonArray(nops, mentionEntity); + string json = activity.ToJson(); + Assert.NotNull(json); + Assert.Contains("\"type\": \"mention\"", json); + Assert.Contains("\"id\": \"user1\"", json); + Assert.Contains("\"name\": \"UserOne\"", json); + Assert.Contains("\"text\": \"\\u003Cat\\u003EUser One\\u003C/at\\u003E\"", json); + } + + [Fact] + public void Entity_RoundTrip() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "mention", + "mentioned": { + "id": "user1", + "name": "User One" + }, + "text": "User One" + } + ] + } + """; + CoreActivity activity = CoreActivity.FromJsonString(json); + string serialized = activity.ToJson(); + Assert.NotNull(serialized); + Assert.Contains("\"type\": \"mention\"", serialized); + Assert.Contains("\"id\": \"user1\"", serialized); + Assert.Contains("\"name\": \"User One\"", serialized); + Assert.Contains("\"text\": \"\\u003Cat\\u003EUser One\\u003C/at\\u003E\"", serialized); + + } +} diff --git a/core/test/README.md b/core/test/README.md new file mode 100644 index 00000000..6149a020 --- /dev/null +++ b/core/test/README.md @@ -0,0 +1,30 @@ +# Tests + +.vscode/settings.json + +```json +{ + "dotnet.unitTests.runSettingsPath": "./.runsettings" +} +``` + + +.runsettings +```xml + + + + + test_value + 19:9f2af1bee7cc4a71af25ac72478fd5c6@thread.tacv2 + https://login.microsoftonline.com/ + + + ClientSecret + + Warning + Information + + + +``` \ No newline at end of file diff --git a/core/test/aot-checks/Program.cs b/core/test/aot-checks/Program.cs new file mode 100644 index 00000000..345f33d9 --- /dev/null +++ b/core/test/aot-checks/Program.cs @@ -0,0 +1,5 @@ +using Microsoft.Teams.Bot.Core.Schema; + +CoreActivity coreActivity = CoreActivity.FromJsonString(SampleActivities.TeamsMessage); + +System.Console.WriteLine(coreActivity.ToJson()); \ No newline at end of file diff --git a/core/test/aot-checks/SampleActivities.cs b/core/test/aot-checks/SampleActivities.cs new file mode 100644 index 00000000..757e29e2 --- /dev/null +++ b/core/test/aot-checks/SampleActivities.cs @@ -0,0 +1,68 @@ +internal static class SampleActivities +{ + public const string TeamsMessage = """ + { + "type": "message", + "channelId": "msteams", + "text": "\u003Cat\u003Eridotest\u003C/at\u003E reply to thread", + "id": "1759944781430", + "serviceUrl": "https://smba.trafficmanager.net/amer/50612dbb-0237-4969-b378-8d42590f9c00/", + "channelData": { + "teamsChannelId": "19:6848757105754c8981c67612732d9aa7@thread.tacv2", + "teamsTeamId": "19:66P469zibfbsGI-_a0aN_toLTZpyzS6u7CT3TsXdgPw1@thread.tacv2", + "channel": { + "id": "19:6848757105754c8981c67612732d9aa7@thread.tacv2" + }, + "team": { + "id": "19:66P469zibfbsGI-_a0aN_toLTZpyzS6u7CT3TsXdgPw1@thread.tacv2" + }, + "tenant": { + "id": "50612dbb-0237-4969-b378-8d42590f9c00" + } + }, + "from": { + "id": "29:17bUvCasIPKfQIXHvNzcPjD86fwm6GkWc1PvCGP2-NSkNb7AyGYpjQ7Xw-XgTwaHW5JxZ4KMNDxn1kcL8fwX1Nw", + "name": "rido", + "aadObjectId": "b15a9416-0ad3-4172-9210-7beb711d3f70" + }, + "recipient": { + "id": "28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", + "name": "ridotest" + }, + "conversation": { + "id": "19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", + "isGroup": true, + "conversationType": "channel", + "tenantId": "50612dbb-0237-4969-b378-8d42590f9c00" + }, + "entities": [ + { + "mentioned": { + "id": "28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", + "name": "ridotest" + }, + "text": "\u003Cat\u003Eridotest\u003C/at\u003E", + "type": "mention" + }, + { + "locale": "en-US", + "country": "US", + "platform": "Web", + "timezone": "America/Los_Angeles", + "type": "clientInfo" + } + ], + "textFormat": "plain", + "attachments": [ + { + "contentType": "text/html", + "content": "\u003Cp\u003E\u003Cspan itemtype=\u0022http://schema.skype.com/Mention\u0022 itemscope=\u0022\u0022 itemid=\u00220\u0022\u003Eridotest\u003C/span\u003E\u0026nbsp;reply to thread\u003C/p\u003E" + } + ], + "timestamp": "2025-10-08T17:33:01.4953744Z", + "localTimestamp": "2025-10-08T10:33:01.4953744-07:00", + "locale": "en-US", + "localTimezone": "America/Los_Angeles" + } + """; +} \ No newline at end of file diff --git a/core/test/aot-checks/aot-checks.csproj b/core/test/aot-checks/aot-checks.csproj new file mode 100644 index 00000000..b69654e0 --- /dev/null +++ b/core/test/aot-checks/aot-checks.csproj @@ -0,0 +1,17 @@ + + + + + + + + Exe + net10.0 + aot_checks + enable + enable + true + true + + + diff --git a/core/test/msal-config-api/Program.cs b/core/test/msal-config-api/Program.cs new file mode 100644 index 00000000..18bd340a --- /dev/null +++ b/core/test/msal-config-api/Program.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + + +string ConversationId = "a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT"; +string FromId = "28:56653e9d-2158-46ee-90d7-675c39642038"; +string ServiceUrl = "https://smba.trafficmanager.net/teams/"; + +ConversationClient conversationClient = CreateConversationClient(); +await conversationClient.SendActivityAsync(new CoreActivity +{ + Conversation = new() { Id = ConversationId }, + ServiceUrl = new Uri(ServiceUrl), + From = new() { Id = FromId }, + Properties = { { "text", "Test Message" } } + + +}, cancellationToken: default); + +await conversationClient.SendActivityAsync(new CoreActivity +{ + //Text = "Hello from MSAL Config API test!", + Conversation = new() { Id = "bad conversation" }, + ServiceUrl = new Uri(ServiceUrl), + From = new() { Id = FromId } + +}, cancellationToken: default); + + + +static ConversationClient CreateConversationClient() +{ + ServiceCollection services = InitializeDIContainer(); + services.AddConversationClient(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + ConversationClient conversationClient = serviceProvider.GetRequiredService(); + return conversationClient; +} + +static ServiceCollection InitializeDIContainer() +{ + IConfigurationBuilder builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables(); + + IConfiguration configuration = builder.Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddLogging(configure => configure.AddConsole()); + return services; +} diff --git a/core/test/msal-config-api/msal-config-api.csproj b/core/test/msal-config-api/msal-config-api.csproj new file mode 100644 index 00000000..9907a3f8 --- /dev/null +++ b/core/test/msal-config-api/msal-config-api.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + msal_config_api + enable + enable + false + + + + + + + diff --git a/core/version.json b/core/version.json new file mode 100644 index 00000000..ce1d64b1 --- /dev/null +++ b/core/version.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.0.1-alpha.{height}", + "pathFilters": ["."], + "publicReleaseRefSpec": [ + "^refs/heads/main$", + "^refs/heads/next/core$", + "^refs/heads/v\\d+(?:\\.\\d+)?$" + ], + "cloudBuild": { + "buildNumber": { + "enabled": true + } + } +} \ No newline at end of file diff --git a/version.json b/version.json index 15ee855f..90a3ed7b 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", "version": "2.0.5-beta.{height}", + "pathFilters": ["./Libraries"], "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$",